Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-12 06:56:51 +02:00
commit b1841a596e
29 changed files with 549 additions and 165 deletions

View file

@ -1 +1 @@
0.39.1 0.40.0

View file

@ -1,12 +1,14 @@
"""Download retention garbage collector (a scheduler job). """Download retention garbage collector (a scheduler job).
Three passes each run: Four passes each run:
1. Pre-expiry warning for ready assets expiring within the grace window, notify each holder 1. Pre-expiry warning for ready assets expiring within the grace window, notify each holder
once (gc_notified flag) so a download isn't silently deleted. once (gc_notified flag) so a download isn't silently deleted.
2. Expiry deletion delete assets past their TTL (files + row). The FK SET NULL nulls the 2. Expiry deletion delete assets past their TTL (files + row). The FK SET NULL nulls the
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI. holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
3. LRU eviction if a total cache cap is set and exceeded, delete least-recently-accessed 3. LRU eviction if a total cache cap is set and exceeded, delete least-recently-accessed
ready assets until back under it. ready assets until back under it.
4. Orphan reap delete errored, fileless, unreferenced asset rows (they carry no TTL, so
passes 1-3 skip them; benign row-count hygiene).
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work no YouTube quota. Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work no YouTube quota.
""" """
@ -58,7 +60,7 @@ def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
def run_download_gc(db: Session) -> dict: def run_download_gc(db: Session) -> dict:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
grace_days = sysconfig.get_int(db, "download_gc_grace_days") grace_days = sysconfig.get_int(db, "download_gc_grace_days")
warned = expired = evicted = 0 warned = expired = evicted = reaped = 0
# 1. Pre-expiry warnings. # 1. Pre-expiry warnings.
warn_before = now + timedelta(days=grace_days) warn_before = now + timedelta(days=grace_days)
@ -114,6 +116,22 @@ def run_download_gc(db: Session) -> dict:
evicted += 1 evicted += 1
db.commit() db.commit()
result = {"warned": warned, "expired": expired, "evicted": evicted} # 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
# referenced errored row to pending on re-enqueue, so these only accumulate once every
# holder is gone; they carry no expires_at, so passes 1-3 never touch them.
orphans = db.execute(
select(MediaAsset).where(
MediaAsset.status == "error",
MediaAsset.ref_count == 0,
MediaAsset.rel_path.is_(None),
)
).scalars().all()
for asset in orphans:
db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id
reaped += 1
if orphans:
db.commit()
result = {"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped}
log.info("download_gc %s", result) log.info("download_gc %s", result)
return result return result

View file

@ -175,15 +175,21 @@ def discover_channels(
rows = _discovery_rows(db, user) rows = _discovery_rows(db, user)
# Enrich stub channels' metadata (uses the API key — no write scope needed). Re-read # Enrich stub channels' metadata (uses the API key — no write scope needed). The Channel
# the rows afterwards so the response carries the fresh subscriber counts/thumbnails. # objects are identity-mapped, so apply_channel_details mutates the very rows we already hold
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can
# change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB.
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
if need: if need:
try: try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels(need)) apply_channel_details(db, yt.get_channels(need))
db.commit() db.commit()
rows = _discovery_rows(db, user) # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
rows = sorted(
rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower())
)
except YouTubeError as exc: except YouTubeError as exc:
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc) log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc)
db.rollback() db.rollback()
@ -418,11 +424,18 @@ def reset_backfill(
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
if channel is None: if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel") raise HTTPException(status_code=404, detail="Unknown channel")
sub = _user_subscription(db, user, channel_id)
channel.backfill_done = False channel.backfill_done = False
channel.backfill_cursor = None channel.backfill_cursor = None
channel.recent_synced_at = None channel.recent_synced_at = None
sub.deep_requested = True # Re-arm the demand-driven deep scheduler for this channel. run_deep_backfill gates on an
# EXISTS over subscriptions with deep_requested=True, so flip it on EVERY subscriber rather
# than requiring the admin to be personally subscribed (they reset from the manager, not
# their own feed). No-op row count if the channel has no subscribers.
db.execute(
update(Subscription)
.where(Subscription.channel_id == channel_id)
.values(deep_requested=True)
)
db.commit() db.commit()
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
@ -455,25 +468,30 @@ def subscribe(
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.") raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
try: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt: try:
yt_sub_id = yt.insert_subscription(channel_id) yt_sub_id = yt.insert_subscription(channel_id)
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up except YouTubeError as exc:
# properly right away; no video pull here. # Already following on YouTube but not recorded here (a desync — e.g. a stale import
if channel.details_synced_at is None: # dropped the local row, so it resurfaced in discovery). That's not a failure: the end
# state the user wanted already holds, so record it locally and move on. The next
# subscription resync fills in the resource id. Any other YouTube error is a real
# problem — surface it with a clear message (422, not a 502 that the client would
# mistake for a transient "connection lost").
msg = str(exc).lower()
if "already exists" in msg or "duplicate" in msg:
yt_sub_id = ""
else:
raise HTTPException(status_code=422, detail=f"YouTube couldn't subscribe you: {exc}")
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
# render. Keep the client open (we're still inside the `with`); a metadata failure here
# must not block recording the subscription the user asked for.
if channel.details_synced_at is None:
try:
apply_channel_details(db, yt.get_channels([channel_id])) apply_channel_details(db, yt.get_channels([channel_id]))
except YouTubeError as exc: except YouTubeError as exc:
# Already following on YouTube but not recorded here (a desync — e.g. a stale import log.warning("subscribe: stub enrich failed (%s): %s", channel_id, exc)
# dropped the local row, so it resurfaced in discovery). That's not a failure: the end
# state the user wanted already holds, so record it locally and move on. The next
# subscription resync fills in the resource id. Any other YouTube error is a real
# problem — surface it with a clear message (422, not a 502 that the client would
# mistake for a transient "connection lost").
msg = str(exc).lower()
if "already exists" in msg or "duplicate" in msg:
yt_sub_id = ""
else:
raise HTTPException(status_code=422, detail=f"YouTube couldn't subscribe you: {exc}")
db.add( db.add(
Subscription( Subscription(
user_id=user.id, user_id=user.id,

View file

@ -265,7 +265,7 @@ def enqueue_download(
db, user.id, source_kind, source_ref, spec, profile_id, display_name db, user.id, source_kind, source_ref, spec, profile_id, display_name
) )
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_detail(e))
return _serialize_job(db, job) return _serialize_job(db, job)
@ -288,27 +288,16 @@ def enqueue_edit(
try: try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name) job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_detail(e))
except service.EditError as e: except service.EditError as e:
raise HTTPException(status_code=400, detail=_edit_message(e)) raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason})
return _serialize_job(db, job) return _serialize_job(db, job)
def _edit_message(e: service.EditError) -> str: def _quota_detail(e: quota.QuotaExceeded) -> dict:
if e.reason == "empty_edit": """Structured error the trilingual client localizes (reason key + numbers), instead of a
return "Choose a trim range or crop area first." pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
if e.reason == "source_not_ready": return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
return "The source download isn't ready to edit."
return "That edit couldn't be created."
def _quota_message(e: quota.QuotaExceeded) -> str:
if e.reason == "max_jobs":
return f"You've reached your download limit ({e.limit} items). Remove some first."
if e.reason == "max_bytes":
gb = e.limit / 1_073_741_824
return f"You've used your download storage quota ({gb:.1f} GB). Free up space first."
return "Download quota exceeded."
@router.get("") @router.get("")

View file

@ -17,7 +17,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_human, resolved_user_id from app.auth import require_human, resolved_user_id
@ -220,25 +220,40 @@ def list_conversations(
with the latest message + my unread count, newest first. Lazily creates the welcome on the with the latest message + my unread count, newest first. Lazily creates the welcome on the
first open. User-message previews are ciphertext the client decrypts them.""" first open. User-message previews are ciphertext the client decrypts them."""
ensure_welcome(db, user) ensure_welcome(db, user)
msgs = ( # The conversation partner for a message, from this user's perspective (system → 0).
partner_expr = case(
(Message.kind == "system", SYSTEM_PARTNER_ID),
(Message.sender_id == user.id, Message.recipient_id),
else_=Message.sender_id,
)
mine = or_(Message.sender_id == user.id, Message.recipient_id == user.id)
# Latest message per conversation in ONE query (DISTINCT ON, Postgres) instead of loading the
# user's whole message history into Python — bounded by the number of conversations.
latest = (
db.execute( db.execute(
select(Message) select(Message)
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id)) .where(mine)
.order_by(Message.created_at.desc()) .distinct(partner_expr)
.order_by(partner_expr, Message.created_at.desc())
) )
.scalars() .scalars()
.all() .all()
) )
convs: dict[int, dict] = {} # Unread incoming, grouped per conversation.
for m in msgs: unread = dict(
db.execute(
select(partner_expr, func.count())
.where(mine, Message.recipient_id == user.id, Message.read_at.is_(None))
.group_by(partner_expr)
).all()
)
def _pid(m: Message) -> int:
if m.kind == "system": if m.kind == "system":
pid = SYSTEM_PARTNER_ID return SYSTEM_PARTNER_ID
else: return m.recipient_id if m.sender_id == user.id else m.sender_id
pid = m.recipient_id if m.sender_id == user.id else m.sender_id
c = convs.setdefault(pid, {"last": m, "unread": 0}) user_ids = [_pid(m) for m in latest if m.kind != "system"]
if m.recipient_id == user.id and m.read_at is None:
c["unread"] += 1
user_ids = [pid for pid in convs if pid != SYSTEM_PARTNER_ID]
partners = {} partners = {}
if user_ids: if user_ids:
partners = { partners = {
@ -246,7 +261,8 @@ def list_conversations(
for u in db.execute(select(User).where(User.id.in_(user_ids))).scalars().all() for u in db.execute(select(User).where(User.id.in_(user_ids))).scalars().all()
} }
items = [] items = []
for pid, c in convs.items(): for m in latest:
pid = _pid(m)
if pid == SYSTEM_PARTNER_ID: if pid == SYSTEM_PARTNER_ID:
partner = _system_partner() partner = _system_partner()
elif pid in partners: elif pid in partners:
@ -254,7 +270,7 @@ def list_conversations(
else: else:
continue continue
items.append( items.append(
{"partner": partner, "last_message": _serialize_msg(c["last"]), "unread": c["unread"]} {"partner": partner, "last_message": _serialize_msg(m), "unread": int(unread.get(pid, 0))}
) )
items.sort(key=lambda x: x["last_message"]["created_at"] or "", reverse=True) items.sort(key=lambda x: x["last_message"]["created_at"] or "", reverse=True)
return {"items": items} return {"items": items}
@ -275,9 +291,6 @@ def get_thread(
cond = and_(Message.recipient_id == user.id, Message.kind == "system") cond = and_(Message.recipient_id == user.id, Message.kind == "system")
else: else:
p = db.get(User, partner_id) p = db.get(User, partner_id)
if p is None:
raise HTTPException(status_code=404, detail="User not found")
partner = _serialize_user(p)
cond = and_( cond = and_(
Message.kind == "user", Message.kind == "user",
or_( or_(
@ -285,6 +298,13 @@ def get_thread(
and_(Message.sender_id == partner_id, Message.recipient_id == user.id), and_(Message.sender_id == partner_id, Message.recipient_id == user.id),
), ),
) )
# MB2: don't leak arbitrary user identities. Resolve a partner only if they're messageable
# OR we already share a thread (so a conversation with a since-suspended/deactivated user
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found")
partner = _serialize_user(p)
msgs = ( msgs = (
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())) db.execute(select(Message).where(cond).order_by(Message.created_at.asc()))
.scalars() .scalars()
@ -299,6 +319,10 @@ def get_thread(
changed = True changed = True
if changed: if changed:
db.commit() db.commit()
# MB3: ping this user's OTHER tabs/devices that their unread dropped so the badge
# refreshes live instead of waiting for the next 20s poll. The client re-fetches the
# count on this signal, so no number is carried here.
manager.push(user.id, {"type": "unread"})
return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]} return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]}

View file

@ -92,19 +92,12 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
def _summary( def _summary(
db: Session,
pl: Playlist, pl: Playlist,
count: int, count: int,
total_duration: int = 0, total_duration: int = 0,
has_video: bool | None = None, has_video: bool | None = None,
cover: str | None = None,
) -> dict: ) -> 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 = { out = {
"id": pl.id, "id": pl.id,
"name": pl.name, "name": pl.name,
@ -121,18 +114,6 @@ def _summary(
return out 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("") @router.get("")
def list_playlists( def list_playlists(
contains: str | None = None, contains: str | None = None,
@ -183,13 +164,34 @@ def list_playlists(
.scalars() .scalars()
.all() .all()
) )
# 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 [ return [
_summary( _summary(
db,
p, p,
counts.get(p.id, 0), counts.get(p.id, 0),
durations.get(p.id, 0), durations.get(p.id, 0),
(p.id in member) if contains else None, (p.id in member) if contains else None,
covers.get(p.id),
) )
for p in pls for p in pls
] ]
@ -207,7 +209,7 @@ def create_playlist(
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id)) pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
db.add(pl) db.add(pl)
db.commit() db.commit()
return _summary(db, pl, 0) return _summary(pl, 0)
def _watch_later(db: Session, user: User) -> Playlist: def _watch_later(db: Session, user: User) -> Playlist:
@ -350,13 +352,23 @@ def push(
) )
try: try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_push(db, user, pl) # 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): if plan["units_estimate"] > quota.remaining_today(db):
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="Not enough YouTube quota left today for this sync.", detail="Not enough YouTube quota left today for this sync.",
) )
return push_playlist(db, user, pl) return push_playlist(db, user, pl, live=live)
except YouTubeError: except YouTubeError:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.") raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
@ -405,10 +417,25 @@ def rename_playlist(
pl.name = name pl.name = name
recompute_dirty(db, pl) recompute_dirty(db, pl)
db.commit() db.commit()
count = db.scalar( # One aggregate for count + duration (outer join so an empty playlist still returns 0),
select(func.count()).where(PlaylistItem.playlist_id == pl.id) # 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)
) )
return _summary(db, pl, count or 0, _total_duration(db, pl.id)) return _summary(pl, count or 0, total or 0, cover=cover)
@router.delete("/{playlist_id}") @router.delete("/{playlist_id}")

View file

@ -60,6 +60,11 @@ def _classify_shorts(db: Session, videos: list[Video]) -> None:
for v in videos: for v in videos:
if v.shorts_probed: if v.shorts_probed:
continue continue
if v.enriched_at is None:
# Enrichment failed (proxy blip) or YouTube omitted the row — duration is unknown,
# so DON'T finalise shorts_probed off a null duration; leave it for the scheduler
# (which only classifies enriched rows) to retry. Otherwise a real Short would leak.
continue
if ( if (
v.live_status != "none" v.live_status != "none"
or v.duration_seconds is None or v.duration_seconds is None
@ -164,7 +169,13 @@ def _ingest_candidates(
# 4) Confirm/deny Shorts (no quota) so none enter via search. # 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos) _classify_shorts(db, videos)
keep = {v.id for v in videos if not v.is_short and v.live_status not in LIVE_OR_UPCOMING} keep = {
v.id
for v in videos
if v.enriched_at is not None
and not v.is_short
and v.live_status not in LIVE_OR_UPCOMING
}
return [vid for vid in ids if vid in keep] return [vid for vid in ids if vid in keep]

View file

@ -217,11 +217,12 @@ def _reorder_moves(current: list[str], desired: list[str]) -> int:
return moves return moves
def plan_push(db: Session, user: User, pl: Playlist) -> dict: def plan_push(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
"""Compute (without writing) what pushing this local playlist to YouTube would do: """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 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 YouTube has diverged (items present there that local will remove). Costs read units
(1/page) for a linked playlist; nothing for a fresh export.""" (1/page) for a linked playlist; nothing for a fresh export. `live` = a pre-fetched
(current_items, snippet) tuple so the caller can share one read with push_playlist."""
desired = _local_video_ids(db, pl) desired = _local_video_ids(db, pl)
if not pl.yt_playlist_id: if not pl.yt_playlist_id:
ops = 1 + len(desired) # create the playlist + one insert per video ops = 1 + len(desired) # create the playlist + one insert per video
@ -234,9 +235,12 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict:
"yt_extra": 0, "yt_extra": 0,
"units_estimate": ops * WRITE_UNIT_COST, "units_estimate": ops * WRITE_UNIT_COST,
} }
with YouTubeClient(db, user) as yt: if live is not None:
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) current, snippet = live
snippet = yt.get_playlist_snippet(pl.yt_playlist_id) else:
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 rename = bool(snippet) and (snippet.get("title") or "") != pl.name
cur_vids = [it["video_id"] for it in current] cur_vids = [it["video_id"] for it in current]
cur_set = set(cur_vids) cur_set = set(cur_vids)
@ -259,10 +263,11 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict:
} }
def push_playlist(db: Session, user: User, pl: Playlist) -> dict: def push_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
"""Push a local playlist to YouTube: create it if needed, then reconcile membership """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. 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.""" Caller must have checked the write scope and the quota budget. `live` = a pre-fetched
(current_items, snippet) tuple (from plan_push's read) to avoid a second live page-through."""
desired = _local_video_ids(db, pl) desired = _local_video_ids(db, pl)
inserted = deleted = reordered = created = 0 inserted = deleted = reordered = created = 0
failures: list[str] = [] failures: list[str] = []
@ -291,14 +296,17 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
"yt_playlist_id": pl.yt_playlist_id, "yt_playlist_id": pl.yt_playlist_id,
} }
# Existing link: diff against the live YouTube state. # Existing link: diff against the live YouTube state (reuse plan_push's read if given).
snippet = yt.get_playlist_snippet(pl.yt_playlist_id) if live is not None:
current, snippet = live
else:
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
if snippet is not None and (snippet.get("title") or "") != pl.name: if snippet is not None and (snippet.get("title") or "") != pl.name:
try: try:
yt.update_playlist_title(pl.yt_playlist_id, pl.name) yt.update_playlist_title(pl.yt_playlist_id, pl.name)
except YouTubeError: except YouTubeError:
failures.append("title") 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} item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
cur_vids = [it["video_id"] for it in current] cur_vids = [it["video_id"] for it in current]
desired_set = set(desired) desired_set = set(desired)

