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:
parent
0456fa893f
commit
9084f1b7bb
7 changed files with 173 additions and 11 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue