feat(playlists): rail sorting, consolidated item sort, persist selection

1) Fix: the selected playlist is now persisted (localStorage) and scrolled into view,
   so F5 keeps it instead of jumping to the first one.
2) Consolidate the in-detail sort: one key select (Title/Duration/Channel) + an
   asc/desc direction toggle (was separate A-Z / Z-A / shortest / longest options);
   add Channel as a sort key. Direction also orders the channel groups when grouping.
3) Left rail sorting: by name / item count / total length, asc/desc, plus an
   'unsynced first' toggle that floats playlists with unpushed edits to the top.
   Backend: list/summary now return total_duration_seconds (grouped sum). The rail
   also shows each playlist's total length. Sort prefs persist to localStorage.
This commit is contained in:
npeter83 2026-06-15 22:13:32 +02:00
parent 156e10235e
commit a00779a1c9
6 changed files with 237 additions and 57 deletions

View file

@ -31,7 +31,13 @@ def _mark_dirty(pl: Playlist) -> None:
pl.dirty = True
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
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)
@ -47,6 +53,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
"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:
@ -54,6 +61,18 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
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,
@ -81,6 +100,17 @@ def list_playlists(
.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(
@ -94,7 +124,13 @@ def list_playlists(
.all()
)
return [
_summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None)
_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
]
@ -339,7 +375,7 @@ def rename_playlist(
count = db.scalar(
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
)
return _summary(db, pl, count or 0)
return _summary(db, pl, count or 0, _total_duration(db, pl.id))
@router.delete("/{playlist_id}")