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:
parent
e952ea5f66
commit
d30149db14
3 changed files with 249 additions and 0 deletions
|
|
@ -49,6 +49,7 @@ def create_video_blueprint() -> Blueprint:
|
||||||
from .calendar import register_routes as reg_calendar
|
from .calendar import register_routes as reg_calendar
|
||||||
from .watchlist import register_routes as reg_watchlist
|
from .watchlist import register_routes as reg_watchlist
|
||||||
from .wishlist import register_routes as reg_wishlist
|
from .wishlist import register_routes as reg_wishlist
|
||||||
|
from .youtube import register_routes as reg_youtube
|
||||||
reg_dashboard(bp)
|
reg_dashboard(bp)
|
||||||
reg_scan(bp)
|
reg_scan(bp)
|
||||||
reg_library(bp)
|
reg_library(bp)
|
||||||
|
|
@ -61,5 +62,6 @@ def create_video_blueprint() -> Blueprint:
|
||||||
reg_calendar(bp)
|
reg_calendar(bp)
|
||||||
reg_watchlist(bp)
|
reg_watchlist(bp)
|
||||||
reg_wishlist(bp)
|
reg_wishlist(bp)
|
||||||
|
reg_youtube(bp)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
|
||||||
147
api/video/youtube.py
Normal file
147
api/video/youtube.py
Normal 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
|
||||||
|
|
@ -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
|
assert client.post("/api/video/wishlist/backfill-art").get_json()["success"] is True
|
||||||
season = db.query_wishlist("show")["items"][0]["seasons"][0]
|
season = db.query_wishlist("show")["items"][0]["seasons"][0]
|
||||||
assert season["poster_url"] == "/s1.jpg" and season["episodes"][0]["still_url"] == "/s.jpg"
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue