Merge: promote dev to prod
This commit is contained in:
commit
81b7e1d1b9
41 changed files with 3150 additions and 68 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.2.0
|
||||
0.3.0
|
||||
|
|
|
|||
88
backend/alembic/versions/0011_playlists.py
Normal file
88
backend/alembic/versions/0011_playlists.py
Normal 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")
|
||||
65
backend/alembic/versions/0012_watch_later.py
Normal file
65
backend/alembic/versions/0012_watch_later.py
Normal 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'")
|
||||
64
backend/alembic/versions/0013_playlist_fingerprint.py
Normal file
64
backend/alembic/versions/0013_playlist_fingerprint.py
Normal 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")
|
||||
|
|
@ -89,6 +89,7 @@ class Settings(BaseSettings):
|
|||
autotag_title_sample: int = 40
|
||||
autotag_interval_minutes: int = 30
|
||||
subscriptions_resync_minutes: int = 360
|
||||
playlist_sync_minutes: int = 360
|
||||
# live_status values hidden from the feed by default. Completed-stream VODs
|
||||
# ("was_live") are real watchable content and stay visible.
|
||||
feed_default_hidden_live: str = "live,upcoming"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||
|
||||
from app import auth
|
||||
from app.config import settings
|
||||
from app.routes import admin, channels, feed, health, me, quota, sync, tags, version
|
||||
from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version
|
||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||
|
||||
|
||||
|
|
@ -66,6 +66,7 @@ app.include_router(tags.router)
|
|||
app.include_router(feed.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(channels.router)
|
||||
app.include_router(playlists.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(quota.router)
|
||||
app.include_router(version.router)
|
||||
|
|
|
|||
|
|
@ -301,3 +301,68 @@ class AppState(Base):
|
|||
sync_paused: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||
|
||||
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
||||
`source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube
|
||||
playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id.
|
||||
`dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read
|
||||
sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT
|
||||
sync phases; the foundation phase only uses local playlists.)"""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(16), default="local", server_default="local"
|
||||
)
|
||||
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
||||
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
||||
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
||||
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||
back_populates="playlist",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PlaylistItem.position",
|
||||
)
|
||||
|
||||
|
||||
class PlaylistItem(Base):
|
||||
"""A video's membership in a playlist, with its order within that playlist."""
|
||||
|
||||
__tablename__ = "playlist_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
video_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
||||
|
|
|
|||
|
|
@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased
|
|||
from app import quota
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
||||
from app.models import (
|
||||
Channel,
|
||||
ChannelTag,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
Subscription,
|
||||
Tag,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.sync.videos import parse_iso8601_duration
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["feed"])
|
||||
|
||||
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
||||
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||
VALID_STATES = {"new", "watched", "hidden"}
|
||||
HIDDEN_LIVE = ("live", "upcoming")
|
||||
|
||||
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
||||
|
|
@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str:
|
|||
return f"https://www.youtube.com/channel/{channel_id}"
|
||||
|
||||
|
||||
def _saved_expr(user: User):
|
||||
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
|
||||
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
|
||||
return (
|
||||
select(1)
|
||||
.select_from(PlaylistItem)
|
||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||
.where(
|
||||
Playlist.user_id == user.id,
|
||||
Playlist.kind == "watch_later",
|
||||
PlaylistItem.video_id == Video.id,
|
||||
)
|
||||
.exists()
|
||||
.label("saved")
|
||||
)
|
||||
|
||||
|
||||
def _serialize(row) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
|
|
@ -45,6 +73,7 @@ def _serialize(row) -> dict:
|
|||
"live_status": row.live_status,
|
||||
"status": row.status or "new",
|
||||
"position_seconds": row.position_seconds or 0,
|
||||
"saved": bool(getattr(row, "saved", False)),
|
||||
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +125,7 @@ def _filtered_query(
|
|||
Video.live_status,
|
||||
status_expr.label("status"),
|
||||
position_expr.label("position_seconds"),
|
||||
_saved_expr(user),
|
||||
).join(Channel, Channel.id == Video.channel_id)
|
||||
|
||||
if scope == "all":
|
||||
|
|
@ -176,7 +206,7 @@ def _filtered_query(
|
|||
|
||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||
explicit_view = show in ("watched", "saved", "hidden")
|
||||
explicit_view = show in ("watched", "hidden")
|
||||
if not explicit_view:
|
||||
type_clauses = []
|
||||
if show_normal:
|
||||
|
|
@ -198,8 +228,6 @@ def _filtered_query(
|
|||
)
|
||||
elif show == "watched":
|
||||
query = query.where(status_expr == "watched")
|
||||
elif show == "saved":
|
||||
query = query.where(status_expr == "saved")
|
||||
elif show == "hidden":
|
||||
query = query.where(status_expr == "hidden")
|
||||
else: # all
|
||||
|
|
|
|||
507
backend/app/routes/playlists.py
Normal file
507
backend/app/routes/playlists.py
Normal 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}
|
||||
|
|
@ -9,6 +9,7 @@ from app.config import settings
|
|||
from app.db import SessionLocal
|
||||
from app.state import is_sync_paused
|
||||
from app.sync.autotag import run_autotag_all
|
||||
from app.sync.playlists import sync_all_playlists
|
||||
from app.sync.runner import (
|
||||
run_deep_backfill,
|
||||
run_enrich,
|
||||
|
|
@ -79,6 +80,12 @@ def _subscriptions_job() -> None:
|
|||
_job("subscriptions", work)
|
||||
|
||||
|
||||
def _playlist_sync_job() -> None:
|
||||
# Mirror each read-scope user's YouTube playlists; per-user quota attribution is
|
||||
# handled inside sync_all_playlists.
|
||||
_job("playlist_sync", sync_all_playlists)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _scheduler
|
||||
if not settings.scheduler_enabled or _scheduler is not None:
|
||||
|
|
@ -114,6 +121,12 @@ def start_scheduler() -> None:
|
|||
minutes=settings.subscriptions_resync_minutes,
|
||||
id="subscriptions",
|
||||
)
|
||||
scheduler.add_job(
|
||||
_playlist_sync_job,
|
||||
"interval",
|
||||
minutes=settings.playlist_sync_minutes,
|
||||
id="playlist_sync",
|
||||
)
|
||||
scheduler.start()
|
||||
_scheduler = scheduler
|
||||
logger.info("scheduler started")
|
||||
|
|
|
|||
397
backend/app/sync/playlists.py
Normal file
397
backend/app/sync/playlists.py
Normal 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}
|
||||
|
|
@ -125,6 +125,49 @@ class YouTubeClient:
|
|||
if not page_token:
|
||||
break
|
||||
|
||||
def iter_my_playlists(self) -> Iterator[dict]:
|
||||
"""Yield the authenticated user's own playlists (OAuth, so private ones are
|
||||
included). Note: YouTube's special Watch Later / History playlists are NOT
|
||||
returned by the Data API and cannot be synced."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
data = self._get("playlists", params, cost=1, allow_key=False)
|
||||
for item in data.get("items", []):
|
||||
sn = item.get("snippet", {})
|
||||
yield {
|
||||
"id": item.get("id"),
|
||||
"title": sn.get("title"),
|
||||
"item_count": item.get("contentDetails", {}).get("itemCount"),
|
||||
"thumbnail_url": best_thumbnail(sn.get("thumbnails")),
|
||||
}
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]:
|
||||
"""Yield the video ids of one of the user's own playlists, in playlist order
|
||||
(OAuth, so private/unlisted playlists work)."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
"part": "contentDetails",
|
||||
"playlistId": playlist_id,
|
||||
"maxResults": 50,
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
data = self._get("playlistItems", params, cost=1, allow_key=False)
|
||||
for item in data.get("items", []):
|
||||
vid = item.get("contentDetails", {}).get("videoId")
|
||||
if vid:
|
||||
yield vid
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def get_channels(self, channel_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(channel_ids, 50):
|
||||
|
|
@ -170,6 +213,114 @@ class YouTubeClient:
|
|||
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
|
||||
def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]:
|
||||
"""Yield {item_id, video_id} for each item of one of the user's playlists, in
|
||||
playlist order. `item_id` is the playlistItem resource id, needed to delete or
|
||||
reposition the item via the write API. OAuth (private playlists work)."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
"part": "contentDetails",
|
||||
"playlistId": playlist_id,
|
||||
"maxResults": 50,
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
data = self._get("playlistItems", params, cost=1, allow_key=False)
|
||||
for item in data.get("items", []):
|
||||
vid = item.get("contentDetails", {}).get("videoId")
|
||||
if vid:
|
||||
yield {"item_id": item.get("id"), "video_id": vid}
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
|
||||
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
|
||||
headers = {"Authorization": f"Bearer {self._access_token()}"}
|
||||
resp = self._http.request(
|
||||
method, f"{API_BASE}/{path}", params=params, json=json, headers=headers
|
||||
)
|
||||
quota.record_usage(self.db, 50)
|
||||
if resp.status_code not in (200, 204):
|
||||
log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200])
|
||||
raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def get_playlist_snippet(self, playlist_id: str) -> dict | None:
|
||||
"""The snippet (title/description) of one playlist, or None if it no longer
|
||||
exists. 1 unit (OAuth, so private playlists work)."""
|
||||
data = self._get(
|
||||
"playlists",
|
||||
{"part": "snippet", "id": playlist_id, "maxResults": 1},
|
||||
cost=1,
|
||||
allow_key=False,
|
||||
)
|
||||
items = data.get("items", [])
|
||||
return items[0].get("snippet") if items else None
|
||||
|
||||
def update_playlist_title(self, playlist_id: str, title: str) -> None:
|
||||
"""Rename a playlist on YouTube. 50 units."""
|
||||
self._write(
|
||||
"PUT",
|
||||
"playlists",
|
||||
params={"part": "snippet"},
|
||||
json={"id": playlist_id, "snippet": {"title": title[:150]}},
|
||||
)
|
||||
|
||||
def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str:
|
||||
"""Create a new YouTube playlist; returns its id. 50 units."""
|
||||
data = self._write(
|
||||
"POST",
|
||||
"playlists",
|
||||
params={"part": "snippet,status"},
|
||||
json={
|
||||
"snippet": {"title": title[:150], "description": description[:5000]},
|
||||
"status": {"privacyStatus": privacy},
|
||||
},
|
||||
)
|
||||
return data["id"]
|
||||
|
||||
def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str:
|
||||
"""Append (or insert at `position`) a video into a playlist; returns the new
|
||||
playlistItem id. 50 units."""
|
||||
snippet = {
|
||||
"playlistId": playlist_id,
|
||||
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
||||
}
|
||||
if position is not None:
|
||||
snippet["position"] = position
|
||||
data = self._write(
|
||||
"POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet}
|
||||
)
|
||||
return data["id"]
|
||||
|
||||
def move_playlist_item(
|
||||
self, item_id: str, playlist_id: str, video_id: str, position: int
|
||||
) -> None:
|
||||
"""Reposition an existing playlistItem to absolute 0-based `position`. 50 units."""
|
||||
self._write(
|
||||
"PUT",
|
||||
"playlistItems",
|
||||
params={"part": "snippet"},
|
||||
json={
|
||||
"id": item_id,
|
||||
"snippet": {
|
||||
"playlistId": playlist_id,
|
||||
"position": position,
|
||||
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def delete_playlist_item(self, item_id: str) -> None:
|
||||
"""Remove a playlistItem from its playlist. 50 units."""
|
||||
self._write("DELETE", "playlistItems", params={"id": item_id})
|
||||
|
||||
def delete_playlist(self, playlist_id: str) -> None:
|
||||
"""Delete a whole playlist on YouTube. 50 units."""
|
||||
self._write("DELETE", "playlists", params={"id": playlist_id})
|
||||
|
||||
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(video_ids, 50):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import Header from "./components/Header";
|
|||
import Sidebar from "./components/Sidebar";
|
||||
import Feed from "./components/Feed";
|
||||
import Channels, { type ChannelStatusFilter } from "./components/Channels";
|
||||
import Playlists from "./components/Playlists";
|
||||
import Stats from "./components/Stats";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import OnboardingWizard from "./components/OnboardingWizard";
|
||||
|
|
@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||
};
|
||||
|
||||
const FILTERS_KEY = "subfeed.filters";
|
||||
const PAGE_KEY = "siftlode.page";
|
||||
|
||||
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||
function loadInitialPage(): Page {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("page")) return readPage();
|
||||
const stored = localStorage.getItem(PAGE_KEY);
|
||||
if (stored === "channels" || stored === "stats" || stored === "playlists") return stored;
|
||||
return "feed";
|
||||
}
|
||||
|
||||
function loadStoredFilters(): FeedFilters {
|
||||
try {
|
||||
|
|
@ -69,7 +81,7 @@ export default function App() {
|
|||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(readPage);
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
||||
|
|
@ -89,6 +101,7 @@ export default function App() {
|
|||
|
||||
function setPage(next: Page) {
|
||||
setPageState(next);
|
||||
localStorage.setItem(PAGE_KEY, next);
|
||||
}
|
||||
|
||||
function setSidebarLayout(next: SidebarLayout) {
|
||||
|
|
@ -218,6 +231,8 @@ export default function App() {
|
|||
/>
|
||||
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
||||
<Stats />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
|
|
|
|||
196
frontend/src/components/AddToPlaylist.tsx
Normal file
196
frontend/src/components/AddToPlaylist.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { formatEta } from "../lib/format";
|
|||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ export default function Channels({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
|
||||
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
|
||||
|
|
@ -309,13 +311,14 @@ export default function Channels({
|
|||
c={c}
|
||||
userTags={userTags}
|
||||
canWrite={canWrite}
|
||||
onUnsubscribe={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t("channels.confirmUnsubscribe", { name: c.title ?? c.id })
|
||||
)
|
||||
)
|
||||
unsubscribe.mutate(c.id);
|
||||
onUnsubscribe={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.unsubscribeOnYoutube"),
|
||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate(c.id);
|
||||
}}
|
||||
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
||||
|
|
|
|||
74
frontend/src/components/ConfirmProvider.tsx
Normal file
74
frontend/src/components/ConfirmProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean {
|
|||
return status === "hidden";
|
||||
case "watched":
|
||||
return status === "watched";
|
||||
case "saved":
|
||||
return status === "saved";
|
||||
case "unwatched":
|
||||
case "in_progress":
|
||||
// (in_progress is further narrowed server-side by resume position; here we only
|
||||
|
|
@ -42,6 +40,7 @@ export default function Feed({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null
|
||||
|
|
@ -64,8 +63,14 @@ export default function Feed({
|
|||
// arrives — after a refetch the server is authoritative, so a stale override
|
||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||
// filtered out of the current view.
|
||||
useEffect(() => setOverrides({}), [filters]);
|
||||
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
|
||||
useEffect(() => {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [filters]);
|
||||
useEffect(() => {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
const countQuery = useQuery({
|
||||
queryKey: ["feed-count", filters],
|
||||
|
|
@ -139,10 +144,24 @@ export default function Feed({
|
|||
[filters, setFilters]
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||
})
|
||||
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
||||
},
|
||||
[qc]
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = loaded;
|
||||
const items: Video[] = loaded
|
||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
||||
.filter((v) => matchesView(v.status, filters.show));
|
||||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
|
|
@ -192,6 +211,7 @@ export default function Feed({
|
|||
video={v}
|
||||
view="grid"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
|
|
@ -205,6 +225,7 @@ export default function Feed({
|
|||
video={v}
|
||||
view="list"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||
import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
|
|
@ -90,7 +90,11 @@ export default function Header({
|
|||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -194,6 +198,12 @@ function AccountMenu({
|
|||
{t("header.account.channels")}
|
||||
</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" && (
|
||||
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next";
|
|||
import type { TFunction } from "i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
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 AddToPlaylist from "./AddToPlaylist";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
|
||||
|
|
@ -183,6 +184,8 @@ function loadYouTubeApi(): Promise<any> {
|
|||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
queue,
|
||||
startIndex,
|
||||
onClose,
|
||||
onState,
|
||||
}: {
|
||||
|
|
@ -190,30 +193,47 @@ export default function PlayerModal({
|
|||
// 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.
|
||||
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;
|
||||
onState: (id: string, status: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Resume point for the video we opened with.
|
||||
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
||||
// Track the playing item by id (not a frozen index) so it survives the queue changing
|
||||
// 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 playerRef = useRef<any>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
|
||||
// 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.
|
||||
const [currentVideoId, setCurrentVideoId] = useState(video.id);
|
||||
const currentIdRef = useRef(video.id);
|
||||
const navigated = currentVideoId !== video.id;
|
||||
// currently-playing id may differ from the active item.
|
||||
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||||
const currentIdRef = useRef(active.id);
|
||||
const navigated = currentVideoId !== active.id;
|
||||
// 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 loadVideo = (id: string, start: number | null) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.loadVideoById !== "function") return;
|
||||
// Explicit start wins; otherwise resume the opened video, start others at 0.
|
||||
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0;
|
||||
// Explicit start wins; otherwise resume the active item, start others at 0.
|
||||
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
||||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||
currentIdRef.current = id;
|
||||
setCurrentVideoId(id);
|
||||
|
|
@ -222,14 +242,22 @@ export default function PlayerModal({
|
|||
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are
|
||||
// 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 setWatched = (on: boolean) => {
|
||||
const next = on ? "watched" : "new";
|
||||
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 p = playerRef.current;
|
||||
if (p && typeof p.seekTo === "function") {
|
||||
|
|
@ -291,10 +319,11 @@ export default function PlayerModal({
|
|||
// auto-mark watched once playback reaches the end.
|
||||
useEffect(() => {
|
||||
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
|
||||
// videos the player navigated to via description links.
|
||||
// Auto-watch only applies to the active item — not to other videos the player
|
||||
// navigated to via description links.
|
||||
const maybeAutoWatch = (current: number, duration: number) => {
|
||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
|
||||
if (current > duration - FINISH_MARGIN) {
|
||||
|
|
@ -341,11 +370,14 @@ export default function PlayerModal({
|
|||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||
if (d) setLiveData({ title: d.title, author: d.author });
|
||||
}
|
||||
// 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch).
|
||||
if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) {
|
||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||
if (e?.data === 0 && currentIdRef.current === id) {
|
||||
if (!autoMarkedRef.current) {
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
// Final flush, then refresh the feed so the card's resume bar reflects this session.
|
||||
Promise.resolve(persist()).finally(() =>
|
||||
qc.invalidateQueries({ queryKey: ["feed"] })
|
||||
);
|
||||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||
});
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.destroy === "function") {
|
||||
try {
|
||||
|
|
@ -372,7 +405,7 @@ export default function PlayerModal({
|
|||
playerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [video.id]);
|
||||
}, [active.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -389,6 +422,28 @@ export default function PlayerModal({
|
|||
<div ref={mountRef} className="w-full h-full" />
|
||||
</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">
|
||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -400,12 +455,12 @@ export default function PlayerModal({
|
|||
onMouseEnter={openDesc}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
{navigated ? liveData?.title ?? t("player.loading") : video.title}
|
||||
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
||||
</span>
|
||||
</h2>
|
||||
{navigated && (
|
||||
<button
|
||||
onClick={() => loadVideo(video.id, null)}
|
||||
onClick={() => loadVideo(active.id, null)}
|
||||
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"
|
||||
>
|
||||
|
|
@ -462,8 +517,8 @@ export default function PlayerModal({
|
|||
<div className="flex items-center gap-3 mt-3">
|
||||
{!navigated && (
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
src={active.channel_thumbnail}
|
||||
fallback={active.channel_title ?? ""}
|
||||
className="w-9 h-9 rounded-full shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -482,24 +537,24 @@ export default function PlayerModal({
|
|||
)
|
||||
) : (
|
||||
<a
|
||||
href={video.channel_url}
|
||||
href={active.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
>
|
||||
{video.channel_title}
|
||||
{active.channel_title}
|
||||
</a>
|
||||
)}
|
||||
{!navigated ? (
|
||||
<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 && (
|
||||
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span>
|
||||
{active.view_count != null && (
|
||||
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
|
||||
)}
|
||||
<span>· {relativeTime(video.published_at)}</span>
|
||||
{video.duration_seconds != null && (
|
||||
<span>· {formatDuration(video.duration_seconds)}</span>
|
||||
<span>· {relativeTime(active.published_at)}</span>
|
||||
{active.duration_seconds != null && (
|
||||
<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">
|
||||
{t("player.stream")}
|
||||
</span>
|
||||
|
|
@ -518,12 +573,18 @@ export default function PlayerModal({
|
|||
</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 && (
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
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
|
||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||
|
|
|
|||
856
frontend/src/components/Playlists.tsx
Normal file
856
frontend/src/components/Playlists.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ const SORT_IDS = [
|
|||
"shuffle",
|
||||
];
|
||||
|
||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||
|
||||
// Fresh shuffle token for the "surprise me" sort.
|
||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||
|
|
@ -202,7 +202,7 @@ export default function Sidebar({
|
|||
async function shareView() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl(filters));
|
||||
notify({ message: t("sidebar.shareCopied") });
|
||||
notify({ level: "success", message: t("sidebar.shareCopied") });
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ export default function SyncStatus({
|
|||
const { data } = useQuery({
|
||||
queryKey: ["my-status"],
|
||||
queryFn: api.myStatus,
|
||||
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
|
||||
// refresh immediately on tab focus (the global default disables focus refetch), so the
|
||||
// "N without full history" count keeps ticking down without a manual reload.
|
||||
refetchInterval: 30_000,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 25_000,
|
||||
});
|
||||
|
||||
|
|
|
|||
61
frontend/src/components/UndoToolbar.tsx
Normal file
61
frontend/src/components/UndoToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import clsx from "clsx";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
|
|
@ -18,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
|||
function Actions({
|
||||
video,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
}: {
|
||||
video: Video;
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -30,6 +33,11 @@ function Actions({
|
|||
e.stopPropagation();
|
||||
onState(video.id, video.status === status ? "new" : status);
|
||||
};
|
||||
const toggleSave = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSave(video.id, !video.saved);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
|
|
@ -47,15 +55,16 @@ function Actions({
|
|||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={act("saved")}
|
||||
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
|
||||
onClick={toggleSave}
|
||||
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
||||
className={clsx(
|
||||
"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" />
|
||||
</button>
|
||||
<AddToPlaylist videoId={video.id} />
|
||||
<button
|
||||
onClick={act("hidden")}
|
||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||
|
|
@ -155,7 +164,7 @@ function Thumb({
|
|||
{t("card.stream")}
|
||||
</span>
|
||||
)}
|
||||
{video.status === "saved" && (
|
||||
{video.saved && (
|
||||
<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" />
|
||||
</span>
|
||||
|
|
@ -207,12 +216,14 @@ function VideoCard({
|
|||
video,
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
onOpen,
|
||||
}: {
|
||||
video: Video;
|
||||
view: "grid" | "list";
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
|
|
@ -262,7 +273,7 @@ function VideoCard({
|
|||
</a>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</div>
|
||||
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -293,7 +304,7 @@ function VideoCard({
|
|||
{video.channel_title}
|
||||
</a>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
"termsOfService": "Nutzungsbedingungen",
|
||||
"close": "Schließen",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"confirmTitle": "Bist du sicher?",
|
||||
"save": "Speichern",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederholen",
|
||||
"loading": "Wird geladen…",
|
||||
"language": "Sprache",
|
||||
"somethingWrong": "Etwas ist schiefgelaufen.",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"admin": "Admin",
|
||||
"feed": "Feed",
|
||||
"channels": "Kanäle",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen",
|
||||
"about": "Über",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
"loading": "Wird geladen…",
|
||||
"backToOriginal": "Zurück zum ursprünglichen Video",
|
||||
"back": "Zurück",
|
||||
"previous": "Vorheriges",
|
||||
"next": "Nächstes",
|
||||
"close": "Schließen",
|
||||
"closeEsc": "Schließen (Esc)",
|
||||
"description": "Beschreibung",
|
||||
|
|
|
|||
59
frontend/src/i18n/locales/de/playlists.json
Normal file
59
frontend/src/i18n/locales/de/playlists.json
Normal 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"
|
||||
}
|
||||
|
|
@ -3,7 +3,11 @@
|
|||
"termsOfService": "Terms of Service",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"save": "Save",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"loading": "Loading…",
|
||||
"language": "Language",
|
||||
"somethingWrong": "Something went wrong.",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"admin": "Admin",
|
||||
"feed": "Feed",
|
||||
"channels": "Channels",
|
||||
"playlists": "Playlists",
|
||||
"stats": "Stats",
|
||||
"settings": "Settings",
|
||||
"about": "About",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
"loading": "Loading…",
|
||||
"backToOriginal": "Back to the original video",
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"close": "Close",
|
||||
"closeEsc": "Close (Esc)",
|
||||
"description": "Description",
|
||||
|
|
|
|||
59
frontend/src/i18n/locales/en/playlists.json
Normal file
59
frontend/src/i18n/locales/en/playlists.json
Normal 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"
|
||||
}
|
||||
|
|
@ -3,7 +3,11 @@
|
|||
"termsOfService": "Felhasználási feltételek",
|
||||
"close": "Bezárás",
|
||||
"cancel": "Mégse",
|
||||
"confirm": "Megerősítés",
|
||||
"confirmTitle": "Biztos vagy benne?",
|
||||
"save": "Mentés",
|
||||
"undo": "Visszavonás",
|
||||
"redo": "Újra",
|
||||
"loading": "Betöltés…",
|
||||
"language": "Nyelv",
|
||||
"somethingWrong": "Hiba történt.",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"admin": "Adminisztrátor",
|
||||
"feed": "Hírfolyam",
|
||||
"channels": "Csatornák",
|
||||
"playlists": "Lejátszási listák",
|
||||
"stats": "Statisztika",
|
||||
"settings": "Beállítások",
|
||||
"about": "Névjegy",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
"loading": "Betöltés…",
|
||||
"backToOriginal": "Vissza az eredeti videóhoz",
|
||||
"back": "Vissza",
|
||||
"previous": "Előző",
|
||||
"next": "Következő",
|
||||
"close": "Bezárás",
|
||||
"closeEsc": "Bezárás (Esc)",
|
||||
"description": "Leírás",
|
||||
|
|
|
|||
59
frontend/src/i18n/locales/hu/playlists.json
Normal file
59
frontend/src/i18n/locales/hu/playlists.json
Normal 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"
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ export interface Video {
|
|||
live_status: string;
|
||||
status: string;
|
||||
position_seconds: number;
|
||||
saved: boolean; // is the video in the user's built-in Watch later playlist
|
||||
watch_url: string;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +69,49 @@ export interface FeedResponse {
|
|||
limit: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string; // "user" | "watch_later"
|
||||
source: string; // "local" | "youtube"
|
||||
yt_playlist_id: string | null;
|
||||
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
||||
item_count: number;
|
||||
total_duration_seconds: number;
|
||||
cover_thumbnail: string | null;
|
||||
has_video?: boolean; // only set when listed with ?contains=<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 {
|
||||
tags: number[];
|
||||
tagMode: "or" | "and";
|
||||
|
|
@ -294,6 +338,45 @@ export const api = {
|
|||
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||
|
||||
// --- playlists ---
|
||||
playlists: (containsVideoId?: string): Promise<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 ---
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,21 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-06-16",
|
||||
summary:
|
||||
"Playlists — build your own, mirror your YouTube ones, and sync edits back to YouTube.",
|
||||
features: [
|
||||
"Local playlists: create named, ordered playlists, reorder by drag, and add videos straight from the feed or the player. Play a whole playlist with auto-advance and prev/next.",
|
||||
"Watch later is now a built-in playlist — the bookmark button adds to it, and you can play and manage it like any other list.",
|
||||
"Your YouTube playlists are mirrored in automatically and kept fresh, shown with a YouTube badge and an “open on YouTube” link.",
|
||||
"Two-way YouTube sync: export a local playlist to YouTube, or push your edits (add, remove, reorder, rename) back to a linked playlist. Every push shows an estimate and a confirm first, because YouTube playlist writes are quota-heavy.",
|
||||
"Edit mirrored playlists freely — your unsynced changes are protected from the automatic refresh, an “Unsynced” marker shows when you have pending edits, and “Reset to YouTube” discards them to reload the original.",
|
||||
"Sort a playlist by title, duration or channel (ascending/descending) and optionally group by channel — with full undo/redo (buttons and Ctrl/Cmd+Z / Ctrl+Y).",
|
||||
"The playlist list can be ordered by name, item count or total length (and float your unsynced lists to the top); it shows each list’s total length and remembers your selection.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-06-15",
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
|||
return KEYS.some((k) => params.has(k));
|
||||
}
|
||||
|
||||
export type Page = "feed" | "channels" | "stats";
|
||||
export type Page = "feed" | "channels" | "stats" | "playlists";
|
||||
|
||||
export function readPage(): Page {
|
||||
const p = new URLSearchParams(window.location.search).get("page");
|
||||
return p === "channels" || p === "stats" ? p : "feed";
|
||||
return p === "channels" || p === "stats" || p === "playlists" ? p : "feed";
|
||||
}
|
||||
|
||||
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
||||
|
|
|
|||
90
frontend/src/lib/useUndoable.ts
Normal file
90
frontend/src/lib/useUndoable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||
import Terms from "./components/legal/Terms";
|
||||
import "./i18n";
|
||||
|
|
@ -21,7 +22,9 @@ const root =
|
|||
path === "/terms" ? <Terms /> : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue