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).
|
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
|
## 0.110.0 — 2026-10-23
|
||||||
|
|
||||||
### New
|
### 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
|
Boolean, default=False, server_default="false", index=True
|
||||||
)
|
)
|
||||||
# True when this channel exists only because a user EXPLORED it (opened its page without
|
# 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
|
# subscribing) — an ephemeral, per-explorer addition. After a grace period the cleanup job
|
||||||
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
|
# hard-deletes its via_explore videos nobody keeps (no subscription, no live ExploredChannel
|
||||||
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
|
# row, no per-user state), and then the channel row itself once it holds no videos — unless a
|
||||||
# subscribes (the "keep" signal promotes it to organic).
|
# 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(
|
from_explore: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
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
|
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
|
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
|
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
|
purge: of a from_explore channel with no live ExploredChannel row (and no subscription) the
|
||||||
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
|
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`
|
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."""
|
(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),
|
subscribed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if channel.from_explore:
|
db.flush()
|
||||||
channel.from_explore = False
|
settle_explored(db, channel)
|
||||||
db.execute(
|
|
||||||
update(Video)
|
|
||||||
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
|
def settle_explored(db: Session, channel: Channel) -> None:
|
||||||
.values(via_explore=False)
|
"""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()
|
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
|
"""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
|
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.
|
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
|
The id is not a foreign key for that reason. A watched row's channel and length feed the
|
||||||
watch but do not follow" list."""
|
"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"
|
__tablename__ = "takeout_items"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -277,3 +278,21 @@ class TakeoutItem(Base, TimestampMixin):
|
|||||||
# Watched: the last time in the export; Watch later: when it was added.
|
# Watched: the last time in the export; Watch later: when it was added.
|
||||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
outcome: Mapped[str | None] = mapped_column(String(12))
|
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.collation import collated
|
||||||
from app.feedquery import feed_columns, serialize
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import User
|
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 (
|
from app.reader.models import (
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistItem,
|
PlaylistItem,
|
||||||
SavedView,
|
SavedView,
|
||||||
Tag,
|
Tag,
|
||||||
|
TakeoutChannelDismissal,
|
||||||
TakeoutItem,
|
TakeoutItem,
|
||||||
TakeoutKind,
|
TakeoutKind,
|
||||||
VideoState,
|
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]]:
|
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."""
|
"""(user id, video id) for every user who has one of these videos in a playlist."""
|
||||||
return list(
|
return list(
|
||||||
@@ -533,3 +546,194 @@ def takeout_users_watch_later_waiting(db: Session) -> list[int]:
|
|||||||
.distinct()
|
.distinct()
|
||||||
).scalars()
|
).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 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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ from app.reader.models import (
|
|||||||
PlaylistItem,
|
PlaylistItem,
|
||||||
SavedView,
|
SavedView,
|
||||||
Tag,
|
Tag,
|
||||||
|
TakeoutChannelDismissal,
|
||||||
TakeoutItem,
|
TakeoutItem,
|
||||||
TakeoutKind,
|
TakeoutKind,
|
||||||
TakeoutOutcome,
|
TakeoutOutcome,
|
||||||
@@ -662,3 +665,51 @@ def apply_takeout_watch_later(
|
|||||||
"watch_later_present": len(done[TakeoutOutcome.PRESENT]),
|
"watch_later_present": len(done[TakeoutOutcome.PRESENT]),
|
||||||
"watch_later_missing": len(done[TakeoutOutcome.MISSING]),
|
"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
|
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
|
`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,
|
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
|
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 import queries, service
|
||||||
from app.reader.models import TakeoutKind
|
from app.reader.models import TakeoutKind
|
||||||
from app.sync.runner import get_service_user
|
from app.sync.runner import get_service_user
|
||||||
from app.sync.videos import ensure_videos
|
from app.sync.subscriptions import describe_channels
|
||||||
from app.youtube.client import YouTubeClient
|
from app.sync.videos import ensure_videos, parse_iso8601_duration
|
||||||
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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:
|
# 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)
|
_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:
|
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)
|
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:
|
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 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
|
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:
|
with _one_import(user_id), SessionLocal() as db:
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -363,6 +442,7 @@ def run_import(user_id: int) -> dict:
|
|||||||
db.commit()
|
db.commit()
|
||||||
result |= _put_in_watch_later(db, user)
|
result |= _put_in_watch_later(db, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
result |= _channel_list(db, user)
|
||||||
outcomes = queries.takeout_outcomes(db, user)
|
outcomes = queries.takeout_outcomes(db, user)
|
||||||
result["waiting"] = outcomes.get((TakeoutKind.WATCHED, None), 0)
|
result["waiting"] = outcomes.get((TakeoutKind.WATCHED, None), 0)
|
||||||
if outcomes.get((TakeoutKind.WATCH_LATER, None)):
|
if outcomes.get((TakeoutKind.WATCH_LATER, None)):
|
||||||
@@ -373,13 +453,15 @@ def run_import(user_id: int) -> dict:
|
|||||||
def apply_waiting(db: Session) -> dict:
|
def apply_waiting(db: Session) -> dict:
|
||||||
"""Settle what every user's import left waiting and can be settled now, one commit each: a
|
"""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
|
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
|
`shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when
|
||||||
nothing was waiting that could move."""
|
nothing was waiting that could move."""
|
||||||
marked = added = failed = 0
|
marked = added = asked = failed = 0
|
||||||
ready = set(queries.takeout_users_ready(db))
|
ready = set(queries.takeout_users_ready(db))
|
||||||
later = set(queries.takeout_users_watch_later_waiting(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)
|
user = db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
continue
|
continue
|
||||||
@@ -393,6 +475,8 @@ def apply_waiting(db: Session) -> dict:
|
|||||||
if user_id in later:
|
if user_id in later:
|
||||||
added += _put_in_watch_later(db, user).get("watch_later_added", 0)
|
added += _put_in_watch_later(db, user).get("watch_later_added", 0)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if user_id in lengths:
|
||||||
|
asked += _channel_list(db, user).get("lengths_asked", 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
failed += 1
|
failed += 1
|
||||||
@@ -406,6 +490,8 @@ def apply_waiting(db: Session) -> dict:
|
|||||||
out["takeout_watched"] = marked
|
out["takeout_watched"] = marked
|
||||||
if added:
|
if added:
|
||||||
out["takeout_watch_later_added"] = added
|
out["takeout_watch_later_added"] = added
|
||||||
|
if asked:
|
||||||
|
out["takeout_lengths_asked"] = asked
|
||||||
if failed:
|
if failed:
|
||||||
out["takeout_failed"] = failed
|
out["takeout_failed"] = failed
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -307,9 +307,9 @@ def explore_channel(
|
|||||||
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
|
"""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
|
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
|
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.
|
it, then ingests one page of its uploads (newest-first), enriched — marked via_explore while
|
||||||
Returns the next page token so the page can "load more". Guarded against the shared
|
nobody follows it. Returns the next page token so the page can "load more". Guarded against
|
||||||
backfill quota reserve."""
|
the shared backfill quota reserve."""
|
||||||
channel = _channel_or_404(db, channel_id)
|
channel = _channel_or_404(db, channel_id)
|
||||||
if queries.is_blocked(db, user, channel_id):
|
if queries.is_blocked(db, user, channel_id):
|
||||||
raise refused(403, "channel_blocked")
|
raise refused(403, "channel_blocked")
|
||||||
|
|||||||
+101
-2
@@ -3,12 +3,13 @@ from collections.abc import AsyncIterator
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
from app import jobruns, quota, scheduler
|
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.auth import current_user
|
||||||
from app.db import get_db, release_read_connection
|
from app.db import get_db, release_read_connection
|
||||||
from app.mailtext import lang_for_user
|
from app.mailtext import lang_for_user
|
||||||
@@ -24,7 +25,7 @@ from app.routes._common import (
|
|||||||
not_found,
|
not_found,
|
||||||
youtube_refusal,
|
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.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.refusal import refused
|
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),
|
**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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
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.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video
|
||||||
from app.reader import queries as reader_queries
|
from app.reader import queries as reader_queries
|
||||||
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
|
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(
|
def explore_ingest_page(
|
||||||
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
|
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
|
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new videos
|
||||||
videos via_explore, then enrich them. Returns the count ingested and the next page token
|
via_explore while the channel is an exploration's (`from_explore`): of a channel someone follows
|
||||||
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
|
they are its uploads like any other, and a flag there would never be cleared, since subscribing
|
||||||
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
|
clears only an explored channel's. Then enrich them. Returns the count ingested and the next
|
||||||
the organic deep backfill) is never touched."""
|
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:
|
if not channel.uploads_playlist_id:
|
||||||
return {"ingested": 0, "next_page_token": None}
|
return {"ingested": 0, "next_page_token": None}
|
||||||
try:
|
try:
|
||||||
@@ -86,9 +89,10 @@ def explore_ingest_page(
|
|||||||
for item in data.get("items", []):
|
for item in data.get("items", []):
|
||||||
stub = stub_from_playlist_item(item, channel.id)
|
stub = stub_from_playlist_item(item, channel.id)
|
||||||
if stub is not None:
|
if stub is not None:
|
||||||
stub["via_explore"] = True
|
stub["via_explore"] = channel.from_explore
|
||||||
stubs.append(stub)
|
stubs.append(stub)
|
||||||
inserted = insert_stubs(db, stubs)
|
inserted = insert_stubs(db, stubs)
|
||||||
|
catalog_service.settle_explored(db, channel)
|
||||||
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
|
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
|
||||||
log.info(
|
log.info(
|
||||||
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
|
"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 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),
|
- 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.
|
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")
|
grace_days = sysconfig.get_int(db, "explore_grace_days")
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=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()
|
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
|
||||||
channels_deleted = db.execute(
|
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
|
).rowcount
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.catalog import service as catalog_service
|
||||||
from app.catalog.models import Channel, Subscription
|
from app.catalog.models import Channel, Subscription
|
||||||
from app.utils import normalize_description
|
from app.utils import normalize_description
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail
|
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
|
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:
|
def should_prune(fetched_count: int, existing_count: int) -> bool:
|
||||||
"""Whether an import may delete the local subscriptions missing from this fetch.
|
"""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)
|
db.add(sub_row)
|
||||||
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
|
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
|
||||||
db.flush()
|
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
|
# Prune subscriptions removed on YouTube — but only when this fetch is trustworthy
|
||||||
# (see `should_prune`).
|
# (see `should_prune`).
|
||||||
|
|||||||
@@ -420,11 +420,15 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
|
|||||||
return {"probed": probed, "shorts": shorts}
|
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
|
"""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
|
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
|
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:
|
if not video_ids:
|
||||||
return
|
return
|
||||||
have = set(db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all())
|
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)
|
seen.add(vid)
|
||||||
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
|
# 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.
|
# 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)
|
apply_video_details(v, it)
|
||||||
v.enriched_at = now
|
v.enriched_at = now
|
||||||
db.add(v)
|
db.add(v)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.catalog import queries, search, service
|
from app.catalog import queries, search, service
|
||||||
|
from app.db import SessionLocal
|
||||||
from app.catalog.models import (
|
from app.catalog.models import (
|
||||||
BlockedChannel,
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
@@ -22,6 +23,8 @@ from app.catalog.models import (
|
|||||||
)
|
)
|
||||||
from app.reader import queries as reader_queries
|
from app.reader import queries as reader_queries
|
||||||
from app.reader import service as reader_service
|
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):
|
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
|
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):
|
def test_a_block_is_once_and_ends_the_exploration(db, make_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
ch = _channel(db)
|
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",
|
"SavedView",
|
||||||
"ComfortPin",
|
"ComfortPin",
|
||||||
"TakeoutItem",
|
"TakeoutItem",
|
||||||
|
"TakeoutChannelDismissal",
|
||||||
"VideoUnmark",
|
"VideoUnmark",
|
||||||
"Tag",
|
"Tag",
|
||||||
"ChannelTag",
|
"ChannelTag",
|
||||||
@@ -367,6 +368,8 @@ UNIT_COMMITS = {
|
|||||||
"reader/takeout.py:run_import",
|
"reader/takeout.py:run_import",
|
||||||
# Run inside the Shorts job, on its session: each user's waiting rows land on their own.
|
# Run inside the Shorts job, on its session: each user's waiting rows land on their own.
|
||||||
"reader/takeout.py:apply_waiting",
|
"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
|
# 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
|
# 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"],
|
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||||
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||||
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
|
"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/plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||||
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
|
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
|
||||||
"components/ui/ActionMenu.tsx": [1, "a menu row"],
|
"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",
|
"version": "0.110.0",
|
||||||
"date": "2026-10-23",
|
"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",
|
"version": "0.110.0",
|
||||||
"date": "2026-10-23",
|
"date": "2026-10-23",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"subscribed": "Subscriptions",
|
"subscribed": "Subscriptions",
|
||||||
"discovery": "Discover from playlists",
|
"discovery": "Discover from playlists",
|
||||||
|
"watched": "Discover from your history",
|
||||||
"hidden": "Hidden",
|
"hidden": "Hidden",
|
||||||
"blocked": "Blocked"
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
@@ -154,5 +155,38 @@
|
|||||||
"hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.",
|
"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.",
|
"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"
|
"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": {
|
"apiToken": {
|
||||||
"create": "Couldn't create the token",
|
"create": "Couldn't create the token",
|
||||||
"revoke": "Couldn't revoke 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",
|
"ok": "OK",
|
||||||
"offline": {
|
"offline": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"subscribed": "Feliratkozások",
|
"subscribed": "Feliratkozások",
|
||||||
"discovery": "Felfedezés a lejátszási listákból",
|
"discovery": "Felfedezés a lejátszási listákból",
|
||||||
|
"watched": "Felfedezés az előzményekből",
|
||||||
"hidden": "Elrejtett",
|
"hidden": "Elrejtett",
|
||||||
"blocked": "Letiltott"
|
"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.",
|
"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.",
|
"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"
|
"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": {
|
"apiToken": {
|
||||||
"create": "Nem sikerült létrehozni a tokent",
|
"create": "Nem sikerült létrehozni a tokent",
|
||||||
"revoke": "Nem sikerült visszavonni 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",
|
"ok": "OK",
|
||||||
"offline": {
|
"offline": {
|
||||||
|
|||||||
@@ -195,6 +195,33 @@ export interface TakeoutStatus {
|
|||||||
watch_later?: TakeoutCounts;
|
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 = {
|
export const feedApi = {
|
||||||
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
||||||
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
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.
|
// 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),
|
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
|
||||||
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
|
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,
|
channels: () => ["channels"] as const,
|
||||||
channel: (id: string) => ["channel", id] as const,
|
channel: (id: string) => ["channel", id] as const,
|
||||||
discoveredChannels: () => ["discovered-channels"] 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,
|
blockedChannels: () => ["blocked-channels"] as const,
|
||||||
|
|
||||||
// --- playlists ---
|
// --- playlists ---
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("a subscription change", () => {
|
|||||||
qk.feed(),
|
qk.feed(),
|
||||||
qk.feedSummary(),
|
qk.feedSummary(),
|
||||||
qk.discoveredChannels(),
|
qk.discoveredChannels(),
|
||||||
|
qk.watchedChannels(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
|
|
||||||
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
|
/** 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
|
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
|
||||||
* channel leaves it). ONE set for every site that makes the change: each used to keep
|
* 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. */
|
* its own list, and each list missed something. */
|
||||||
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
|
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
|
||||||
for (const queryKey of [
|
for (const queryKey of [
|
||||||
@@ -14,6 +14,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
|
|||||||
qk.feed(),
|
qk.feed(),
|
||||||
qk.feedSummary(),
|
qk.feedSummary(),
|
||||||
qk.discoveredChannels(),
|
qk.discoveredChannels(),
|
||||||
|
qk.watchedChannels(),
|
||||||
])
|
])
|
||||||
void qc.invalidateQueries({ queryKey });
|
void qc.invalidateQueries({ queryKey });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
|||||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
|
import WatchedChannels from "./WatchedChannels";
|
||||||
import TagManager from "../../features/channel/TagManager";
|
import TagManager from "../../features/channel/TagManager";
|
||||||
import {
|
import {
|
||||||
useChannelLayout,
|
useChannelLayout,
|
||||||
@@ -183,6 +184,8 @@ export default function Channels() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
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} />
|
<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.
|
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
||||||
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
||||||
id,
|
id,
|
||||||
@@ -546,7 +549,7 @@ export default function Channels() {
|
|||||||
labelClassName="hidden lg:inline"
|
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
|
// 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.
|
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||||
const sortControl = (
|
const sortControl = (
|
||||||
@@ -612,7 +615,7 @@ export default function Channels() {
|
|||||||
|
|
||||||
const viewTabs: TabDef[] = useMemo(
|
const viewTabs: TabDef[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
(["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||||
id: v,
|
id: v,
|
||||||
label: t(`channels.tabs.${v}`),
|
label: t(`channels.tabs.${v}`),
|
||||||
})),
|
})),
|
||||||
@@ -778,6 +781,13 @@ export default function Channels() {
|
|||||||
onOpenWizard={onOpenWizard}
|
onOpenWizard={onOpenWizard}
|
||||||
onViewChannel={onViewChannel}
|
onViewChannel={onViewChannel}
|
||||||
/>
|
/>
|
||||||
|
) : view === "watched" ? (
|
||||||
|
<WatchedChannels
|
||||||
|
canWrite={canWrite}
|
||||||
|
search={search}
|
||||||
|
onOpenWizard={onOpenWizard}
|
||||||
|
onViewChannel={onViewChannel}
|
||||||
|
/>
|
||||||
) : view === "blocked" ? (
|
) : view === "blocked" ? (
|
||||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
/* 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">
|
<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.
|
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||||
|
|
||||||
function clampView(v: string | null): ChannelsView {
|
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
|
// 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.
|
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||||
|
|
||||||
// The manager's tabs.
|
// The manager's tabs.
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
export type ChannelsView = "subscribed" | "discovery" | "watched" | "hidden" | "blocked";
|
||||||
|
|
||||||
export type ChannelsState = {
|
export type ChannelsState = {
|
||||||
search: string;
|
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.channel(channelId) });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
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
|
// 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).
|
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
|
||||||
|
|||||||
Reference in New Issue
Block a user