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:
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user