YouTube channels (3/4): API endpoints

api/video/youtube.py wired into the blueprint:
- GET  /youtube/resolve   — preview a pasted channel (meta + recent uploads) +
                            a 'following' hydration flag; no writes.
- POST /youtube/follow    — {url} or pre-resolved {channel} → follow + wish its
                            recent uploads (capped 30). Returns 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.
- POST /youtube/wishlist/remove — scope channel|video.

yt-dlp call lives behind resolve (injectable/mockable). 38 API tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-17 00:38:11 -07:00
parent e952ea5f66
commit d30149db14
3 changed files with 249 additions and 0 deletions

View file

@ -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

147
api/video/youtube.py Normal file
View file

@ -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

View file

@ -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