diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/backend/alembic/versions/0011_playlists.py b/backend/alembic/versions/0011_playlists.py new file mode 100644 index 0000000..efd5c05 --- /dev/null +++ b/backend/alembic/versions/0011_playlists.py @@ -0,0 +1,88 @@ +"""local playlists: playlists + playlist_items + +Revision ID: 0011_playlists +Revises: 0010_watch_progress +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_playlists" +down_revision: Union[str, None] = "0010_watch_progress" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "playlists", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column( + "kind", sa.String(length=16), nullable=False, server_default="user" + ), + sa.Column( + "source", sa.String(length=16), nullable=False, server_default="local" + ), + sa.Column("yt_playlist_id", sa.String(length=64), nullable=True), + sa.Column( + "dirty", sa.Boolean(), nullable=False, server_default="false" + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_playlists_user_id", "playlists", ["user_id"]) + + op.create_table( + "playlist_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "playlist_id", + sa.Integer(), + sa.ForeignKey("playlists.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "video_id", + sa.String(length=16), + sa.ForeignKey("videos.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "added_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + op.create_index("ix_playlist_items_playlist_id", "playlist_items", ["playlist_id"]) + op.create_index("ix_playlist_items_video_id", "playlist_items", ["video_id"]) + + +def downgrade() -> None: + op.drop_index("ix_playlist_items_video_id", table_name="playlist_items") + op.drop_index("ix_playlist_items_playlist_id", table_name="playlist_items") + op.drop_table("playlist_items") + op.drop_index("ix_playlists_user_id", table_name="playlists") + op.drop_table("playlists") diff --git a/backend/alembic/versions/0012_watch_later.py b/backend/alembic/versions/0012_watch_later.py new file mode 100644 index 0000000..fd5a847 --- /dev/null +++ b/backend/alembic/versions/0012_watch_later.py @@ -0,0 +1,65 @@ +"""unify Saved into a built-in Watch later playlist + +Revision ID: 0012_watch_later +Revises: 0011_playlists +Create Date: 2026-06-15 + +Migrates the old per-video status='saved' into membership of a built-in 'watch_later' +playlist (one per user who had saved videos), then demotes those states to 'new'. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0012_watch_later" +down_revision: Union[str, None] = "0011_playlists" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1) A Watch later playlist for each user that has saved videos (and none yet). + op.execute( + """ + INSERT INTO playlists (user_id, name, kind, source, dirty, position, created_at, updated_at) + SELECT DISTINCT vs.user_id, 'Watch later', 'watch_later', 'local', false, 0, now(), now() + FROM video_states vs + WHERE vs.status = 'saved' + AND NOT EXISTS ( + SELECT 1 FROM playlists p + WHERE p.user_id = vs.user_id AND p.kind = 'watch_later' + ) + """ + ) + # 2) Add each saved video as an item, ordered (newest-saved first → position 0). + op.execute( + """ + INSERT INTO playlist_items (playlist_id, video_id, position, added_at) + SELECT p.id, vs.video_id, + row_number() OVER ( + PARTITION BY vs.user_id + ORDER BY COALESCE(vs.watched_at, vs.updated_at) DESC + ) - 1, + COALESCE(vs.watched_at, vs.updated_at, now()) + FROM video_states vs + JOIN playlists p ON p.user_id = vs.user_id AND p.kind = 'watch_later' + WHERE vs.status = 'saved' + ON CONFLICT (playlist_id, video_id) DO NOTHING + """ + ) + # 3) Demote the migrated saved states to the default. + op.execute("UPDATE video_states SET status = 'new' WHERE status = 'saved'") + + +def downgrade() -> None: + # Best-effort reverse: restore status='saved' for videos in watch_later lists, then drop them. + op.execute( + """ + UPDATE video_states vs SET status = 'saved' + FROM playlist_items pi + JOIN playlists p ON p.id = pi.playlist_id AND p.kind = 'watch_later' + WHERE pi.video_id = vs.video_id AND p.user_id = vs.user_id + AND vs.status = 'new' + """ + ) + op.execute("DELETE FROM playlists WHERE kind = 'watch_later'") diff --git a/backend/alembic/versions/0013_playlist_fingerprint.py b/backend/alembic/versions/0013_playlist_fingerprint.py new file mode 100644 index 0000000..7ec6dbc --- /dev/null +++ b/backend/alembic/versions/0013_playlist_fingerprint.py @@ -0,0 +1,64 @@ +"""record a synced-state fingerprint per playlist + +Revision ID: 0013_playlist_fingerprint +Revises: 0012_watch_later +Create Date: 2026-06-15 + +Adds playlists.synced_fingerprint — a SHA-256 of the playlist's name + ordered video ids +as last synced from / pushed to YouTube. `dirty` is then derived by comparing the current +fingerprint to this baseline, so manually restoring the original order clears dirty without +a YouTube read. Backfills the baseline for already-clean linked playlists (the current +state == the synced state for them); dirty ones are left NULL so they stay dirty until the +next real sync establishes a baseline. +""" +import hashlib +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0013_playlist_fingerprint" +down_revision: Union[str, None] = "0012_watch_later" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _fingerprint(name: str, ids: list[str]) -> str: + h = hashlib.sha256() + h.update((name or "").encode()) + h.update(b"\n") + h.update(",".join(ids).encode()) + return h.hexdigest() + + +def upgrade() -> None: + op.add_column( + "playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True) + ) + bind = op.get_bind() + # Backfill clean, YouTube-linked playlists: their current state IS the synced state. + rows = bind.execute( + sa.text( + "SELECT id, name FROM playlists " + "WHERE yt_playlist_id IS NOT NULL AND dirty = false" + ) + ).fetchall() + for pid, name in rows: + ids = [ + r[0] + for r in bind.execute( + sa.text( + "SELECT video_id FROM playlist_items " + "WHERE playlist_id = :pid ORDER BY position" + ), + {"pid": pid}, + ).fetchall() + ] + bind.execute( + sa.text("UPDATE playlists SET synced_fingerprint = :fp WHERE id = :pid"), + {"fp": _fingerprint(name, ids), "pid": pid}, + ) + + +def downgrade() -> None: + op.drop_column("playlists", "synced_fingerprint") diff --git a/backend/app/config.py b/backend/app/config.py index 3e7db21..52bbd8a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -89,6 +89,7 @@ class Settings(BaseSettings): autotag_title_sample: int = 40 autotag_interval_minutes: int = 30 subscriptions_resync_minutes: int = 360 + playlist_sync_minutes: int = 360 # live_status values hidden from the feed by default. Completed-stream VODs # ("was_live") are real watchable content and stay visible. feed_default_hidden_live: str = "live,upcoming" diff --git a/backend/app/main.py b/backend/app/main.py index cb07a68..34fef9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import admin, channels, feed, health, me, quota, sync, tags, version +from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version from app.scheduler import shutdown_scheduler, start_scheduler @@ -66,6 +66,7 @@ app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) app.include_router(channels.router) +app.include_router(playlists.router) app.include_router(admin.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index 46bb24c..cf4d044 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -301,3 +301,68 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + + +class Playlist(Base): + """A per-user named, ordered collection of videos from the shared catalog. + + `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). + `source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube + playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id. + `dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read + sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT + sync phases; the foundation phase only uses local playlists.)""" + + __tablename__ = "playlists" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + source: Mapped[str] = mapped_column( + String(16), default="local", server_default="local" + ) + yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) + # True when the current items/name differ from `synced_fingerprint` (unpushed edits). + dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the + # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). + synced_fingerprint: Mapped[str | None] = mapped_column(String) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + items: Mapped[list["PlaylistItem"]] = relationship( + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistItem.position", + ) + + +class PlaylistItem(Base): + """A video's membership in a playlist, with its order within that playlist.""" + + __tablename__ = "playlist_items" + __table_args__ = ( + UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + playlist_id: Mapped[int] = mapped_column( + ForeignKey("playlists.id", ondelete="CASCADE"), index=True + ) + video_id: Mapped[str] = mapped_column( + ForeignKey("videos.id", ondelete="CASCADE"), index=True + ) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + added_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + playlist: Mapped["Playlist"] = relationship(back_populates="items") diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index b743b92..0ef733b 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user from app.db import get_db -from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState +from app.models import ( + Channel, + ChannelTag, + Playlist, + PlaylistItem, + Subscription, + Tag, + User, + Video, + VideoState, +) from app.sync.videos import parse_iso8601_duration from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) -VALID_STATES = {"new", "watched", "saved", "hidden"} +# "saved" used to be a status; it's now membership in the built-in Watch later playlist. +VALID_STATES = {"new", "watched", "hidden"} HIDDEN_LIVE = ("live", "upcoming") # Resume-position thresholds (mirror the client): positions below this are "didn't @@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str: return f"https://www.youtube.com/channel/{channel_id}" +def _saved_expr(user: User): + """A correlated EXISTS labelled `saved`: is this video in the user's Watch later list? + (Watch later is the built-in playlist that replaced the old per-video 'saved' status.)""" + return ( + select(1) + .select_from(PlaylistItem) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .where( + Playlist.user_id == user.id, + Playlist.kind == "watch_later", + PlaylistItem.video_id == Video.id, + ) + .exists() + .label("saved") + ) + + def _serialize(row) -> dict: return { "id": row.id, @@ -45,6 +73,7 @@ def _serialize(row) -> dict: "live_status": row.live_status, "status": row.status or "new", "position_seconds": row.position_seconds or 0, + "saved": bool(getattr(row, "saved", False)), "watch_url": f"https://www.youtube.com/watch?v={row.id}", } @@ -96,6 +125,7 @@ def _filtered_query( Video.live_status, status_expr.label("status"), position_expr.label("position_seconds"), + _saved_expr(user), ).join(Channel, Channel.id == Video.channel_id) if scope == "all": @@ -176,7 +206,7 @@ def _filtered_query( # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. - explicit_view = show in ("watched", "saved", "hidden") + explicit_view = show in ("watched", "hidden") if not explicit_view: type_clauses = [] if show_normal: @@ -198,8 +228,6 @@ def _filtered_query( ) elif show == "watched": query = query.where(status_expr == "watched") - elif show == "saved": - query = query.where(status_expr == "saved") elif show == "hidden": query = query.where(status_expr == "hidden") else: # all diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py new file mode 100644 index 0000000..b923161 --- /dev/null +++ b/backend/app/routes/playlists.py @@ -0,0 +1,507 @@ +"""Local playlists: per-user named, ordered collections of videos from the shared +catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder +items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select, and_ +from sqlalchemy.orm import Session, aliased + +from app import quota +from app.auth import current_user, has_read_scope, has_write_scope +from app.db import get_db +from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState +from app.routes.feed import _saved_expr, _serialize +from app.sync.playlists import ( + plan_push, + push_playlist, + recompute_dirty, + repull_playlist, + sync_user_playlists, +) +from app.youtube.client import YouTubeClient, YouTubeError + +router = APIRouter(prefix="/api/playlists", tags=["playlists"]) + + +def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: + pl = db.get(Playlist, playlist_id) + if pl is None or pl.user_id != user.id: + raise HTTPException(status_code=404, detail="Unknown playlist") + return pl + + +def _summary( + db: Session, + pl: Playlist, + count: int, + total_duration: int = 0, + has_video: bool | None = None, +) -> dict: + cover = db.scalar( + select(Video.thumbnail_url) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + .limit(1) + ) + out = { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, + "item_count": count, + "total_duration_seconds": total_duration, + "cover_thumbnail": cover, + } + if has_video is not None: + out["has_video"] = has_video + return out + + +def _total_duration(db: Session, playlist_id: int) -> int: + return ( + db.scalar( + select(func.coalesce(func.sum(Video.duration_seconds), 0)) + .select_from(PlaylistItem) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == playlist_id) + ) + or 0 + ) + + +@router.get("") +def list_playlists( + contains: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> list[dict]: + """The user's playlists (newest custom order). With `contains=`, each entry + also reports whether that video is already in it — used by the add-to-playlist popover.""" + pls = ( + db.execute( + select(Playlist) + .where(Playlist.user_id == user.id) + .order_by(Playlist.position, Playlist.created_at) + ) + .scalars() + .all() + ) + if not pls: + return [] + ids = [p.id for p in pls] + counts = dict( + db.execute( + select(PlaylistItem.playlist_id, func.count()) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) + durations = dict( + db.execute( + select( + PlaylistItem.playlist_id, + func.coalesce(func.sum(Video.duration_seconds), 0), + ) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) + member: set[int] = set() + if contains: + member = set( + db.execute( + select(PlaylistItem.playlist_id).where( + PlaylistItem.playlist_id.in_(ids), + PlaylistItem.video_id == contains, + ) + ) + .scalars() + .all() + ) + return [ + _summary( + db, + p, + counts.get(p.id, 0), + durations.get(p.id, 0), + (p.id in member) if contains else None, + ) + for p in pls + ] + + +@router.post("") +def create_playlist( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist(user_id=user.id, name=name, position=next_pos) + db.add(pl) + db.commit() + return _summary(db, pl, 0) + + +def _watch_later(db: Session, user: User) -> Playlist: + """The user's built-in Watch later playlist, created on first use.""" + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist( + user_id=user.id, name="Watch later", kind="watch_later", position=next_pos + ) + db.add(pl) + db.commit() + return pl + + +@router.post("/watch-later") +def add_watch_later( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Bookmark shortcut: add a video to the built-in Watch later playlist.""" + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + pl = _watch_later(db, user) + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)) + db.commit() + return {"saved": True, "playlist_id": pl.id} + + +@router.delete("/watch-later/{video_id}") +def remove_watch_later( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is not None: + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + db.commit() + return {"saved": False} + + +@router.post("/sync-youtube") +def sync_youtube( + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Pull the user's YouTube playlists into local mirrors now (read-only direction). + Also runs automatically on the scheduler; this is the manual 'sync now'.""" + if not has_read_scope(user): + raise HTTPException( + status_code=403, + detail="Connect YouTube (read access) to sync your playlists.", + ) + with quota.attribute(user.id, "playlist_sync"): + return sync_user_playlists(db, user) + + +@router.post("/{playlist_id}/revert-youtube") +def revert_youtube( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Reset one YouTube-linked playlist to its current YouTube state, discarding local + edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list + once the in-session undo history is gone.""" + pl = _own_playlist(db, user, playlist_id) + if not pl.yt_playlist_id: + raise HTTPException(status_code=400, detail="This playlist isn't linked to YouTube.") + if not has_read_scope(user): + raise HTTPException( + status_code=403, detail="Connect YouTube (read access) to sync your playlists." + ) + try: + with quota.attribute(user.id, "playlist_sync"): + return repull_playlist(db, user, pl) + except YouTubeError: + db.rollback() + raise HTTPException(status_code=502, detail="Couldn't refresh from YouTube.") + + +def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: + """A local playlist the user may push to YouTube (create or update). The built-in + Watch later and read-only YouTube mirrors are never pushable.""" + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException( + status_code=400, detail="The Watch later list can't be sent to YouTube." + ) + return pl + + +@router.get("/{playlist_id}/push-plan") +def push_plan( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a + confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + except YouTubeError: + raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.") + plan["remaining_today"] = quota.remaining_today(db) + plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] + return plan + + +@router.post("/{playlist_id}/push") +def push( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Push a local playlist to YouTube (create it on first push, else reconcile to match + local — local wins). Refuses if the estimated quota exceeds what's left today, so a + big playlist can't strand half-pushed.""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + if plan["units_estimate"] > quota.remaining_today(db): + raise HTTPException( + status_code=429, + detail="Not enough YouTube quota left today for this sync.", + ) + return push_playlist(db, user, pl) + except YouTubeError: + db.rollback() + raise HTTPException(status_code=502, detail="YouTube playlist sync failed.") + + +@router.get("/{playlist_id}") +def get_playlist( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + state = aliased(VideoState) + rows = db.execute( + select( + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + func.coalesce(state.status, "new").label("status"), + func.coalesce(state.position_seconds, 0).label("position_seconds"), + _saved_expr(user), + ) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .join(Channel, Channel.id == Video.channel_id) + .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ).all() + return { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, + "items": [_serialize(r) for r in rows], + } + + +@router.patch("/{playlist_id}") +def rename_playlist( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if "name" in payload: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name cannot be empty") + if name != pl.name: + pl.name = name + recompute_dirty(db, pl) + db.commit() + count = db.scalar( + select(func.count()).where(PlaylistItem.playlist_id == pl.id) + ) + return _summary(db, pl, count or 0, _total_duration(db, pl.id)) + + +@router.delete("/{playlist_id}") +def delete_playlist( + playlist_id: int, + on_youtube: bool = False, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") + if on_youtube: + if not pl.yt_playlist_id: + raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_delete"): + with YouTubeClient(db, user) as yt: + yt.delete_playlist(pl.yt_playlist_id) + except YouTubeError: + raise HTTPException(status_code=502, detail="YouTube delete failed.") + db.delete(pl) # items cascade + db.commit() + return {"deleted": playlist_id} + + +@router.post("/{playlist_id}/items") +def add_item( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add( + PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) + ) + recompute_dirty(db, pl) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.delete("/{playlist_id}/items/{video_id}") +def remove_item( + playlist_id: int, + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + recompute_dirty(db, pl) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.put("/{playlist_id}/order") +def reorder_items( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + order = payload.get("video_ids") or [] + items = { + it.video_id: it + for it in db.execute( + select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id) + ).scalars() + } + pos = 0 + for vid in order: + it = items.get(vid) + if it is not None: + it.position = pos + pos += 1 + recompute_dirty(db, pl) + db.commit() + return {"playlist_id": pl.id, "count": pos} diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index bd93d07..8d6d0c3 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -9,6 +9,7 @@ from app.config import settings from app.db import SessionLocal from app.state import is_sync_paused from app.sync.autotag import run_autotag_all +from app.sync.playlists import sync_all_playlists from app.sync.runner import ( run_deep_backfill, run_enrich, @@ -79,6 +80,12 @@ def _subscriptions_job() -> None: _job("subscriptions", work) +def _playlist_sync_job() -> None: + # Mirror each read-scope user's YouTube playlists; per-user quota attribution is + # handled inside sync_all_playlists. + _job("playlist_sync", sync_all_playlists) + + def start_scheduler() -> None: global _scheduler if not settings.scheduler_enabled or _scheduler is not None: @@ -114,6 +121,12 @@ def start_scheduler() -> None: minutes=settings.subscriptions_resync_minutes, id="subscriptions", ) + scheduler.add_job( + _playlist_sync_job, + "interval", + minutes=settings.playlist_sync_minutes, + id="playlist_sync", + ) scheduler.start() _scheduler = scheduler logger.info("scheduler started") diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py new file mode 100644 index 0000000..bb60489 --- /dev/null +++ b/backend/app/sync/playlists.py @@ -0,0 +1,397 @@ +"""Read-direction YouTube playlist sync: mirror each user's own YouTube playlists into +local `source='youtube'` playlists (kept fresh by the scheduler). One-way (YT -> local); +the write-back direction (local edits -> YouTube) is a later phase. + +YouTube's special Watch Later / History playlists are not exposed by the Data API and are +never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and +ingested (with a stub channel) so the mirror is faithful.""" +import hashlib +import logging +from datetime import datetime, timezone + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app import quota +from app.auth import has_read_scope, has_write_scope +from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video +from app.sync.videos import apply_video_details, parse_dt +from app.youtube.client import YouTubeClient, YouTubeError + +log = logging.getLogger("subfeed.sync") + +# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete). +WRITE_UNIT_COST = 50 + + +def fingerprint(name: str, ids: list[str]) -> str: + """A stable checksum of a playlist's name + ordered video ids. Stored as the synced + baseline; the current fingerprint is compared to it to derive `dirty`.""" + h = hashlib.sha256() + h.update((name or "").encode()) + h.update(b"\n") + h.update(",".join(ids).encode()) + return h.hexdigest() + + +def current_fingerprint(db: Session, pl: Playlist) -> str: + return fingerprint(pl.name, _local_video_ids(db, pl)) + + +def recompute_dirty(db: Session, pl: Playlist) -> None: + """Set `dirty` by comparing the current state to the synced baseline. Unlinked lists + (and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state).""" + if not pl.yt_playlist_id: + pl.dirty = False + return + # The session has autoflush off, so the caller's pending item/order/name edits aren't + # visible to current_fingerprint's query yet — flush first or dirty would lag one edit. + db.flush() + pl.dirty = pl.synced_fingerprint != current_fingerprint(db, pl) + + +def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None: + """Make sure every given video id exists in the catalog, fetching + ingesting the + missing ones (and a stub channel for any unknown channel). Deleted/private videos that + the API won't return are simply left out.""" + if not video_ids: + return + have = set( + db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all() + ) + missing = [v for v in dict.fromkeys(video_ids) if v not in have] + if not missing: + return + items = yt.get_videos(missing) + chan_ids = { + it.get("snippet", {}).get("channelId") + for it in items + if it.get("snippet", {}).get("channelId") + } + existing_ch = set( + db.execute(select(Channel.id).where(Channel.id.in_(chan_ids))).scalars().all() + ) + now = datetime.now(timezone.utc) + for it in items: + sn = it.get("snippet", {}) + cid = sn.get("channelId") + if cid and cid not in existing_ch: + db.add(Channel(id=cid, title=sn.get("channelTitle"))) + existing_ch.add(cid) + db.flush() + seen: set[str] = set() + for it in items: + vid = it.get("id") + sn = it.get("snippet", {}) + cid = sn.get("channelId") + if not vid or not cid or vid in seen: + continue + seen.add(vid) + v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt"))) + apply_video_details(v, it) + v.enriched_at = now + db.add(v) + db.commit() + + +def sync_user_playlists(db: Session, user: User) -> dict: + """Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves + the user's local playlists and the built-in Watch later untouched.""" + if not has_read_scope(user): + return {"synced": 0, "reason": "no read scope"} + synced = 0 + with YouTubeClient(db, user) as yt: + yt_playlists = list(yt.iter_my_playlists()) + yt_ids = {p["id"] for p in yt_playlists if p.get("id")} + existing = { + pl.yt_playlist_id: pl + for pl in db.execute( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.source == "youtube" + ) + ).scalars() + } + # YouTube playlists that we already own as local (exported) playlists must not be + # re-mirrored, or every exported list would also show up as a read-only duplicate. + owned_yt_ids = set( + db.execute( + select(Playlist.yt_playlist_id).where( + Playlist.user_id == user.id, + Playlist.source == "local", + Playlist.yt_playlist_id.is_not(None), + ) + ) + .scalars() + .all() + ) + # Drop mirrors whose YouTube playlist no longer exists. + for ytid, pl in existing.items(): + if ytid not in yt_ids: + db.delete(pl) + db.flush() + + for idx, p in enumerate(yt_playlists): + ytid = p.get("id") + if not ytid or ytid in owned_yt_ids: + continue + pl = existing.get(ytid) + if pl is not None and pl.dirty: + # Local edits not yet pushed: don't clobber them with YouTube's state. + # A successful 'Sync to YouTube' clears dirty and lets the mirror refresh. + continue + if pl is None: + pl = Playlist( + user_id=user.id, + name=p.get("title") or "Untitled", + kind="user", + source="youtube", + yt_playlist_id=ytid, + position=1000 + idx, # sort mirrored lists after local ones + ) + db.add(pl) + db.flush() + else: + pl.name = p.get("title") or pl.name + ids = list(yt.iter_my_playlist_video_ids(ytid)) + _ensure_videos(db, yt, ids) + present = ( + set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) + if ids + else set() + ) + ordered: list[str] = [] + seen: set[str] = set() + for v in ids: + if v in present and v not in seen: + seen.add(v) + ordered.append(v) + # Mirror is authoritative: replace the items to match YouTube's order. + db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) + for pos, vid in enumerate(ordered): + db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.synced_fingerprint = fingerprint(pl.name, ordered) + pl.dirty = False + db.commit() + synced += 1 + return {"synced": synced} + + +def _local_video_ids(db: Session, pl: Playlist) -> list[str]: + return list( + db.execute( + select(PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ) + .scalars() + .all() + ) + + +def _reorder_moves(current: list[str], desired: list[str]) -> int: + """Count the playlistItems.update moves an insertion-sort would need to turn + `current` order into `desired` order (both already the same set of video ids). + Items already in place are skipped; this matches what apply_push actually executes.""" + model = list(current) + moves = 0 + for i, want in enumerate(desired): + if i < len(model) and model[i] == want: + continue + j = model.index(want, i) + model.pop(j) + model.insert(i, want) + moves += 1 + return moves + + +def plan_push(db: Session, user: User, pl: Playlist) -> dict: + """Compute (without writing) what pushing this local playlist to YouTube would do: + create vs update, how many inserts/deletes/reorders, the quota estimate, and whether + YouTube has diverged (items present there that local will remove). Costs read units + (1/page) for a linked playlist; nothing for a fresh export.""" + desired = _local_video_ids(db, pl) + if not pl.yt_playlist_id: + ops = 1 + len(desired) # create the playlist + one insert per video + return { + "action": "create", + "to_insert": len(desired), + "to_delete": 0, + "to_reorder": 0, + "rename": False, + "yt_extra": 0, + "units_estimate": ops * WRITE_UNIT_COST, + } + with YouTubeClient(db, user) as yt: + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + rename = bool(snippet) and (snippet.get("title") or "") != pl.name + cur_vids = [it["video_id"] for it in current] + cur_set = set(cur_vids) + desired_set = set(desired) + to_insert = [v for v in desired if v not in cur_set] + to_delete = [v for v in cur_vids if v not in desired_set] + # After membership reconciliation, the YT order is current-minus-deletes plus inserts + # appended; the reorder pass then moves items into the desired order. + after_membership = [v for v in cur_vids if v in desired_set] + to_insert + reorders = _reorder_moves(after_membership, desired) + ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0) + return { + "action": "update", + "to_insert": len(to_insert), + "to_delete": len(to_delete), + "to_reorder": reorders, + "rename": rename, + "yt_extra": len(to_delete), + "units_estimate": ops * WRITE_UNIT_COST, + } + + +def push_playlist(db: Session, user: User, pl: Playlist) -> dict: + """Push a local playlist to YouTube: create it if needed, then reconcile membership + and order so YouTube matches local (local wins). Marks the playlist clean on success. + Caller must have checked the write scope and the quota budget.""" + desired = _local_video_ids(db, pl) + inserted = deleted = reordered = created = 0 + failures: list[str] = [] + with YouTubeClient(db, user) as yt: + if not pl.yt_playlist_id: + pl.yt_playlist_id = yt.create_playlist(pl.name) + db.commit() + created = 1 + # Brand-new playlist: just append every video in order. + for vid in desired: + try: + yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + if not failures: + pl.synced_fingerprint = fingerprint(pl.name, desired) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + # Existing link: diff against the live YouTube state. + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if snippet is not None and (snippet.get("title") or "") != pl.name: + try: + yt.update_playlist_title(pl.yt_playlist_id, pl.name) + except YouTubeError: + failures.append("title") + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} + cur_vids = [it["video_id"] for it in current] + desired_set = set(desired) + # Delete extras (local is authoritative). + for it in current: + if it["video_id"] not in desired_set: + try: + yt.delete_playlist_item(it["item_id"]) + deleted += 1 + except YouTubeError: + failures.append(it["video_id"]) + # Insert missing (append; the reorder pass below fixes positions). + cur_set = set(cur_vids) + for vid in desired: + if vid not in cur_set: + try: + item_id_by_vid[vid] = yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + # Reorder to match `desired` via insertion-sort moves over a local model. + model = [v for v in cur_vids if v in desired_set] + [ + v for v in desired if v not in cur_set and v in item_id_by_vid + ] + for i, want in enumerate(desired): + if want not in item_id_by_vid: + continue # failed insert; skip + if i < len(model) and model[i] == want: + continue + try: + yt.move_playlist_item(item_id_by_vid[want], pl.yt_playlist_id, want, i) + reordered += 1 + except YouTubeError: + failures.append(want) + continue + j = model.index(want) + model.pop(j) + model.insert(i, want) + if not failures: + pl.synced_fingerprint = fingerprint(pl.name, desired) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + +def repull_playlist(db: Session, user: User, pl: Playlist) -> dict: + """Force-refresh ONE YouTube-linked playlist from YouTube, discarding local edits and + clearing dirty. This is the per-playlist 'reset to YouTube' — unlike the bulk read-sync + it deliberately does NOT skip a dirty playlist (overwriting local changes is the point). + Works for both mirrors (source='youtube') and exported local playlists (yt_playlist_id).""" + if not pl.yt_playlist_id: + raise YouTubeError("Playlist is not linked to YouTube") + with YouTubeClient(db, user) as yt: + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if snippet is None: + raise YouTubeError("Playlist no longer exists on YouTube") + ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id)) + _ensure_videos(db, yt, ids) + present = ( + set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) + if ids + else set() + ) + ordered: list[str] = [] + seen: set[str] = set() + for v in ids: + if v in present and v not in seen: + seen.add(v) + ordered.append(v) + pl.name = snippet.get("title") or pl.name + db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) + for pos, vid in enumerate(ordered): + db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.synced_fingerprint = fingerprint(pl.name, ordered) + pl.dirty = False + db.commit() + return {"items": len(ordered)} + + +def sync_all_playlists(db: Session) -> dict: + """Scheduler entry point: mirror playlists for every user with a read scope + token.""" + users = ( + db.execute( + select(User).join(OAuthToken).where(OAuthToken.refresh_token_enc.is_not(None)) + ) + .scalars() + .all() + ) + total = 0 + for user in users: + if not has_read_scope(user): + continue + try: + with quota.attribute(user.id, "playlist_sync"): + total += sync_user_playlists(db, user).get("synced", 0) + except YouTubeError: + db.rollback() + log.warning("Playlist sync failed for user %s", user.id) + except Exception: + db.rollback() + log.exception("Playlist sync crashed for user %s", user.id) + return {"users": len(users), "playlists_synced": total} diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 62f2125..f0d0da4 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -125,6 +125,49 @@ class YouTubeClient: if not page_token: break + def iter_my_playlists(self) -> Iterator[dict]: + """Yield the authenticated user's own playlists (OAuth, so private ones are + included). Note: YouTube's special Watch Later / History playlists are NOT + returned by the Data API and cannot be synced.""" + page_token = None + while True: + params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50} + if page_token: + params["pageToken"] = page_token + data = self._get("playlists", params, cost=1, allow_key=False) + for item in data.get("items", []): + sn = item.get("snippet", {}) + yield { + "id": item.get("id"), + "title": sn.get("title"), + "item_count": item.get("contentDetails", {}).get("itemCount"), + "thumbnail_url": best_thumbnail(sn.get("thumbnails")), + } + page_token = data.get("nextPageToken") + if not page_token: + break + + def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]: + """Yield the video ids of one of the user's own playlists, in playlist order + (OAuth, so private/unlisted playlists work).""" + page_token = None + while True: + params = { + "part": "contentDetails", + "playlistId": playlist_id, + "maxResults": 50, + } + if page_token: + params["pageToken"] = page_token + data = self._get("playlistItems", params, cost=1, allow_key=False) + for item in data.get("items", []): + vid = item.get("contentDetails", {}).get("videoId") + if vid: + yield vid + page_token = data.get("nextPageToken") + if not page_token: + break + def get_channels(self, channel_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(channel_ids, 50): @@ -170,6 +213,114 @@ class YouTubeClient: f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" ) + def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: + """Yield {item_id, video_id} for each item of one of the user's playlists, in + playlist order. `item_id` is the playlistItem resource id, needed to delete or + reposition the item via the write API. OAuth (private playlists work).""" + page_token = None + while True: + params = { + "part": "contentDetails", + "playlistId": playlist_id, + "maxResults": 50, + } + if page_token: + params["pageToken"] = page_token + data = self._get("playlistItems", params, cost=1, allow_key=False) + for item in data.get("items", []): + vid = item.get("contentDetails", {}).get("videoId") + if vid: + yield {"item_id": item.get("id"), "video_id": vid} + page_token = data.get("nextPageToken") + if not page_token: + break + + # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- + def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: + headers = {"Authorization": f"Bearer {self._access_token()}"} + resp = self._http.request( + method, f"{API_BASE}/{path}", params=params, json=json, headers=headers + ) + quota.record_usage(self.db, 50) + if resp.status_code not in (200, 204): + log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200]) + raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") + return resp.json() if resp.content else {} + + def get_playlist_snippet(self, playlist_id: str) -> dict | None: + """The snippet (title/description) of one playlist, or None if it no longer + exists. 1 unit (OAuth, so private playlists work).""" + data = self._get( + "playlists", + {"part": "snippet", "id": playlist_id, "maxResults": 1}, + cost=1, + allow_key=False, + ) + items = data.get("items", []) + return items[0].get("snippet") if items else None + + def update_playlist_title(self, playlist_id: str, title: str) -> None: + """Rename a playlist on YouTube. 50 units.""" + self._write( + "PUT", + "playlists", + params={"part": "snippet"}, + json={"id": playlist_id, "snippet": {"title": title[:150]}}, + ) + + def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: + """Create a new YouTube playlist; returns its id. 50 units.""" + data = self._write( + "POST", + "playlists", + params={"part": "snippet,status"}, + json={ + "snippet": {"title": title[:150], "description": description[:5000]}, + "status": {"privacyStatus": privacy}, + }, + ) + return data["id"] + + def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str: + """Append (or insert at `position`) a video into a playlist; returns the new + playlistItem id. 50 units.""" + snippet = { + "playlistId": playlist_id, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + } + if position is not None: + snippet["position"] = position + data = self._write( + "POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet} + ) + return data["id"] + + def move_playlist_item( + self, item_id: str, playlist_id: str, video_id: str, position: int + ) -> None: + """Reposition an existing playlistItem to absolute 0-based `position`. 50 units.""" + self._write( + "PUT", + "playlistItems", + params={"part": "snippet"}, + json={ + "id": item_id, + "snippet": { + "playlistId": playlist_id, + "position": position, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + }, + }, + ) + + def delete_playlist_item(self, item_id: str) -> None: + """Remove a playlistItem from its playlist. 50 units.""" + self._write("DELETE", "playlistItems", params={"id": item_id}) + + def delete_playlist(self, playlist_id: str) -> None: + """Delete a whole playlist on YouTube. 50 units.""" + self._write("DELETE", "playlists", params={"id": playlist_id}) + def get_videos(self, video_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(video_ids, 50): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 719da76..21b27e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; +import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; @@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = { }; const FILTERS_KEY = "subfeed.filters"; +const PAGE_KEY = "siftlode.page"; + +// Page is navigation state, not a filter, but it's also kept out of the address bar — so +// persist it to localStorage to survive a reload. A share link's ?page= still wins. +function loadInitialPage(): Page { + const params = new URLSearchParams(window.location.search); + if (params.has("page")) return readPage(); + const stored = localStorage.getItem(PAGE_KEY); + if (stored === "channels" || stored === "stats" || stored === "playlists") return stored; + return "feed"; +} function loadStoredFilters(): FeedFilters { try { @@ -69,7 +81,7 @@ export default function App() { const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); - const [page, setPageState] = useState(readPage); + const [page, setPageState] = useState(loadInitialPage); const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [channelFilter, setChannelFilter] = useState("all"); @@ -89,6 +101,7 @@ export default function App() { function setPage(next: Page) { setPageState(next); + localStorage.setItem(PAGE_KEY, next); } function setSidebarLayout(next: SidebarLayout) { @@ -218,6 +231,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( + ) : ( (null); + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + + const membership = useQuery({ + queryKey: ["playlist-membership", videoId], + queryFn: () => api.playlists(videoId), + enabled: open, + }); + + // Re-place once the panel is actually rendered (so we know its real height) and whenever + // its content — hence height — changes, so the flip-above decision uses the true size. + useLayoutEffect(() => { + if (open) place(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, membership.data]); + + function place() { + const r = triggerRef.current?.getBoundingClientRect(); + if (!r) return; + const width = 240; + const margin = 8; + // Use the panel's real height once it's mounted; estimate on the first open. + const h = panelRef.current?.offsetHeight ?? 300; + let top = r.bottom + 6; + if (top + h > window.innerHeight - margin) { + // Not enough room below — open upward, clamped to the viewport. + top = Math.max(margin, r.top - h - 6); + } + const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin); + setCoords({ top: Math.round(top), left: Math.round(left) }); + } + + function toggleOpen(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!open) place(); + setOpen((o) => !o); + } + + useEffect(() => { + if (!open) return; + function onDoc(e: MouseEvent) { + if ( + !panelRef.current?.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + function refresh() { + qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); + qc.invalidateQueries({ queryKey: ["playlists"] }); + // Also invalidate every open/cached playlist detail (["playlist", id]) so the + // Playlists page reflects the add/remove when navigated to, not only after F5. + qc.invalidateQueries({ queryKey: ["playlist"] }); + } + + async function toggle(pl: Playlist) { + if (busy) return; + setBusy(true); + try { + if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); + else await api.addToPlaylist(pl.id, videoId); + refresh(); + } finally { + setBusy(false); + } + } + + async function createAndAdd(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + const pl = await api.createPlaylist(name); + await api.addToPlaylist(pl.id, videoId); + setNewName(""); + refresh(); + } finally { + setBusy(false); + } + } + + // All of the user's playlists are add targets, including YouTube-linked ones — adding + // marks them dirty, and a later "Sync to YouTube" pushes the change back. + const lists = membership.data ?? []; + + return ( + <> + + + {open && + createPortal( +
e.stopPropagation()} + > +
+ {t("playlists.addToPlaylist")} +
+
+ {lists.length === 0 && !membership.isLoading && ( +
+ {t("playlists.noneYet")} +
+ )} + {lists.map((pl) => ( + + ))} +
+
+ + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + +
, + document.body + )} + + ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 2e32004..3db9eb1 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -18,6 +18,7 @@ import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +import { useConfirm } from "./ConfirmProvider"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -43,6 +44,7 @@ export default function Channels({ }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // granted the needed scope — surface that with a "Connect" action instead of a vague fail. @@ -309,13 +311,14 @@ export default function Channels({ c={c} userTags={userTags} canWrite={canWrite} - onUnsubscribe={() => { - if ( - window.confirm( - t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) - ) - ) - unsubscribe.mutate(c.id); + onUnsubscribe={async () => { + const ok = await confirm({ + title: t("channels.row.unsubscribeOnYoutube"), + message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.unsubscribeOnYoutube"), + danger: true, + }); + if (ok) unsubscribe.mutate(c.id); }} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onPriority={(d) => bumpPriority(c.id, c.priority, d)} diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx new file mode 100644 index 0000000..6794a9b --- /dev/null +++ b/frontend/src/components/ConfirmProvider.tsx @@ -0,0 +1,74 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import Modal from "./Modal"; + +// App-styled, promise-based replacement for window.confirm. Wrap the tree in +// once, then anywhere: const confirm = useConfirm(); +// if (await confirm({ message: "…", danger: true })) { … } +export interface ConfirmOptions { + title?: string; + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(() => Promise.resolve(false)); + +export function useConfirm(): ConfirmFn { + return useContext(ConfirmContext); +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [state, setState] = useState<{ + opts: ConfirmOptions; + resolve: (ok: boolean) => void; + } | null>(null); + + const confirm = useCallback( + (opts) => new Promise((resolve) => setState({ opts, resolve })), + [] + ); + + function close(ok: boolean) { + state?.resolve(ok); + setState(null); + } + + return ( + + {children} + {state && ( + close(false)} + maxWidth="max-w-sm" + > +

{state.opts.message}

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7380bb0..9133f7b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean { return status === "hidden"; case "watched": return status === "watched"; - case "saved": - return status === "saved"; case "unwatched": case "in_progress": // (in_progress is further narrowed server-side by resume position; here we only @@ -42,6 +40,7 @@ export default function Feed({ }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); + const [savedOverrides, setSavedOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( null @@ -64,8 +63,14 @@ export default function Feed({ // arrives — after a refetch the server is authoritative, so a stale override // (e.g. a video reverted to "new" from the notification center) won't keep it // filtered out of the current view. - useEffect(() => setOverrides({}), [filters]); - useEffect(() => setOverrides({}), [query.dataUpdatedAt]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [filters]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: ["feed-count", filters], @@ -139,10 +144,24 @@ export default function Feed({ [filters, setFilters] ); + const onToggleSave = useCallback( + (id: string, saved: boolean) => { + setSavedOverrides((o) => ({ ...o, [id]: saved })); + (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) + .then(() => { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }) + .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); + }, + [qc] + ); + const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; const items: Video[] = loaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) + .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
{t("feed.loading")}
; @@ -192,6 +211,7 @@ export default function Feed({ video={v} view="grid" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> @@ -205,6 +225,7 @@ export default function Feed({ video={v} view="list" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 15dc8d0..1d535f5 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; +import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; @@ -90,7 +90,11 @@ export default function Header({ ) : (
- {page === "stats" ? t("header.usageStats") : t("header.channelManager")} + {page === "stats" + ? t("header.usageStats") + : page === "playlists" + ? t("header.account.playlists") + : t("header.channelManager")}
)} @@ -194,6 +198,12 @@ function AccountMenu({ {t("header.account.channels")} )} + {page !== "playlists" && ( + + )} {me.role === "admin" && page !== "stats" && ( + + {index + 1} / {queue!.length} + + + + )} +
{/* Title row — title (with hover description) on the left, Close on the right. */}
@@ -400,12 +455,12 @@ export default function PlayerModal({ onMouseEnter={openDesc} onMouseLeave={scheduleCloseDesc} > - {navigated ? liveData?.title ?? t("player.loading") : video.title} + {navigated ? liveData?.title ?? t("player.loading") : active.title} {navigated && ( + )} + {index + 1} + + + {video.duration_seconds != null && ( + + {formatDuration(video.duration_seconds)} + + )} + {!readOnly && ( + + )} +
+ ); +} + +export default function Playlists({ canWrite }: { canWrite: boolean }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). + const [selectedId, setSelectedId] = useState(() => { + const s = localStorage.getItem("siftlode.playlist"); + return s ? Number(s) : null; + }); + useEffect(() => { + if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); + }, [selectedId]); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + // How the left playlist rail is ordered (persisted). + const [plSort, setPlSort] = useState(() => { + try { + const s = localStorage.getItem("siftlode.plSort"); + if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; + } catch { + /* ignore */ + } + return PL_SORT_DEFAULT; + }); + useEffect(() => { + localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); + }, [plSort]); + const [newName, setNewName] = useState(""); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + // The item order is undoable: drag, sort and group all go through `order.set`, so each is + // reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server. + const order = useUndoable([], { + onApply: (next) => { + if (selectedId == null) return; + api.reorderPlaylist(selectedId, next.map((v) => v.id)).then(() => { + // Refresh the sidebar (cover may change) and the detail (so the now-dirty state, + // and the Reset/Unsynced indicators, show without an F5). The membership guard + // keeps the refetch from wiping the undo history on a pure reorder. + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + }); + }, + }); + const items = order.value; + const [sortKey, setSortKey] = useState("manual"); + const [sortDir, setSortDir] = useState("asc"); + const [groupBy, setGroupBy] = useState(false); + // Tracks the membership (id set) currently loaded into `order`, so a refetch that only + // re-confirms our own reorder doesn't wipe the undo history — we reset history only when + // the actual set of items changes (different playlist, or an add/remove). + const lastSetRef = useRef(""); + // The open player, as an index into `items` (the queue); null = closed. + const [playingIndex, setPlayingIndex] = useState(null); + + const listQuery = useQuery({ + queryKey: ["playlists"], + queryFn: () => api.playlists(), + refetchOnWindowFocus: true, + }); + const playlists = listQuery.data ?? []; + + // Keep the selection valid: default to the first playlist, and if the selected one + // disappears (e.g. just deleted) drop to the next available, or clear when none remain. + useEffect(() => { + // Wait for the first load before adjusting the selection — otherwise the empty + // placeholder list would null the restored selection and we'd fall back to the first. + if (!listQuery.data) return; + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } + }, [playlists, selectedId, listQuery.data]); + + const detailQuery = useQuery({ + queryKey: ["playlist", selectedId], + queryFn: () => api.playlist(selectedId as number), + enabled: selectedId != null, + // Refetch when returning to the tab so a change made elsewhere (another tab/device) + // shows up — including the player's live N / M count, which reads the queue length. + refetchOnWindowFocus: true, + }); + const detail = detailQuery.data; + + useEffect(() => { + if (!detail) return; + const key = idsKey(detail.items); + if (key !== lastSetRef.current) { + lastSetRef.current = key; + order.reset(detail.items); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detail]); + + // Reset the sort controls when switching playlists (the order itself is per-playlist). + useEffect(() => { + setSortKey("manual"); + setSortDir("asc"); + setGroupBy(false); + }, [selectedId]); + + function applySort(key: SortKey, dir: SortDir, group: boolean) { + order.set(sortItems(items, key, dir, group)); + } + const undo = () => { + setSortKey("manual"); + setGroupBy(false); + order.undo(); + }; + const redo = () => { + setSortKey("manual"); + setGroupBy(false); + order.redo(); + }; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) + ); + + // Watch later is a built-in playlist: show a localized name and no rename/delete. + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + // The left rail in the chosen order. "custom" keeps the server position order (Array.sort + // is stable); "dirty first" floats playlists with unpushed edits to the top. + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; // custom: server order + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + // Scroll the restored selection into view once after the rail first renders. + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + + const builtin = detail?.kind === "watch_later"; + // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a + // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin. + const editable = !builtin; + const linked = editable && !!detail?.yt_playlist_id; + const [syncing, setSyncing] = useState(false); + const [pushing, setPushing] = useState(false); + const [reverting, setReverting] = useState(false); + + async function revertToYoutube() { + if (selectedId == null || !detail || reverting) return; + const ok = await confirm({ + title: t("playlists.revertTitle"), + message: t("playlists.revertMsg"), + confirmLabel: t("playlists.revertConfirm"), + danger: true, + }); + if (!ok) return; + setReverting(true); + try { + await api.revertPlaylist(selectedId); + refreshAll(); + notify({ level: "success", message: t("playlists.revertDone") }); + } catch { + notify({ level: "warning", message: t("playlists.revertFailed") }); + } finally { + setReverting(false); + } + } + + async function pushToYoutube() { + if (selectedId == null || !detail || pushing) return; + setPushing(true); + try { + const plan = await api.playlistPushPlan(selectedId); + if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) { + notify({ level: "info", message: t("playlists.pushUpToDate") }); + return; + } + if (!plan.affordable) { + notify({ + level: "warning", + message: t("playlists.pushNoQuota", { + left: plan.remaining_today, + units: plan.units_estimate, + }), + }); + return; + } + let message = + plan.action === "create" + ? t("playlists.pushPlanCreate", { + count: plan.to_insert, + units: plan.units_estimate, + left: plan.remaining_today, + }) + : t("playlists.pushPlanUpdate", { + insert: plan.to_insert, + del: plan.to_delete, + reorder: plan.to_reorder, + units: plan.units_estimate, + left: plan.remaining_today, + }); + if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra }); + const ok = await confirm({ + title: t("playlists.pushTitle"), + message, + confirmLabel: t("playlists.pushConfirm"), + }); + if (!ok) return; + const r = await api.pushPlaylist(selectedId); + refreshAll(); + if (r.failures.length) { + notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); + } else { + notify({ level: "success", message: t("playlists.pushDone") }); + } + } catch { + notify({ level: "warning", message: t("playlists.pushFailed") }); + } finally { + setPushing(false); + } + } + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); + } catch { + notify({ level: "warning", message: t("playlists.syncFailed") }); + } finally { + setSyncing(false); + } + } + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + function refreshAll() { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + } + + function onDragEnd(e: DragEndEvent) { + const { active: a, over } = e; + if (!over || a.id === over.id || selectedId == null) return; + const oldIndex = items.findIndex((v) => v.id === a.id); + const newIndex = items.findIndex((v) => v.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + // A manual drag breaks any active sort/grouping; reflect that in the controls. + setSortKey("manual"); + setGroupBy(false); + order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists + } + + async function removeItem(videoId: string) { + if (selectedId == null) return; + // Membership changes aren't undoable: reset the order to the new set (clearing history) + // and keep lastSetRef in sync so the follow-up refetch doesn't reset again. + const next = items.filter((v) => v.id !== videoId); + order.reset(next); + lastSetRef.current = idsKey(next); + await api.removeFromPlaylist(selectedId, videoId); + refreshAll(); + } + + async function saveRename() { + if (selectedId == null) return; + const name = renameValue.trim(); + setRenaming(false); + if (name && name !== detail?.name) { + await api.renamePlaylist(selectedId, name); + refreshAll(); + } + } + + async function deletePlaylist() { + if (selectedId == null || !detail) return; + const ok = await confirm({ + title: t("playlists.delete"), + message: t("playlists.confirmDelete", { name: detail.name }), + confirmLabel: t("playlists.delete"), + danger: true, + }); + if (!ok) return; + // For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only). + let onYoutube = false; + if (linked && canWrite) { + onYoutube = await confirm({ + title: t("playlists.deleteOnYoutubeTitle"), + message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }), + confirmLabel: t("playlists.deleteOnYoutubeConfirm"), + cancelLabel: t("playlists.deleteHereOnly"), + danger: true, + }); + } + const deletedId = selectedId; + // Pick the next selection from what's left *now* (don't go via null, or the + // auto-select effect would re-pick the just-deleted id from the stale list). + const remaining = playlists.filter((p) => p.id !== deletedId); + setSelectedId(remaining[0]?.id ?? null); + try { + await api.deletePlaylist(deletedId, onYoutube); + } catch { + notify({ level: "warning", message: t("playlists.deleteYtFailed") }); + } + qc.removeQueries({ queryKey: ["playlist", deletedId] }); + qc.invalidateQueries({ queryKey: ["playlists"] }); + } + + function playerState(id: string, status: string) { + api.setState(id, status).then(() => { + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + }); + } + + return ( +
+ + +
+ {selectedId == null || !detail ? ( +
+ {selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} +
+ ) : ( + <> +
+
+ {items[0]?.thumbnail_url && ( + + )} +
+
+ {renaming ? ( + setRenameValue(e.target.value)} + onBlur={saveRename} + onKeyDown={(e) => { + if (e.key === "Enter") saveRename(); + if (e.key === "Escape") setRenaming(false); + }} + className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" + /> + ) : ( +
+

{plName(detail)}

+ {editable && ( + + )} + {detail.yt_playlist_id && ( + + + + )} +
+ )} +
+ {t("playlists.itemCount", { count: items.length })} +
+
+ + {editable && canWrite && ( + + )} + {linked && detail.dirty && ( + + )} + {editable && ( + + )} + {linked && detail.dirty && ( + + + {t("playlists.unsynced")} + + )} +
+
+
+ + {editable && items.length > 1 && ( +
+ {t("playlists.sortLabel")} + + + +
+ +
+
+ )} + + {items.length === 0 ? ( +
+
+ + {t("playlists.empty")} +
+
+ ) : ( + + v.id)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((v, i) => ( + setPlayingIndex(i)} + onRemove={() => removeItem(v.id)} + /> + ))} +
+
+
+ )} + + )} +
+ + {playingIndex != null && items[playingIndex] && ( + setPlayingIndex(null)} + onState={playerState} + /> + )} +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 95a8980..0bb9f7d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ const SORT_IDS = [ "shuffle", ]; -const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; +const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; // Fresh shuffle token for the "surprise me" sort. const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); @@ -202,7 +202,7 @@ export default function Sidebar({ async function shareView() { try { await navigator.clipboard.writeText(shareUrl(filters)); - notify({ message: t("sidebar.shareCopied") }); + notify({ level: "success", message: t("sidebar.shareCopied") }); } catch { notify({ level: "warning", message: t("sidebar.shareFailed") }); } diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 2e5c368..0175365 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -20,7 +20,12 @@ export default function SyncStatus({ const { data } = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus, + // Track the scheduler near-real-time: poll even when the tab is backgrounded, and + // refresh immediately on tab focus (the global default disables focus refetch), so the + // "N without full history" count keeps ticking down without a manual reload. refetchInterval: 30_000, + refetchIntervalInBackground: true, + refetchOnWindowFocus: true, staleTime: 25_000, }); diff --git a/frontend/src/components/UndoToolbar.tsx b/frontend/src/components/UndoToolbar.tsx new file mode 100644 index 0000000..5588757 --- /dev/null +++ b/frontend/src/components/UndoToolbar.tsx @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Redo2, Undo2 } from "lucide-react"; + +// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z +// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any +// undo source. The shortcuts ignore keystrokes while a text field is focused. +export default function UndoToolbar({ + canUndo, + canRedo, + onUndo, + onRedo, +}: { + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; +}) { + const { t } = useTranslation(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey)) return; + const tgt = e.target as HTMLElement | null; + if ( + tgt && + (tgt.tagName === "INPUT" || + tgt.tagName === "TEXTAREA" || + tgt.isContentEditable) + ) + return; + const k = e.key.toLowerCase(); + if (k === "z" && !e.shiftKey) { + if (canUndo) { + e.preventDefault(); + onUndo(); + } + } else if (k === "y" || (k === "z" && e.shiftKey)) { + if (canRedo) { + e.preventDefault(); + onRedo(); + } + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [canUndo, canRedo, onUndo, onRedo]); + + const btn = + "p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"; + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 12cfda4..597470c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -11,6 +11,7 @@ import { RotateCcw, } from "lucide-react"; import Avatar from "./Avatar"; +import AddToPlaylist from "./AddToPlaylist"; import clsx from "clsx"; import type { Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; @@ -18,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format"; function Actions({ video, onState, + onToggleSave, onChannelFilter, }: { video: Video; onState: (id: string, status: string) => void; + onToggleSave: (id: string, saved: boolean) => void; onChannelFilter?: (channelId: string, channelName: string) => void; }) { const { t } = useTranslation(); @@ -30,6 +33,11 @@ function Actions({ e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; + const toggleSave = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onToggleSave(video.id, !video.saved); + }; return (
+
- +
); } @@ -293,7 +304,7 @@ function VideoCard({ {video.channel_title}
{meta}
- + diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index fe81995..8ae17fd 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -3,7 +3,11 @@ "termsOfService": "Nutzungsbedingungen", "close": "Schließen", "cancel": "Abbrechen", + "confirm": "Bestätigen", + "confirmTitle": "Bist du sicher?", "save": "Speichern", + "undo": "Rückgängig", + "redo": "Wiederholen", "loading": "Wird geladen…", "language": "Sprache", "somethingWrong": "Etwas ist schiefgelaufen.", diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 1b4a765..482db47 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -14,6 +14,7 @@ "admin": "Admin", "feed": "Feed", "channels": "Kanäle", + "playlists": "Wiedergabelisten", "stats": "Statistik", "settings": "Einstellungen", "about": "Über", diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json index 5daa6f3..1acf7b8 100644 --- a/frontend/src/i18n/locales/de/player.json +++ b/frontend/src/i18n/locales/de/player.json @@ -2,6 +2,8 @@ "loading": "Wird geladen…", "backToOriginal": "Zurück zum ursprünglichen Video", "back": "Zurück", + "previous": "Vorheriges", + "next": "Nächstes", "close": "Schließen", "closeEsc": "Schließen (Esc)", "description": "Beschreibung", diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json new file mode 100644 index 0000000..e23f006 --- /dev/null +++ b/frontend/src/i18n/locales/de/playlists.json @@ -0,0 +1,59 @@ +{ + "title": "Wiedergabelisten", + "watchLater": "Später ansehen", + "syncYoutube": "Von YouTube synchronisieren", + "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", + "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", + "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert", + "noneYet": "Noch keine Wiedergabelisten", + "newPlaylist": "Neue Liste…", + "itemCount_one": "{{count}} Video", + "itemCount_other": "{{count}} Videos", + "pickOne": "Wähle links eine Liste.", + "loading": "Wird geladen…", + "empty": "Diese Liste ist leer — füge Videos aus dem Feed hinzu.", + "playAll": "Alle abspielen", + "delete": "Löschen", + "rename": "Umbenennen", + "confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.", + "addToPlaylist": "Zur Wiedergabeliste hinzufügen", + "exportToYoutube": "Zu YouTube exportieren", + "pushToYoutube": "Mit YouTube synchronisieren", + "unsynced": "Nicht synchronisiert", + "unsyncedHint": "Du hast lokale Änderungen, die noch nicht mit YouTube synchronisiert sind.", + "openOnYoutube": "Auf YouTube öffnen", + "revert": "Von YouTube zurücksetzen", + "revertTitle": "Lokale Änderungen verwerfen?", + "revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?", + "revertConfirm": "Verwerfen & neu laden", + "revertDone": "Von YouTube neu geladen ✓", + "revertFailed": "Neuladen von YouTube fehlgeschlagen.", + "pushTitle": "Mit YouTube synchronisieren", + "pushConfirm": "Synchronisieren", + "pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", + "pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", + "pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.", + "pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.", + "pushUpToDate": "Bereits mit YouTube synchronisiert.", + "pushDone": "Mit YouTube synchronisiert ✓", + "pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.", + "pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.", + "deleteOnYoutubeTitle": "Auch auf YouTube löschen?", + "deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.", + "deleteOnYoutubeConfirm": "Auf YouTube löschen", + "deleteHereOnly": "Nur hier", + "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.", + "sortLabel": "Sortierung", + "sortManual": "Eigene Reihenfolge", + "sortTitle": "Titel", + "sortDuration": "Dauer", + "sortChannel": "Kanal", + "dirAsc": "Aufsteigend", + "dirDesc": "Absteigend", + "groupByChannel": "Nach Kanal gruppieren", + "railSortCustom": "Eigene Reihenfolge", + "railSortName": "Name", + "railSortCount": "Anzahl", + "railSortDuration": "Gesamtlänge", + "dirtyFirst": "Nicht synchronisierte zuerst" +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index f594fe5..228f33a 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -3,7 +3,11 @@ "termsOfService": "Terms of Service", "close": "Close", "cancel": "Cancel", + "confirm": "Confirm", + "confirmTitle": "Are you sure?", "save": "Save", + "undo": "Undo", + "redo": "Redo", "loading": "Loading…", "language": "Language", "somethingWrong": "Something went wrong.", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 283ff0e..da5a999 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -14,6 +14,7 @@ "admin": "Admin", "feed": "Feed", "channels": "Channels", + "playlists": "Playlists", "stats": "Stats", "settings": "Settings", "about": "About", diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index a3d0ae2..aba13f2 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -2,6 +2,8 @@ "loading": "Loading…", "backToOriginal": "Back to the original video", "back": "Back", + "previous": "Previous", + "next": "Next", "close": "Close", "closeEsc": "Close (Esc)", "description": "Description", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json new file mode 100644 index 0000000..ca48e4c --- /dev/null +++ b/frontend/src/i18n/locales/en/playlists.json @@ -0,0 +1,59 @@ +{ + "title": "Playlists", + "watchLater": "Watch later", + "syncYoutube": "Sync from YouTube", + "syncedToast": "Synced {{count}} playlists from YouTube", + "syncFailed": "Couldn't sync from YouTube", + "ytReadonly": "Synced from YouTube — edits sync back", + "noneYet": "No playlists yet", + "newPlaylist": "New playlist…", + "itemCount_one": "{{count}} video", + "itemCount_other": "{{count}} videos", + "pickOne": "Pick a playlist on the left.", + "loading": "Loading…", + "empty": "This playlist is empty — add videos from the feed.", + "playAll": "Play all", + "delete": "Delete", + "rename": "Rename", + "confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.", + "addToPlaylist": "Add to playlist", + "exportToYoutube": "Export to YouTube", + "pushToYoutube": "Sync to YouTube", + "unsynced": "Unsynced", + "unsyncedHint": "You have local changes not yet synced to YouTube.", + "openOnYoutube": "Open on YouTube", + "revert": "Reset to YouTube", + "revertTitle": "Discard local changes?", + "revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?", + "revertConfirm": "Discard & reload", + "revertDone": "Reloaded from YouTube ✓", + "revertFailed": "Couldn't reload from YouTube.", + "pushTitle": "Sync to YouTube", + "pushConfirm": "Sync", + "pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)", + "pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)", + "pushDiverged": "{{count}} video(s) currently on YouTube will be removed.", + "pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.", + "pushUpToDate": "Already in sync with YouTube.", + "pushDone": "Synced to YouTube ✓", + "pushPartial": "Synced, but {{count}} item(s) were skipped.", + "pushFailed": "Couldn't sync to YouTube.", + "deleteOnYoutubeTitle": "Delete on YouTube too?", + "deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.", + "deleteOnYoutubeConfirm": "Delete on YouTube", + "deleteHereOnly": "Here only", + "deleteYtFailed": "Couldn't delete on YouTube; removed here only.", + "sortLabel": "Sort", + "sortManual": "Custom order", + "sortTitle": "Title", + "sortDuration": "Duration", + "sortChannel": "Channel", + "dirAsc": "Ascending", + "dirDesc": "Descending", + "groupByChannel": "Group by channel", + "railSortCustom": "Custom order", + "railSortName": "Name", + "railSortCount": "Item count", + "railSortDuration": "Total length", + "dirtyFirst": "Unsynced first" +} diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 4584d03..d010adc 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -3,7 +3,11 @@ "termsOfService": "Felhasználási feltételek", "close": "Bezárás", "cancel": "Mégse", + "confirm": "Megerősítés", + "confirmTitle": "Biztos vagy benne?", "save": "Mentés", + "undo": "Visszavonás", + "redo": "Újra", "loading": "Betöltés…", "language": "Nyelv", "somethingWrong": "Hiba történt.", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index cfd8726..5a3d559 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -14,6 +14,7 @@ "admin": "Adminisztrátor", "feed": "Hírfolyam", "channels": "Csatornák", + "playlists": "Lejátszási listák", "stats": "Statisztika", "settings": "Beállítások", "about": "Névjegy", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index 96c0e81..c0e1ec2 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -2,6 +2,8 @@ "loading": "Betöltés…", "backToOriginal": "Vissza az eredeti videóhoz", "back": "Vissza", + "previous": "Előző", + "next": "Következő", "close": "Bezárás", "closeEsc": "Bezárás (Esc)", "description": "Leírás", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json new file mode 100644 index 0000000..b9f6835 --- /dev/null +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -0,0 +1,59 @@ +{ + "title": "Lejátszási listák", + "watchLater": "Megnézem később", + "syncYoutube": "Szinkron YouTube-ról", + "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", + "syncFailed": "Nem sikerült a YouTube-szinkron", + "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók", + "noneYet": "Még nincs lejátszási lista", + "newPlaylist": "Új lista…", + "itemCount_one": "{{count}} videó", + "itemCount_other": "{{count}} videó", + "pickOne": "Válassz egy listát a bal oldalon.", + "loading": "Betöltés…", + "empty": "Ez a lista üres — adj hozzá videókat a hírfolyamból.", + "playAll": "Összes lejátszása", + "delete": "Törlés", + "rename": "Átnevezés", + "confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.", + "addToPlaylist": "Hozzáadás listához", + "exportToYoutube": "Exportálás YouTube-ra", + "pushToYoutube": "Szinkron YouTube-ra", + "unsynced": "Nincs szinkronizálva", + "unsyncedHint": "Vannak helyi módosításaid, amelyek még nincsenek a YouTube-ra szinkronizálva.", + "openOnYoutube": "Megnyitás YouTube-on", + "revert": "Visszaállítás YouTube-ról", + "revertTitle": "Eldobod a helyi módosításokat?", + "revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?", + "revertConfirm": "Eldobás és újratöltés", + "revertDone": "Újratöltve a YouTube-ról ✓", + "revertFailed": "Nem sikerült újratölteni a YouTube-ról.", + "pushTitle": "Szinkron YouTube-ra", + "pushConfirm": "Szinkron", + "pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.", + "pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.", + "pushUpToDate": "Már szinkronban van a YouTube-bal.", + "pushDone": "Szinkronizálva a YouTube-ra ✓", + "pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.", + "pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.", + "deleteOnYoutubeTitle": "Törlöd a YouTube-on is?", + "deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.", + "deleteOnYoutubeConfirm": "Törlés a YouTube-on", + "deleteHereOnly": "Csak itt", + "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.", + "sortLabel": "Rendezés", + "sortManual": "Egyéni sorrend", + "sortTitle": "Cím", + "sortDuration": "Hossz", + "sortChannel": "Csatorna", + "dirAsc": "Növekvő", + "dirDesc": "Csökkenő", + "groupByChannel": "Csoportosítás csatorna szerint", + "railSortCustom": "Egyéni sorrend", + "railSortName": "Név", + "railSortCount": "Elemszám", + "railSortDuration": "Összhossz", + "dirtyFirst": "Nem szinkronizáltak elöl" +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 52b0a97..350c270 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ export interface Video { live_status: string; status: string; position_seconds: number; + saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; } @@ -68,6 +69,49 @@ export interface FeedResponse { limit: number; } +export interface Playlist { + id: number; + name: string; + kind: string; // "user" | "watch_later" + source: string; // "local" | "youtube" + yt_playlist_id: string | null; + dirty: boolean; // linked local playlist has edits not yet pushed to YouTube + item_count: number; + total_duration_seconds: number; + cover_thumbnail: string | null; + has_video?: boolean; // only set when listed with ?contains= +} + +export interface PlaylistDetail { + id: number; + name: string; + kind: string; + source: string; + yt_playlist_id: string | null; + dirty: boolean; + items: Video[]; +} + +export interface PushPlan { + action: "create" | "update"; + to_insert: number; + to_delete: number; + to_reorder: number; + yt_extra: number; // items on YouTube that the push would remove (divergence) + units_estimate: number; + remaining_today: number; + affordable: boolean; +} + +export interface PushResult { + created: number; + inserted: number; + deleted: number; + reordered: number; + failures: string[]; + yt_playlist_id: string | null; +} + export interface FeedFilters { tags: number[]; tagMode: "or" | "and"; @@ -294,6 +338,45 @@ export const api = { req(`/api/channels/${id}/subscription`, { method: "DELETE" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + // --- playlists --- + playlists: (containsVideoId?: string): Promise => + req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`), + playlist: (id: number): Promise => req(`/api/playlists/${id}`), + createPlaylist: (name: string): Promise => + req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }), + renamePlaylist: (id: number, name: string): Promise => + req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), + deletePlaylist: (id: number, onYoutube = false) => + req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }), + addToPlaylist: (id: number, videoId: string) => + req(`/api/playlists/${id}/items`, { + method: "POST", + body: JSON.stringify({ video_id: videoId }), + }), + removeFromPlaylist: (id: number, videoId: string) => + req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }), + reorderPlaylist: (id: number, videoIds: string[]) => + req(`/api/playlists/${id}/order`, { + method: "PUT", + body: JSON.stringify({ video_ids: videoIds }), + }), + // Bookmark shortcut for the built-in Watch later playlist. + watchLaterAdd: (videoId: string) => + req("/api/playlists/watch-later", { + method: "POST", + body: JSON.stringify({ video_id: videoId }), + }), + watchLaterRemove: (videoId: string) => + req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), + syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => + req("/api/playlists/sync-youtube", { method: "POST" }), + revertPlaylist: (id: number): Promise<{ items: number }> => + req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }), + playlistPushPlan: (id: number): Promise => + req(`/api/playlists/${id}/push-plan`), + pushPlaylist: (id: number): Promise => + req(`/api/playlists/${id}/push`, { method: "POST" }), + // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 8c692e7..53d9bd7 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,21 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.3.0", + date: "2026-06-16", + summary: + "Playlists — build your own, mirror your YouTube ones, and sync edits back to YouTube.", + features: [ + "Local playlists: create named, ordered playlists, reorder by drag, and add videos straight from the feed or the player. Play a whole playlist with auto-advance and prev/next.", + "Watch later is now a built-in playlist — the bookmark button adds to it, and you can play and manage it like any other list.", + "Your YouTube playlists are mirrored in automatically and kept fresh, shown with a YouTube badge and an “open on YouTube” link.", + "Two-way YouTube sync: export a local playlist to YouTube, or push your edits (add, remove, reorder, rename) back to a linked playlist. Every push shows an estimate and a confirm first, because YouTube playlist writes are quota-heavy.", + "Edit mirrored playlists freely — your unsynced changes are protected from the automatic refresh, an “Unsynced” marker shows when you have pending edits, and “Reset to YouTube” discards them to reload the original.", + "Sort a playlist by title, duration or channel (ascending/descending) and optionally group by channel — with full undo/redo (buttons and Ctrl/Cmd+Z / Ctrl+Y).", + "The playlist list can be ordered by name, item count or total length (and float your unsynced lists to the top); it shows each list’s total length and remembers your selection.", + ], + }, { version: "0.2.0", date: "2026-06-15", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 4b745f3..325f742 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,11 +78,11 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -export type Page = "feed" | "channels" | "stats"; +export type Page = "feed" | "channels" | "stats" | "playlists"; export function readPage(): Page { const p = new URLSearchParams(window.location.search).get("page"); - return p === "channels" || p === "stats" ? p : "feed"; + return p === "channels" || p === "stats" || p === "playlists" ? p : "feed"; } /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the diff --git a/frontend/src/lib/useUndoable.ts b/frontend/src/lib/useUndoable.ts new file mode 100644 index 0000000..89f86ff --- /dev/null +++ b/frontend/src/lib/useUndoable.ts @@ -0,0 +1,90 @@ +import { useCallback, useReducer, useRef } from "react"; + +// A generic, reusable undo/redo container for a single piece of state. +// +// It keeps a past/present/future snapshot stack: `set` records the current value into the +// past and clears the redo stack; `undo`/`redo` walk the stack. Every value change (set, +// undo, redo) calls `onApply(value)` — the side effect that makes the change real (e.g. +// persist to the server). `reset` installs a fresh value and clears history WITHOUT calling +// onApply (use it when the underlying data is reloaded from the source of truth). +// +// Not playlist-specific: any component with an undoable value (sort order, a draft, a set +// of filters…) can use it. State lives in a ref so the callbacks are stable and never read +// a stale snapshot; a reducer-based force-render keeps the view in sync. +export interface Undoable { + value: T; + set: (next: T) => void; + reset: (value: T) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +} + +interface History { + past: T[]; + present: T; + future: T[]; +} + +export function useUndoable( + initial: T, + opts?: { onApply?: (value: T) => void; limit?: number } +): Undoable { + const [, force] = useReducer((n: number) => n + 1, 0); + const ref = useRef>({ past: [], present: initial, future: [] }); + const onApplyRef = useRef(opts?.onApply); + onApplyRef.current = opts?.onApply; + const limit = opts?.limit ?? 100; + + const set = useCallback( + (next: T) => { + const s = ref.current; + if (Object.is(next, s.present)) return; + const past = [...s.past, s.present]; + if (past.length > limit) past.shift(); + ref.current = { past, present: next, future: [] }; + force(); + onApplyRef.current?.(next); + }, + [limit] + ); + + const undo = useCallback(() => { + const s = ref.current; + if (!s.past.length) return; + const prev = s.past[s.past.length - 1]; + ref.current = { + past: s.past.slice(0, -1), + present: prev, + future: [s.present, ...s.future], + }; + force(); + onApplyRef.current?.(prev); + }, []); + + const redo = useCallback(() => { + const s = ref.current; + if (!s.future.length) return; + const [next, ...rest] = s.future; + ref.current = { past: [...s.past, s.present], present: next, future: rest }; + force(); + onApplyRef.current?.(next); + }, []); + + const reset = useCallback((value: T) => { + ref.current = { past: [], present: value, future: [] }; + force(); + }, []); + + const h = ref.current; + return { + value: h.present, + set, + reset, + undo, + redo, + canUndo: h.past.length > 0, + canRedo: h.future.length > 0, + }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 44320e3..88bb2c6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; +import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; import "./i18n"; @@ -21,7 +22,9 @@ const root = path === "/terms" ? : ( - + + + );