View file

@ -25,6 +25,7 @@ class ConfigSpec:
secret: bool = False secret: bool = False
min: int | None = None min: int | None = None
max: int | None = None max: int | None = None
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it # Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
@ -33,7 +34,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Email / SMTP (one logical group; the password is the only secret) --- # --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"), ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535), ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user"), ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
ConfigSpec("smtp_from", "str", "email", "smtp_from"), ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True), ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
# --- Quota (shared YouTube Data API daily budget) --- # --- Quota (shared YouTube Data API daily budget) ---
@ -59,7 +60,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key). # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy"), ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. --- # install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True), ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
@ -124,7 +125,12 @@ def get(db: Session, key: str):
"""Effective value: the DB override (typed) if set, else the env/config default.""" """Effective value: the DB override (typed) if set, else the env/config default."""
s = _BY_KEY[key] s = _BY_KEY[key]
raw = _raw_override(db, key) raw = _raw_override(db, key)
if raw is None or raw == "": if raw is None:
return _default(s)
if raw == "" and not s.allow_empty:
# For most keys an empty override means "fall back to the env default". Keys that opt
# into allow_empty (e.g. smtp_user, youtube_api_proxy) treat "" as a real value — so an
# admin can blank one to DISABLE it rather than being stuck with the env default.
return _default(s) return _default(s)
try: try:
return _coerce(s, raw) return _coerce(s, raw)
@ -197,6 +203,7 @@ def describe(db: Session) -> dict:
"min": s.min, "min": s.min,
"max": s.max, "max": s.max,
"is_set": is_set, # an override row exists "is_set": is_set, # an override row exists
"allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row
} }
if s.secret: if s.secret:
item["default_is_set"] = bool(_default(s)) item["default_is_set"] = bool(_default(s))

View file

@ -275,8 +275,10 @@ export default function App() {
// "Focus this channel in the manager": jump to the Channels page and seed its name filter // "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null); const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const [focusChannelToken, setFocusChannelToken] = useState(0);
const focusChannel = (name: string) => { const focusChannel = (name: string) => {
setFocusChannelName(name); setFocusChannelName(name);
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
setChannelsView("subscribed"); // the name filter applies to the subscriptions table setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels"); setPage("channels");
}; };
@ -803,6 +805,7 @@ export default function App() {
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"} isAdmin={meQuery.data!.role === "admin"}
focusChannelName={focusChannelName} focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel} onFocusChannel={focusChannel}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}

View file

@ -52,19 +52,32 @@ function UsersRoles({ me }: { me: Me }) {
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
const endPending = (id: number) =>
setPendingIds((s) => {
const n = new Set(s);
n.delete(id);
return n;
});
const setRole = useMutation({ const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role), api.setUserRole(id, role),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id),
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
}); });
const suspend = useMutation({ const suspend = useMutation({
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) => mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
api.setUserSuspended(id, suspended), api.setUserSuspended(id, suspended),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ notify({
@ -74,14 +87,17 @@ function UsersRoles({ me }: { me: Me }) {
}), }),
}); });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (demo / self / last admin) surface via the global error dialog. // Guards (demo / self / last admin) surface via the global error dialog.
}); });
const del = useMutation({ const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id),
}); });
const onToggle = async (u: AdminUserRow) => { const onToggle = async (u: AdminUserRow) => {
@ -162,7 +178,7 @@ function UsersRoles({ me }: { me: Me }) {
> >
<button <button
onClick={() => onToggle(u)} onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending} disabled={u.is_demo || self || pendingIds.has(u.id)}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition" className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
> >
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
@ -182,7 +198,7 @@ function UsersRoles({ me }: { me: Me }) {
> >
<button <button
onClick={() => onSuspend(u)} onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending} disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")} aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${ className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended u.is_suspended
@ -196,7 +212,7 @@ function UsersRoles({ me }: { me: Me }) {
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}> <Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button <button
onClick={() => onDelete(u)} onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending} disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")} aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition" className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
> >
@ -217,6 +233,7 @@ function UsersRoles({ me }: { me: Me }) {
function AdminInvites() { function AdminInvites() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() }); const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
const refresh = () => { const refresh = () => {
@ -233,9 +250,21 @@ function AdminInvites() {
}); });
const deny = useMutation({ const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id), mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh, onSuccess: (_d, _id) => {
refresh();
notify({ level: "success", message: t("settings.invites.denied") });
},
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }), onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
}); });
const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.invites.denyTitle"),
message: t("settings.invites.denyConfirm", { email: i.email }),
confirmLabel: t("settings.invites.deny"),
danger: true,
});
if (ok) deny.mutate(i.id);
};
const add = useMutation({ const add = useMutation({
mutationFn: (email: string) => api.addInvite(email), mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => { onSuccess: (_d, email) => {
@ -261,7 +290,7 @@ function AdminInvites() {
key={i.id} key={i.id}
inv={i} inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })} onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)} onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending} busy={approve.isPending || deny.isPending}
/> />
))} ))}
@ -329,9 +358,21 @@ function AdminDemo() {
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id), mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
}); });
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({ const reset = useMutation({
mutationFn: () => api.resetDemo(), mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) => onSuccess: (r: { playlists_seeded: number }) =>
@ -362,7 +403,7 @@ function AdminDemo() {
</div> </div>
<Tooltip hint={t("settings.demo.removeHint")}> <Tooltip hint={t("settings.demo.removeHint")}>
<button <button
onClick={() => remove.mutate(r.id)} onClick={() => onRemove(r)}
disabled={remove.isPending} disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition" className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")} aria-label={t("settings.demo.remove")}

View file

@ -48,6 +48,7 @@ export default function Channels({
onFilterByTag, onFilterByTag,
onFocusChannel, onFocusChannel,
focusChannelName, focusChannelName,
focusChannelToken,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
view, view,
@ -61,6 +62,9 @@ export default function Channels({
onFilterByTag: (tagId: number, name: string) => void; onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
focusChannelName: string | null; focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table // The active tab lives in App so navigation intents that target the subscriptions table
@ -92,6 +96,10 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
}; };
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
@ -189,7 +197,8 @@ export default function Channels({
// A focus-channel intent (header "without full history" / tag manager) seeds the search box. // A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => { useEffect(() => {
if (focusChannelName) setSearch(focusChannelName); if (focusChannelName) setSearch(focusChannelName);
}, [focusChannelName]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
// The header's reset intent clears the search + tag chips (skip the initial mount). // The header's reset intent clears the search + tag chips (skip the initial mount).
const resetRef = useRef(filtersResetToken); const resetRef = useRef(filtersResetToken);
useEffect(() => { useEffect(() => {

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react"; import { ChevronDown, ChevronUp, X } from "lucide-react";
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
import { onMessage } from "../lib/messagesSocket"; import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
@ -39,6 +39,16 @@ export default function ChatDock({ meId }: { meId: number }) {
[qc, meId] [qc, meId]
); );
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
useEffect(
() =>
onUnread(() => {
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
}),
[qc]
);
if (chats.length === 0) return null; if (chats.length === 0) return null;
return ( return (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none"> <div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">

View file

@ -6,12 +6,10 @@ import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { partnerPub, useKeyState } from "../lib/messaging"; import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate"; import KeyGate from "./KeyGate";
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
// The message list + composer for one conversation, shared by the full Messages page and the // The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding // floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts // chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
@ -66,13 +64,28 @@ export default function ChatThread({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]); }, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges. // Opening the thread (or a new incoming message arriving) marks messages read server-side, so
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
const lastIncoming = useRef(-1);
const lastPartner = useRef(partnerId);
useEffect(() => { useEffect(() => {
if (q.dataUpdatedAt) { if (!q.dataUpdatedAt) return;
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
// with the same incoming-count would skip the open-marks-read refresh.
if (lastPartner.current !== partnerId) {
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] }); qc.invalidateQueries({ queryKey: ["message-unread"] });
} }
}, [q.dataUpdatedAt, qc]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, partnerId, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`, // Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader // which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader

View file

@ -38,15 +38,23 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged"). // clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({}); const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
// refetch does NOT (see the effect below).
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is // One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded. // stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab(LS.configTab); const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset). // A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
useEffect(() => { useEffect(() => {
setDraft(baseline); setDraft(baseline);
setSecretReset({}); setSecretReset({});
}, [baseline]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => { const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key]; if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
@ -67,12 +75,16 @@ export default function ConfigPanel() {
} else if (it.type === "bool") { } else if (it.type === "bool") {
await api.setConfig(key, raw === "true"); await api.setConfig(key, raw === "true");
} else if (raw === "") { } else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default // allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else { } else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw); await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
} }
} }
await qc.invalidateQueries({ queryKey: ["admin-config"] }); await qc.invalidateQueries({ queryKey: ["admin-config"] });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved"); setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch { } catch {

View file

@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api"; import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock"; import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
@ -13,8 +13,6 @@ import Avatar from "./Avatar";
import KeyGate from "./KeyGate"; import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
const POLL_MS = 20000;
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean }; type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) { export default function Messages({ meId }: { meId: number }) {

View file

@ -107,6 +107,10 @@ export default function PlayerModal({
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
const errorStreakRef = useRef(0);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player // Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native // iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video // controls; the interaction overlay below also stops the iframe stealing focus on a video
@ -502,6 +506,7 @@ export default function PlayerModal({
onStateChange: (e: any) => { onStateChange: (e: any) => {
// 1 === playing → sync the navigated-to video's title/author for display. // 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) { if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current; const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author }); if (d) setLiveData({ title: d.title, author: d.author });
@ -517,17 +522,47 @@ export default function PlayerModal({
}, },
// The embed couldn't play this video (embedding disabled, removed, private…). // The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch. // Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1), onError: (e: any) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
// NOT mark the broken video watched (only the ended-handler does that).
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
}, },
}); });
}); });
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000); const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
// description-link video may not be in this user's library (the server would 404).
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session. // Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => { Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed"] });

View file

@ -318,11 +318,33 @@ export default function Scheduler() {
}); });
const data = q.data; const data = q.data;
// Tick once a second so the per-job countdowns move between polls. // Tick once a second so the per-job countdowns move between polls — but only while the tab is
// visible; a hidden tab doesn't need to re-render the whole page every second (SB3).
const [, setTick] = useState(0); const [, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000); let id: ReturnType<typeof setInterval> | undefined;
return () => clearInterval(id); const start = () => {
if (id == null) id = setInterval(() => setTick((n) => n + 1), 1000);
};
const stop = () => {
if (id != null) {
clearInterval(id);
id = undefined;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else {
setTick((n) => n + 1); // snap the countdown to the correct value on return
start();
}
};
onVisibility(); // honor the current visibility on mount
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []); }, []);
const pauseResume = useMutation({ const pauseResume = useMutation({

View file

@ -185,5 +185,17 @@
"added": "Hinzugefügt", "added": "Hinzugefügt",
"user": "Nutzer" "user": "Nutzer"
}, },
"clipBadge": "Clip" "clipBadge": "Clip",
"errors": {
"quota": {
"max_bytes": "Dein Download-Speicherkontingent ist aufgebraucht ({{size}} GB). Gib zuerst Speicher frei.",
"max_jobs": "Du hast dein Download-Limit erreicht ({{limit}} Objekte). Entferne zuerst einige.",
"generic": "Download-Kontingent überschritten."
},
"edit": {
"empty_edit": "Wähle zuerst einen Zuschneidebereich oder Ausschnitt.",
"source_not_ready": "Der Quell-Download ist noch nicht bearbeitbar.",
"generic": "Dieser Schnitt konnte nicht erstellt werden."
}
}
} }

View file

@ -124,7 +124,10 @@
"resetTitle": "Demo-Zustand zurücksetzen?", "resetTitle": "Demo-Zustand zurücksetzen?",
"resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.", "resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.",
"resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt", "resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt",
"resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden" "resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden",
"removed": "Von der Demo-Whitelist entfernt",
"removeTitle": "Demo-Zugang entfernen?",
"removeConfirm": "{{email}} von der Demo-Whitelist entfernen? Der Zugang zum Demo-Konto ist dann nicht mehr möglich."
}, },
"invites": { "invites": {
"title": "Zugriffsanfragen", "title": "Zugriffsanfragen",
@ -141,6 +144,9 @@
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.", "approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",
"approve": "Genehmigen", "approve": "Genehmigen",
"denyHint": "Diese Anfrage ablehnen.", "denyHint": "Diese Anfrage ablehnen.",
"deny": "Ablehnen" "deny": "Ablehnen",
"denied": "Anfrage abgelehnt",
"denyTitle": "Zugriffsanfrage ablehnen?",
"denyConfirm": "Die Zugriffsanfrage von {{email}} ablehnen?"
} }
} }

View file

@ -185,5 +185,17 @@
"added": "Added", "added": "Added",
"user": "User" "user": "User"
}, },
"clipBadge": "Clip" "clipBadge": "Clip",
"errors": {
"quota": {
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
"max_jobs": "You've reached your download limit ({{limit}} items). Remove some first.",
"generic": "Download quota exceeded."
},
"edit": {
"empty_edit": "Choose a trim range or crop area first.",
"source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created."
}
}
} }

View file

@ -124,7 +124,10 @@
"resetTitle": "Reset demo state?", "resetTitle": "Reset demo state?",
"resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.", "resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.",
"resetDone": "Demo reset — {{count}} sample playlist(s) seeded", "resetDone": "Demo reset — {{count}} sample playlist(s) seeded",
"resetFailed": "Couldn't reset the demo account" "resetFailed": "Couldn't reset the demo account",
"removed": "Removed from the demo whitelist",
"removeTitle": "Remove demo access?",
"removeConfirm": "Remove {{email}} from the demo whitelist? They'll no longer be able to enter the demo account."
}, },
"invites": { "invites": {
"title": "Access requests", "title": "Access requests",
@ -141,6 +144,9 @@
"approveHint": "Approve — whitelist this email and email them they're in.", "approveHint": "Approve — whitelist this email and email them they're in.",
"approve": "Approve", "approve": "Approve",
"denyHint": "Deny this request.", "denyHint": "Deny this request.",
"deny": "Deny" "deny": "Deny",
"denied": "Request denied",
"denyTitle": "Deny access request?",
"denyConfirm": "Deny the access request from {{email}}?"
} }
} }

View file

@ -185,5 +185,17 @@
"added": "Hozzáadva", "added": "Hozzáadva",
"user": "Felhasználó" "user": "Felhasználó"
}, },
"clipBadge": "Klip" "clipBadge": "Klip",
"errors": {
"quota": {
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
"max_jobs": "Elérted a letöltési korlátodat ({{limit}} elem). Előbb távolíts el néhányat.",
"generic": "Túllépted a letöltési kvótát."
},
"edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.",
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"generic": "Ezt a vágást nem sikerült létrehozni."
}
}
} }

View file

@ -124,7 +124,10 @@
"resetTitle": "Visszaállítod a demo állapotot?", "resetTitle": "Visszaállítod a demo állapotot?",
"resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.", "resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.",
"resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva", "resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva",
"resetFailed": "Nem sikerült visszaállítani a demo fiókot" "resetFailed": "Nem sikerült visszaállítani a demo fiókot",
"removed": "Eltávolítva a demo fehérlistáról",
"removeTitle": "Visszavonod a demo hozzáférést?",
"removeConfirm": "Eltávolítod {{email}} címet a demo fehérlistáról? Többé nem tud belépni a demo fiókba."
}, },
"invites": { "invites": {
"title": "Hozzáférési kérelmek", "title": "Hozzáférési kérelmek",
@ -141,6 +144,9 @@
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.", "approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
"approve": "Jóváhagyás", "approve": "Jóváhagyás",
"denyHint": "Kérelem elutasítása.", "denyHint": "Kérelem elutasítása.",
"deny": "Elutasítás" "deny": "Elutasítás",
"denied": "Kérelem elutasítva",
"denyTitle": "Elutasítod a hozzáférési kérelmet?",
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?"
} }
} }

View file

@ -209,13 +209,35 @@ export interface VersionInfo {
class HttpError extends Error { class HttpError extends Error {
status: number; status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present detail?: string; // server-provided reason (FastAPI's `detail`), when present
constructor(status: number, detail?: string) { detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: any) {
super(detail || `HTTP ${status}`); super(detail || `HTTP ${status}`);
this.status = status; this.status = status;
this.detail = detail; this.detail = detail;
this.detailData = detailData;
} }
} }
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: any): string {
const t = i18n.t.bind(i18n);
if (d?.code === "quota") {
if (d.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(d.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
return t("downloads.errors.quota.generic");
}
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we // A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the // show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps // server again we remove it. This makes good on its "clears once it's back" copy and keeps
@ -277,6 +299,29 @@ const setupHeaders = (token: string) => ({
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
export const getActiveAccount = activeAccountId; export const getActiveAccount = activeAccountId;
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
function beacon(url: string, body: Record<string, unknown>): void {
const active = getActiveAccount();
try {
void fetch(url, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify(body),
}).catch(() => {});
} catch {
/* ignore */
}
}
export function setActiveAccount(id: number): void { export function setActiveAccount(id: number): void {
try { try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
@ -337,9 +382,14 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
if (!r.ok) { if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined; let detail: string | undefined;
let detailData: any;
try { try {
const body = await r.json(); const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail; if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} catch { } catch {
/* no JSON body */ /* no JSON body */
} }
@ -361,7 +411,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
} else if (r.status === 400 || r.status === 409 || r.status === 422) { } else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail); reportError(detail);
} }
throw new HttpError(r.status, detail); throw new HttpError(r.status, detail, detailData);
} }
return r.status === 204 ? null : r.json(); return r.status === 204 ? null : r.json();
} }
@ -601,6 +651,7 @@ export interface ConfigItem {
min: number | null; min: number | null;
max: number | null; max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default) is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
value?: string | number | boolean; // non-secret: effective value value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists default_is_set?: boolean; // secret: whether an env default exists
@ -1074,6 +1125,12 @@ export const api = {
}, },
{ idempotent: true } { idempotent: true }
), ),
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
beacon(`/api/videos/${encodeURIComponent(id)}/progress`, {
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`), videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }), pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),
@ -1306,23 +1363,12 @@ export const api = {
position_seconds: number, position_seconds: number,
duration_seconds: number, duration_seconds: number,
final = false, final = false,
): void => { ): void =>
const active = getActiveAccount(); beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
try { position_seconds,
void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, { duration_seconds,
method: "POST", final,
keepalive: true, }),
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}).catch(() => {});
} catch {
/* ignore */
}
},
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> => plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/state`, { req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
method: "POST", method: "POST",

View file

@ -112,6 +112,15 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
return false; return false;
} }
// Install a freshly-imported private key as the current identity: drop any derived conversation
// keys, persist it to this device, and notify unlock subscribers. Shared by setup() and unlock().
async function installKey(userId: number, key: CryptoKey): Promise<void> {
privKey = key;
convKeys.clear();
await idbPut(userId, key);
emitUnlock();
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a // First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server. // non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> { export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
@ -128,10 +137,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok"))); const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
const spki = await subtle.exportKey("spki", kp.publicKey); const spki = await subtle.exportKey("spki", kp.publicKey);
privKey = await importPrivate(pkcs8); await installKey(userId, await importPrivate(pkcs8));
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
return { return {
public_key: b64(spki), public_key: b64(spki),
wrapped_private_key: b64(wrapped), wrapped_private_key: b64(wrapped),
@ -154,10 +160,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
wrapKey, wrapKey,
bsrc(ub64(bundle.wrapped_private_key)) bsrc(ub64(bundle.wrapped_private_key))
); );
privKey = await importPrivate(pkcs8); await installKey(userId, await importPrivate(pkcs8));
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
} }
// --- per-conversation encryption --- // --- per-conversation encryption ---

View file

@ -11,8 +11,10 @@ interface IncomingMessage {
to?: MessageUser; to?: MessageUser;
} }
type Handler = (e: IncomingMessage) => void; type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>(); const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let started = false; let started = false;
let backoff = 1000; let backoff = 1000;
@ -21,7 +23,9 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string { function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws"; const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the // A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side). // query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount(); const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : ""; const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`; return `${proto}://${location.host}/api/messages/ws${qs}`;
@ -43,6 +47,9 @@ function open() {
if (data?.type === "message" && data.message) { if (data?.type === "message" && data.message) {
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to }; const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
for (const h of handlers) h(e); for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
} }
} catch { } catch {
/* ignore malformed frames */ /* ignore malformed frames */
@ -87,3 +94,13 @@ export function onMessage(h: Handler): () => void {
} }
}; };
} }
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}

View file

@ -6,6 +6,9 @@ import { isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
export const POLL_MS = 20000;
// Set-once cache of partner public keys (the server is the directory; keys never change). // Set-once cache of partner public keys (the server is the directory; keys never change).
const pubCache = new Map<number, string>(); const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> { export async function partnerPub(partnerId: number): Promise<string> {

View file

@ -14,6 +14,22 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.40.0",
date: "2026-07-12",
summary: "A wave of polish and reliability fixes closing out the internal cleanup pass.",
fixes: [
"Your place in a video is now saved even if you refresh or close the tab right after skipping around.",
"Auto-advance no longer gets stuck on a video that can't be played — it moves on to the next one.",
"The unread badge on your messages now updates live across your other open tabs.",
"Download storage/limit messages now appear in your language, with the right number format.",
"Removing a demo email or denying an access request now asks for confirmation first.",
"After syncing your subscriptions, the feed refreshes right away to reflect the change.",
],
chores: [
"Many internal correctness and efficiency fixes across search, playlists, channels, messages, and downloads; admin-panel polish. No feature changes.",
],
},
{ {
version: "0.39.1", version: "0.39.1",
date: "2026-07-12", date: "2026-07-12",