Add video reference tracking and display

- Add "Most referenced" sort option to sort by backlink count
- Backend now supports sorting by referenced_by_count field
- Search results now display reference counts as badges:
  - Shows number of backlinks (videos linking to this one)
  - Shows number of internal references (outbound links)
- Reference badges appear alongside transcript source badges

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-05 10:52:00 -05:00
parent 2846e13a81
commit 7988e2751a
3 changed files with 133 additions and 4 deletions

View File

@@ -286,6 +286,24 @@ def parse_channel_params(values: Iterable[Optional[str]]) -> List[str]:
return channels return channels
def build_year_filter(year: Optional[str]) -> Optional[Dict]:
if not year:
return None
try:
year_int = int(year)
return {
"range": {
"date": {
"gte": f"{year_int}-01-01",
"lt": f"{year_int + 1}-01-01",
"format": "yyyy-MM-dd"
}
}
}
except (ValueError, TypeError):
return None
def build_channel_filter(channels: Optional[Sequence[str]]) -> Optional[Dict]: def build_channel_filter(channels: Optional[Sequence[str]]) -> Optional[Dict]:
if not channels: if not channels:
return None return None
@@ -320,6 +338,7 @@ def build_query_payload(
query: str, query: str,
*, *,
channels: Optional[Sequence[str]] = None, channels: Optional[Sequence[str]] = None,
year: Optional[str] = None,
sort: str = "relevant", sort: str = "relevant",
use_exact: bool = True, use_exact: bool = True,
use_fuzzy: bool = True, use_fuzzy: bool = True,
@@ -333,6 +352,10 @@ def build_query_payload(
if channel_filter: if channel_filter:
filters.append(channel_filter) filters.append(channel_filter)
year_filter = build_year_filter(year)
if year_filter:
filters.append(year_filter)
if use_query_string: if use_query_string:
base_fields = ["title^3", "description^2", "transcript_full", "transcript_secondary_full"] base_fields = ["title^3", "description^2", "transcript_full", "transcript_secondary_full"]
qs_query = (query or "").strip() or "*" qs_query = (query or "").strip() or "*"
@@ -376,6 +399,8 @@ def build_query_payload(
body["sort"] = [{"date": {"order": "desc"}}] body["sort"] = [{"date": {"order": "desc"}}]
elif sort == "older": elif sort == "older":
body["sort"] = [{"date": {"order": "asc"}}] body["sort"] = [{"date": {"order": "asc"}}]
elif sort == "referenced":
body["sort"] = [{"referenced_by_count": {"order": "desc"}}]
return body return body
if query: if query:
@@ -479,6 +504,8 @@ def build_query_payload(
body["sort"] = [{"date": {"order": "desc"}}] body["sort"] = [{"date": {"order": "desc"}}]
elif sort == "older": elif sort == "older":
body["sort"] = [{"date": {"order": "asc"}}] body["sort"] = [{"date": {"order": "asc"}}]
elif sort == "referenced":
body["sort"] = [{"referenced_by_count": {"order": "desc"}}]
return body return body
@@ -570,6 +597,53 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
data.sort(key=lambda item: item["Name"].lower()) data.sort(key=lambda item: item["Name"].lower())
return jsonify(data) return jsonify(data)
@app.route("/api/years")
def years():
body = {
"size": 0,
"aggs": {
"years": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy",
"order": {"_key": "desc"}
}
}
}
}
if config.elastic.debug:
LOGGER.info(
"Elasticsearch years request: %s",
json.dumps({"index": index, "body": body}, indent=2),
)
response = client.search(index=index, body=body)
if config.elastic.debug:
LOGGER.info(
"Elasticsearch years response: %s",
json.dumps(response, indent=2, default=str),
)
buckets = (
response.get("aggregations", {})
.get("years", {})
.get("buckets", [])
)
data = [
{
"Year": bucket.get("key_as_string"),
"Count": bucket.get("doc_count", 0),
}
for bucket in buckets
if bucket.get("doc_count", 0) > 0
]
return jsonify(data)
@app.route("/api/search") @app.route("/api/search")
def search(): def search():
query = request.args.get("q", "", type=str) query = request.args.get("q", "", type=str)
@@ -578,6 +652,7 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
if legacy_channel: if legacy_channel:
raw_channels.append(legacy_channel) raw_channels.append(legacy_channel)
channels = parse_channel_params(raw_channels) channels = parse_channel_params(raw_channels)
year = request.args.get("year", "", type=str) or None
sort = request.args.get("sort", "relevant", type=str) sort = request.args.get("sort", "relevant", type=str)
page = max(request.args.get("page", 0, type=int), 0) page = max(request.args.get("page", 0, type=int), 0)
size = max(request.args.get("size", 10, type=int), 1) size = max(request.args.get("size", 10, type=int), 1)
@@ -598,6 +673,7 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
payload = build_query_payload( payload = build_query_payload(
query, query,
channels=channels, channels=channels,
year=year,
sort=sort, sort=sort,
use_exact=use_exact, use_exact=use_exact,
use_fuzzy=use_fuzzy, use_fuzzy=use_fuzzy,
@@ -671,6 +747,8 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
"primary": bool(highlight_map.get("transcript_full")), "primary": bool(highlight_map.get("transcript_full")),
"secondary": bool(highlight_map.get("transcript_secondary_full")), "secondary": bool(highlight_map.get("transcript_secondary_full")),
}, },
"internal_references_count": source.get("internal_references_count", 0),
"referenced_by_count": source.get("referenced_by_count", 0),
} }
) )
@@ -716,6 +794,7 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
if legacy_channel: if legacy_channel:
raw_channels.append(legacy_channel) raw_channels.append(legacy_channel)
channels = parse_channel_params(raw_channels) channels = parse_channel_params(raw_channels)
year = request.args.get("year", "", type=str) or None
interval = (request.args.get("interval", "month") or "month").lower() interval = (request.args.get("interval", "month") or "month").lower()
allowed_intervals = {"day", "week", "month", "quarter", "year"} allowed_intervals = {"day", "week", "month", "quarter", "year"}
if interval not in allowed_intervals: if interval not in allowed_intervals:
@@ -727,6 +806,9 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
channel_filter = build_channel_filter(channels) channel_filter = build_channel_filter(channels)
if channel_filter: if channel_filter:
filters.append(channel_filter) filters.append(channel_filter)
year_filter = build_year_filter(year)
if year_filter:
filters.append(year_filter)
if start or end: if start or end:
range_filter: Dict[str, Dict[str, Dict[str, str]]] = {"range": {"date": {}}} range_filter: Dict[str, Dict[str, Dict[str, str]]] = {"range": {"date": {}}}
if start: if start:

