YouTube channels (2/4): schema bridge + DB methods
Bridges YouTube onto the existing watchlist/wishlist tables (the chosen approach) via a generic source/source_id (+ parent_source_id on wishlist) and a stable surrogate for the NOT NULL tmdb_id, so existing dedup/group-by machinery is untouched. SCHEMA_VERSION 13, additive column migrations. - Channel follow = video_watchlist kind='channel' (source='youtube'). - Wished video = video_wishlist kind='video' grouped by parent channel. - youtube_surrogate_id(), add/remove/list/hydrate channels, add_videos_to_wishlist, query_youtube_wishlist (channel=group, videos=newest-first feed), youtube_wishlist_counts, scoped removal. - Kept wishlist_counts/watchlist_counts byte-identical (exact-equality tests); YouTube counts live on their own method. 80 DB tests green.
This commit is contained in:
parent
68383a16b3
commit
e952ea5f66
3 changed files with 342 additions and 15 deletions
|
|
@ -16,6 +16,7 @@ verbatim on first init.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
|
@ -29,7 +30,7 @@ logger = get_logger("video_database")
|
|||
|
||||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
||||
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
|
||||
SCHEMA_VERSION = 12
|
||||
SCHEMA_VERSION = 13
|
||||
|
||||
_DEFAULT_DB_PATH = "database/video_library.db"
|
||||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
||||
|
|
@ -102,9 +103,24 @@ _COLUMN_MIGRATIONS = [
|
|||
("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time)
|
||||
("video_wishlist", "season_poster_url", "TEXT"), # the episode's season poster
|
||||
("video_wishlist", "episode_overview", "TEXT"), # episode synopsis
|
||||
# generic source bridge (YouTube channels/videos ride the existing tables)
|
||||
("video_watchlist", "source", "TEXT NOT NULL DEFAULT 'tmdb'"),
|
||||
("video_watchlist", "source_id", "TEXT"),
|
||||
("video_wishlist", "source", "TEXT NOT NULL DEFAULT 'tmdb'"),
|
||||
("video_wishlist", "source_id", "TEXT"),
|
||||
("video_wishlist", "parent_source_id", "TEXT"), # owning channel youtube id (video rows)
|
||||
]
|
||||
|
||||
|
||||
def youtube_surrogate_id(source_id: str) -> int:
|
||||
"""A stable positive 60-bit int derived from a YouTube id, used as the
|
||||
NOT NULL ``tmdb_id`` surrogate for non-tmdb rows so the existing
|
||||
UNIQUE(kind, tmdb_id) dedup + group-by machinery keeps working unchanged.
|
||||
Collision probability across realistic channel counts is negligible."""
|
||||
h = hashlib.sha1((source_id or "").encode("utf-8")).hexdigest()
|
||||
return int(h[:15], 16) # 60 bits — comfortably inside SQLite's signed 64-bit INTEGER
|
||||
|
||||
|
||||
class VideoDatabase:
|
||||
"""Connection + schema manager for the isolated video library DB."""
|
||||
|
||||
|
|
@ -1763,6 +1779,205 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
# ── YouTube channels (bridged onto the watchlist/wishlist tables) ─────────
|
||||
# A followed CHANNEL is a video_watchlist row (kind='channel', source='youtube',
|
||||
# source_id=channel id). Its wished VIDEOS are video_wishlist rows
|
||||
# (kind='video', source_id=video id, parent_source_id=channel id). tmdb_id on
|
||||
# both carries the channel's surrogate so existing dedup/grouping just works.
|
||||
def add_channel_to_watchlist(self, channel: dict) -> bool:
|
||||
"""Follow a YouTube channel. ``channel`` = {youtube_id, title, avatar_url?}.
|
||||
Idempotent upsert on the channel surrogate. Returns True on success."""
|
||||
cid = (channel or {}).get("youtube_id")
|
||||
title = (channel or {}).get("title")
|
||||
if not cid or not title:
|
||||
return False
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, source, source_id, state)
|
||||
VALUES ('channel', ?, ?, ?, 'youtube', ?, 'follow')
|
||||
ON CONFLICT(kind, tmdb_id) DO UPDATE SET
|
||||
state='follow', title=excluded.title,
|
||||
poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url),
|
||||
source='youtube', source_id=excluded.source_id""",
|
||||
(youtube_surrogate_id(cid), title, channel.get("avatar_url"), cid))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("add_channel_to_watchlist failed (%s)", cid)
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def remove_channel_from_watchlist(self, youtube_id: str) -> bool:
|
||||
"""Un-follow a channel (hard delete — channels have no airing-default to
|
||||
guard against, so no tombstone). Its already-wished videos are left alone."""
|
||||
if not youtube_id:
|
||||
return False
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM video_watchlist WHERE kind='channel' AND source_id=?", (youtube_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_watchlist_channels(self) -> list[dict]:
|
||||
"""Followed channels (newest first), each with a wished-video count for the card."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT w.title, w.poster_url, w.source_id, w.date_added, "
|
||||
"(SELECT COUNT(*) FROM video_wishlist v WHERE v.kind='video' "
|
||||
" AND v.parent_source_id = w.source_id) AS video_count "
|
||||
"FROM video_watchlist w WHERE w.kind='channel' AND w.state='follow' "
|
||||
"ORDER BY w.date_added DESC, w.id DESC").fetchall()
|
||||
return [{"kind": "channel", "youtube_id": r["source_id"], "title": r["title"],
|
||||
"poster_url": r["poster_url"], "video_count": r["video_count"],
|
||||
"date_added": r["date_added"]} for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def channel_watch_state(self, youtube_ids) -> dict:
|
||||
"""{youtube_id: True} for followed channels — hydrates the Follow button."""
|
||||
out: dict = {}
|
||||
ids = [str(x) for x in (youtube_ids or []) if x]
|
||||
if not ids:
|
||||
return out
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
for i in range(0, len(ids), 400):
|
||||
chunk = ids[i:i + 400]
|
||||
ph = ",".join("?" * len(chunk))
|
||||
for r in conn.execute(
|
||||
f"SELECT source_id FROM video_watchlist WHERE kind='channel' "
|
||||
f"AND state='follow' AND source_id IN ({ph})", chunk):
|
||||
out[r["source_id"]] = True
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_videos_to_wishlist(self, channel: dict, videos: list, *, server_source=None) -> int:
|
||||
"""Wish for a channel's videos. ``channel`` = {youtube_id, title, avatar_url?};
|
||||
``videos`` = [{youtube_id, title, published_at?, thumbnail_url?, description?}, …].
|
||||
Idempotent per video id. Returns the count written."""
|
||||
cid = (channel or {}).get("youtube_id")
|
||||
ctitle = (channel or {}).get("title")
|
||||
if not cid or not ctitle or not videos:
|
||||
return 0
|
||||
avatar = (channel or {}).get("avatar_url")
|
||||
surrogate = youtube_surrogate_id(cid)
|
||||
conn = self._get_connection()
|
||||
n = 0
|
||||
try:
|
||||
for v in videos:
|
||||
vid = v.get("youtube_id")
|
||||
if not vid:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO video_wishlist
|
||||
(kind, tmdb_id, title, poster_url, episode_title, still_url,
|
||||
episode_overview, air_date, source, source_id, parent_source_id, server_source)
|
||||
VALUES ('video', ?, ?, ?, ?, ?, ?, ?, 'youtube', ?, ?, ?)
|
||||
ON CONFLICT(source_id) WHERE kind='video' DO UPDATE SET
|
||||
title=excluded.title,
|
||||
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
|
||||
episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title),
|
||||
still_url=COALESCE(excluded.still_url, video_wishlist.still_url),
|
||||
episode_overview=COALESCE(excluded.episode_overview, video_wishlist.episode_overview),
|
||||
air_date=COALESCE(excluded.air_date, video_wishlist.air_date)""",
|
||||
(surrogate, ctitle, avatar, v.get("title"), v.get("thumbnail_url"),
|
||||
v.get("description"), v.get("published_at"), vid, cid, server_source))
|
||||
n += 1
|
||||
conn.commit()
|
||||
return n
|
||||
except Exception:
|
||||
logger.exception("add_videos_to_wishlist failed (%s)", cid)
|
||||
conn.rollback()
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def remove_youtube_from_wishlist(self, scope: str, source_id: str) -> int:
|
||||
"""Remove wished videos: scope 'channel' (all of a channel, source_id=channel
|
||||
id) or 'video' (one, source_id=video id). Returns rows removed."""
|
||||
if not source_id:
|
||||
return 0
|
||||
if scope == "channel":
|
||||
sql = "DELETE FROM video_wishlist WHERE kind='video' AND parent_source_id=?"
|
||||
elif scope == "video":
|
||||
sql = "DELETE FROM video_wishlist WHERE kind='video' AND source_id=?"
|
||||
else:
|
||||
return 0
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cur = conn.execute(sql, (source_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def youtube_wishlist_counts(self) -> dict:
|
||||
"""{'channel': n distinct channels, 'video': n videos} in the wishlist."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
video = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='video'").fetchone()["c"]
|
||||
channel = conn.execute(
|
||||
"SELECT COUNT(DISTINCT parent_source_id) c FROM video_wishlist WHERE kind='video'").fetchone()["c"]
|
||||
return {"channel": channel, "video": video}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def query_youtube_wishlist(self, *, search=None, sort="added", page=1, limit=60) -> dict:
|
||||
"""Wished YouTube videos grouped by channel (channel = orb, videos = flat
|
||||
newest-first feed). ``sort`` ∈ added | oldest | title | wanted. Mirrors the
|
||||
{items, pagination} shape of query_wishlist."""
|
||||
try:
|
||||
page = max(1, int(page or 1))
|
||||
limit = max(1, min(200, int(limit or 60)))
|
||||
except (TypeError, ValueError):
|
||||
page, limit = 1, 60
|
||||
s = (search or "").strip()
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
where, args = ["kind='video'"], []
|
||||
if s:
|
||||
where.append("(title LIKE ? COLLATE NOCASE OR episode_title LIKE ? COLLATE NOCASE)")
|
||||
args += ["%" + s + "%", "%" + s + "%"]
|
||||
wsql = " WHERE " + " AND ".join(where)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(DISTINCT parent_source_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||||
order = {"title": "title COLLATE NOCASE", "wanted": "video_count DESC, last_added DESC",
|
||||
"oldest": "last_added ASC", "added": "last_added DESC"}.get(sort, "last_added DESC")
|
||||
chan_rows = conn.execute(
|
||||
"SELECT parent_source_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
|
||||
"COUNT(*) AS video_count, "
|
||||
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
|
||||
"MAX(date_added) AS last_added "
|
||||
"FROM video_wishlist" + wsql +
|
||||
" GROUP BY parent_source_id ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||||
args + [limit, (page - 1) * limit]).fetchall()
|
||||
items = []
|
||||
for cr in chan_rows:
|
||||
vids = conn.execute(
|
||||
"SELECT source_id, episode_title, still_url, episode_overview, air_date, status "
|
||||
"FROM video_wishlist WHERE kind='video' AND parent_source_id=? "
|
||||
"ORDER BY (air_date IS NULL), air_date DESC, id DESC", (cr["parent_source_id"],)).fetchall()
|
||||
items.append({
|
||||
"kind": "channel", "youtube_id": cr["parent_source_id"], "title": cr["title"],
|
||||
"poster_url": cr["poster_url"], "video_count": cr["video_count"],
|
||||
"done": cr["done"] or 0,
|
||||
"videos": [{"youtube_id": v["source_id"], "title": v["episode_title"],
|
||||
"still_url": v["still_url"], "overview": v["episode_overview"],
|
||||
"published_at": v["air_date"], "status": v["status"]} for v in vids]})
|
||||
finally:
|
||||
conn.close()
|
||||
total_pages = max(1, (total + limit - 1) // limit)
|
||||
page = min(page, total_pages)
|
||||
return {"items": items, "pagination": {
|
||||
"page": page, "total_pages": total_pages, "total_count": total,
|
||||
"has_prev": page > 1, "has_next": page < total_pages}}
|
||||
|
||||
def movie_detail(self, movie_id: int) -> dict | None:
|
||||
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
|
||||
video movie-detail page."""
|
||||
|
|
|
|||
|
|
@ -338,11 +338,15 @@ CREATE INDEX IF NOT EXISTS idx_activity_created ON activity(created_at);
|
|||
-- later phase — this table just records membership + enough to render + link.
|
||||
CREATE TABLE IF NOT EXISTS video_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- 'show' | 'person'
|
||||
tmdb_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, -- show title / person name
|
||||
poster_url TEXT, -- poster (show) / photo (person)
|
||||
kind TEXT NOT NULL, -- 'show' | 'person' | 'channel' (youtube)
|
||||
tmdb_id INTEGER NOT NULL, -- tmdb id; for non-tmdb sources a stable surrogate of source_id
|
||||
title TEXT NOT NULL, -- show title / person name / channel title
|
||||
poster_url TEXT, -- poster (show) / photo (person) / avatar (channel)
|
||||
library_id INTEGER, -- shows.id when owned (else NULL)
|
||||
-- generic source bridge: 'tmdb' (default) or 'youtube'; source_id = native id
|
||||
-- (channel youtube id) for non-tmdb rows. One table, both worlds.
|
||||
source TEXT NOT NULL DEFAULT 'tmdb',
|
||||
source_id TEXT,
|
||||
-- 'follow' = explicit user follow. 'mute' = a TOMBSTONE: the user
|
||||
-- un-followed something that is on the watchlist by default (an actively
|
||||
-- airing library show), so the default must not re-add it. Library shows
|
||||
|
|
@ -359,30 +363,39 @@ CREATE INDEX IF NOT EXISTS idx_video_watchlist_kind ON video_watchlist(kind);
|
|||
-- so the wishlist only ever holds things you can actually acquire right now.
|
||||
CREATE TABLE IF NOT EXISTS video_wishlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- 'movie' | 'episode'
|
||||
tmdb_id INTEGER NOT NULL, -- movie's tmdb id | the SHOW's tmdb id (episode rows)
|
||||
title TEXT NOT NULL, -- movie title | show title
|
||||
poster_url TEXT, -- movie/show poster
|
||||
kind TEXT NOT NULL, -- 'movie' | 'episode' | 'video' (youtube)
|
||||
tmdb_id INTEGER NOT NULL, -- movie's tmdb id | the SHOW's tmdb id (episode) | channel surrogate (video)
|
||||
title TEXT NOT NULL, -- movie title | show title | channel title (video rows)
|
||||
poster_url TEXT, -- movie/show poster | channel avatar (video rows)
|
||||
year INTEGER, -- movie year (movie rows)
|
||||
season_number INTEGER, -- episode rows
|
||||
episode_number INTEGER, -- episode rows
|
||||
episode_title TEXT, -- episode rows
|
||||
still_url TEXT, -- episode still thumbnail (episode rows)
|
||||
episode_overview TEXT, -- episode synopsis (episode rows)
|
||||
episode_title TEXT, -- episode rows | video title (video rows)
|
||||
still_url TEXT, -- episode still | video thumbnail (video rows)
|
||||
episode_overview TEXT, -- episode synopsis | video description (video rows)
|
||||
season_poster_url TEXT, -- the episode's SEASON poster (episode rows)
|
||||
air_date TEXT, -- episode rows (already aired by the time it's here)
|
||||
air_date TEXT, -- episode air date | video published_at (video rows)
|
||||
status TEXT NOT NULL DEFAULT 'wanted', -- wanted|searching|downloading|downloaded|failed
|
||||
library_id INTEGER, -- owned movies.id/shows.id when re-downloading
|
||||
server_source TEXT, -- server context that added it (informational)
|
||||
-- generic source bridge (mirrors video_watchlist). For 'video' rows:
|
||||
-- source='youtube', source_id=video youtube id, parent_source_id=channel youtube id.
|
||||
source TEXT NOT NULL DEFAULT 'tmdb',
|
||||
source_id TEXT,
|
||||
parent_source_id TEXT, -- owning channel's youtube id (video rows)
|
||||
date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- one row per movie, one per (show, season, episode) — partial uniques so the two
|
||||
-- shapes don't collide and re-adding is an idempotent upsert.
|
||||
-- one row per movie, one per (show, season, episode), one per youtube video —
|
||||
-- partial uniques so the shapes don't collide and re-adding is an idempotent upsert.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_movie
|
||||
ON video_wishlist(tmdb_id) WHERE kind = 'movie';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_episode
|
||||
ON video_wishlist(tmdb_id, season_number, episode_number) WHERE kind = 'episode';
|
||||
CREATE INDEX IF NOT EXISTS idx_video_wishlist_show ON video_wishlist(tmdb_id) WHERE kind = 'episode';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_video
|
||||
ON video_wishlist(source_id) WHERE kind = 'video';
|
||||
CREATE INDEX IF NOT EXISTS idx_video_wishlist_channel
|
||||
ON video_wishlist(parent_source_id) WHERE kind = 'video';
|
||||
|
||||
-- ── Derived views: Watchlist / Wishlist / Calendar ──────────────────────────
|
||||
-- WATCHLIST = things you follow for NEW content: monitored shows + channels.
|
||||
|
|
|
|||
|
|
@ -989,3 +989,102 @@ def test_wishlist_episode_overview_roundtrips_and_backfills(db):
|
|||
assert (1396, 1) in {(t["tmdb_id"], t["season_number"]) for t in db.wishlist_art_backfill_targets()}
|
||||
assert db.set_wishlist_episode_overview(1396, 1, 2, "Filled in.") is True
|
||||
assert db.set_wishlist_episode_overview(1396, 1, 1, "nope") is False # won't clobber
|
||||
|
||||
|
||||
# ── YouTube channels (bridged onto watchlist/wishlist) ───────────────────────
|
||||
|
||||
from database.video_database import youtube_surrogate_id
|
||||
|
||||
|
||||
def test_youtube_surrogate_id_is_stable_and_distinct():
|
||||
a = youtube_surrogate_id("UCPlayStation")
|
||||
assert a == youtube_surrogate_id("UCPlayStation") # deterministic
|
||||
assert a > 0 and a < (1 << 63) # fits SQLite INTEGER
|
||||
assert a != youtube_surrogate_id("UCGoodMythical") # distinct ids → distinct
|
||||
|
||||
|
||||
def test_follow_channel_lists_and_hydrates(db):
|
||||
ch = {"youtube_id": "UCPlay", "title": "PlayStation", "avatar_url": "http://a/p.jpg"}
|
||||
assert db.add_channel_to_watchlist(ch) is True
|
||||
chans = db.list_watchlist_channels()
|
||||
assert len(chans) == 1
|
||||
assert chans[0]["youtube_id"] == "UCPlay" and chans[0]["title"] == "PlayStation"
|
||||
assert chans[0]["poster_url"] == "http://a/p.jpg" and chans[0]["video_count"] == 0
|
||||
# hydration
|
||||
assert db.channel_watch_state(["UCPlay", "UCnope"]) == {"UCPlay": True}
|
||||
# idempotent re-follow refreshes title, no duplicate
|
||||
db.add_channel_to_watchlist({"youtube_id": "UCPlay", "title": "PlayStation US"})
|
||||
chans = db.list_watchlist_channels()
|
||||
assert len(chans) == 1 and chans[0]["title"] == "PlayStation US"
|
||||
|
||||
|
||||
def test_unfollow_channel_removes_row(db):
|
||||
db.add_channel_to_watchlist({"youtube_id": "UCPlay", "title": "PlayStation"})
|
||||
assert db.remove_channel_from_watchlist("UCPlay") is True
|
||||
assert db.list_watchlist_channels() == []
|
||||
assert db.channel_watch_state(["UCPlay"]) == {}
|
||||
|
||||
|
||||
def test_add_videos_groups_under_channel_newest_first(db):
|
||||
ch = {"youtube_id": "UCPlay", "title": "PlayStation", "avatar_url": "http://a/p.jpg"}
|
||||
vids = [
|
||||
{"youtube_id": "v1", "title": "Old Trailer", "published_at": "2023-01-01",
|
||||
"thumbnail_url": "http://t/1.jpg", "description": "older"},
|
||||
{"youtube_id": "v2", "title": "New State of Play", "published_at": "2024-06-01",
|
||||
"thumbnail_url": "http://t/2.jpg", "description": "newer"},
|
||||
{"youtube_id": "v3", "title": "Undated", "thumbnail_url": "http://t/3.jpg"},
|
||||
]
|
||||
assert db.add_videos_to_wishlist(ch, vids) == 3
|
||||
res = db.query_youtube_wishlist()
|
||||
assert res["pagination"]["total_count"] == 1
|
||||
grp = res["items"][0]
|
||||
assert grp["youtube_id"] == "UCPlay" and grp["title"] == "PlayStation"
|
||||
assert grp["poster_url"] == "http://a/p.jpg" and grp["video_count"] == 3
|
||||
# newest-first; dated before undated
|
||||
assert [v["youtube_id"] for v in grp["videos"]] == ["v2", "v1", "v3"]
|
||||
v2 = grp["videos"][0]
|
||||
assert v2["title"] == "New State of Play" and v2["still_url"] == "http://t/2.jpg"
|
||||
assert v2["overview"] == "newer" and v2["published_at"] == "2024-06-01"
|
||||
assert v2["status"] == "wanted"
|
||||
|
||||
|
||||
def test_add_videos_is_idempotent_per_video(db):
|
||||
ch = {"youtube_id": "UCPlay", "title": "PlayStation"}
|
||||
db.add_videos_to_wishlist(ch, [{"youtube_id": "v1", "title": "A"}])
|
||||
db.add_videos_to_wishlist(ch, [{"youtube_id": "v1", "title": "A (updated)"},
|
||||
{"youtube_id": "v2", "title": "B"}])
|
||||
grp = db.query_youtube_wishlist()["items"][0]
|
||||
assert grp["video_count"] == 2
|
||||
titles = {v["youtube_id"]: v["title"] for v in grp["videos"]}
|
||||
assert titles == {"v1": "A (updated)", "v2": "B"}
|
||||
|
||||
|
||||
def test_youtube_counts_and_removal_scopes(db):
|
||||
a = {"youtube_id": "UCa", "title": "Chan A"}
|
||||
b = {"youtube_id": "UCb", "title": "Chan B"}
|
||||
db.add_videos_to_wishlist(a, [{"youtube_id": "a1", "title": "A1"},
|
||||
{"youtube_id": "a2", "title": "A2"}])
|
||||
db.add_videos_to_wishlist(b, [{"youtube_id": "b1", "title": "B1"}])
|
||||
assert db.youtube_wishlist_counts() == {"channel": 2, "video": 3}
|
||||
# remove one video
|
||||
assert db.remove_youtube_from_wishlist("video", "a1") == 1
|
||||
assert db.youtube_wishlist_counts() == {"channel": 2, "video": 2}
|
||||
# remove a whole channel
|
||||
assert db.remove_youtube_from_wishlist("channel", "UCa") == 1 # only a2 left
|
||||
assert db.youtube_wishlist_counts() == {"channel": 1, "video": 1}
|
||||
|
||||
|
||||
def test_youtube_rows_do_not_disturb_tmdb_counts(db):
|
||||
"""The bridge must not leak into the existing movie/episode + show/person counts."""
|
||||
db.add_movie_to_wishlist(101, "A Movie", year=2020)
|
||||
db.add_episodes_to_wishlist(202, "A Show", [{"season_number": 1, "episode_number": 1}])
|
||||
db.add_to_watchlist("show", 303, "Watched Show")
|
||||
db.add_channel_to_watchlist({"youtube_id": "UCx", "title": "Chan"})
|
||||
db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "Chan"},
|
||||
[{"youtube_id": "x1", "title": "X1"}])
|
||||
# existing shapes unchanged
|
||||
assert db.wishlist_counts() == {"movie": 1, "show": 1, "episode": 1, "total": 2}
|
||||
assert db.watchlist_counts() == {"show": 1, "person": 0, "total": 1}
|
||||
# youtube counts live on their own surface
|
||||
assert db.youtube_wishlist_counts() == {"channel": 1, "video": 1}
|
||||
assert db.list_watchlist_channels()[0]["video_count"] == 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue