Compare commits
6
Commits
84af92aa29
...
4695e1a62f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4695e1a62f | ||
|
|
1adf59ea1b | ||
|
|
bc8fe2cc6c | ||
|
|
33dd9ff054 | ||
|
|
b5f9cf14db | ||
|
|
bd2340c204 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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=<id>` (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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+101
-2
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -56,6 +56,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<FeedResponse> =>
|
||||
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<TakeoutUpload> => uploadReq("/api/takeout", file),
|
||||
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
|
||||
watchedChannels: (): Promise<WatchedChannels> => req("/api/takeout/channels"),
|
||||
watchedChannelVideos: (id: string): Promise<WatchedChannelVideos> =>
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("a subscription change", () => {
|
||||
qk.feed(),
|
||||
qk.feedSummary(),
|
||||
qk.discoveredChannels(),
|
||||
qk.watchedChannels(),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||
);
|
||||
|
||||
// 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<ChannelLayout>[] = 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" ? (
|
||||
<WatchedChannels
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && "count" in o ? `${key}:${String(o.count)}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
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 <div data-testid="player" />;
|
||||
},
|
||||
}));
|
||||
|
||||
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> = {}): 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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<WatchedChannels canWrite search="" onOpenWizard={() => {}} onViewChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<WatchedChannel | null>(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<WatchedChannel>[] = [
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip
|
||||
always={!canWrite}
|
||||
hint={
|
||||
canWrite
|
||||
? t("channels.discovery.subscribeHint")
|
||||
: t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<UserPlus />}
|
||||
onClick={() => void actions.onSubscribe(c)}
|
||||
aria-disabled={!canWrite}
|
||||
loading={actions.subscribing}
|
||||
>
|
||||
{t("channels.discovery.subscribe")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("channels.watched.dismissHint")}>
|
||||
<Button variant="ghost" size="xs" icon={<EyeOff />} onClick={() => actions.dismiss(c)}>
|
||||
{t("channels.watched.dismiss")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("channels.cols.channel"),
|
||||
cardPrimary: true,
|
||||
render: (c) => (
|
||||
<div className="max-w-[18rem]">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
header: t("channels.cols.about"),
|
||||
hideInCard: true,
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
{ ...subsColumn<WatchedChannel>(t, lang), sortable: false },
|
||||
{
|
||||
key: "watched",
|
||||
header: t("channels.watched.cols.watched"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (c) => (
|
||||
<Tooltip hint={t("channels.watched.cols.watchedHint", { count: c.watched })}>
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="tabular-nums"
|
||||
icon={<History />}
|
||||
onClick={() => setOpen(c)}
|
||||
>
|
||||
{c.watched}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "last",
|
||||
header: t("channels.watched.cols.last"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatDate(c.last_watched_at, lang)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const pending = query.data?.pending ?? 0;
|
||||
const more = Math.min(WATCHED_PAGE, rows.length - visible.length);
|
||||
const body =
|
||||
query.isError && !query.data ? (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<LoadingState shape="list" label={t("channels.watched.loading")} />
|
||||
) : !query.data?.imported ? (
|
||||
// The message and the way to the upload, one line: StateMessage lays its content in a row.
|
||||
<StateMessage>
|
||||
{t("channels.watched.notImported")}
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<History />}
|
||||
onClick={() => {
|
||||
focusSettingsTab("account");
|
||||
setPage("settings");
|
||||
}}
|
||||
>
|
||||
{t("channels.watched.openTakeout")}
|
||||
</Button>
|
||||
</StateMessage>
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={visible}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
pager={false}
|
||||
emptyText={t("channels.watched.empty")}
|
||||
keyboardNav
|
||||
narrowedBy={q}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={visible}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.watched.empty")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.watched.intro")}</p>
|
||||
{pending > 0 && (
|
||||
<p className="text-xs text-muted leading-relaxed pt-1" role="status">
|
||||
{t("channels.watched.pending", { count: pending })}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
{rows.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 pt-3 text-xs text-muted">
|
||||
<span className="tabular-nums">
|
||||
{t("channels.watched.shown", { shown: visible.length, total: rows.length })}
|
||||
</span>
|
||||
{more > 0 && (
|
||||
<Button size="sm" onClick={() => setShown((n) => n + WATCHED_PAGE)}>
|
||||
{t("channels.watched.more", { count: more })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<WatchedVideosDialog
|
||||
channel={open}
|
||||
canWrite={canWrite}
|
||||
actions={actions}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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<WatchedVideo>[] = [
|
||||
{
|
||||
key: "title",
|
||||
header: t("channels.watched.videos.colTitle"),
|
||||
width: "100%",
|
||||
cardPrimary: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Pressable
|
||||
data-nav-open
|
||||
onClick={() => 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 && (
|
||||
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</Pressable>
|
||||
<span className="min-w-0 line-clamp-2 text-sm">{v.title ?? v.id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "length",
|
||||
header: t("channels.watched.videos.colLength"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted tabular-nums">{formatDuration(v.duration_seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "watched",
|
||||
header: t("channels.watched.videos.colWatched"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted tabular-nums">{formatDate(v.takeout_watched_at, lang)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const body =
|
||||
query.isError && !query.data ? (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<LoadingState shape="list" label={t("channels.watched.videos.loading")} />
|
||||
) : (
|
||||
<DataTable
|
||||
rows={items}
|
||||
columns={columns}
|
||||
rowKey={(v) => v.id}
|
||||
pager={false}
|
||||
emptyText={t("channels.watched.videos.empty")}
|
||||
keyboardNav
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("channels.watched.videos.title", { name })}
|
||||
initialFocus="close"
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-3xl"
|
||||
headerActions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Play className="fill-current" />}
|
||||
onClick={() => setPlaying(0)}
|
||||
disabled={!items.length}
|
||||
>
|
||||
{t("playlists.playAll")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted pb-2">
|
||||
{t("channels.watched.videos.summary", {
|
||||
count: channel.watched,
|
||||
when: formatDate(channel.last_watched_at, lang),
|
||||
})}
|
||||
{gone > 0 && ` · ${t("channels.watched.videos.gone", { count: gone })}`}
|
||||
</p>
|
||||
{body}
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={<EyeOff />}
|
||||
onClick={() => {
|
||||
actions.dismiss(channel);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("channels.watched.dismiss")}
|
||||
</Button>
|
||||
<Tooltip
|
||||
always={!canWrite}
|
||||
hint={
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
icon={<UserPlus />}
|
||||
aria-disabled={!canWrite}
|
||||
loading={actions.subscribing}
|
||||
onClick={() => {
|
||||
void actions.onSubscribe(channel).then((ok) => ok && onClose());
|
||||
}}
|
||||
>
|
||||
{t("channels.discovery.subscribe")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ModalFooter>
|
||||
{playing != null && items[playing] && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={items[playing]}
|
||||
queue={items}
|
||||
startIndex={playing}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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<typeof useWatchedChannelActions>;
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user