View File

@@ -35,6 +35,7 @@
const channelDropdown = document.getElementById("channelDropdown"); const channelDropdown = document.getElementById("channelDropdown");
const channelSummary = document.getElementById("channelSummary"); const channelSummary = document.getElementById("channelSummary");
const channelOptions = document.getElementById("channelOptions"); const channelOptions = document.getElementById("channelOptions");
const yearSel = document.getElementById("year");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
const sizeSel = document.getElementById("size"); const sizeSel = document.getElementById("size");
const exactToggle = document.getElementById("exactToggle"); const exactToggle = document.getElementById("exactToggle");
@@ -140,8 +141,29 @@
} }
} }
async function loadYears() {
if (!yearSel) return;
try {
const res = await fetch("/api/years");
const data = await res.json();
// Keep the "All Years" option
yearSel.innerHTML = '<option value="">All Years</option>';
data.forEach((item) => {
const option = document.createElement("option");
option.value = item.Year;
option.textContent = `${item.Year} (${item.Count})`;
yearSel.appendChild(option);
});
} catch (err) {
console.error("Failed to load years", err);
}
}
function setFromQuery() { function setFromQuery() {
qInput.value = qs.get("q") || ""; qInput.value = qs.get("q") || "";
yearSel.value = qs.get("year") || "";
sortSel.value = qs.get("sort") || "relevant"; sortSel.value = qs.get("sort") || "relevant";
sizeSel.value = qs.get("size") || "10"; sizeSel.value = qs.get("size") || "10";
pendingChannelSelection = parseChannelParams(qs); pendingChannelSelection = parseChannelParams(qs);
@@ -305,13 +327,18 @@
} }
} }
function updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode) { function updateUrl(q, sort, channels, year, page, size, exact, fuzzy, phrase, queryMode) {
const next = new URL(window.location.href); const next = new URL(window.location.href);
next.searchParams.set("q", q); next.searchParams.set("q", q);
next.searchParams.set("sort", sort); next.searchParams.set("sort", sort);
next.searchParams.delete("channel_id"); next.searchParams.delete("channel_id");
next.searchParams.delete("channel"); next.searchParams.delete("channel");
channels.forEach((id) => next.searchParams.append("channel_id", id)); channels.forEach((id) => next.searchParams.append("channel_id", id));
if (year) {
next.searchParams.set("year", year);
} else {
next.searchParams.delete("year");
}
next.searchParams.set("page", page); next.searchParams.set("page", page);
next.searchParams.set("size", size); next.searchParams.set("size", size);
next.searchParams.set("exact", exact ? "1" : "0"); next.searchParams.set("exact", exact ? "1" : "0");
@@ -893,7 +920,7 @@ function renderFrequencyChart(buckets, channelTotals) {
freqChart.appendChild(legend); freqChart.appendChild(legend);
} }
async function updateFrequencyChart(term, channels, queryMode) { async function updateFrequencyChart(term, channels, year, queryMode) {
if (!freqChart || typeof d3 === "undefined") { if (!freqChart || typeof d3 === "undefined") {
return; return;
} }
@@ -911,6 +938,9 @@ async function updateFrequencyChart(term, channels, queryMode) {
params.set("term", trimmed); params.set("term", trimmed);
params.set("interval", "month"); params.set("interval", "month");
(channels || []).forEach((id) => params.append("channel_id", id)); (channels || []).forEach((id) => params.append("channel_id", id));
if (year) {
params.set("year", year);
}
if (queryMode) { if (queryMode) {
params.set("query_string", "1"); params.set("query_string", "1");
} }
@@ -961,6 +991,13 @@ async function updateFrequencyChart(term, channels, queryMode) {
const badges = []; const badges = [];
if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript'); if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript');
if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript'); if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript');
// Add reference count badges
const refByCount = item.referenced_by_count || 0;
const refToCount = item.internal_references_count || 0;
if (refByCount > 0) badges.push(`${refByCount} backlink${refByCount !== 1 ? 's' : ''}`);
if (refToCount > 0) badges.push(`${refToCount} reference${refToCount !== 1 ? 's' : ''}`);
const badgeHtml = badges.length const badgeHtml = badges.length
? `<div class="badge-row">${badges ? `<div class="badge-row">${badges
.map((b) => `<span class="badge">${escapeHtml(b)}</span>` ) .map((b) => `<span class="badge">${escapeHtml(b)}</span>` )
@@ -1041,6 +1078,7 @@ async function updateFrequencyChart(term, channels, queryMode) {
async function runSearch(pageOverride, pushState = true) { async function runSearch(pageOverride, pushState = true) {
const q = qInput.value.trim(); const q = qInput.value.trim();
const channels = getSelectedChannels(); const channels = getSelectedChannels();
const year = yearSel.value;
const sort = sortSel.value; const sort = sortSel.value;
const size = parseInt(sizeSel.value, 10) || 10; const size = parseInt(sizeSel.value, 10) || 10;
const queryMode = queryToggle && queryToggle.checked; const queryMode = queryToggle && queryToggle.checked;
@@ -1062,7 +1100,7 @@ async function updateFrequencyChart(term, channels, queryMode) {
currentPage = page; currentPage = page;
if (pushState) { if (pushState) {
updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode); updateUrl(q, sort, channels, year, page, size, exact, fuzzy, phrase, queryMode);
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -1075,17 +1113,19 @@ async function updateFrequencyChart(term, channels, queryMode) {
params.set("phrase", phrase ? "1" : "0"); params.set("phrase", phrase ? "1" : "0");
params.set("query_string", queryMode ? "1" : "0"); params.set("query_string", queryMode ? "1" : "0");
channels.forEach((id) => params.append("channel_id", id)); channels.forEach((id) => params.append("channel_id", id));
if (year) params.set("year", year);
const res = await fetch(`/api/search?${params.toString()}`); const res = await fetch(`/api/search?${params.toString()}`);
const payload = await res.json(); const payload = await res.json();
renderResults(payload, page); renderResults(payload, page);
updateFrequencyChart(q, channels, queryMode); updateFrequencyChart(q, channels, year, queryMode);
} }
searchBtn.addEventListener("click", () => runSearch(0)); searchBtn.addEventListener("click", () => runSearch(0));
qInput.addEventListener("keypress", (e) => { qInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") runSearch(0); if (e.key === "Enter") runSearch(0);
}); });
yearSel.addEventListener("change", () => runSearch(0));
sortSel.addEventListener("change", () => runSearch(0)); sortSel.addEventListener("change", () => runSearch(0));
sizeSel.addEventListener("change", () => runSearch(0)); sizeSel.addEventListener("change", () => runSearch(0));
exactToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); }); exactToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
@@ -1104,6 +1144,7 @@ window.addEventListener("popstate", () => {
setFromQuery(); setFromQuery();
loadMetrics(); loadMetrics();
loadYears();
loadChannels().then(() => runSearch(currentPage)); loadChannels().then(() => runSearch(currentPage));
})(); })();

View File

@@ -38,11 +38,17 @@
</div> </div>
</details> </details>
<label for="year" style="margin-left: 8px;">Year:</label>
<select id="year">
<option value="">All Years</option>
</select>
<label for="sort" style="margin-left: 8px;">Sort:</label> <label for="sort" style="margin-left: 8px;">Sort:</label>
<select id="sort"> <select id="sort">
<option value="relevant">Most relevant</option> <option value="relevant">Most relevant</option>
<option value="newer">Newest first</option> <option value="newer">Newest first</option>
<option value="older">Oldest first</option> <option value="older">Oldest first</option>
<option value="referenced">Most referenced</option>
</select> </select>
<label for="size" style="margin-left: 8px;">Size:</label> <label for="size" style="margin-left: 8px;">Size:</label>