siftlode/backend/app/routes/playlists.py

529 lines
18 KiB
Python
Raw Permalink Normal View History

"""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 _serialize, feed_columns
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 _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user_id
)
)
+ 1
)
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == playlist_id
)
)
+ 1
)
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is not None:
return False
db.add(
PlaylistItem(
playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id)
)
)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is None:
return False
db.delete(item)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
def _summary(
pl: Playlist,
count: int,
total_duration: int = 0,
has_video: bool | None = None,
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
cover: str | None = None,
) -> dict:
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
@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()
)
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
# Batch the cover thumbnails (first item per playlist by position) in ONE window query
# instead of a per-playlist SELECT inside _summary (the old N+1).
rn = func.row_number().over(
partition_by=PlaylistItem.playlist_id,
order_by=PlaylistItem.position,
).label("rn")
first_items = (
select(
PlaylistItem.playlist_id.label("pid"),
Video.thumbnail_url.label("thumb"),
rn,
)
.join(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id.in_(ids))
.subquery()
)
covers = dict(
db.execute(
select(first_items.c.pid, first_items.c.thumb).where(first_items.c.rn == 1)
).all()
)
return [
_summary(
p,
counts.get(p.id, 0),
durations.get(p.id, 0),
(p.id in member) if contains else None,
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
covers.get(p.id),
)
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")
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
db.add(pl)
db.commit()
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
return _summary(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:
pl = Playlist(
user_id=user.id,
name="Watch later",
kind="watch_later",
position=_next_playlist_position(db, user.id),
)
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)
_add_item(db, pl, video_id, mark_dirty=False)
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:
_remove_item(db, pl, video_id, mark_dirty=False)
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, quota.QuotaAction.PLAYLISTS_PULL):
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, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl)
except YouTubeError:
db.rollback()
raise HTTPException(status_code=422, 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, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl)
except YouTubeError:
raise HTTPException(status_code=422, 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, quota.QuotaAction.PLAYLISTS_PUSH):
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
# Read the live playlist ONCE (linked playlists only) and share it with both the
# plan and the push, halving read-quota and closing the second read→write TOCTOU
# window. A fresh export (no yt_playlist_id) reads nothing.
live = None
if pl.yt_playlist_id:
with YouTubeClient(db, user) as yt:
live = (
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
yt.get_playlist_snippet(pl.yt_playlist_id),
)
plan = plan_push(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise HTTPException(
status_code=429,
detail="Not enough YouTube quota left today for this sync.",
)
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
return push_playlist(db, user, pl, live=live)
except YouTubeError:
db.rollback()
raise HTTPException(status_code=422, 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(*feed_columns(user, state))
.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()
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
# One aggregate for count + duration (outer join so an empty playlist still returns 0),
# plus a single cover lookup — was 3 separate round-trips.
count, total = db.execute(
select(
func.count(PlaylistItem.id),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.select_from(PlaylistItem)
.outerjoin(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id == pl.id)
).one()
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)
)
fix(deferred): close backend correctness/efficiency tails from the hygiene sweep Verified each deferred per-subsystem item against current source, then fixed the real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout): - search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment failure/omitted rows) — restores the scheduler's enriched_at guard so a real Short can't leak into the feed and never get reclassified. - downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets (they carry no TTL, so the expiry passes never cleared them). - downloads B6: quota/edit errors now return a structured {code,reason,limit} the trilingual client localises, instead of hardcoded English + dot-decimal GB. - channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update) instead of 404ing when the admin isn't personally subscribed. - channels BB2: enrich the stub on BOTH the normal and "already exists" desync path (nest the insert try inside the client `with`). - channels CB3: drop the redundant second _discovery_rows read (identity-mapped rows are already enriched; re-sort in Python for the title tie-break). - playlists PC1: read the live playlist once in push() and share it with plan_push + push_playlist (halves read-quota, closes the second TOCTOU window). - playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1); rename combines count+duration into one aggregate + a single cover lookup. - messages MB2: get_thread only resolves a messageable partner OR one we already share a thread with (closes the user-enumeration oracle). - messages MB3: push a live unread-count to the user's other tabs after mark-read. - messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of loading the whole message history into memory. - config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
return _summary(pl, count or 0, total or 0, cover=cover)
@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, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError:
raise HTTPException(status_code=422, 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")
_add_item(db, pl, video_id, mark_dirty=True)
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)
_remove_item(db, pl, video_id, mark_dirty=True)
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
seen: set[str] = set()
for vid in order:
it = items.get(vid)
if it is not None:
it.position = pos
pos += 1
seen.add(vid)
# Any items the payload omitted (e.g. added concurrently in another tab) keep their relative
# order but get fresh trailing positions, so no two items collide on the same position.
for it in sorted(
(it for vid, it in items.items() if vid not in seen), key=lambda i: i.position
):
it.position = pos
pos += 1
recompute_dirty(db, pl)
db.commit()
return {"playlist_id": pl.id, "count": pos}