diff --git a/api/video/__init__.py b/api/video/__init__.py index db7f8bc8..5646fc1f 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -49,6 +49,7 @@ def create_video_blueprint() -> Blueprint: from .calendar import register_routes as reg_calendar from .watchlist import register_routes as reg_watchlist from .wishlist import register_routes as reg_wishlist + from .youtube import register_routes as reg_youtube reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -61,5 +62,6 @@ def create_video_blueprint() -> Blueprint: reg_calendar(bp) reg_watchlist(bp) reg_wishlist(bp) + reg_youtube(bp) return bp diff --git a/api/video/youtube.py b/api/video/youtube.py new file mode 100644 index 00000000..9f88312a --- /dev/null +++ b/api/video/youtube.py @@ -0,0 +1,147 @@ +"""Video YouTube API — follow a channel as a "show", its uploads flow to the +wishlist (visual-first: no downloading yet). + + GET /youtube/resolve?url=... → preview a pasted channel (meta + recent + uploads) + whether it's already followed. + POST /youtube/follow → {url} (or a pre-resolved channel) → follow + + wish its videos. Returns the channel + counts. + POST /youtube/unfollow → {youtube_id} → un-follow (keeps wished videos). + GET /youtube/channels → followed channels (for the watchlist page). + GET /youtube/wishlist → wished videos grouped by channel (+ counts). + POST /youtube/wishlist/remove → {scope: channel|video, source_id}. + +yt-dlp only; reads/writes only video_library.db. +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.youtube") + +# Cap how many recent uploads a follow pulls into the wishlist. Channels can have +# thousands of videos; flat listing is cheap but we don't want to wish them all. +_FOLLOW_LIMIT = 30 +_RESOLVE_LIMIT = 24 + + +def _server(): + try: + from core.video.sources import resolve_video_server + return resolve_video_server() + except Exception: + return None + + +def register_routes(bp): + @bp.route("/youtube/resolve", methods=["GET"]) + def video_youtube_resolve(): + """Preview a pasted channel URL without committing. Returns the channel + + recent uploads, plus ``following`` so the button can hydrate.""" + from . import get_video_db + from core.video import youtube as yt + url = (request.args.get("url") or "").strip() + if not url: + return jsonify({"success": False, "error": "url is required"}), 400 + try: + limit = int(request.args.get("limit") or _RESOLVE_LIMIT) + except (TypeError, ValueError): + limit = _RESOLVE_LIMIT + try: + channel = yt.resolve_channel(url, limit=max(1, min(50, limit))) + if not channel: + return jsonify({"success": False, + "error": "Not a YouTube channel link (paste a channel URL like " + "youtube.com/@handle)"}), 404 + following = bool(get_video_db().channel_watch_state([channel["youtube_id"]])) + return jsonify({"success": True, "channel": channel, "following": following}) + except Exception: + logger.exception("youtube resolve failed for %r", url) + return jsonify({"success": False, "error": "Could not read that channel"}), 500 + + @bp.route("/youtube/follow", methods=["POST"]) + def video_youtube_follow(): + """Follow a channel + wish its recent uploads. Body: {url} (re-resolved) or + {channel: {...}} already resolved (avoids a second yt-dlp call).""" + from . import get_video_db + from core.video import youtube as yt + body = request.get_json(silent=True) or {} + db = get_video_db() + try: + channel = body.get("channel") + if not channel: + url = (body.get("url") or "").strip() + if not url: + return jsonify({"success": False, "error": "url or channel required"}), 400 + channel = yt.resolve_channel(url, limit=_FOLLOW_LIMIT) + if not channel or not channel.get("youtube_id"): + return jsonify({"success": False, "error": "Could not resolve channel"}), 404 + + followed = db.add_channel_to_watchlist(channel) + added = db.add_videos_to_wishlist(channel, channel.get("videos") or [], server_source=_server()) + return jsonify({"success": followed, "following": followed, "added_videos": added, + "channel": {k: channel.get(k) for k in ("youtube_id", "title", "avatar_url")}, + "counts": db.youtube_wishlist_counts()}) + except Exception: + logger.exception("youtube follow failed") + return jsonify({"success": False, "error": "Failed to follow channel"}), 500 + + @bp.route("/youtube/unfollow", methods=["POST"]) + def video_youtube_unfollow(): + """Un-follow a channel. Body: {youtube_id}. Wished videos are left in place.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + cid = (body.get("youtube_id") or "").strip() + if not cid: + return jsonify({"success": False, "error": "youtube_id is required"}), 400 + try: + get_video_db().remove_channel_from_watchlist(cid) + return jsonify({"success": True, "following": False}) + except Exception: + logger.exception("youtube unfollow failed") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/youtube/channels", methods=["GET"]) + def video_youtube_channels(): + """Followed channels (newest first) for the watchlist page.""" + from . import get_video_db + try: + db = get_video_db() + return jsonify({"success": True, "channels": db.list_watchlist_channels(), + "counts": db.youtube_wishlist_counts()}) + except Exception: + logger.exception("youtube channels list failed") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/youtube/wishlist", methods=["GET"]) + def video_youtube_wishlist(): + """Wished videos grouped by channel (channel = group, videos = feed).""" + from . import get_video_db + try: + db = get_video_db() + res = db.query_youtube_wishlist( + search=request.args.get("search", ""), sort=request.args.get("sort", "added"), + page=request.args.get("page", 1), limit=request.args.get("limit", 60)) + return jsonify({"success": True, "counts": db.youtube_wishlist_counts(), **res}) + except Exception: + logger.exception("youtube wishlist list failed") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/youtube/wishlist/remove", methods=["POST"]) + def video_youtube_wishlist_remove(): + """Remove wished videos. Body: {scope: 'channel'|'video', source_id}.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + scope = body.get("scope") + source_id = (body.get("source_id") or "").strip() + if scope not in ("channel", "video") or not source_id: + return jsonify({"success": False, "error": "scope and source_id are required"}), 400 + try: + db = get_video_db() + removed = db.remove_youtube_from_wishlist(scope, source_id) + return jsonify({"success": True, "removed": removed, "counts": db.youtube_wishlist_counts()}) + except Exception: + logger.exception("youtube wishlist remove failed") + return jsonify({"success": False, "error": "Failed"}), 500 diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 8bd2e288..3d2c6fca 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -510,3 +510,103 @@ def test_wishlist_backfill_art_endpoint(tmp_path, monkeypatch): assert client.post("/api/video/wishlist/backfill-art").get_json()["success"] is True season = db.query_wishlist("show")["items"][0]["seasons"][0] assert season["poster_url"] == "/s1.jpg" and season["episodes"][0]["still_url"] == "/s.jpg" + + +# ── YouTube channels ───────────────────────────────────────────────────────── + +def test_youtube_routes_registered(): + from api.video import create_video_blueprint + app = Flask(__name__) + app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") + rules = {r.rule for r in app.url_map.iter_rules()} + for r in ("/api/video/youtube/resolve", "/api/video/youtube/follow", + "/api/video/youtube/unfollow", "/api/video/youtube/channels", + "/api/video/youtube/wishlist", "/api/video/youtube/wishlist/remove"): + assert r in rules + + +_CHANNEL = { + "youtube_id": "UCPlay", "title": "PlayStation", "avatar_url": "http://a/p.jpg", + "handle": "@PlayStation", "videos": [ + {"youtube_id": "v1", "title": "State of Play", "published_at": "2024-06-01", + "thumbnail_url": "http://t/1.jpg", "description": "the latest"}, + {"youtube_id": "v2", "title": "Trailer", "published_at": "2024-01-01"}, + ]} + + +def test_youtube_resolve_previews_without_committing(tmp_path, monkeypatch): + client, videoapi = _make_client(tmp_path) + import core.video.youtube as ytmod + monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=24: dict(_CHANNEL)) + r = client.get("/api/video/youtube/resolve?url=https://youtube.com/@PlayStation") + data = r.get_json() + assert data["success"] is True + assert data["channel"]["youtube_id"] == "UCPlay" + assert data["following"] is False # not committed yet + # nothing was written + assert videoapi._video_db.youtube_wishlist_counts() == {"channel": 0, "video": 0} + + +def test_youtube_resolve_rejects_non_channel(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + import core.video.youtube as ytmod + monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=24: None) + r = client.get("/api/video/youtube/resolve?url=https://youtube.com/watch?v=x") + assert r.status_code == 404 and r.get_json()["success"] is False + + +def test_youtube_follow_then_channels_and_wishlist(tmp_path): + client, _ = _make_client(tmp_path) + # pre-resolved channel in the body → no network/yt-dlp needed + r = client.post("/api/video/youtube/follow", json={"channel": _CHANNEL}) + data = r.get_json() + assert data["success"] is True and data["following"] is True + assert data["added_videos"] == 2 + assert data["counts"] == {"channel": 1, "video": 2} + + # appears on the watchlist channels list with a video count + chans = client.get("/api/video/youtube/channels").get_json() + assert chans["channels"][0]["youtube_id"] == "UCPlay" + assert chans["channels"][0]["video_count"] == 2 + + # appears in the youtube wishlist grouped by channel, newest-first + wl = client.get("/api/video/youtube/wishlist?kind=channel").get_json() + grp = wl["items"][0] + assert grp["youtube_id"] == "UCPlay" and grp["video_count"] == 2 + assert [v["youtube_id"] for v in grp["videos"]] == ["v1", "v2"] + + # resolve now reports following=True (hydration) — stub resolve to avoid network + import core.video.youtube as ytmod + ytmod_resolve = ytmod.resolve_channel + try: + ytmod.resolve_channel = lambda url, limit=24: dict(_CHANNEL) + rr = client.get("/api/video/youtube/resolve?url=@PlayStation").get_json() + assert rr["following"] is True + finally: + ytmod.resolve_channel = ytmod_resolve + + +def test_youtube_unfollow_and_remove_scopes(tmp_path): + client, db_api = _make_client(tmp_path) + client.post("/api/video/youtube/follow", json={"channel": _CHANNEL}) + + # unfollow removes the watchlist row but keeps wished videos + r = client.post("/api/video/youtube/unfollow", json={"youtube_id": "UCPlay"}) + assert r.get_json() == {"success": True, "following": False} + assert client.get("/api/video/youtube/channels").get_json()["channels"] == [] + assert db_api._video_db.youtube_wishlist_counts() == {"channel": 1, "video": 2} + + # remove a single video + r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "v1"}) + assert r.get_json()["removed"] == 1 + assert r.get_json()["counts"] == {"channel": 1, "video": 1} + + # remove the whole channel's videos + r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "channel", "source_id": "UCPlay"}) + assert r.get_json()["counts"] == {"channel": 0, "video": 0} + + +def test_youtube_follow_requires_url_or_channel(tmp_path): + client, _ = _make_client(tmp_path) + r = client.post("/api/video/youtube/follow", json={}) + assert r.status_code == 400