diff --git a/CHANGELOG.md b/CHANGELOG.md index a1cc11d9..1608a336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.111.0 — 2026-10-24 + +### New + +- Channels › Discover from your history: the channels your uploaded YouTube history shows you watching often (five videos of five minutes or more) but not following. +- Follow one, put it out of the list (with Undo), or open the videos you watched there and play them one by one or all in a row. + +### Fixed + +- A channel someone explored and then followed on YouTube is now part of the shared Library like any followed channel, and so are its videos; before, they could stay hidden and their watched marks never applied. + ## 0.110.0 — 2026-10-23 ### New diff --git a/VERSION b/VERSION index 068bced3..5af665a1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.110.0 +0.111.0 diff --git a/backend/alembic/versions/0115_takeout_channels.py b/backend/alembic/versions/0115_takeout_channels.py new file mode 100644 index 00000000..b7ef1b95 --- /dev/null +++ b/backend/alembic/versions/0115_takeout_channels.py @@ -0,0 +1,49 @@ +"""A watched Takeout video's length, and the channels a user put out of their list + +Revision ID: 0115_takeout_channels +Revises: 0114_video_unmarks +Create Date: 2026-10-24 + +The "channels you watch but do not follow" list counts a user's watched videos over five minutes +by channel. The length is asked of YouTube for that list and kept on the Takeout row; a channel the +user does not want on the list is kept apart. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0115_takeout_channels" +down_revision: str | None = "0114_video_unmarks" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column( + "takeout_items", sa.Column("looked_up_at", sa.DateTime(timezone=True), nullable=True) + ) + op.add_column("takeout_items", sa.Column("duration_seconds", sa.Integer(), nullable=True)) + op.create_table( + "takeout_channel_dismissals", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("channel_id", sa.String(32), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("user_id", "channel_id", name="uq_takeout_channel_dismissal"), + ) + + +def downgrade() -> None: + op.drop_table("takeout_channel_dismissals") + op.drop_column("takeout_items", "duration_seconds") + op.drop_column("takeout_items", "looked_up_at") diff --git a/backend/alembic/versions/0116_followed_explored_channels.py b/backend/alembic/versions/0116_followed_explored_channels.py new file mode 100644 index 00000000..8d62657a --- /dev/null +++ b/backend/alembic/versions/0116_followed_explored_channels.py @@ -0,0 +1,34 @@ +"""An explored channel someone follows is the catalog's own, and so are its videos + +Revision ID: 0116_followed_explored_channels +Revises: 0115_takeout_channels +Create Date: 2026-10-24 + +A follow pulled in from YouTube (the subscription import) left an explored channel flagged as an +exploration's, and a subscribe landing while an exploration's videos came in left those videos +flagged on a channel that no longer was one. Both are held in the code now; this clears what they +left: the channel of a subscription is not an exploration's, and no video is an exploration's on a +channel that is not one. Data only, so the downgrade has nothing to undo. +""" + +from alembic import op + +revision: str = "0116_followed_explored_channels" +down_revision: str | None = "0115_takeout_channels" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + "UPDATE channels SET from_explore = false WHERE from_explore" + " AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.channel_id = channels.id)" + ) + op.execute( + "UPDATE videos SET via_explore = false WHERE via_explore" + " AND channel_id IN (SELECT id FROM channels WHERE NOT from_explore)" + ) + + +def downgrade() -> None: + pass diff --git a/backend/app/catalog/models.py b/backend/app/catalog/models.py index b1bd89e3..5ea760b9 100644 --- a/backend/app/catalog/models.py +++ b/backend/app/catalog/models.py @@ -71,10 +71,12 @@ class Channel(Base, TimestampMixin): Boolean, default=False, server_default="false", index=True ) # True when this channel exists only because a user EXPLORED it (opened its page without - # subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a - # channel (and its via_explore videos) once nobody keeps it (no subscription, no live - # ExploredChannel row, no per-user state) after a grace period. Cleared when someone - # subscribes (the "keep" signal promotes it to organic). + # subscribing) — an ephemeral, per-explorer addition. After a grace period the cleanup job + # hard-deletes its via_explore videos nobody keeps (no subscription, no live ExploredChannel + # row, no per-user state), and then the channel row itself once it holds no videos — unless a + # Takeout watch history shows it watched: the "watched but not followed" list names it from + # that row. Cleared when someone subscribes, here or on YouTube (the "keep" signal promotes it + # to organic). from_explore: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) @@ -235,8 +237,9 @@ class ExploredChannel(Base, TimestampMixin): Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period - purge: a from_explore channel with no live ExploredChannel row (and no subscription / no - per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep" + purge: of a from_explore channel with no live ExploredChannel row (and no subscription) the + cleanup job hard-deletes the videos without per-user state, then the channel once it holds none + and no Takeout watch history shows it watched. Subscribing is the "keep" signal — it promotes the channel to organic and these rows become irrelevant. `created_at` (from TimestampMixin) is the grace clock; a re-visit refreshes it.""" diff --git a/backend/app/catalog/service.py b/backend/app/catalog/service.py index 1004a96e..7567b2a4 100644 --- a/backend/app/catalog/service.py +++ b/backend/app/catalog/service.py @@ -96,13 +96,24 @@ def record_subscription(db: Session, user: User, channel: Channel, yt_subscripti subscribed_at=datetime.now(timezone.utc), ) ) - if channel.from_explore: - channel.from_explore = False - db.execute( - update(Video) - .where(Video.channel_id == channel.id, Video.via_explore.is_(True)) - .values(via_explore=False) - ) + db.flush() + settle_explored(db, channel) + + +def settle_explored(db: Session, channel: Channel) -> None: + """Hold the rule that a channel's videos are an exploration's only while nobody follows it, + decided by who follows it now, not by the flag held in memory: a subscribe in another request + may have cleared that while YouTube answered this one, and the videos that came in meanwhile + would stay flagged. A channel someone follows, however the follow came (here or pulled in from + YouTube), is the catalog's own, and so are its videos.""" + if channel.from_explore and not queries.followed(db, channel.id): + return + channel.from_explore = False + db.execute( + update(Video) + .where(Video.channel_id == channel.id, Video.via_explore.is_(True)) + .values(via_explore=False) + ) db.flush() diff --git a/backend/app/reader/models.py b/backend/app/reader/models.py index 7ff5d7b1..a146e058 100644 --- a/backend/app/reader/models.py +++ b/backend/app/reader/models.py @@ -254,8 +254,9 @@ class TakeoutItem(Base, TimestampMixin): """One video from a user's Google Takeout export: watched (with when) or in their YouTube Watch later list (with when it was added). The rows are both the import job's input and its memory: a video the catalog does not know yet waits here, with no outcome, until it enters the catalog. - The id is not a foreign key for that reason. The channel is kept for a later "channels you - watch but do not follow" list.""" + The id is not a foreign key for that reason. A watched row's channel and length feed the + "channels you watch but do not follow" list: the length is asked of YouTube for that list alone + and kept here, so its videos need not enter the shared catalog to be counted.""" __tablename__ = "takeout_items" __table_args__ = ( @@ -277,3 +278,21 @@ class TakeoutItem(Base, TimestampMixin): # Watched: the last time in the export; Watch later: when it was added. at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) outcome: Mapped[str | None] = mapped_column(String(12)) + # When YouTube was asked for the video's length (NULL = not yet), and the length it gave; a + # video it no longer has keeps a NULL length. + looked_up_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + duration_seconds: Mapped[int | None] = mapped_column(Integer) + + +class TakeoutChannelDismissal(Base, TimestampMixin): + """A channel the user put out of their "watched but not followed" list. The id is not a foreign + key: the channel may never have entered the catalog, or may leave it, and the choice stays.""" + + __tablename__ = "takeout_channel_dismissals" + __table_args__ = ( + UniqueConstraint("user_id", "channel_id", name="uq_takeout_channel_dismissal"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + channel_id: Mapped[str] = mapped_column(String(32)) diff --git a/backend/app/reader/queries.py b/backend/app/reader/queries.py index b06eb402..ff090100 100644 --- a/backend/app/reader/queries.py +++ b/backend/app/reader/queries.py @@ -16,13 +16,14 @@ from sqlalchemy.orm import InstrumentedAttribute, Session, aliased from app.collation import collated from app.feedquery import feed_columns, serialize from app.models import User -from app.catalog.models import Channel, Subscription, Video +from app.catalog.models import BlockedChannel, Channel, Subscription, Video from app.reader.models import ( ChannelTag, Playlist, PlaylistItem, SavedView, Tag, + TakeoutChannelDismissal, TakeoutItem, TakeoutKind, VideoState, @@ -243,6 +244,18 @@ def kept_by_a_reader(video_id: InstrumentedAttribute[str]) -> ColumnElement[bool ) +def in_a_watch_history(channel_id: InstrumentedAttribute[str]) -> ColumnElement[bool]: + """A channel someone's uploaded Takeout history shows watched. The "watched but not followed" + list names a channel from its catalog row, so the explore cleanup keeps the row of one (its + videos it may still reclaim: opening the list's videos brings them back). As a clause, so the + cleanup's DELETE stays one statement.""" + return ( + select(TakeoutItem.id) + .where(TakeoutItem.channel_id == channel_id, TakeoutItem.kind == TakeoutKind.WATCHED) + .exists() + ) + + def playlisted_by(db: Session, video_ids: list[str]) -> list[tuple[int, str]]: """(user id, video id) for every user who has one of these videos in a playlist.""" return list( @@ -533,3 +546,194 @@ def takeout_users_watch_later_waiting(db: Session) -> list[int]: .distinct() ).scalars() ) + + +# --- channels watched but not followed --------------------------------------------------------- + +# A channel joins the list with this many watched videos of at least this length. A Short is three +# minutes at most, so the length alone keeps them out, and a video under five minutes says too +# little of what a channel is about to count towards it. +WATCHED_CHANNEL_MIN_VIDEOS = 5 +WATCHED_CHANNEL_MIN_SECONDS = 300 + + +def _unfollowed(user: User) -> ColumnElement[bool]: + """A watched row whose channel the user neither follows, blocked, put out of the list, nor is.""" + subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user)) + blocked = select(BlockedChannel.channel_id).where(owned_by(BlockedChannel.user_id, user)) + dismissed = select(TakeoutChannelDismissal.channel_id).where( + owned_by(TakeoutChannelDismissal.user_id, user) + ) + clauses = [ + owned_by(TakeoutItem.user_id, user), + TakeoutItem.kind == TakeoutKind.WATCHED, + TakeoutItem.channel_id.is_not(None), + TakeoutItem.channel_id.not_in(subscribed), + TakeoutItem.channel_id.not_in(blocked), + TakeoutItem.channel_id.not_in(dismissed), + ] + if user.yt_channel_id: + clauses.append(TakeoutItem.channel_id != user.yt_channel_id) + return and_(*clauses) + + +def _candidate_channels(user: User): + """The unfollowed channels with enough watched videos to make the list once their lengths are + known: (channel id, how many watched).""" + return ( + select(TakeoutItem.channel_id, func.count().label("watched")) + .where(_unfollowed(user)) + .group_by(TakeoutItem.channel_id) + .having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS) + .subquery() + ) + + +def takeout_lengths_to_ask(db: Session, user: User) -> list[str]: + """The watched videos whose length the list still needs: those of the candidate channels not + asked about yet, the most watched channels first, so a lookup cut short fills the top.""" + cand = _candidate_channels(user) + return list( + db.execute( + select(TakeoutItem.video_id) + .join(cand, cand.c.channel_id == TakeoutItem.channel_id) + .where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None)) + .order_by(cand.c.watched.desc(), TakeoutItem.channel_id, TakeoutItem.at.desc()) + ).scalars() + ) + + +def watched_channels_pending(db: Session, user: User) -> int: + """How many channels a later run may still add to the list: the candidates with a watched video + whose length is not known, and the ones that made it but have no catalog row to be listed from + yet. One the catalog holds without details is listed already (by the name it came in with), so + it is not one to come.""" + cand = _candidate_channels(user) + unasked = db.execute( + select(TakeoutItem.channel_id) + .join(cand, cand.c.channel_id == TakeoutItem.channel_id) + .where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None)) + .distinct() + ).scalars() + counted = _counted_channels(user) + unlisted = db.execute( + select(counted.c.channel_id) + .outerjoin(Channel, Channel.id == counted.c.channel_id) + .where(Channel.id.is_(None)) + ).scalars() + return len(set(unasked) | set(unlisted)) + + +def _counted_channels(user: User): + """The channels that make the list by their counted videos, whatever the catalog holds of them.""" + return ( + select(TakeoutItem.channel_id) + .where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS) + .group_by(TakeoutItem.channel_id) + .having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS) + .subquery() + ) + + +def watched_channels_to_describe(db: Session, user: User) -> list[str]: + """The channels on the list (by their counted videos) that the catalog lacks or has no details + of yet: what the list cannot name, or names with no more than a video brought.""" + counted = _counted_channels(user) + return list( + db.execute( + select(counted.c.channel_id) + .outerjoin(Channel, Channel.id == counted.c.channel_id) + .where(Channel.details_synced_at.is_(None)) + ).scalars() + ) + + +def watched_channels(db: Session, user: User) -> list[tuple[Channel, int, datetime]]: + """The channels the user watched often but does not follow: (channel, how many of its videos + over five minutes they watched, when last). Most watched first; on a tie the one watched more + lately, which is the likelier interest.""" + n = func.count(TakeoutItem.id) + last = func.max(TakeoutItem.at) + return list( + db.execute( + select(Channel, n, last) + .join(Channel, Channel.id == TakeoutItem.channel_id) + .where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS) + .group_by(Channel.id) + .having(n >= WATCHED_CHANNEL_MIN_VIDEOS) + .order_by(n.desc(), last.desc(), Channel.id) + ).tuples() + ) + + +def has_watch_history(db: Session, user: User) -> bool: + """Whether the user has uploaded a Takeout watch history at all.""" + return bool( + db.scalar( + select( + select(TakeoutItem.id) + .where(owned_by(TakeoutItem.user_id, user), TakeoutItem.kind == TakeoutKind.WATCHED) + .exists() + ) + ) + ) + + +def watched_in_history(db: Session, user: User, channel_id: str) -> bool: + """Whether the user's Takeout history shows them watching this channel at all.""" + return bool( + db.scalar( + select( + select(TakeoutItem.id) + .where( + owned_by(TakeoutItem.user_id, user), + TakeoutItem.kind == TakeoutKind.WATCHED, + TakeoutItem.channel_id == channel_id, + ) + .exists() + ) + ) + ) + + +def watched_channel_videos( + db: Session, user: User, channel_id: str +) -> tuple[list[tuple[str, datetime]], int]: + """The user's watched videos of one channel that the list counts, last watched first: (video + id, when). And how many YouTube no longer has.""" + rows = db.execute( + select(TakeoutItem.video_id, TakeoutItem.at, TakeoutItem.duration_seconds) + .where( + owned_by(TakeoutItem.user_id, user), + TakeoutItem.kind == TakeoutKind.WATCHED, + TakeoutItem.channel_id == channel_id, + TakeoutItem.looked_up_at.is_not(None), + ) + .order_by(TakeoutItem.at.desc(), TakeoutItem.video_id) + ).tuples() + counted: list[tuple[str, datetime]] = [] + gone = 0 + for vid, at, seconds in rows: + if seconds is None: + gone += 1 + elif seconds >= WATCHED_CHANNEL_MIN_SECONDS: + counted.append((vid, at)) + return counted, gone + + +def takeout_users_with_list_work(db: Session) -> list[int]: + """Every user whose "watched but not followed" list a run can still add to: lengths to ask, or + channels to name. Asked of each user whose history holds a channel of enough watched videos, + since what is off a list (followed, blocked, put out, their own) is that user's own — a row of + one is never asked about, and would otherwise bring the user back on every run.""" + per_channel = ( + select(TakeoutItem.user_id) + .where(TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.channel_id.is_not(None)) + .group_by(TakeoutItem.user_id, TakeoutItem.channel_id) + .having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS) + .subquery() + ) + users = db.execute( + select(User).where(User.id.in_(select(per_channel.c.user_id))).order_by(User.id) + ).scalars() + return [user.id for user in users if watched_channels_pending(db, user)] diff --git a/backend/app/reader/service.py b/backend/app/reader/service.py index 08bbabc2..5402e653 100644 --- a/backend/app/reader/service.py +++ b/backend/app/reader/service.py @@ -9,7 +9,9 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes from datetime import datetime, timezone -from sqlalchemy import exists, func, select, text, update +from typing import cast + +from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -21,6 +23,7 @@ from app.reader.models import ( PlaylistItem, SavedView, Tag, + TakeoutChannelDismissal, TakeoutItem, TakeoutKind, TakeoutOutcome, @@ -662,3 +665,51 @@ def apply_takeout_watch_later( "watch_later_present": len(done[TakeoutOutcome.PRESENT]), "watch_later_missing": len(done[TakeoutOutcome.MISSING]), } + + +# --- channels watched but not followed --------------------------------------------------------- + + +def record_takeout_lengths( + db: Session, user: User, asked: list[str], lengths: dict[str, int | None] +) -> None: + """Keep what YouTube said of the user's watched videos `asked` about: each one's length, or none + for a video it no longer has (`lengths` leaves it out). Every asked row is marked asked, so a + video YouTube lost is not asked about again.""" + if not asked: + return + lock_takeout(db, user) # these rows are an upload's to update too + now = datetime.now(timezone.utc) + table = cast(Table, TakeoutItem.__table__) + db.execute( + table.update() + .where( + owned_by(TakeoutItem.user_id, user), + table.c.kind == TakeoutKind.WATCHED, + table.c.video_id == bindparam("vid"), + ) + .values(looked_up_at=now, duration_seconds=bindparam("seconds")), + [{"vid": vid, "seconds": lengths.get(vid)} for vid in asked], + ) + db.flush() + + +def dismiss_watched_channel(db: Session, user: User, channel_id: str) -> None: + """Put a channel out of the user's "watched but not followed" list (again is a no-op).""" + db.execute( + pg_insert(TakeoutChannelDismissal) + .values(user_id=user.id, channel_id=channel_id) + .on_conflict_do_nothing(constraint="uq_takeout_channel_dismissal") + ) + db.flush() + + +def restore_watched_channel(db: Session, user: User, channel_id: str) -> None: + """Let a channel the user put out of the list back on it.""" + db.execute( + delete(TakeoutChannelDismissal).where( + owned_by(TakeoutChannelDismissal.user_id, user), + TakeoutChannelDismissal.channel_id == channel_id, + ) + ) + db.flush() diff --git a/backend/app/reader/takeout.py b/backend/app/reader/takeout.py index 35d5fc1d..301408c2 100644 --- a/backend/app/reader/takeout.py +++ b/backend/app/reader/takeout.py @@ -4,7 +4,9 @@ only way in. Two files of the export are read, each uploaded on its own: `history/watch-history.json` and `playlists/Watch later-videos.csv`. Their videos are kept (`TakeoutItem`) and the import job applies -what it can; a video the catalog does not know yet waits for it. +what it can; a video the catalog does not know yet waits for it. The watched videos' channels also +make the list of channels the user watches but does not follow (`queries.watched_channels`): the +job asks YouTube how long those videos are, and what the channels are, for it. The format, measured on a real export (2026-10): the history is a JSON list, one entry per view, newest first. A view of a video has a `titleUrl` with `watch?v=` (on www.youtube.com or @@ -37,8 +39,9 @@ from app.ratelimit import RateLimiter from app.reader import queries, service from app.reader.models import TakeoutKind from app.sync.runner import get_service_user -from app.sync.videos import ensure_videos -from app.youtube.client import YouTubeClient +from app.sync.subscriptions import describe_channels +from app.sync.videos import ensure_videos, parse_iso8601_duration +from app.youtube.client import YouTubeClient, YouTubeError log = logs.get_logger(__name__) @@ -317,8 +320,15 @@ def stage(db: Session, user: User, parsed: Parsed) -> None: # How many unknown Watch later videos one user may have looked up on the shared API budget in a day: -# two full Watch later lists (two units a hundred ids). The rest wait for the next day's runs. +# two full Watch later lists (two units a hundred ids). The rest wait for the next day's runs. Both +# shares pace the work, they do not bound its cost: they are held in memory, so a restart starts +# them afresh, which only brings the rest forward — every video is asked about once (a looked-up +# row is marked), and `quota.backfill_reserve_reached` still guards the shared budget. _lookups = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600) +# How many watched videos' lengths one user may ask about in a day for the "watched but not followed" +# list (a unit a fifty). A share of its own: a large history asks for thousands, and on the Watch +# later share it would leave the export's Watch later file waiting a day behind it. +_lengths = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600) def _put_in_watch_later(db: Session, user: User) -> dict: @@ -351,10 +361,79 @@ def _put_in_watch_later(db: Session, user: User) -> dict: return service.apply_takeout_watch_later(db, user, waiting, asked) +def _ask_lengths(db: Session, user: User) -> dict: + """Ask YouTube how long the user's watched videos are that the "watched but not followed" list + still needs, and keep the answer — on the same terms as the Watch later lookups, on a daily + share of its own. What is not asked yet (no YouTube-connected account, the reserve reached, the + share spent) waits for a later run, and the list says it is not complete. A YouTube failure is + logged and left for a later run too: it is no reason to fail the import it follows. The caller + commits.""" + ask = queries.takeout_lengths_to_ask(db, user) + fetcher = get_service_user(db) if ask else None + if fetcher is None or quota.backfill_reserve_reached(): + return {} + ask = ask[: int(_lengths.room(str(user.id)))] + if not ask: + return {} + try: + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP): + with YouTubeClient(db, fetcher) as yt: + items = yt.get_videos(ask) + except YouTubeError as exc: + log.warning( + "Takeout: asking the watched videos' lengths failed: %s", + exc, + extra=logs.event("takeout.lengths.failed", user_id=user.id, reason=exc.reason), + ) + return {} + _lengths.allow(str(user.id), cost=len(ask)) + lengths = { + it["id"]: parse_iso8601_duration(it.get("contentDetails", {}).get("duration")) + for it in items + if it.get("id") + } + service.record_takeout_lengths(db, user, ask, lengths) + return {"lengths_asked": len(ask)} + + +def _describe_channels(db: Session, user: User) -> dict: + """Bring the channels the user's list holds into the catalog with their details (name, picture, + description), the ones it lacks: without them the list cannot show a channel. A YouTube failure + is logged and left for a later run. The caller commits.""" + need = queries.watched_channels_to_describe(db, user) + fetcher = get_service_user(db) if need else None + if fetcher is None or quota.backfill_reserve_reached(): + return {} + try: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER): + with YouTubeClient(db, fetcher) as yt: + describe_channels(db, yt, need) + except YouTubeError as exc: + db.rollback() + log.warning( + "Takeout: describing the watched channels failed: %s", + exc, + extra=logs.event("takeout.channels.failed", user_id=user.id, reason=exc.reason), + ) + return {} + return {"channels_described": len(need)} + + +def _channel_list(db: Session, user: User) -> dict: + """The "watched but not followed" list's part of a run: the lengths, landed, then the channels. + Commits each.""" + result = _ask_lengths(db, user) + db.commit() + result |= _describe_channels(db, user) + db.commit() + return result + + def run_import(user_id: int) -> dict: """The import job: apply what the user's kept Takeout rows can say now, as one recorded run. The watched part commits before the Watch later part asks YouTube for the videos the catalog - lacks, so a YouTube failure there loses nothing already marked. Returns the counts.""" + lacks, so a YouTube failure there loses nothing already marked; the "watched but not followed" + list's lookups come last. Returns the counts.""" with _one_import(user_id), SessionLocal() as db: user = db.get(User, user_id) if user is None: @@ -363,6 +442,7 @@ def run_import(user_id: int) -> dict: db.commit() result |= _put_in_watch_later(db, user) db.commit() + result |= _channel_list(db, user) outcomes = queries.takeout_outcomes(db, user) result["waiting"] = outcomes.get((TakeoutKind.WATCHED, None), 0) if outcomes.get((TakeoutKind.WATCH_LATER, None)): @@ -373,13 +453,15 @@ def run_import(user_id: int) -> dict: def apply_waiting(db: Session) -> dict: """Settle what every user's import left waiting and can be settled now, one commit each: a watched video the catalog has come to know and the Shorts probe has cleared (by its job or a - search's), and the Watch later videos a run could not put in yet. Run after each run of the + search's), the Watch later videos a run could not put in yet, and what the "watched but not + followed" list still needs asked of YouTube. Run after each run of the `shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when nothing was waiting that could move.""" - marked = added = failed = 0 + marked = added = asked = failed = 0 ready = set(queries.takeout_users_ready(db)) later = set(queries.takeout_users_watch_later_waiting(db)) - for user_id in sorted(ready | later): + lengths = set(queries.takeout_users_with_list_work(db)) + for user_id in sorted(ready | later | lengths): user = db.get(User, user_id) if user is None: continue @@ -393,6 +475,8 @@ def apply_waiting(db: Session) -> dict: if user_id in later: added += _put_in_watch_later(db, user).get("watch_later_added", 0) db.commit() + if user_id in lengths: + asked += _channel_list(db, user).get("lengths_asked", 0) except Exception: db.rollback() failed += 1 @@ -406,6 +490,8 @@ def apply_waiting(db: Session) -> dict: out["takeout_watched"] = marked if added: out["takeout_watch_later_added"] = added + if asked: + out["takeout_lengths_asked"] = asked if failed: out["takeout_failed"] = failed return out diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index fc2a995d..284ea366 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -307,9 +307,9 @@ def explore_channel( """Open an un-subscribed channel for browsing. Records the exploration (a per-user, grace-clocked row that gates this user's access to the channel's ephemeral videos and drives the later cleanup), flags the channel ephemeral unless someone organically follows - it, then ingests one page of its uploads (newest-first) marked via_explore + enriched. - Returns the next page token so the page can "load more". Guarded against the shared - backfill quota reserve.""" + it, then ingests one page of its uploads (newest-first), enriched — marked via_explore while + nobody follows it. Returns the next page token so the page can "load more". Guarded against + the shared backfill quota reserve.""" channel = _channel_or_404(db, channel_id) if queries.is_blocked(db, user, channel_id): raise refused(403, "channel_blocked") diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 491a104b..60b68538 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -3,12 +3,13 @@ from collections.abc import AsyncIterator from datetime import date from functools import partial -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Path, Query, Request from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy.orm.exc import StaleDataError from app import jobruns, quota, scheduler +from app.catalog import queries as catalog_queries, service as catalog_service from app.auth import current_user from app.db import get_db, release_read_connection from app.mailtext import lang_for_user @@ -24,7 +25,7 @@ from app.routes._common import ( not_found, youtube_refusal, ) -from app.sync.videos import parse_iso8601_duration +from app.sync.videos import ensure_videos, parse_iso8601_duration from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError from app.refusal import refused @@ -417,3 +418,101 @@ def takeout_status(user: User = Depends(current_user), db: Session = Depends(get }, **queries.takeout_summary(db, user), } + + +# --- channels watched but not followed ----------------------------------------------------------- + + +@router.get("/takeout/channels") +def watched_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + """The channels the user's Takeout history shows them watching often but not following: each + with how many of its videos over five minutes they watched, and when last. `pending` is how many + more channels may join once the import has asked YouTube for their videos' lengths or their + names, so a list still being made does not read as the whole of it; `imported` tells "no history + uploaded" from "nothing to suggest".""" + return { + "imported": queries.has_watch_history(db, user), + "pending": queries.watched_channels_pending(db, user), + "channels": [ + { + "id": ch.id, + "title": ch.title, + "handle": ch.handle, + "thumbnail_url": ch.thumbnail_url, + "subscriber_count": ch.subscriber_count, + "video_count": ch.video_count, + "description": ch.description, + "watched": int(n), + "last_watched_at": iso(last), + } + for ch, n, last in queries.watched_channels(db, user) + ], + } + + +@router.post("/takeout/channels/{channel_id}/videos") +def watched_channel_videos( + channel_id: str, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """The videos of a channel that the user's list counts, ready to play, last watched first. The + channel is explored for the user (as its page does), and the videos the catalog lacks come in as + the exploration's: the player needs them in the catalog, the shared Library does not get them, + and the cleanup reclaims them once nobody keeps them. Of a channel someone follows they come in + as the catalog's own, as its uploads do. `gone`: how many YouTube no longer has.""" + channel = catalog_queries.channel(db, channel_id) + if channel is None: + raise not_found("channel") + if catalog_queries.is_blocked(db, user, channel_id): + raise refused(403, "channel_blocked") + counted, gone = queries.watched_channel_videos(db, user, channel_id) + ids = [vid for vid, _ in counted] + known = queries.known_video_ids(db, ids) + missing = [vid for vid in ids if vid not in known] + if missing and quota.backfill_reserve_reached(): + raise refused(429, "quota_reserve") + catalog_service.explore(db, user, channel) + db.commit() + if missing: + try: + with ( + quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE), + YouTubeClient(db, user) as yt, + ): + ensure_videos(db, yt, missing, explore=channel.from_explore) + except YouTubeError as exc: + raise youtube_refusal(exc) from exc + catalog_service.settle_explored(db, channel) + db.commit() + items = queries.feed_rows_by_ids(db, user, ids) + watched_at = dict(counted) + for item in items: + item["takeout_watched_at"] = iso(watched_at[item["id"]]) + # A video YouTube still had when its length was asked may have gone since. + return {"ok": True, "items": items, "gone": gone + len(ids) - len(items)} + + +@router.put("/takeout/channels/{channel_id}/dismissal") +def dismiss_watched_channel( + channel_id: str = Path(max_length=32), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Put a channel out of the user's list (it stays out until restored). Only one their own history + shows them watching: a made-up id would only pile up rows every list read then has to skip.""" + if not queries.watched_in_history(db, user, channel_id): + raise not_found("channel") + service.dismiss_watched_channel(db, user, channel_id) + db.commit() + return {"ok": True, "channel_id": channel_id} + + +@router.delete("/takeout/channels/{channel_id}/dismissal") +def restore_watched_channel( + channel_id: str = Path(max_length=32), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Let a channel the user put out of the list back on it.""" + service.restore_watched_channel(db, user, channel_id) + db.commit() + return {"ok": True, "channel_id": channel_id} diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py index d3257e24..f98172cf 100644 --- a/backend/app/sync/explore.py +++ b/backend/app/sync/explore.py @@ -14,6 +14,7 @@ from sqlalchemy import delete, or_, select from sqlalchemy.orm import Session from app import logs, sysconfig +from app.catalog import service as catalog_service from app.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video from app.reader import queries as reader_queries from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details @@ -66,11 +67,13 @@ def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int: def explore_ingest_page( db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None ) -> dict: - """Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new - videos via_explore, then enrich them. Returns the count ingested and the next page token - (None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless - pagination: the caller round-trips the token, so the channel's own backfill cursor (used by - the organic deep backfill) is never touched.""" + """Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new videos + via_explore while the channel is an exploration's (`from_explore`): of a channel someone follows + they are its uploads like any other, and a flag there would never be cleared, since subscribing + clears only an explored channel's. Then enrich them. Returns the count ingested and the next + page token (None when the back-catalogue is exhausted) so the caller can offer "load more". + Stateless pagination: the caller round-trips the token, so the channel's own backfill cursor + (used by the organic deep backfill) is never touched.""" if not channel.uploads_playlist_id: return {"ingested": 0, "next_page_token": None} try: @@ -86,9 +89,10 @@ def explore_ingest_page( for item in data.get("items", []): stub = stub_from_playlist_item(item, channel.id) if stub is not None: - stub["via_explore"] = True + stub["via_explore"] = channel.from_explore stubs.append(stub) inserted = insert_stubs(db, stubs) + catalog_service.settle_explored(db, channel) self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs]) log.info( "explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched @@ -105,7 +109,9 @@ def purge_unkept_explored(db: Session) -> dict: - a live ExploredChannel row (someone explored it within the grace window — channel-level), or - a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem), or surfaced by the user's own search (SearchFind) — these survive even if the channel goes. - A channel row is dropped only once it holds no videos at all. 0 quota.""" + A channel row is dropped only once it holds no videos at all, and not while someone's Takeout + history shows it watched (their "watched but not followed" list names it from that row). + 0 quota.""" grace_days = sysconfig.get_int(db, "explore_grace_days") cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days) @@ -135,7 +141,9 @@ def purge_unkept_explored(db: Session) -> dict: has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists() channels_deleted = db.execute( - delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos) + delete(Channel).where( + Channel.id.in_(orphan_ids), ~has_videos, ~reader_queries.in_a_watch_history(Channel.id) + ) ).rowcount db.commit() return { diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index 301cd019..bb37da66 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app import logs from app.models import User +from app.catalog import service as catalog_service from app.catalog.models import Channel, Subscription from app.utils import normalize_description from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail @@ -109,6 +110,21 @@ def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str]) channel.details_synced_at = now +def describe_channels(db: Session, yt: YouTubeClient, channel_ids: list[str]) -> None: + """Make sure each given channel is in the catalog with its details: a channel it lacks is added + as a stub, then all of them are asked for (`channels.list`, 1 unit a 50). Flushes; the caller + commits.""" + if not channel_ids: + return + have = set(db.execute(select(Channel.id).where(Channel.id.in_(channel_ids))).scalars()) + for cid in dict.fromkeys(channel_ids): + if cid not in have: + db.add(Channel(id=cid)) + db.flush() + apply_channel_details(db, yt.get_channels(channel_ids), channel_ids) + db.flush() + + def should_prune(fetched_count: int, existing_count: int) -> bool: """Whether an import may delete the local subscriptions missing from this fetch. @@ -182,6 +198,10 @@ def import_subscriptions(db: Session, user: User) -> dict: db.add(sub_row) sub_row.yt_subscription_id = sub.get("yt_subscription_id") db.flush() + # A follow made on youtube.com keeps an explored channel as a follow made here does. + explored = select(Channel).where(Channel.id.in_(fetched), Channel.from_explore.is_(True)) + for channel in db.execute(explored).scalars(): + catalog_service.settle_explored(db, channel) # Prune subscriptions removed on YouTube — but only when this fetch is trustworthy # (see `should_prune`). diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index 6ae40983..2f77b9df 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -420,11 +420,15 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict: return {"probed": probed, "shorts": shorts} -def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None: +def ensure_videos( + db: Session, yt: YouTubeClient, video_ids: list[str], *, explore: bool = False +) -> None: """Make sure every given video id exists in the catalog, fetching + ingesting the missing ones (and a stub channel for any unknown channel). Deleted/private videos that the API won't return are simply left out. The catalog's write for a playlist sync that names - videos it has never seen; commits.""" + videos it has never seen; commits. `explore` adds the new ones as an exploration's + (`via_explore`): visible to who explores or follows their channel, and reclaimed by the cleanup + once nobody keeps them.""" if not video_ids: return have = set(db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all()) @@ -458,7 +462,12 @@ def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None: seen.add(vid) # published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now() # fallback here too so the constructor never carries an explicit NULL. - v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now) + v = Video( + id=vid, + channel_id=cid, + published_at=parse_dt(sn.get("publishedAt")) or now, + via_explore=explore, + ) apply_video_details(v, it) v.enriched_at = now db.add(v) diff --git a/backend/tests/test_catalog_service.py b/backend/tests/test_catalog_service.py index c01d0e9b..a30af2b8 100644 --- a/backend/tests/test_catalog_service.py +++ b/backend/tests/test_catalog_service.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select from app.catalog import queries, search, service +from app.db import SessionLocal from app.catalog.models import ( BlockedChannel, Channel, @@ -22,6 +23,8 @@ from app.catalog.models import ( ) from app.reader import queries as reader_queries from app.reader import service as reader_service +from app.sync import explore as sync_explore +from app.sync import subscriptions as sync_subscriptions def _channel(db, cid="UCc", **kw): @@ -141,6 +144,89 @@ def test_subscribing_keeps_an_explored_channel_and_its_videos(db, make_user): assert queries.subscription(db, user, "UCc") is None +def test_exploring_a_channel_someone_follows_brings_its_uploads_in_as_the_catalogs_own( + db, make_user, monkeypatch +): + follower, explorer = make_user(), make_user() + ch = _channel(db, uploads_playlist_id="UUc") + db.add(Subscription(user_id=follower.id, channel_id="UCc")) + db.commit() + service.explore(db, explorer, ch) + db.commit() + + class _Yt: + def get_playlist_items(self, playlist_id, page_token): + item = {"contentDetails": {"videoId": "v1", "videoPublishedAt": "2026-05-01T00:00:00Z"}} + return {"items": [item]} + + monkeypatch.setattr(sync_explore, "_enrich_new", lambda session, yt, ids: 0) + sync_explore.explore_ingest_page(db, _Yt(), ch) + db.commit() + db.expire_all() + # The channel is followed, so not the exploration's: a video flagged as one would stay flagged + # (subscribing clears only an explored channel's) and its watched marks would wait for good. + assert (ch.from_explore, db.get(Video, "v1").via_explore) == (False, False) + + +def test_a_follow_pulled_in_from_youtube_makes_an_explored_channel_the_catalogs_own( + db, make_user, monkeypatch +): + explorer, follower = make_user(), make_user() + ch = _channel(db, details_synced_at=datetime.now(timezone.utc)) + service.explore(db, explorer, ch) + db.commit() + _video(db, "v1", via_explore=True) + + class _Yt: + def __init__(self, session, user): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def iter_subscriptions(self): + yield {"channel_id": "UCc", "title": "UCc", "yt_subscription_id": "sub-UCc"} + + def get_channels(self, ids): + return [] + + monkeypatch.setattr(sync_subscriptions, "YouTubeClient", _Yt) + sync_subscriptions.import_subscriptions(db, follower) + db.commit() + db.expire_all() + # Followed on youtube.com is followed all the same: left flagged, the channel's later videos + # came in as an exploration's, hidden from the Library and their watched marks waiting for good. + assert (db.get(Channel, "UCc").from_explore, db.get(Video, "v1").via_explore) == (False, False) + + +def test_a_follow_landing_while_an_explored_page_comes_in_leaves_none_of_it_flagged( + db, make_user, monkeypatch +): + explorer, follower = make_user(), make_user() + ch = _channel(db, uploads_playlist_id="UUc") + service.explore(db, explorer, ch) + db.commit() + + class _Yt: + def get_playlist_items(self, playlist_id, page_token): + # Someone subscribes, in a request of their own, while YouTube answers this one. + with SessionLocal() as other: + service.record_subscription(other, follower, other.get(Channel, "UCc"), "sub-UCc") + other.commit() + item = {"contentDetails": {"videoId": "v1", "videoPublishedAt": "2026-05-01T00:00:00Z"}} + return {"items": [item]} + + monkeypatch.setattr(sync_explore, "_enrich_new", lambda session, yt, ids: 0) + sync_explore.explore_ingest_page(db, _Yt(), ch) + db.commit() + db.expire_all() + # The subscribe cleared what was flagged before it; a page that lands after it must not be. + assert (db.get(Channel, "UCc").from_explore, db.get(Video, "v1").via_explore) == (False, False) + + def test_a_block_is_once_and_ends_the_exploration(db, make_user): user = make_user() ch = _channel(db) diff --git a/backend/tests/test_migration_0116.py b/backend/tests/test_migration_0116.py new file mode 100644 index 00000000..813b8ecb --- /dev/null +++ b/backend/tests/test_migration_0116.py @@ -0,0 +1,50 @@ +"""Migration 0116's data step, DB-lane: an explored channel someone already follows becomes the +catalog's own, and no video stays flagged as an exploration's on a channel that is not one. + +The schema half is checked by test_migrations.py; a flag left on is invisible there. +""" + +from alembic import command +from alembic.config import Config +from sqlalchemy import text + +from app.db import engine + + +def test_followed_explored_channels_and_their_videos_are_the_catalogs_own(db, make_user): + follower = make_user().id + cfg = Config("alembic.ini") + command.downgrade(cfg, "0115_takeout_channels") + try: + with engine.begin() as conn: + + def channel(cid, explored): + conn.execute( + text("INSERT INTO channels (id, from_explore) VALUES (:c, :e)"), + {"c": cid, "e": explored}, + ) + conn.execute( + text( + "INSERT INTO videos (id, channel_id, published_at, via_explore)" + " VALUES (:v, :c, now(), true)" + ), + {"v": f"v-{cid}", "c": cid}, + ) + + channel("UCfollowed", True) # explored, then followed on youtube.com + channel("UCexplored", True) # explored, nobody follows it: stays the exploration's + channel("UCorganic", False) # a video flagged after a subscribe had cleared the channel + conn.execute( + text("INSERT INTO subscriptions (user_id, channel_id) VALUES (:u, 'UCfollowed')"), + {"u": follower}, + ) + finally: + command.upgrade(cfg, "head") + + with engine.connect() as conn: + channels = dict(conn.execute(text("SELECT id, from_explore FROM channels")).tuples().all()) + videos = dict( + conn.execute(text("SELECT channel_id, via_explore FROM videos")).tuples().all() + ) + assert channels == {"UCfollowed": False, "UCexplored": True, "UCorganic": False} + assert videos == {"UCfollowed": False, "UCexplored": True, "UCorganic": False} diff --git a/backend/tests/test_unit_fences.py b/backend/tests/test_unit_fences.py index c189977e..985df0d2 100644 --- a/backend/tests/test_unit_fences.py +++ b/backend/tests/test_unit_fences.py @@ -45,6 +45,7 @@ UNIT_MODELS = { "SavedView", "ComfortPin", "TakeoutItem", + "TakeoutChannelDismissal", "VideoUnmark", "Tag", "ChannelTag", @@ -367,6 +368,8 @@ UNIT_COMMITS = { "reader/takeout.py:run_import", # Run inside the Shorts job, on its session: each user's waiting rows land on their own. "reader/takeout.py:apply_waiting", + # Inside either of those: the lengths YouTube gave land before the channels are asked for. + "reader/takeout.py:_channel_list", }, # The catalog's ingest jobs commit per page or batch, a YouTube call between each: a page # fetched and stored is not lost when the next call fails, and an enrich that fails rolls back diff --git a/backend/tests/test_watched_channels.py b/backend/tests/test_watched_channels.py new file mode 100644 index 00000000..2b76cdba --- /dev/null +++ b/backend/tests/test_watched_channels.py @@ -0,0 +1,538 @@ +"""The channels a user's Takeout history shows them watching but not following: which channels make +the list (five videos of five minutes or more, none they follow, block, put out or own), what the +import asks YouTube for it and when, and the channel's watched videos made ready to play.""" + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select, update + +from app import quota +from app.catalog import service as catalog_service +from app.catalog.models import BlockedChannel, Channel, ExploredChannel, Subscription, Video +from app.db import SessionLocal +from app.reader import queries, service, takeout +from app.reader.models import TakeoutChannelDismissal, TakeoutItem, TakeoutKind +from app.routes import feed as feed_routes +from app.sync import explore as sync_explore +from app.sync import videos as sync_videos +from app.youtube.client import YouTubeError + +T0 = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) +LONG = 600 +SHORT = 120 + + +def _ch(n: int) -> str: + return f"UC{n:022d}" + + +def _vid(channel: int, n: int) -> str: + return f"c{channel:02d}v{n:07d}" + + +def _watched(db, user, channel: int, lengths: list[int | None], *, at=T0, asked=True): + """Stage `len(lengths)` watched videos of a channel, the newest first, each with its length + already asked (None: YouTube no longer has it) — or not asked yet.""" + service.stage_takeout( + db, + user, + TakeoutKind.WATCHED, + {_vid(channel, i): (at - timedelta(days=i), _ch(channel)) for i in range(len(lengths))}, + ) + db.flush() + if asked: + ids = [_vid(channel, i) for i in range(len(lengths))] + service.record_takeout_lengths( + db, user, ids, {v: s for v, s in zip(ids, lengths, strict=True) if s is not None} + ) + db.commit() + + +def _channel(db, n: int, title: str | None = None): + db.add(Channel(id=_ch(n), title=title or f"Channel {n}", details_synced_at=T0)) + db.commit() + + +def _listed(db, user) -> list[tuple[str, int]]: + return [(ch.id, n) for ch, n, _ in queries.watched_channels(db, user)] + + +# --- which channels make the list -------------------------------------------------------------- + + +def test_a_channel_makes_the_list_with_five_watched_videos_of_five_minutes_or_more(db, make_user): + user = make_user("list@example.com") + for n in (1, 2, 3): + _channel(db, n) + _watched(db, user, 1, [LONG] * 5 + [SHORT] * 3) # five long, three short: on it with 5 + _watched(db, user, 2, [LONG] * 4 + [SHORT] * 6) # ten watched, only four long: off it + _watched(db, user, 3, [299, 300, 300, 300, 300, 300]) # five at exactly five minutes: on it + # A tie: channel 1's counted videos were watched later (channel 3's newest is under five minutes). + assert _listed(db, user) == [(_ch(1), 5), (_ch(3), 5)] + + +def test_a_video_youtube_no_longer_has_does_not_count(db, make_user): + user = make_user("gone@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 4 + [None] * 3) + assert _listed(db, user) == [] + + +def test_the_most_watched_lead_and_a_tie_goes_to_the_one_watched_more_lately(db, make_user): + user = make_user("order@example.com") + for n in (1, 2, 3): + _channel(db, n) + _watched(db, user, 1, [LONG] * 6, at=T0 - timedelta(days=100)) + _watched(db, user, 2, [LONG] * 9, at=T0 - timedelta(days=300)) + _watched(db, user, 3, [LONG] * 6, at=T0) + assert _listed(db, user) == [(_ch(2), 9), (_ch(3), 6), (_ch(1), 6)] + last = {ch.id: when for ch, _, when in queries.watched_channels(db, user)} + assert last[_ch(3)] == T0 # the last watched of the counted ones + + +def test_a_channel_the_user_follows_blocked_put_out_or_owns_is_not_on_the_list(db, make_user): + user = make_user("off@example.com", yt_channel_id=_ch(4)) + for n in (1, 2, 3, 4, 5): + _channel(db, n) + _watched(db, user, n, [LONG] * 5) + db.add(Subscription(user_id=user.id, channel_id=_ch(1))) + db.add(BlockedChannel(user_id=user.id, channel_id=_ch(2))) + db.commit() + service.dismiss_watched_channel(db, user, _ch(3)) + db.commit() + assert _listed(db, user) == [(_ch(5), 5)] + + +def test_one_users_follows_and_dismissals_leave_anothers_list_alone(db, make_user): + me, other = make_user("me@example.com"), make_user("other@example.com") + _channel(db, 1) + for user in (me, other): + _watched(db, user, 1, [LONG] * 5) + db.add(Subscription(user_id=other.id, channel_id=_ch(1))) + db.commit() + service.dismiss_watched_channel(db, other, _ch(1)) + db.commit() + assert _listed(db, me) == [(_ch(1), 5)] + assert _listed(db, other) == [] + + +def test_a_channel_whose_lengths_are_not_all_known_is_pending_until_they_are(db, make_user): + user = make_user("pending@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 6, asked=False) + _watched(db, user, 2, [LONG] * 4, asked=False) # four watched: it can never make the list + assert queries.watched_channels_pending(db, user) == 1 + assert _listed(db, user) == [] + ids = [_vid(1, i) for i in range(6)] + service.record_takeout_lengths(db, user, ids, dict.fromkeys(ids, LONG)) + db.commit() + assert queries.watched_channels_pending(db, user) == 0 + assert _listed(db, user) == [(_ch(1), 6)] + + +def test_a_new_upload_of_the_history_keeps_the_lengths_already_asked(db, make_user): + user = make_user("again@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 5) + _watched(db, user, 1, [LONG] * 5, at=T0 + timedelta(days=1), asked=False) + assert _listed(db, user) == [(_ch(1), 5)] + assert queries.takeout_lengths_to_ask(db, user) == [] + + +# --- what the import asks YouTube for ---------------------------------------------------------- + + +class FakeYouTube: + """Stands in for `YouTubeClient`: answers `videos.list` with the lengths it was given and + `channels.list` with a title per channel, and records what it was asked.""" + + def __init__(self, lengths: dict[str, int] | None = None, *, fail: bool = False): + self.lengths = lengths or {} + self.fail = fail + self.fail_channels = False + self.videos_asked: list[list[str]] = [] + self.channels_asked: list[list[str]] = [] + self.actions: list[tuple[int | None, str]] = [] + + def __call__(self, db, user): + return self + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get_videos(self, ids): + self.videos_asked.append(list(ids)) + self.actions.append((quota._actor_id.get(), quota._action.get())) + if self.fail: + raise YouTubeError("refused", reason="quotaExceeded", path="videos") + return [ + {"id": v, "contentDetails": {"duration": f"PT{self.lengths[v]}S"}} + for v in ids + if v in self.lengths + ] + + def get_channels(self, ids): + self.channels_asked.append(list(ids)) + self.actions.append((quota._actor_id.get(), quota._action.get())) + if self.fail_channels: + raise YouTubeError("refused", reason="backendError", path="channels") + return [{"id": c, "snippet": {"title": f"Named {c[-2:]}"}} for c in ids] + + +@pytest.fixture +def youtube(monkeypatch, db, make_user): + fetcher = make_user("fetcher@example.com") + fake = FakeYouTube() + monkeypatch.setattr(takeout, "YouTubeClient", fake) + monkeypatch.setattr(takeout, "get_service_user", lambda session: fetcher) + monkeypatch.setattr(takeout.quota, "backfill_reserve_reached", lambda: False) + return fake + + +def test_the_import_asks_the_lengths_the_list_needs_and_names_its_channels(db, make_user, youtube): + user = make_user("import@example.com") + _watched(db, user, 1, [None] * 7, asked=False) # the most watched channel + _watched(db, user, 2, [None] * 5, asked=False) + _watched(db, user, 3, [None] * 4, asked=False) # too few to ever make it: not asked about + youtube.lengths = {_vid(1, i): LONG for i in range(6)} | {_vid(2, i): SHORT for i in range(5)} + + result = takeout.run_import(user.id) + + asked = [_vid(1, i) for i in range(7)] + [_vid(2, i) for i in range(5)] + assert youtube.videos_asked == [asked] # the top channel first + assert result["lengths_asked"] == 12 + # Only channel 1 made it (six long; its seventh is gone, channel 2's are short): it is named. + assert youtube.channels_asked == [[_ch(1)]] + assert result["channels_described"] == 1 + assert { + (user.id, quota.QuotaAction.VIDEOS_LOOKUP), + (user.id, quota.QuotaAction.CHANNELS_DISCOVER), + } == set(youtube.actions) + db.expire_all() + assert [(ch.title, n) for ch, n, _ in queries.watched_channels(db, user)] == [("Named 01", 6)] + assert queries.watched_channels_pending(db, user) == 0 + # Asked once: a second run asks nothing again, the gone video included. + youtube.videos_asked.clear() + youtube.channels_asked.clear() + assert "lengths_asked" not in takeout.run_import(user.id) + assert youtube.videos_asked == [] and youtube.channels_asked == [] + + +def test_the_lookups_stop_at_the_users_daily_share_and_the_rest_wait( + db, make_user, youtube, monkeypatch +): + user = make_user("share@example.com") + _watched(db, user, 1, [None] * 8, asked=False) + _watched(db, user, 2, [None] * 6, asked=False) + monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600)) + takeout.run_import(user.id) + assert youtube.videos_asked == [[_vid(1, i) for i in range(8)] + [_vid(2, 0), _vid(2, 1)]] + db.expire_all() + assert queries.watched_channels_pending(db, user) == 1 # channel 2 still has some to ask + youtube.videos_asked.clear() + takeout.run_import(user.id) + assert youtube.videos_asked == [] # the share is spent for today + + +@pytest.mark.parametrize("why", ["no_fetcher", "reserve"]) +def test_nothing_is_asked_without_a_connected_account_or_past_the_reserve( + db, make_user, youtube, monkeypatch, why +): + user = make_user(f"{why}@example.com") + _watched(db, user, 1, [None] * 5, asked=False) + if why == "no_fetcher": + monkeypatch.setattr(takeout, "get_service_user", lambda session: None) + else: + monkeypatch.setattr(takeout.quota, "backfill_reserve_reached", lambda: True) + assert "lengths_asked" not in takeout.run_import(user.id) + assert youtube.videos_asked == [] + assert queries.watched_channels_pending(db, user) == 1 + + +def test_a_youtube_failure_asking_lengths_fails_neither_the_import_nor_its_marks( + db, make_user, youtube +): + user = make_user("fail@example.com") + _watched(db, user, 1, [None] * 5, asked=False) + youtube.fail = True + result = takeout.run_import(user.id) + assert "lengths_asked" not in result + assert queries.takeout_lengths_to_ask(db, user) == [_vid(1, i) for i in range(5)] + db.expire_all() + rows = db.execute(select(TakeoutItem).where(TakeoutItem.user_id == user.id)).scalars() + assert all(r.looked_up_at is None for r in rows) # left to ask again + + +def test_the_shorts_job_asks_what_an_import_could_not(db, make_user, youtube): + user = make_user("later@example.com") + _watched(db, user, 1, [None] * 5, asked=False) + youtube.lengths = {_vid(1, i): LONG for i in range(5)} + assert takeout.apply_waiting(db)["takeout_lengths_asked"] == 5 + db.expire_all() + assert [n for _, n, _ in queries.watched_channels(db, user)] == [5] + + +@pytest.mark.parametrize("why", ["follows", "blocked", "dismissed", "own"]) +def test_a_channel_off_the_list_is_not_asked_about_nor_looked_at_again( + db, make_user, youtube, monkeypatch, why +): + user = make_user(f"off-{why}@example.com", yt_channel_id=_ch(1) if why == "own" else None) + _channel(db, 1) + _watched(db, user, 1, [None] * 5, asked=False) + if why == "follows": + db.add(Subscription(user_id=user.id, channel_id=_ch(1))) + elif why == "blocked": + db.add(BlockedChannel(user_id=user.id, channel_id=_ch(1))) + elif why == "dismissed": + service.dismiss_watched_channel(db, user, _ch(1)) + db.commit() + runs = [] + monkeypatch.setattr(takeout, "_channel_list", lambda session, u: runs.append(u.id) or {}) + assert takeout.apply_waiting(db) == {} + # Its rows are never asked about, so they would stay "to ask" for good: the Shorts job must not + # take the user up again for them on every run. + assert runs == [] + assert youtube.videos_asked == [] + + +def test_naming_the_channels_is_tried_again_and_meanwhile_the_list_says_it_is_not_complete( + db, make_user, youtube +): + user = make_user("unnamed@example.com") + _watched(db, user, 1, [None] * 5, asked=False) + youtube.lengths = {_vid(1, i): LONG for i in range(5)} + youtube.fail_channels = True + takeout.run_import(user.id) + db.expire_all() + # Every length is known, the channel could not be named: not on the list, and not complete. + assert queries.takeout_lengths_to_ask(db, user) == [] + assert _listed(db, user) == [] + assert queries.watched_channels_pending(db, user) == 1 + youtube.fail_channels = False + takeout.apply_waiting(db) + db.expire_all() + assert [(ch.title, n) for ch, n, _ in queries.watched_channels(db, user)] == [("Named 01", 5)] + assert queries.watched_channels_pending(db, user) == 0 + + +def test_a_listed_channel_still_to_be_described_is_not_counted_as_one_to_come(db, make_user): + user = make_user("stub@example.com") + # A stub the catalog made when one of its videos came in: a title, no details yet. + db.add(Channel(id=_ch(1), title="From a video")) + db.commit() + _watched(db, user, 1, [LONG] * 5) + assert queries.watched_channels_to_describe(db, user) == [_ch(1)] + assert _listed(db, user) == [(_ch(1), 5)] + # It is on the list already: "the list may grow" would promise one more that cannot come. + assert queries.watched_channels_pending(db, user) == 0 + + +def test_the_lengths_leave_the_watch_later_lookups_their_own_share( + db, make_user, youtube, monkeypatch +): + user = make_user("shares@example.com") + monkeypatch.setattr(takeout, "_lookups", takeout.RateLimiter(10, 3600)) + monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600)) + _watched(db, user, 1, [None] * 15, asked=False) + takeout.run_import(user.id) # the history: its lengths spend all they may today + youtube.videos_asked.clear() + service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"later000001": (T0, None)}) + db.commit() + takeout.run_import(user.id) # then the export's Watch later file + assert ["later000001"] in youtube.videos_asked + + +# --- the channel's watched videos, ready to play ------------------------------------------------ + + +def _fake_ensure(calls): + def ensure(session, yt, ids, *, explore=False): + calls.append((list(ids), explore)) + for vid in ids: + if vid.endswith("4"): + continue # gone from YouTube since its length was asked + session.add(Video(id=vid, channel_id=_ch(1), published_at=T0, via_explore=explore)) + session.commit() + + return ensure + + +def test_a_channels_watched_videos_come_ready_to_play_and_explored( + db, make_user, as_user, monkeypatch +): + user = make_user("play@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG, SHORT, LONG, None, LONG, LONG, LONG]) + db.add(Video(id=_vid(1, 0), channel_id=_ch(1), published_at=T0)) # already in the catalog + db.commit() + calls = [] + monkeypatch.setattr(feed_routes, "ensure_videos", _fake_ensure(calls)) + monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False) + + resp = as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos") + + assert resp.status_code == 200, resp.text + body = resp.json() + # Only the counted ones, newest first; the short one and the gone ones are left out. + assert calls == [([_vid(1, 2), _vid(1, 4), _vid(1, 5), _vid(1, 6)], True)] + assert [it["id"] for it in body["items"]] == [_vid(1, 0), _vid(1, 2), _vid(1, 5), _vid(1, 6)] + assert body["items"][0]["takeout_watched_at"].startswith("2026-05-01") + assert body["gone"] == 2 # one YouTube had lost before, one since + assert body["ok"] is True + db.expire_all() + assert db.execute( + select(ExploredChannel).where( + ExploredChannel.user_id == user.id, ExploredChannel.channel_id == _ch(1) + ) + ).scalar_one() + assert ( + db.get(Channel, _ch(1)).from_explore is True + ) # nobody follows it: the cleanup's to reclaim + + +def test_a_followed_channels_watched_videos_come_in_as_the_catalogs_own( + db, make_user, as_user, monkeypatch +): + user, follower = make_user("viewer@example.com"), make_user("follower@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 5) + db.add(Subscription(user_id=follower.id, channel_id=_ch(1))) + db.commit() + calls = [] + monkeypatch.setattr(feed_routes, "ensure_videos", _fake_ensure(calls)) + monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False) + assert as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 200 + # Someone follows it, so it is no exploration's to reclaim: a video flagged as one would never + # be cleared again (subscribing clears only an explored channel's) and never marked watched. + assert calls == [([_vid(1, i) for i in range(5)], False)] + db.expire_all() + assert db.get(Channel, _ch(1)).from_explore is False + + +def test_a_follow_landing_while_the_videos_come_in_leaves_none_of_them_flagged( + db, make_user, as_user, monkeypatch +): + user, follower = make_user("racer@example.com"), make_user("late@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 5) + fake = _fake_ensure(calls := []) + + def ensure(session, yt, ids, *, explore=False): + # Someone subscribes, in a request of their own, while YouTube answers this one. + with SessionLocal() as other: + ch = other.get(Channel, _ch(1)) + catalog_service.record_subscription(other, follower, ch, "sub-late") + other.commit() + fake(session, yt, ids, explore=explore) + + monkeypatch.setattr(feed_routes, "ensure_videos", ensure) + monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False) + assert as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 200 + assert calls[0][1] is True # asked as the exploration's + db.expire_all() + flagged = db.scalars(select(Video.id).where(Video.via_explore.is_(True))).all() + assert (db.get(Channel, _ch(1)).from_explore, flagged) == (False, []) + + +def test_the_cleanup_keeps_a_channel_a_watch_history_lists_and_reclaims_its_videos(db, make_user): + user = make_user("kept@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 5) + catalog_service.explore(db, user, db.get(Channel, _ch(1))) + db.add(Video(id=_vid(1, 0), channel_id=_ch(1), published_at=T0, via_explore=True)) + db.add(Channel(id=_ch(2), from_explore=True)) # explored, in nobody's history + db.commit() + db.execute(update(ExploredChannel).values(created_at=T0 - timedelta(days=365))) + db.commit() + out = sync_explore.purge_unkept_explored(db) + # The unplayed video goes (opening the list's videos brings it back); the channel stays listed. + assert (out["videos_deleted"], out["channels_deleted"]) == (1, 1) + db.expire_all() + assert db.get(Channel, _ch(2)) is None + assert _listed(db, user) == [(_ch(1), 5)] + + +def test_a_blocked_or_unknown_channels_videos_are_refused(db, make_user, as_user): + user = make_user("refused@example.com") + _channel(db, 1) + db.add(BlockedChannel(user_id=user.id, channel_id=_ch(1))) + db.commit() + client = as_user(user) + assert client.post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 403 + assert client.post(f"/api/takeout/channels/{_ch(9)}/videos").status_code == 404 + + +def test_ensure_videos_adds_an_explorations_videos_as_its_own(db): + db.add(Channel(id=_ch(1), title="C")) + db.commit() + + class YT: + def get_videos(self, ids): + return [{"id": v, "snippet": {"channelId": _ch(1)}} for v in ids] + + sync_videos.ensure_videos(db, YT(), ["exploredvid"], explore=True) + sync_videos.ensure_videos(db, YT(), ["organicvid0"]) + db.expire_all() + assert db.get(Video, "exploredvid").via_explore is True + assert db.get(Video, "organicvid0").via_explore is False + + +# --- the routes ----------------------------------------------------------------------------------- + + +def test_the_list_route_says_what_it_holds_and_whether_a_history_was_uploaded( + db, make_user, as_user +): + user = make_user("route@example.com") + client = as_user(user) + assert client.get("/api/takeout/channels").json() == { + "imported": False, + "pending": 0, + "channels": [], + } + _channel(db, 1, "Some channel") + _watched(db, user, 1, [LONG] * 5) + _watched(db, user, 2, [None] * 5, asked=False) + body = client.get("/api/takeout/channels").json() + assert body["imported"] is True and body["pending"] == 1 + assert [(c["id"], c["title"], c["watched"]) for c in body["channels"]] == [ + (_ch(1), "Some channel", 5) + ] + assert body["channels"][0]["last_watched_at"].startswith("2026-05-01") + + +def test_a_channel_put_out_of_the_list_comes_back_when_restored(db, make_user, as_user): + user = make_user("dismiss@example.com") + _channel(db, 1) + _watched(db, user, 1, [LONG] * 5) + client = as_user(user) + url = f"/api/takeout/channels/{_ch(1)}/dismissal" + assert client.put(url).json() == {"ok": True, "channel_id": _ch(1)} + assert client.put(url).status_code == 200 # again: no error + assert client.get("/api/takeout/channels").json()["channels"] == [] + assert client.delete(url).json() == {"ok": True, "channel_id": _ch(1)} + assert [c["id"] for c in client.get("/api/takeout/channels").json()["channels"]] == [_ch(1)] + db.expire_all() + assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == [] + + +def test_only_a_channel_of_the_users_own_history_can_be_put_out_of_the_list(db, make_user, as_user): + user, other = make_user("mine@example.com"), make_user("theirs@example.com") + _channel(db, 1) + _watched(db, other, 1, [LONG] * 5) + client = as_user(user) + assert client.put(f"/api/takeout/channels/{_ch(1)}/dismissal").status_code == 404 + assert client.put(f"/api/takeout/channels/{_ch(9)}/dismissal").status_code == 404 + db.expire_all() + assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == [] + + +def test_a_dismissal_of_an_overlong_channel_id_is_refused_before_the_database( + db, make_user, as_user +): + user = make_user("long@example.com") + assert as_user(user).put(f"/api/takeout/channels/{'U' * 33}/dismissal").status_code == 422 diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index b0ac57fc..5425ae26 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -56,6 +56,7 @@ const PLAIN: Record = { "modules/files/VideoEditor.tsx": [2, "timeline glyphs"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"], "modules/playlists/playlistColumns.tsx": [1, "the thumbnail"], + "modules/channels/WatchedVideosDialog.tsx": [1, "the thumbnail (its title sits beside it)"], "modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"], "modules/plex/plexViews.tsx": [1, "the rating filter (a number)"], "components/ui/ActionMenu.tsx": [1, "a menu row"], diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 2a1304d6..12d3da4b 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,15 @@ [ + { + "version": "0.111.0", + "date": "2026-10-24", + "new": [ + "Channels › Discover from your history: the channels your uploaded YouTube history shows you watching often (five videos of five minutes or more) but not following.", + "Follow one, put it out of the list (with Undo), or open the videos you watched there and play them one by one or all in a row." + ], + "fixed": [ + "A channel someone explored and then followed on YouTube is now part of the shared Library like any followed channel, and so are its videos; before, they could stay hidden and their watched marks never applied." + ] + }, { "version": "0.110.0", "date": "2026-10-23", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 8166f125..e5631c05 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,15 @@ [ + { + "version": "0.111.0", + "date": "2026-10-24", + "new": [ + "Csatornák › Felfedezés az előzményekből: azok a csatornák, amelyeket a feltöltött YouTube-előzményeid szerint sokat nézel (öt legalább ötperces videó), de nem követsz.", + "Követheted, kiveheted a listából (visszavonható), vagy megnyithatod az ott megnézett videóit, és egyenként vagy sorban lejátszhatod őket." + ], + "fixed": [ + "Ha egy felfedezett csatornát valaki a YouTube-on kezd követni, az most a többi követett csatornához hasonlóan a közös Könyvtár része lesz a videóival együtt; eddig rejtve maradhattak, és a megnézett jelöléseik sem érvényesültek." + ] + }, { "version": "0.110.0", "date": "2026-10-23", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 3d9b956b..4cc834fa 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Subscriptions", "discovery": "Discover from playlists", + "watched": "Discover from your history", "hidden": "Hidden", "blocked": "Blocked" }, @@ -154,5 +155,38 @@ "hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.", "empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal Feed without unsubscribing.", "unhide": "Unhide" + }, + "watched": { + "intro": "Channels you often watched on YouTube but don't follow, from the watch history you uploaded. Only videos of five minutes or more count, so Shorts stay out. Follow a channel to get its new uploads in your Feed, or take it off the list.", + "loading": "Finding channels…", + "empty": "No suggestions — you follow every channel you watched five or more longer videos from.", + "notImported": "Upload your YouTube watch history to see the channels you watch but don't follow.", + "openTakeout": "Open YouTube history", + "pending_one": "{{count}} more channel is still being looked at — the list may grow.", + "pending_other": "{{count}} more channels are still being looked at — the list may grow.", + "more_one": "Show {{count}} more", + "more_other": "Show {{count}} more", + "shown": "{{shown}} of {{total}}", + "dismiss": "Not interested", + "dismissHint": "Take this channel off the list. You can undo it right after.", + "dismissed": "{{name}} is off the list", + "cols": { + "watched": "Watched", + "watchedHint_one": "Show the {{count}} video of five minutes or more you watched from this channel", + "watchedHint_other": "Show the {{count}} videos of five minutes or more you watched from this channel", + "last": "Last watched" + }, + "videos": { + "title": "Videos you watched from {{name}}", + "summary_one": "{{count}} video of five minutes or more · last watched {{when}}", + "summary_other": "{{count}} videos of five minutes or more · last watched {{when}}", + "loading": "Getting the videos ready…", + "gone_one": "{{count}} video is no longer on YouTube", + "gone_other": "{{count}} videos are no longer on YouTube", + "empty": "None of these videos is on YouTube any more.", + "colTitle": "Video", + "colLength": "Length", + "colWatched": "Watched" + } } } diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index c5d7d4dd..0b1ae16c 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Couldn't create the token", "revoke": "Couldn't revoke the token" - } + }, + "dismissWatchedChannel": "Couldn't take the channel off the list", + "restoreWatchedChannel": "Couldn't put the channel back on the list" }, "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index f73a6dd6..5ff9c159 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Feliratkozások", "discovery": "Felfedezés a lejátszási listákból", + "watched": "Felfedezés az előzményekből", "hidden": "Elrejtett", "blocked": "Letiltott" }, @@ -154,5 +155,38 @@ "hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Könyvtárban továbbra is megjelennek.", "empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.", "unhide": "Felfedés" + }, + "watched": { + "intro": "Csatornák, amelyeket YouTube-on gyakran néztél, de nem követsz — a feltöltött nézési előzményeid alapján. Csak az öt percnél hosszabb videók számítanak, így a Shorts kimarad. Kövesd a csatornát, és az új videói megjelennek a hírfolyamodban, vagy vedd le a listáról.", + "loading": "Csatornák keresése…", + "empty": "Nincs javaslat — minden csatornát követsz, amelyről legalább öt hosszabb videót néztél.", + "notImported": "Töltsd fel a YouTube nézési előzményeidet, és itt látod a csatornákat, amelyeket nézel, de nem követsz.", + "openTakeout": "YouTube-előzmények megnyitása", + "pending_one": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.", + "pending_other": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.", + "more_one": "További {{count}}", + "more_other": "További {{count}}", + "shown": "{{shown}} / {{total}}", + "dismiss": "Nem érdekel", + "dismissHint": "A csatorna lekerül a listáról. Utána rögtön visszavonhatod.", + "dismissed": "{{name}} lekerült a listáról", + "cols": { + "watched": "Nézett", + "watchedHint_one": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása", + "watchedHint_other": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása", + "last": "Utoljára nézve" + }, + "videos": { + "title": "{{name}}: a videók, amelyeket láttál", + "summary_one": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}", + "summary_other": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}", + "loading": "A videók előkészítése…", + "gone_one": "{{count}} videó már nincs fent a YouTube-on", + "gone_other": "{{count}} videó már nincs fent a YouTube-on", + "empty": "Ezek közül egyik videó sincs már fent a YouTube-on.", + "colTitle": "Videó", + "colLength": "Hossz", + "colWatched": "Nézve" + } } } diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 39b735b4..b902ced6 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Nem sikerült létrehozni a tokent", "revoke": "Nem sikerült visszavonni a tokent" - } + }, + "dismissWatchedChannel": "Nem sikerült levenni a csatornát a listáról", + "restoreWatchedChannel": "Nem sikerült visszatenni a csatornát a listára" }, "ok": "OK", "offline": { diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 41a89376..0e53ed30 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -195,6 +195,33 @@ export interface TakeoutStatus { watch_later?: TakeoutCounts; } +// A channel the user's Takeout history shows them watching often but not following +// (`GET /api/takeout/channels`): `watched` counts its videos of five minutes or more they watched. +export interface WatchedChannel { + id: string; + title: string | null; + handle: string | null; + thumbnail_url: string | null; + subscriber_count: number | null; + video_count: number | null; + description: string | null; + watched: number; + last_watched_at: string | null; +} +export interface WatchedChannels { + /** Whether a watch history was uploaded at all ("nothing to suggest" vs "nothing to go on"). */ + imported: boolean; + /** How many more channels may join once the import has asked YouTube for their videos' lengths. */ + pending: number; + channels: WatchedChannel[]; +} +/** A channel's counted videos, ready to play, last watched first; `gone`: those YouTube lost. */ +export interface WatchedChannelVideos { + ok: true; + items: (Video & { takeout_watched_at: string | null })[]; + gone: number; +} + export const feedApi = { feed: (f: FeedFilters, cursor: string | null, limit: number): Promise => req(`/api/feed?${feedQuery(f, cursor, limit)}`), @@ -260,4 +287,11 @@ export const feedApi = { // One file of a Google Takeout export, its bytes as they are; the server tells the two apart. takeoutUpload: (file: File): Promise => uploadReq("/api/takeout", file), takeoutStatus: (): Promise => req("/api/takeout"), + watchedChannels: (): Promise => req("/api/takeout/channels"), + watchedChannelVideos: (id: string): Promise => + req(`/api/takeout/channels/${encodeURIComponent(id)}/videos`, { method: "POST" }), + dismissWatchedChannel: (id: string) => + req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "PUT" }), + restoreWatchedChannel: (id: string) => + req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "DELETE" }), }; diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 22c2c030..7a138cf6 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -37,6 +37,10 @@ export const qk = { channels: () => ["channels"] as const, channel: (id: string) => ["channel", id] as const, discoveredChannels: () => ["discovered-channels"] as const, + watchedChannels: () => ["watched-channels"] as const, + // A root of its own, not under the list's: asking for them explores the channel (a write), so the + // list's invalidations must not refetch them under an open dialog. + watchedChannelVideos: (id: string) => ["watched-channel-videos", id] as const, blockedChannels: () => ["blocked-channels"] as const, // --- playlists --- diff --git a/frontend/src/lib/subscriptionCache.test.ts b/frontend/src/lib/subscriptionCache.test.ts index ff9f6109..bc5efd25 100644 --- a/frontend/src/lib/subscriptionCache.test.ts +++ b/frontend/src/lib/subscriptionCache.test.ts @@ -20,6 +20,7 @@ describe("a subscription change", () => { qk.feed(), qk.feedSummary(), qk.discoveredChannels(), + qk.watchedChannels(), ]); }); diff --git a/frontend/src/lib/subscriptionCache.ts b/frontend/src/lib/subscriptionCache.ts index 20721000..dff6d221 100644 --- a/frontend/src/lib/subscriptionCache.ts +++ b/frontend/src/lib/subscriptionCache.ts @@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; /** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its - * counts, the feed and its facets (the channel's videos come or go), and discovery (a subscribed - * channel leaves it). ONE set for every site that makes the change: each used to keep + * counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a + * subscribed channel leaves them). ONE set for every site that makes the change: each used to keep * its own list, and each list missed something. */ export function invalidateSubscription(qc: QueryClient, channelId: string): void { for (const queryKey of [ @@ -14,6 +14,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void qk.feed(), qk.feedSummary(), qk.discoveredChannels(), + qk.watchedChannels(), ]) void qc.invalidateQueries({ queryKey }); } diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index a1ee78a0..9358d4aa 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups"; import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { optionsFromColumns } from "../../lib/sortOptions"; import ChannelDiscovery from "./ChannelDiscovery"; +import WatchedChannels from "./WatchedChannels"; import TagManager from "../../features/channel/TagManager"; import { useChannelLayout, @@ -183,6 +184,8 @@ export default function Channels() { onSuccess: () => { qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.feed() }); + // A blocked channel is off the watched-channels list; unblocked, it may be back on it. + qc.invalidateQueries({ queryKey: qk.watchedChannels() }); }, }); @@ -530,7 +533,7 @@ export default function Channels() { ); - // Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no + // Table / cards density — shared by the subscribed + both discovery tables (blocked is a chip list, no // layout to switch). Rides the same generic ViewSwitcher the feed uses. const layoutOptions: ViewOption[] = CHANNEL_LAYOUTS.map((id) => ({ id, @@ -546,7 +549,7 @@ export default function Channels() { labelClassName="hidden lg:inline" /> ); - const hasLayout = view === "subscribed" || view === "discovery"; + const hasLayout = view === "subscribed" || view === "discovery" || view === "watched"; // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks. const sortControl = ( @@ -612,7 +615,7 @@ export default function Channels() { const viewTabs: TabDef[] = useMemo( () => - (["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ + (["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ id: v, label: t(`channels.tabs.${v}`), })), @@ -778,6 +781,13 @@ export default function Channels() { onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} /> + ) : view === "watched" ? ( + ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 0bfc05d1..21581803 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -48,7 +48,9 @@ import { // The contexts, their value types and the hooks live in channelsContext.ts. function clampView(v: string | null): ChannelsView { - return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed"; + return v === "discovery" || v === "watched" || v === "blocked" || v === "hidden" + ? v + : "subscribed"; } // One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab diff --git a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx new file mode 100644 index 00000000..5789b065 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom +// +// The Channel manager's "Discover from your history" tab: what it says before a history was +// uploaded and while the list is still being made, its top twenty and "show more", taking a channel +// off the list with an Undo, and a row's count opening the channel's watched videos to play. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const h = vi.hoisted(() => ({ + notify: vi.fn(), + confirm: vi.fn(async () => true), + setPage: vi.fn(), + focusSettingsTab: vi.fn(), + player: vi.fn(), +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: Record) => + o && "count" in o ? `${key}:${String(o.count)}` : key, + }), + }; +}); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: (...a: unknown[]) => h.notify(...a), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); +vi.mock("../../features/navigation/navigationContext", () => ({ + useNavigationActions: () => ({ setPage: h.setPage }), +})); +vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab })); +vi.mock("../../features/player/PlayerModal", () => ({ + default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => { + h.player( + p.video.id, + p.queue.map((v) => v.id), + p.startIndex, + ); + return
; + }, +})); + +import WatchedChannels from "./WatchedChannels"; +import { qk } from "../../lib/queryKeys"; +import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const fn of Object.values(h)) fn.mockReset(); + h.confirm.mockImplementation(async () => true); +}); + +const channel = (n: number, over: Partial = {}): WatchedChannel => ({ + id: `UC${n}`, + title: `Channel ${n}`, + handle: null, + thumbnail_url: null, + subscriber_count: 1000, + video_count: 50, + description: null, + watched: 30 - n, + last_watched_at: "2026-09-24T10:00:00Z", + ...over, +}); + +const video = (id: string): Video & { takeout_watched_at: string } => ({ + id, + title: `Video ${id}`, + channel_id: "UC1", + channel_title: "Channel 1", + channel_thumbnail: null, + channel_url: "", + published_at: null, + thumbnail_url: null, + duration_seconds: 600, + view_count: null, + is_short: false, + live_status: "none", + status: "new", + position_seconds: 0, + saved: false, + watch_url: "", + takeout_watched_at: "2026-09-24T10:00:00Z", +}); + +function mount(list: List) { + vi.spyOn(api, "watchedChannels").mockResolvedValue(list); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + {}} onViewChannel={() => {}} /> + , + ); + return qc; +} + +describe("WatchedChannels", () => { + it("sends a user with no history uploaded to the upload, not to an empty list", async () => { + mount({ imported: false, pending: 0, channels: [] }); + fireEvent.click(await screen.findByRole("button", { name: "channels.watched.openTakeout" })); + expect(h.focusSettingsTab).toHaveBeenCalledWith("account"); + expect(h.setPage).toHaveBeenCalledWith("settings"); + expect(screen.queryByText("channels.watched.empty")).toBeNull(); + }); + + it("says when more channels may still join, and when there is nothing to suggest", async () => { + mount({ imported: true, pending: 3, channels: [] }); + expect(await screen.findByText("channels.watched.empty")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe("channels.watched.pending:3"); + }); + + it("shows the top twenty, the most watched first, and twenty more on asking", async () => { + mount({ + imported: true, + pending: 0, + channels: Array.from({ length: 27 }, (_, i) => channel(i)), + }); + await screen.findByText("Channel 0"); + expect(screen.getByText("Channel 19")).toBeTruthy(); + expect(screen.queryByText("Channel 20")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "channels.watched.more:7" })); + expect(screen.getByText("Channel 26")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /channels\.watched\.more/ })).toBeNull(); + }); + + it("takes a channel off the list with an Undo that puts it back", async () => { + const dismiss = vi.spyOn(api, "dismissWatchedChannel").mockResolvedValue({ ok: true }); + const restore = vi.spyOn(api, "restoreWatchedChannel").mockResolvedValue({ ok: true }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" })); + await waitFor(() => expect(h.notify).toHaveBeenCalled()); + expect(dismiss).toHaveBeenCalledWith("UC1"); + const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } }; + expect(toast.action.label).toBe("common.undo"); + act(() => toast.action.onClick()); + await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1")); + }); + + it("opens a row's watched videos from its count, plays one and plays them all", async () => { + const videos = vi + .spyOn(api, "watchedChannelVideos") + .mockResolvedValue({ ok: true, items: [video("a"), video("b"), video("c")], gone: 2 }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + await within(dialog).findByText("Video b"); + expect(videos).toHaveBeenCalledWith("UC1"); + expect(dialog.textContent).toContain("channels.watched.videos.gone:2"); + fireEvent.click(within(dialog).getByRole("button", { name: "card.play — Video b" })); + await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("b", ["a", "b", "c"], 1)); + fireEvent.click(within(dialog).getByRole("button", { name: "playlists.playAll" })); + await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0)); + }); + + it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => { + // The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow) + // must not repeat it under an open dialog, nor swap the queue of a video playing. + const videos = vi + .spyOn(api, "watchedChannelVideos") + .mockResolvedValue({ ok: true, items: [video("a")], gone: 0 }); + const qc = mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + await within(await screen.findByRole("dialog")).findByText("Video a"); + await act(() => qc.invalidateQueries({ queryKey: qk.watchedChannels() })); + await waitFor(() => expect(api.watchedChannels).toHaveBeenCalledTimes(2)); + expect(videos).toHaveBeenCalledTimes(1); + }); + + it("follows a channel from its videos after the confirm, and closes", async () => { + vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ + ok: true, + items: [video("a")], + gone: 0, + }); + const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); + await waitFor(() => expect(subscribe).toHaveBeenCalledWith("UC1")); + expect(h.confirm).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("follows nothing and stays open when the confirm is declined", async () => { + vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ + ok: true, + items: [video("a")], + gone: 0, + }); + const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); + h.confirm.mockImplementation(async () => false); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); + await waitFor(() => expect(h.confirm).toHaveBeenCalled()); + expect(subscribe).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/channels/WatchedChannels.tsx b/frontend/src/modules/channels/WatchedChannels.tsx new file mode 100644 index 00000000..f8591648 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.tsx @@ -0,0 +1,227 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { EyeOff, History, UserPlus } from "lucide-react"; +import { qk } from "../../lib/queryKeys"; +import { api, type WatchedChannel } from "../../lib/api"; +import { formatDate } from "../../lib/format"; +import { focusSettingsTab } from "../../lib/settingsTab"; +import { useNavigationActions } from "../../features/navigation/navigationContext"; +import Tooltip from "../../components/ui/Tooltip"; +import DataTable, { type Column } from "../../components/ui/DataTable"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import { Button } from "../../components/ui/Button"; +import ChannelLink from "./ChannelLink"; +import AboutCell from "./AboutCell"; +import ChannelLayoutGrid from "./ChannelLayoutGrid"; +import { subsColumn } from "./channelColumns"; +import { useChannelLayout } from "./channelsContext"; +import { useWatchedChannelActions } from "./useWatchedChannelActions"; + +const WatchedVideosDialog = lazy(() => import("./WatchedVideosDialog")); + +// How many channels the list shows at first, and how many more each "Show more" adds. The list is a +// ranking — the most watched first — so a short top is the point: past it, the reader asks for more. +const WATCHED_PAGE = 20; + +// The Channel manager's "Discover from your history" tab: the channels the user's Takeout watch +// history shows them watching often but not following, the most watched first. A row's count opens +// the videos it counts, ready to play — a quick look at what the channel was, before following it. +export default function WatchedChannels({ + canWrite, + search, + onOpenWizard, + onViewChannel, +}: { + canWrite: boolean; + // Client-side name filter from the shared top SearchBar (matches title + handle). + search: string; + onOpenWizard: () => void; + onViewChannel: (id: string, name: string) => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const layout = useChannelLayout(); + const { setPage } = useNavigationActions(); + const [shown, setShown] = useState(WATCHED_PAGE); + const [open, setOpen] = useState(null); + const actions = useWatchedChannelActions(onOpenWizard); + + const query = useQuery({ queryKey: qk.watchedChannels(), queryFn: api.watchedChannels }); + + const q = search.trim().toLowerCase(); + const rows = (query.data?.channels ?? []).filter( + (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), + ); + const visible = rows.slice(0, shown); + + // The order is the ranking itself, so no column sorts: a header sort would only reorder the top + // twenty and hide that it is a top. + const columns: Column[] = [ + { + key: "actions", + header: t("channels.cols.actions"), + align: "left", + nowrap: true, + cardLabel: false, + render: (c) => ( +
+ + + + + + +
+ ), + }, + { + key: "channel", + header: t("channels.cols.channel"), + cardPrimary: true, + render: (c) => ( +
+ onViewChannel(c.id, c.title ?? c.id)} + /> +
+ ), + }, + { + key: "about", + header: t("channels.cols.about"), + hideInCard: true, + render: (c) => , + }, + { ...subsColumn(t, lang), sortable: false }, + { + key: "watched", + header: t("channels.watched.cols.watched"), + align: "right", + nowrap: true, + render: (c) => ( + + + + ), + }, + { + key: "last", + header: t("channels.watched.cols.last"), + align: "right", + nowrap: true, + render: (c) => ( + {formatDate(c.last_watched_at, lang)} + ), + }, + ]; + + const pending = query.data?.pending ?? 0; + const more = Math.min(WATCHED_PAGE, rows.length - visible.length); + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : !query.data?.imported ? ( + // The message and the way to the upload, one line: StateMessage lays its content in a row. + + {t("channels.watched.notImported")} + + + ) : layout === "table" ? ( + c.id} + pager={false} + emptyText={t("channels.watched.empty")} + keyboardNav + narrowedBy={q} + /> + ) : ( + c.id} + aboutKey="about" + actionsKey="actions" + emptyText={t("channels.watched.empty")} + /> + ); + + return ( +
+

{t("channels.watched.intro")}

+ {pending > 0 && ( +

+ {t("channels.watched.pending", { count: pending })} +

+ )} + {body} + {rows.length > 0 && ( +
+ + {t("channels.watched.shown", { shown: visible.length, total: rows.length })} + + {more > 0 && ( + + )} +
+ )} + {open && ( + + setOpen(null)} + /> + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/WatchedVideosDialog.tsx b/frontend/src/modules/channels/WatchedVideosDialog.tsx new file mode 100644 index 00000000..4f9c435a --- /dev/null +++ b/frontend/src/modules/channels/WatchedVideosDialog.tsx @@ -0,0 +1,205 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { EyeOff, Play, UserPlus } from "lucide-react"; +import { qk } from "../../lib/queryKeys"; +import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api"; +import { formatDate, formatDuration } from "../../lib/format"; +import { notifyActionError } from "../../lib/actionErrors"; +import { useStableCallback } from "../../lib/useLatest"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import DataTable, { type Column } from "../../components/ui/DataTable"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import { Button } from "../../components/ui/Button"; +import { Pressable } from "../../components/ui/Pressable"; +import RemoteImage from "../../components/ui/RemoteImage"; +import Tooltip from "../../components/ui/Tooltip"; +import type { WatchedChannelActions } from "./useWatchedChannelActions"; + +const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); + +type WatchedVideo = Video & { takeout_watched_at: string | null }; + +// The videos of one channel that the watched-channels list counts, last watched first, each ready to +// play — and Play all through them, as on a playlist. Opening it explores the channel for the user +// (the server brings in the videos the catalog lacks as the exploration's), so it is asked for once +// per opening, not refetched behind the reader's back. +export default function WatchedVideosDialog({ + channel, + canWrite, + actions, + onClose, +}: { + channel: WatchedChannel; + canWrite: boolean; + actions: WatchedChannelActions; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const qc = useQueryClient(); + const name = channel.title ?? channel.id; + const key = qk.watchedChannelVideos(channel.id); + const query = useQuery({ + queryKey: key, + queryFn: () => api.watchedChannelVideos(channel.id), + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }); + const items = query.data?.items ?? []; + const gone = query.data?.gone ?? 0; + const [playing, setPlaying] = useState(null); + + // The player's watched / in-progress marks: saved, then shown on this list and the feed's. + const onState = useStableCallback((id: string, status: VideoStatus) => { + api + .setState(id, status) + .then(() => { + qc.setQueryData( + key, + (old: typeof query.data) => + old && { + ...old, + items: old.items.map((v) => (v.id === id ? { ...v, status } : v)), + }, + ); + void qc.invalidateQueries({ queryKey: qk.feed() }); + }) + .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); + }); + const closePlayer = useStableCallback(() => setPlaying(null)); + + const columns: Column[] = [ + { + key: "title", + header: t("channels.watched.videos.colTitle"), + width: "100%", + cardPrimary: true, + render: (v) => ( +
+ setPlaying(items.indexOf(v))} + aria-label={`${t("card.play")} — ${v.title ?? v.id}`} + className="relative w-20 h-11.25 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target" + > + {v.thumbnail_url && ( + + )} + + + + + {v.title ?? v.id} +
+ ), + }, + { + key: "length", + header: t("channels.watched.videos.colLength"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDuration(v.duration_seconds)} + ), + }, + { + key: "watched", + header: t("channels.watched.videos.colWatched"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDate(v.takeout_watched_at, lang)} + ), + }, + ]; + + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : ( + v.id} + pager={false} + emptyText={t("channels.watched.videos.empty")} + keyboardNav + /> + ); + + return ( + } + onClick={() => setPlaying(0)} + disabled={!items.length} + > + {t("playlists.playAll")} + + } + > +

+ {t("channels.watched.videos.summary", { + count: channel.watched, + when: formatDate(channel.last_watched_at, lang), + })} + {gone > 0 && ` · ${t("channels.watched.videos.gone", { count: gone })}`} +

+ {body} + + + + + + + {playing != null && items[playing] && ( + + + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/channelsContext.ts b/frontend/src/modules/channels/channelsContext.ts index 47828f36..aa729c60 100644 --- a/frontend/src/modules/channels/channelsContext.ts +++ b/frontend/src/modules/channels/channelsContext.ts @@ -7,7 +7,7 @@ import type { FilterMap } from "../../lib/tableColumns"; // components (Fast Refresh). The provider owns the state; this is the read side every consumer imports. // The manager's tabs. -export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; +export type ChannelsView = "subscribed" | "discovery" | "watched" | "hidden" | "blocked"; export type ChannelsState = { search: string; diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts new file mode 100644 index 00000000..8b30bd7e --- /dev/null +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../../lib/queryKeys"; +import { invalidateSubscription } from "../../lib/subscriptionCache"; +import { api, type WatchedChannel } from "../../lib/api"; +import { notify } from "../../lib/notifications"; +import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; +import { useConfirm } from "../../components/ui/confirmContext"; + +/** Follow and take-off-the-list for a watched channel: the tab's rows and its videos dialog share + * them, so the confirm, the toasts and the cache refresh are written once. */ +export function useWatchedChannelActions(onOpenWizard: () => void) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const nameOf = (c: WatchedChannel) => c.title ?? c.id; + + const subscribe = useMutation({ + mutationFn: (c: WatchedChannel) => api.subscribeChannel(c.id), + onSuccess: (_data, c) => { + invalidateSubscription(qc, c.id); + notify({ + level: "success", + title: t("channels.discovery.subscribedTitle"), + message: t("channels.discovery.subscribedBody", { name: nameOf(c) }), + meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) }, + }); + }, + onError: (err: unknown) => + notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), + }); + const restore = useMutation({ + mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id), + meta: { failTitle: "errors.writeFailed.restoreWatchedChannel" }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.watchedChannels() }), + }); + // Taken off the list at once, with the toast's Undo as the way back (the same bargain as hiding a + // channel): the next channel steps up into its place. + const dismiss = useMutation({ + mutationFn: (c: WatchedChannel) => api.dismissWatchedChannel(c.id), + meta: { failTitle: "errors.writeFailed.dismissWatchedChannel" }, + onSuccess: (_data, c) => { + void qc.invalidateQueries({ queryKey: qk.watchedChannels() }); + notify({ + level: "success", + message: t("channels.watched.dismissed", { name: nameOf(c) }), + action: { label: t("common.undo"), onClick: () => restore.mutate(c) }, + }); + }, + }); + + // Subscribing changes the user's real YouTube account and spends a little quota — confirmed, as + // on the Discovery tab. Resolves whether it went ahead. + const onSubscribe = async (c: WatchedChannel): Promise => { + const ok = await confirm({ + title: t("channels.discovery.subscribe"), + message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }), + confirmLabel: t("channels.discovery.subscribe"), + }); + if (ok) subscribe.mutate(c); + return ok; + }; + return { + onSubscribe, + subscribing: subscribe.isPending, + dismiss: (c: WatchedChannel) => dismiss.mutate(c), + }; +} + +export type WatchedChannelActions = ReturnType; diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index 0551865d..18147aa9 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -157,6 +157,8 @@ export default function ChannelPage({ qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() }); + // A blocked channel is off the watched-channels list; unblocked, it may be back on it. + qc.invalidateQueries({ queryKey: qk.watchedChannels() }); }, // No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide // reporter names the failure the error dialog does not cover (a 404 for a channel gone).