Search: include YouTube channel results alongside TMDB

A text search now also surfaces matching YouTube channels (not just the paste-a-
link path). search_channels() extracts the channels-filtered YouTube results
page (flat); API GET /youtube/search hydrates a 'following' flag. The search
page fires it in parallel with the TMDB search and appends a 'YouTube channels'
group when it returns (best-effort; never blocks/breaks TMDB results). Cards open
the in-app channel page. 82 youtube+api tests green; balanced; music untouched.
This commit is contained in:
BoulderBadgeDad 2026-06-17 09:06:41 -07:00
parent 0456fa893f
commit 9084f1b7bb
7 changed files with 173 additions and 11 deletions

View file

@ -183,6 +183,25 @@ def register_routes(bp):
logger.exception("youtube video detail failed for %r", video_id)
return jsonify({"success": False, "error": "Could not load video"}), 500
@bp.route("/youtube/search", methods=["GET"])
def video_youtube_search():
"""Channel search results for the search page (alongside TMDB), each with a
followed flag so the card can hydrate. Best-effort."""
from . import get_video_db
from core.video import youtube as yt
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"success": True, "channels": []})
try:
chans = yt.search_channels(q)
following = get_video_db().channel_watch_state([c["youtube_id"] for c in chans])
for c in chans:
c["following"] = c["youtube_id"] in following
return jsonify({"success": True, "channels": chans})
except Exception:
logger.exception("youtube search failed for %r", q)
return jsonify({"success": False, "channels": []})
@bp.route("/youtube/playlists/<channel_id>", methods=["GET"])
def video_youtube_playlists(channel_id):
"""The channel's playlists (rendered as 'seasons' on the channel page)."""

View file

@ -250,6 +250,45 @@ def resolve_channel(raw, limit=30, ydl_factory=None, db=None):
return shaped if shaped.get("youtube_id") else None
def search_channels(query, limit=6, ydl_factory=None):
"""Search YouTube for CHANNELS (the results page filtered to channels) → a few
{youtube_id, title, handle, avatar_url, subscriber_count} for the search page.
Best-effort; entries that aren't channels are skipped."""
from urllib.parse import quote
q = (query or "").strip()
if not q:
return []
# sp=EgIQAg%3D%3D is YouTube's "Type: Channel" search filter.
url = "https://www.youtube.com/results?search_query=" + quote(q) + "&sp=EgIQAg%3D%3D"
info = _extract(url, _ydl_opts(limit * 3), ydl_factory) # over-fetch; some entries may be videos
out = []
for e in [x for x in ((info or {}).get("entries") or []) if isinstance(x, dict)]:
cid = e.get("channel_id")
if not cid and str(e.get("id", "")).startswith("UC"):
cid = e.get("id")
if not cid:
m = re.search(r"/channel/(UC[\w-]+)", e.get("url") or "")
if m:
cid = m.group(1)
if not cid or not str(cid).startswith("UC"):
continue
title = e.get("channel") or e.get("title") or e.get("uploader") or ""
if not title:
continue
uid = e.get("uploader_id")
out.append({
"youtube_id": cid,
"title": title,
"handle": uid if str(uid or "").startswith("@") else None,
"avatar_url": _best_thumb(e.get("thumbnails")),
"subscriber_count": e.get("channel_follower_count"),
"video_count": e.get("playlist_count"),
})
if len(out) >= limit:
break
return out
def channel_playlists(channel_id, limit=50, ydl_factory=None):
"""The channel's playlists (flat) → [{playlist_id, title, video_count,
thumbnail_url}] rendered as "seasons" on the channel page. Lazy-loaded."""

View file

@ -701,3 +701,15 @@ def test_youtube_playlists_and_playlist_videos(tmp_path, monkeypatch):
pv = client.get("/api/video/youtube/playlist/PL1").get_json()
wished = {v["youtube_id"]: v["wished"] for v in pv["videos"]}
assert wished == {"a": True, "b": False}
def test_youtube_search_endpoint_hydrates_following(tmp_path, monkeypatch):
client, videoapi = _make_client(tmp_path)
import core.video.youtube as ytmod
monkeypatch.setattr(ytmod, "search_channels", lambda q: [
{"youtube_id": "UCaaa", "title": "GMM"}, {"youtube_id": "UCbbb", "title": "Other"}])
videoapi._video_db.add_channel_to_watchlist({"youtube_id": "UCaaa", "title": "GMM"})
d = client.get("/api/video/youtube/search?q=gmm").get_json()
flags = {c["youtube_id"]: c["following"] for c in d["channels"]}
assert flags == {"UCaaa": True, "UCbbb": False}
assert client.get("/api/video/youtube/search?q=").get_json()["channels"] == []

View file

@ -285,3 +285,34 @@ def test_playlist_videos_reuses_entry_shape():
def test_channel_playlists_empty_on_no_id():
assert yt.channel_playlists("", ydl_factory=_PlaylistsYDL) == []
assert yt.playlist_videos("", ydl_factory=_FakeYDL) == []
# ── search_channels (channel results for the search page) ────────────────────
class _SearchYDL:
def __init__(self, opts): pass
def __enter__(self): return self
def __exit__(self, *a): return False
def extract_info(self, url, download=False):
return {"entries": [
{"channel_id": "UCaaa", "channel": "Good Mythical Morning", "uploader_id": "@goodmythicalmorning",
"channel_follower_count": 18_000_000,
"thumbnails": [{"url": "http://a/gmm.jpg", "width": 800, "height": 800}]},
{"id": "UCbbb", "title": "Rhett & Link", "thumbnails": []}, # id is the channel id
{"url": "https://www.youtube.com/channel/UCccc", "title": "Mythical"}, # id from url
{"id": "dQw4", "title": "a video, not a channel"}, # skipped (not UC)
None,
]}
def test_search_channels_extracts_channel_results():
out = yt.search_channels("good mythical morning", ydl_factory=_SearchYDL)
assert [c["youtube_id"] for c in out] == ["UCaaa", "UCbbb", "UCccc"]
assert out[0]["title"] == "Good Mythical Morning"
assert out[0]["handle"] == "@goodmythicalmorning"
assert out[0]["subscriber_count"] == 18_000_000
assert out[0]["avatar_url"] == "http://a/gmm.jpg"
def test_search_channels_empty_query():
assert yt.search_channels(" ", ydl_factory=_SearchYDL) == []

View file

@ -78,17 +78,14 @@
esc(it.title) + '</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
}
function render(results) {
// TMDB groups (movies/shows/people) + a YouTube channels group, each painted
// as soon as its source resolves (they're fetched in parallel).
function render(results, ytChannels) {
var host = $('[data-video-search-results]');
if (!host) return;
var any = results && results.length;
show('[data-video-search-hint]', false);
show('[data-video-search-empty]', !any);
if (!any) { host.innerHTML = ''; return; }
var html = '';
GROUPS.forEach(function (g) {
var items = results.filter(function (r) { return r.kind === g.kind; });
var items = (results || []).filter(function (r) { return r.kind === g.kind; });
if (!items.length) return;
html += '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">' + g.icon + '</span>' + g.label +
@ -97,8 +94,19 @@
items.map(g.kind === 'person' ? personCard : titleCard).join('') +
'</div></div>';
});
host.innerHTML = html;
if (window.VideoWatchlist) VideoWatchlist.hydrate(host);
if (ytChannels && ytChannels.length && window.VideoYoutube) {
html += '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channels' +
'<span class="vsr-group-count">' + ytChannels.length + '</span></h2>' +
'<div class="vsr-grid vyt-result-grid">' +
ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') +
'</div></div>';
}
var any = html.length > 0;
show('[data-video-search-hint]', false);
show('[data-video-search-empty]', !any);
host.innerHTML = any ? html : '';
if (any && window.VideoWatchlist) VideoWatchlist.hydrate(host);
}
// Idle state: a "Trending this week" rail so the page isn't a blank box.
@ -130,21 +138,35 @@
loadTrending();
}
var curResults = null, curYt = null; // TMDB + YouTube halves of the active query
function paint(seq) {
if (seq !== reqSeq) return;
render(curResults || [], curYt || []);
}
function runSearch(q) {
var seq = ++reqSeq;
curResults = null; curYt = null;
show('[data-video-search-loading]', true);
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (seq !== reqSeq) return; // a newer query superseded this one
show('[data-video-search-loading]', false);
render(d && d.results ? d.results : []);
curResults = (d && d.results) ? d.results : [];
paint(seq);
})
.catch(function () {
if (seq !== reqSeq) return;
show('[data-video-search-loading]', false);
render([]);
curResults = [];
paint(seq);
});
// YouTube channels in parallel (best-effort) — appended as their own group.
if (window.VideoYoutube && q.length >= 2) {
VideoYoutube.searchChannels(q)
.then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; paint(seq); })
.catch(function () { if (seq !== reqSeq) return; curYt = []; paint(seq); });
}
}
// A pasted YouTube channel link → resolve + render a Follow chip instead of
@ -239,6 +261,13 @@
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var fb = e.target.closest('[data-vyt-follow]');
if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; }
var ytc = e.target.closest('[data-vyt-open-channel]');
if (ytc && results.contains(ytc)) {
e.preventDefault();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: 'channel', source: 'youtube', id: ytc.getAttribute('data-vyt-open-channel') } }));
return;
}
var card = e.target.closest('[data-vsr-open]');
if (!card || !results.contains(card)) return;
e.preventDefault();

View file

@ -2919,3 +2919,17 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vc-pl-vids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 6px 14px 16px; }
.vc-pl--collapsed .vc-pl-vids { display: none; }
.vc-pl-loading { padding: 14px; color: rgba(255,255,255,0.5); font-size: 13px; }
/* YouTube channel cards in the search results grid */
.vyt-result-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.vyt-result { display: flex; flex-direction: column; align-items: center; text-align: center; text-decoration: none;
padding: 14px 8px; border-radius: 14px; transition: background 0.15s ease; }
.vyt-result:hover { background: rgba(255, 59, 59, 0.07); }
.vyt-result-art { width: 92px; height: 92px; border-radius: 50%; overflow: hidden; border: 2px solid rgba(255,59,59,0.4);
display: flex; align-items: center; justify-content: center; background: #16161d; }
.vyt-result-avatar { width: 100%; height: 100%; object-fit: cover; font-size: 26px; }
.vyt-result-info { margin-top: 10px; display: flex; flex-direction: column; align-items: center; gap: 2px; max-width: 100%; }
.vyt-result-badge { font-size: 9.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #ff6b6b; }
.vyt-result-title { font-size: 13px; font-weight: 800; color: #fff; line-height: 1.2; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vyt-result-sub { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.42); }

View file

@ -129,6 +129,23 @@
return '' + n;
}
function searchChannels(q) {
return fetch('/api/video/youtube/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; });
}
// A compact channel card for the search results grid (→ opens the channel page).
function channelResultCard(ch) {
var sub = ch.subscriber_count ? compactCount(ch.subscriber_count) + ' subscribers' : (ch.handle ? esc(ch.handle) : '');
return '<a class="vyt-result" href="#" data-vyt-open-channel="' + esc(ch.youtube_id) + '">' +
'<span class="vyt-result-art">' + avatar(ch, 'vyt-result-avatar') + '</span>' +
'<span class="vyt-result-info">' +
'<span class="vyt-result-badge">YouTube</span>' +
'<span class="vyt-result-title" title="' + esc(ch.title) + '">' + esc(ch.title) + '</span>' +
(sub ? '<span class="vyt-result-sub">' + sub + '</span>' : '') +
'</span></a>';
}
function resolve(ref) {
return fetch('/api/video/youtube/resolve?url=' + encodeURIComponent(ref),
{ headers: { Accept: 'application/json' } }).then(function (r) { return r.ok ? r.json() : (r.status === 404 ? r.json() : null); });
@ -139,5 +156,6 @@
fmtDuration: fmtDuration, compactCount: compactCount,
videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve,
searchChannels: searchChannels, channelResultCard: channelResultCard,
};
})();