Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-16 00:17:10 +02:00
commit 424e820276
41 changed files with 3150 additions and 68 deletions

View file

@ -1 +1 @@
0.2.0 0.3.0

View file

@ -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")

View file

@ -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'")

View file

@ -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")

View file

@ -89,6 +89,7 @@ class Settings(BaseSettings):
autotag_title_sample: int = 40 autotag_title_sample: int = 40
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360 subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360
# live_status values hidden from the feed by default. Completed-stream VODs # live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible. # ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming" feed_default_hidden_live: str = "live,upcoming"

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings 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 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(feed.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)

View file

@ -301,3 +301,68 @@ class AppState(Base):
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" 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")

View file

@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased
from app import quota from app import quota
from app.auth import current_user from app.auth import current_user
from app.db import get_db 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.sync.videos import parse_iso8601_duration
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api", tags=["feed"]) 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") HIDDEN_LIVE = ("live", "upcoming")
# Resume-position thresholds (mirror the client): positions below this are "didn't # 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}" 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: def _serialize(row) -> dict:
return { return {
"id": row.id, "id": row.id,
@ -45,6 +73,7 @@ def _serialize(row) -> dict:
"live_status": row.live_status, "live_status": row.live_status,
"status": row.status or "new", "status": row.status or "new",
"position_seconds": row.position_seconds or 0, "position_seconds": row.position_seconds or 0,
"saved": bool(getattr(row, "saved", False)),
"watch_url": f"https://www.youtube.com/watch?v={row.id}", "watch_url": f"https://www.youtube.com/watch?v={row.id}",
} }
@ -96,6 +125,7 @@ def _filtered_query(
Video.live_status, Video.live_status,
status_expr.label("status"), status_expr.label("status"),
position_expr.label("position_seconds"), position_expr.label("position_seconds"),
_saved_expr(user),
).join(Channel, Channel.id == Video.channel_id) ).join(Channel, Channel.id == Video.channel_id)
if scope == "all": if scope == "all":
@ -176,7 +206,7 @@ def _filtered_query(
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # 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 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: if not explicit_view:
type_clauses = [] type_clauses = []
if show_normal: if show_normal:
@ -198,8 +228,6 @@ def _filtered_query(
) )
elif show == "watched": elif show == "watched":
query = query.where(status_expr == "watched") query = query.where(status_expr == "watched")
elif show == "saved":
query = query.where(status_expr == "saved")
elif show == "hidden": elif show == "hidden":
query = query.where(status_expr == "hidden") query = query.where(status_expr == "hidden")
else: # all else: # all

View file

@ -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=<video_id>`, 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}

View file

@ -9,6 +9,7 @@ from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.state import is_sync_paused from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
from app.sync.playlists import sync_all_playlists
from app.sync.runner import ( from app.sync.runner import (
run_deep_backfill, run_deep_backfill,
run_enrich, run_enrich,
@ -79,6 +80,12 @@ def _subscriptions_job() -> None:
_job("subscriptions", work) _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: def start_scheduler() -> None:
global _scheduler global _scheduler
if not settings.scheduler_enabled or _scheduler is not None: if not settings.scheduler_enabled or _scheduler is not None:
@ -114,6 +121,12 @@ def start_scheduler() -> None:
minutes=settings.subscriptions_resync_minutes, minutes=settings.subscriptions_resync_minutes,
id="subscriptions", id="subscriptions",
) )
scheduler.add_job(
_playlist_sync_job,
"interval",
minutes=settings.playlist_sync_minutes,
id="playlist_sync",
)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler
logger.info("scheduler started") logger.info("scheduler started")

View file

@ -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}

View file

@ -125,6 +125,49 @@ class YouTubeClient:
if not page_token: if not page_token:
break 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]: def get_channels(self, channel_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(channel_ids, 50): for batch in _chunks(channel_ids, 50):
@ -170,6 +213,114 @@ class YouTubeClient:
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" 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]: def get_videos(self, video_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(video_ids, 50): for batch in _chunks(video_ids, 50):

View file

@ -24,6 +24,7 @@ import Header from "./components/Header";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Channels, { type ChannelStatusFilter } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = {
}; };
const FILTERS_KEY = "subfeed.filters"; 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 { function loadStoredFilters(): FeedFilters {
try { try {
@ -69,7 +81,7 @@ export default function App() {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(readPage); const [page, setPageState] = useState<Page>(loadInitialPage);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all"); const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
@ -89,6 +101,7 @@ export default function App() {
function setPage(next: Page) { function setPage(next: Page) {
setPageState(next); setPageState(next);
localStorage.setItem(PAGE_KEY, next);
} }
function setSidebarLayout(next: SidebarLayout) { function setSidebarLayout(next: SidebarLayout) {
@ -218,6 +231,8 @@ export default function App() {
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} />
) : ( ) : (
<Feed <Feed
filters={filters} filters={filters}

View file

@ -0,0 +1,196 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
export default function AddToPlaylist({
videoId,
className,
}: {
videoId: string;
className?: string;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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 (
<>
<button
ref={triggerRef}
onClick={toggleOpen}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
>
<ListPlus className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-50 glass rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylist")}
</div>
<div className="max-h-56 overflow-y-auto">
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">
{t("playlists.noneYet")}
</div>
)}
{lists.map((pl) => (
<button
key={pl.id}
onClick={() => toggle(pl)}
disabled={busy}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video
? "bg-accent border-accent text-accent-fg"
: "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</button>
))}
</div>
<form
onSubmit={createAndAdd}
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
>
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => 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"
/>
</form>
</div>,
document.body
)}
</>
);
}

View file

@ -18,6 +18,7 @@ import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -43,6 +44,7 @@ export default function Channels({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // 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. // 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} c={c}
userTags={userTags} userTags={userTags}
canWrite={canWrite} canWrite={canWrite}
onUnsubscribe={() => { onUnsubscribe={async () => {
if ( const ok = await confirm({
window.confirm( title: t("channels.row.unsubscribeOnYoutube"),
t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
) confirmLabel: t("channels.row.unsubscribeOnYoutube"),
) danger: true,
unsubscribe.mutate(c.id); });
if (ok) unsubscribe.mutate(c.id);
}} }}
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
onPriority={(d) => bumpPriority(c.id, c.priority, d)} onPriority={(d) => bumpPriority(c.id, c.priority, d)}

View file

@ -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
// <ConfirmProvider> 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<boolean>;
const ConfirmContext = createContext<ConfirmFn>(() => 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<ConfirmFn>(
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
[]
);
function close(ok: boolean) {
state?.resolve(ok);
setState(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{state && (
<Modal
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(false)}
maxWidth="max-w-sm"
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
<div className="flex justify-end gap-2 mt-5">
<button
onClick={() => close(false)}
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
>
{state.opts.cancelLabel ?? t("common.cancel")}
</button>
<button
autoFocus
onClick={() => close(true)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
state.opts.danger
? "bg-red-500 text-white hover:bg-red-600"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
{state.opts.confirmLabel ?? t("common.confirm")}
</button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}

View file

@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean {
return status === "hidden"; return status === "hidden";
case "watched": case "watched":
return status === "watched"; return status === "watched";
case "saved":
return status === "saved";
case "unwatched": case "unwatched":
case "in_progress": case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only // (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 { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null null
@ -64,8 +63,14 @@ export default function Feed({
// arrives — after a refetch the server is authoritative, so a stale override // 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 // (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view. // filtered out of the current view.
useEffect(() => setOverrides({}), [filters]); useEffect(() => {
useEffect(() => setOverrides({}), [query.dataUpdatedAt]); setOverrides({});
setSavedOverrides({});
}, [filters]);
useEffect(() => {
setOverrides({});
setSavedOverrides({});
}, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
queryKey: ["feed-count", filters], queryKey: ["feed-count", filters],
@ -139,10 +144,24 @@ export default function Feed({
[filters, setFilters] [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); const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = loaded; loadedRef.current = loaded;
const items: Video[] = loaded const items: Video[] = loaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .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)); .filter((v) => matchesView(v.status, filters.show));
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>; if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
@ -192,6 +211,7 @@ export default function Feed({
video={v} video={v}
view="grid" view="grid"
onState={onState} onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={openVideo} onOpen={openVideo}
/> />
@ -205,6 +225,7 @@ export default function Feed({
video={v} video={v}
view="list" view="list"
onState={onState} onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={openVideo} onOpen={openVideo}
/> />

View file

@ -1,6 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
@ -90,7 +90,11 @@ export default function Header({
</div> </div>
) : ( ) : (
<div className="flex-1 text-center text-sm font-semibold"> <div className="flex-1 text-center text-sm font-semibold">
{page === "stats" ? t("header.usageStats") : t("header.channelManager")} {page === "stats"
? t("header.usageStats")
: page === "playlists"
? t("header.account.playlists")
: t("header.channelManager")}
</div> </div>
)} )}
@ -194,6 +198,12 @@ function AccountMenu({
{t("header.account.channels")} {t("header.account.channels")}
</button> </button>
)} )}
{page !== "playlists" && (
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
<ListVideo className="w-4 h-4" />
{t("header.account.playlists")}
</button>
)}
{me.role === "admin" && page !== "stats" && ( {me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}> <button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" /> <BarChart3 className="w-4 h-4" />

View file

@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -183,6 +184,8 @@ function loadYouTubeApi(): Promise<any> {
export default function PlayerModal({ export default function PlayerModal({
video, video,
startAt, startAt,
queue,
startIndex,
onClose, onClose,
onState, onState,
}: { }: {
@ -190,30 +193,47 @@ export default function PlayerModal({
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
// to resume from the server-saved position carried on the video. // to resume from the server-saved position carried on the video.
startAt?: number | null; startAt?: number | null;
// Optional playback queue (e.g. a playlist). When present, the player advances through
// it (auto-advance on end + prev/next), recreating per item to reuse the single-video
// resume / auto-watch / progress logic.
queue?: Video[];
startIndex?: number;
onClose: () => void; onClose: () => void;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Resume point for the video we opened with. // Track the playing item by id (not a frozen index) so it survives the queue changing
const resumeAt = startAt != null ? startAt : video.position_seconds || 0; // under us — e.g. an item removed in another tab. The index is derived from the *live*
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
// playback of the current video.
const [playingId, setPlayingId] = useState<string>(
queue?.[startIndex ?? 0]?.id ?? video.id
);
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
if (index < 0) index = 0;
const active = queue && queue[index] ? queue[index] : video;
const hasQueue = !!queue && queue.length > 1;
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// The player can navigate to other videos (YouTube links in a description). The // The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the feed video we opened with. // currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(video.id); const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(video.id); const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== video.id; const navigated = currentVideoId !== active.id;
// Title/author of a navigated-to video, read from the player (free, no API call). // Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
const loadVideo = (id: string, start: number | null) => { const loadVideo = (id: string, start: number | null) => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return; if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the opened video, start others at 0. // Explicit start wins; otherwise resume the active item, start others at 0.
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0; const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id; currentIdRef.current = id;
setCurrentVideoId(id); setCurrentVideoId(id);
@ -222,14 +242,22 @@ export default function PlayerModal({
// Local mirror of watch status so the toggle reacts instantly; changes are // Local mirror of watch status so the toggle reacts instantly; changes are
// propagated to the feed (and server) via onState. // propagated to the feed (and server) via onState.
const [status, setStatus] = useState(video.status); const [status, setStatus] = useState(active.status);
const watched = status === "watched"; const watched = status === "watched";
const setWatched = (on: boolean) => { const setWatched = (on: boolean) => {
const next = on ? "watched" : "new"; const next = on ? "watched" : "new";
setStatus(next); setStatus(next);
onState(video.id, next); onState(active.id, next);
}; };
// On queue advance (active changes), sync local state to the new item.
useEffect(() => {
setStatus(active.status);
setCurrentVideoId(active.id);
currentIdRef.current = active.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
const seekTo = (seconds: number) => { const seekTo = (seconds: number) => {
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.seekTo === "function") { if (p && typeof p.seekTo === "function") {
@ -291,10 +319,11 @@ export default function PlayerModal({
// auto-mark watched once playback reaches the end. // auto-mark watched once playback reaches the end.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const id = video.id; const id = active.id;
autoMarkedRef.current = false;
// Auto-watch only applies to the feed video we opened with — not to other // Auto-watch only applies to the active item — not to other videos the player
// videos the player navigated to via description links. // navigated to via description links.
const maybeAutoWatch = (current: number, duration: number) => { const maybeAutoWatch = (current: number, duration: number) => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
if (current > duration - FINISH_MARGIN) { if (current > duration - FINISH_MARGIN) {
@ -341,10 +370,13 @@ export default function PlayerModal({
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author }); if (d) setLiveData({ title: d.title, author: d.author });
} }
// 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch). // 0 === ended → mark watched, then advance to the next queue item if any.
if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { if (e?.data === 0 && currentIdRef.current === id) {
autoMarkedRef.current = true; if (!autoMarkedRef.current) {
setWatched(true); autoMarkedRef.current = true;
setWatched(true);
}
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
} }
}, },
}, },
@ -357,10 +389,11 @@ export default function PlayerModal({
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(timer); window.clearInterval(timer);
// Final flush, then refresh the feed so the card's resume bar reflects this session. // Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] }) qc.invalidateQueries({ queryKey: ["feed"] });
); qc.invalidateQueries({ queryKey: ["playlist"] });
});
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { if (p && typeof p.destroy === "function") {
try { try {
@ -372,7 +405,7 @@ export default function PlayerModal({
playerRef.current = null; playerRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [video.id]); }, [active.id]);
return ( return (
<div <div
@ -389,6 +422,28 @@ export default function PlayerModal({
<div ref={mountRef} className="w-full h-full" /> <div ref={mountRef} className="w-full h-full" />
</div> </div>
{hasQueue && (
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
<button
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
disabled={index === 0}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
disabled={index === queue!.length - 1}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
</div>
)}
<div className="p-4 sm:p-5"> <div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */} {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -400,12 +455,12 @@ export default function PlayerModal({
onMouseEnter={openDesc} onMouseEnter={openDesc}
onMouseLeave={scheduleCloseDesc} onMouseLeave={scheduleCloseDesc}
> >
{navigated ? liveData?.title ?? t("player.loading") : video.title} {navigated ? liveData?.title ?? t("player.loading") : active.title}
</span> </span>
</h2> </h2>
{navigated && ( {navigated && (
<button <button
onClick={() => loadVideo(video.id, null)} onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")} title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition" className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
> >
@ -462,8 +517,8 @@ export default function PlayerModal({
<div className="flex items-center gap-3 mt-3"> <div className="flex items-center gap-3 mt-3">
{!navigated && ( {!navigated && (
<Avatar <Avatar
src={video.channel_thumbnail} src={active.channel_thumbnail}
fallback={video.channel_title ?? ""} fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0" className="w-9 h-9 rounded-full shrink-0"
/> />
)} )}
@ -482,24 +537,24 @@ export default function PlayerModal({
) )
) : ( ) : (
<a <a
href={video.channel_url} href={active.channel_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-medium hover:text-accent shrink-0" className="font-medium hover:text-accent shrink-0"
> >
{video.channel_title} {active.channel_title}
</a> </a>
)} )}
{!navigated ? ( {!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0"> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{video.view_count != null && ( {active.view_count != null && (
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span> <span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
)} )}
<span>· {relativeTime(video.published_at)}</span> <span>· {relativeTime(active.published_at)}</span>
{video.duration_seconds != null && ( {active.duration_seconds != null && (
<span>· {formatDuration(video.duration_seconds)}</span> <span>· {formatDuration(active.duration_seconds)}</span>
)} )}
{video.live_status === "was_live" && ( {active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded"> <span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{t("player.stream")} {t("player.stream")}
</span> </span>
@ -518,12 +573,18 @@ export default function PlayerModal({
</div> </div>
)} )}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && ( {!navigated && (
<button <button
onClick={() => setWatched(!watched)} onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")} title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={ className={
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " + "shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched (watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90" ? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border") : "text-muted hover:text-fg hover:bg-surface border border-border")

View file

@ -0,0 +1,856 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowDown,
ArrowUp,
Check,
ExternalLink,
GripVertical,
ListPlus,
Pencil,
Pin,
Play,
Plus,
RefreshCw,
RotateCcw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar";
import { useConfirm } from "./ConfirmProvider";
type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc";
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
if (key === "manual") return null;
const sign = dir === "asc" ? 1 : -1;
if (key === "duration")
return (a, b) => {
const av = a.duration_seconds;
const bv = b.duration_seconds;
if (av == null && bv == null) return 0;
if (av == null) return 1; // nulls always last, both directions
if (bv == null) return -1;
return sign * (av - bv);
};
return (a, b) => {
const av = (key === "channel" ? a.channel_title : a.title) ?? "";
const bv = (key === "channel" ? b.channel_title : b.title) ?? "";
return sign * av.localeCompare(bv);
};
}
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
// by channel name in the chosen direction) and the chosen sort is applied within each group;
// with key "manual" the within-group order is left as-is. Returns the same array reference
// when nothing would change, so it doesn't create a spurious undo entry.
function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] {
const cmp = comparator(key, dir);
if (!group) return cmp ? [...items].sort(cmp) : items;
const groups = new Map<string, Video[]>();
for (const v of items) {
const k = v.channel_title ?? "";
const g = groups.get(k);
if (g) g.push(v);
else groups.set(k, [v]);
}
const sign = dir === "asc" ? 1 : -1;
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
const out: Video[] = [];
for (const k of keys) {
const g = groups.get(k)!;
out.push(...(cmp ? [...g].sort(cmp) : g));
}
return out;
}
const idsKey = (items: Video[]) =>
items
.map((v) => v.id)
.sort()
.join(",");
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
function Row({
video,
index,
readOnly,
onPlay,
onRemove,
}: {
video: Video;
index: number;
readOnly?: boolean;
onPlay: () => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: video.id, disabled: readOnly });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card"
>
{readOnly ? (
<span className="w-4" />
) : (
<button
{...attributes}
{...listeners}
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
aria-label="reorder"
>
<GripVertical className="w-4 h-4" />
</button>
)}
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button
onClick={onPlay}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
>
{video.thumbnail_url && (
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
<div className="text-[13px] text-fg truncate">{video.title}</div>
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
</button>
{video.duration_seconds != null && (
<span className="text-[11px] text-muted tabular-nums">
{formatDuration(video.duration_seconds)}
</span>
)}
{!readOnly && (
<button
onClick={onRemove}
title="remove"
className="text-muted hover:text-fg p-1"
aria-label="remove from playlist"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
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<number | null>(() => {
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<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => {
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<Video[]>([], {
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<SortKey>("manual");
const [sortDir, setSortDir] = useState<SortDir>("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<string>("");
// The open player, as an index into `items` (the queue); null = closed.
const [playingIndex, setPlayingIndex] = useState<number | null>(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 (
<div className="flex h-full min-h-0">
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">{t("playlists.title")}</span>
<button
onClick={syncYoutube}
disabled={syncing}
title={t("playlists.syncYoutube")}
aria-label={t("playlists.syncYoutube")}
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
<Youtube className="w-3.5 h-3.5" />
</button>
</div>
{playlists.length > 1 && (
<div className="flex items-center gap-1 mb-2">
<select
value={plSort.key}
onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() =>
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
}
disabled={plSort.key === "custom"}
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className={`p-1 rounded-md border transition ${
plSort.dirtyFirst
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
)}
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
<div className="space-y-1">
{sortedPlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => 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"
/>
</form>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto p-4">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div>
) : (
<>
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img
src={items[0].thumbnail_url}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => items.length && setPlayingIndex(0)}
disabled={!items.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
</div>
</div>
</div>
{editable && items.length > 1 && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const k = e.target.value as SortKey;
setSortKey(k);
applySort(k, sortDir, groupBy);
}}
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
>
<option value="manual">{t("playlists.sortManual")}</option>
<option value="title">{t("playlists.sortTitle")}</option>
<option value="duration">{t("playlists.sortDuration")}</option>
<option value="channel">{t("playlists.sortChannel")}</option>
</select>
<button
onClick={() => {
const d = sortDir === "asc" ? "desc" : "asc";
setSortDir(d);
if (sortKey !== "manual" || groupBy) applySort(sortKey, d, groupBy);
}}
disabled={sortKey === "manual" && !groupBy}
title={sortDir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{sortDir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => {
setGroupBy(e.target.checked);
applySort(sortKey, sortDir, e.target.checked);
}}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={undo}
onRedo={redo}
/>
</div>
</div>
)}
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={items.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 max-w-3xl">
{items.map((v, i) => (
<Row
key={v.id}
video={v}
index={i}
onPlay={() => setPlayingIndex(i)}
onRemove={() => removeItem(v.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</>
)}
</main>
{playingIndex != null && items[playingIndex] && (
<PlayerModal
video={items[playingIndex]}
queue={items}
startIndex={playingIndex}
startAt={null}
onClose={() => setPlayingIndex(null)}
onState={playerState}
/>
)}
</div>
);
}

View file

@ -50,7 +50,7 @@ const SORT_IDS = [
"shuffle", "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. // Fresh shuffle token for the "surprise me" sort.
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
@ -202,7 +202,7 @@ export default function Sidebar({
async function shareView() { async function shareView() {
try { try {
await navigator.clipboard.writeText(shareUrl(filters)); await navigator.clipboard.writeText(shareUrl(filters));
notify({ message: t("sidebar.shareCopied") }); notify({ level: "success", message: t("sidebar.shareCopied") });
} catch { } catch {
notify({ level: "warning", message: t("sidebar.shareFailed") }); notify({ level: "warning", message: t("sidebar.shareFailed") });
} }

View file

@ -20,7 +20,12 @@ export default function SyncStatus({
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["my-status"], queryKey: ["my-status"],
queryFn: api.myStatus, 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, refetchInterval: 30_000,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
staleTime: 25_000, staleTime: 25_000,
}); });

View file

@ -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 (
<div className="inline-flex items-center gap-1">
<button onClick={onUndo} disabled={!canUndo} title={t("common.undo")} aria-label={t("common.undo")} className={btn}>
<Undo2 className="w-4 h-4" />
</button>
<button onClick={onRedo} disabled={!canRedo} title={t("common.redo")} aria-label={t("common.redo")} className={btn}>
<Redo2 className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -11,6 +11,7 @@ import {
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import clsx from "clsx"; import clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -18,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({ function Actions({
video, video,
onState, onState,
onToggleSave,
onChannelFilter, onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -30,6 +33,11 @@ function Actions({
e.stopPropagation(); e.stopPropagation();
onState(video.id, video.status === status ? "new" : status); onState(video.id, video.status === status ? "new" : status);
}; };
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
return ( return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button <button
@ -47,15 +55,16 @@ function Actions({
)} )}
</button> </button>
<button <button
onClick={act("saved")} onClick={toggleSave}
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")} title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx( className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg", "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "fill-current text-accent" video.saved && "fill-current text-accent"
)} )}
> >
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />
</button> </button>
<AddToPlaylist videoId={video.id} />
<button <button
onClick={act("hidden")} onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")} title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
@ -155,7 +164,7 @@ function Thumb({
{t("card.stream")} {t("card.stream")}
</span> </span>
)} )}
{video.status === "saved" && ( {video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1"> <span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
</span> </span>
@ -207,12 +216,14 @@ function VideoCard({
video, video,
view, view,
onState, onState,
onToggleSave,
onChannelFilter, onChannelFilter,
onOpen, onOpen,
}: { }: {
video: Video; video: Video;
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
@ -262,7 +273,7 @@ function VideoCard({
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div> </div>
); );
} }
@ -293,7 +304,7 @@ function VideoCard({
{video.channel_title} {video.channel_title}
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,7 +3,11 @@
"termsOfService": "Nutzungsbedingungen", "termsOfService": "Nutzungsbedingungen",
"close": "Schließen", "close": "Schließen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen",
"confirmTitle": "Bist du sicher?",
"save": "Speichern", "save": "Speichern",
"undo": "Rückgängig",
"redo": "Wiederholen",
"loading": "Wird geladen…", "loading": "Wird geladen…",
"language": "Sprache", "language": "Sprache",
"somethingWrong": "Etwas ist schiefgelaufen.", "somethingWrong": "Etwas ist schiefgelaufen.",

View file

@ -14,6 +14,7 @@
"admin": "Admin", "admin": "Admin",
"feed": "Feed", "feed": "Feed",
"channels": "Kanäle", "channels": "Kanäle",
"playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",

View file

@ -2,6 +2,8 @@
"loading": "Wird geladen…", "loading": "Wird geladen…",
"backToOriginal": "Zurück zum ursprünglichen Video", "backToOriginal": "Zurück zum ursprünglichen Video",
"back": "Zurück", "back": "Zurück",
"previous": "Vorheriges",
"next": "Nächstes",
"close": "Schließen", "close": "Schließen",
"closeEsc": "Schließen (Esc)", "closeEsc": "Schließen (Esc)",
"description": "Beschreibung", "description": "Beschreibung",

View file

@ -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"
}

View file

@ -3,7 +3,11 @@
"termsOfService": "Terms of Service", "termsOfService": "Terms of Service",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"confirmTitle": "Are you sure?",
"save": "Save", "save": "Save",
"undo": "Undo",
"redo": "Redo",
"loading": "Loading…", "loading": "Loading…",
"language": "Language", "language": "Language",
"somethingWrong": "Something went wrong.", "somethingWrong": "Something went wrong.",

View file

@ -14,6 +14,7 @@
"admin": "Admin", "admin": "Admin",
"feed": "Feed", "feed": "Feed",
"channels": "Channels", "channels": "Channels",
"playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",

View file

@ -2,6 +2,8 @@
"loading": "Loading…", "loading": "Loading…",
"backToOriginal": "Back to the original video", "backToOriginal": "Back to the original video",
"back": "Back", "back": "Back",
"previous": "Previous",
"next": "Next",
"close": "Close", "close": "Close",
"closeEsc": "Close (Esc)", "closeEsc": "Close (Esc)",
"description": "Description", "description": "Description",

View file

@ -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"
}

View file

@ -3,7 +3,11 @@
"termsOfService": "Felhasználási feltételek", "termsOfService": "Felhasználási feltételek",
"close": "Bezárás", "close": "Bezárás",
"cancel": "Mégse", "cancel": "Mégse",
"confirm": "Megerősítés",
"confirmTitle": "Biztos vagy benne?",
"save": "Mentés", "save": "Mentés",
"undo": "Visszavonás",
"redo": "Újra",
"loading": "Betöltés…", "loading": "Betöltés…",
"language": "Nyelv", "language": "Nyelv",
"somethingWrong": "Hiba történt.", "somethingWrong": "Hiba történt.",

View file

@ -14,6 +14,7 @@
"admin": "Adminisztrátor", "admin": "Adminisztrátor",
"feed": "Hírfolyam", "feed": "Hírfolyam",
"channels": "Csatornák", "channels": "Csatornák",
"playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",

View file

@ -2,6 +2,8 @@
"loading": "Betöltés…", "loading": "Betöltés…",
"backToOriginal": "Vissza az eredeti videóhoz", "backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza", "back": "Vissza",
"previous": "Előző",
"next": "Következő",
"close": "Bezárás", "close": "Bezárás",
"closeEsc": "Bezárás (Esc)", "closeEsc": "Bezárás (Esc)",
"description": "Leírás", "description": "Leírás",

View file

@ -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"
}

View file

@ -46,6 +46,7 @@ export interface Video {
live_status: string; live_status: string;
status: string; status: string;
position_seconds: number; position_seconds: number;
saved: boolean; // is the video in the user's built-in Watch later playlist
watch_url: string; watch_url: string;
} }
@ -68,6 +69,49 @@ export interface FeedResponse {
limit: number; 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=<video_id>
}
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 { export interface FeedFilters {
tags: number[]; tags: number[];
tagMode: "or" | "and"; tagMode: "or" | "and";
@ -294,6 +338,45 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }), req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- playlists ---
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
createPlaylist: (name: string): Promise<Playlist> =>
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
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<PushPlan> =>
req(`/api/playlists/${id}/push-plan`),
pushPlaylist: (id: number): Promise<PushResult> =>
req(`/api/playlists/${id}/push`, { method: "POST" }),
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),

View file

@ -14,6 +14,21 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: 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 lists total length and remembers your selection.",
],
},
{ {
version: "0.2.0", version: "0.2.0",
date: "2026-06-15", date: "2026-06-15",

View file

@ -78,11 +78,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k)); return KEYS.some((k) => params.has(k));
} }
export type Page = "feed" | "channels" | "stats"; export type Page = "feed" | "channels" | "stats" | "playlists";
export function readPage(): Page { export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("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 /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the

View file

@ -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<T> {
value: T;
set: (next: T) => void;
reset: (value: T) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
}
interface History<T> {
past: T[];
present: T;
future: T[];
}
export function useUndoable<T>(
initial: T,
opts?: { onApply?: (value: T) => void; limit?: number }
): Undoable<T> {
const [, force] = useReducer((n: number) => n + 1, 0);
const ref = useRef<History<T>>({ 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,
};
}

View file

@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms"; import Terms from "./components/legal/Terms";
import "./i18n"; import "./i18n";
@ -21,7 +22,9 @@ const root =
path === "/terms" ? <Terms /> : ( path === "/terms" ? <Terms /> : (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorBoundary> <ErrorBoundary>
<App /> <ConfirmProvider>
<App />
</ConfirmProvider>
</ErrorBoundary> </ErrorBoundary>
</QueryClientProvider> </QueryClientProvider>
); );