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 .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
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue