From 9084f1b7bb9d6b3eb1081a01d2630709c273999d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 09:06:41 -0700 Subject: [PATCH] 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. --- api/video/youtube.py | 19 +++++++++++ core/video/youtube.py | 39 ++++++++++++++++++++++ tests/test_video_api.py | 12 +++++++ tests/test_video_youtube.py | 31 ++++++++++++++++++ webui/static/video/video-search.js | 51 ++++++++++++++++++++++------- webui/static/video/video-side.css | 14 ++++++++ webui/static/video/video-youtube.js | 18 ++++++++++ 7 files changed, 173 insertions(+), 11 deletions(-) diff --git a/api/video/youtube.py b/api/video/youtube.py index e50195ce..6c805dbe 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -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/", methods=["GET"]) def video_youtube_playlists(channel_id): """The channel's playlists (rendered as 'seasons' on the channel page).""" diff --git a/core/video/youtube.py b/core/video/youtube.py index 1217b67b..71a21082 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -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.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 9bd9c824..d3e16c7a 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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"] == [] diff --git a/tests/test_video_youtube.py b/tests/test_video_youtube.py index b9f64f6d..41421135 100644 --- a/tests/test_video_youtube.py +++ b/tests/test_video_youtube.py @@ -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) == [] diff --git a/webui/static/video/video-search.js b/webui/static/video/video-search.js index 505ed236..0c5fc4f3 100644 --- a/webui/static/video/video-search.js +++ b/webui/static/video/video-search.js @@ -78,17 +78,14 @@ esc(it.title) + '' + esc(sub) + ''; } - 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 += '

' + '' + g.label + @@ -97,8 +94,19 @@ items.map(g.kind === 'person' ? personCard : titleCard).join('') + '

'; }); - host.innerHTML = html; - if (window.VideoWatchlist) VideoWatchlist.hydrate(host); + if (ytChannels && ytChannels.length && window.VideoYoutube) { + html += '

' + + 'YouTube channels' + + '' + ytChannels.length + '

' + + '
' + + ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') + + '
'; + } + 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(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 9408d9e8..a53a27fd 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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); } diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index 19dfabef..87f8dcef 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -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 '' + + '' + avatar(ch, 'vyt-result-avatar') + '' + + '' + + 'YouTube' + + '' + esc(ch.title) + '' + + (sub ? '' + sub + '' : '') + + ''; + } + 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, }; })();