Merge chore/deferred-bugs: close the code-hygiene sweep's deferred 🟡/🟢 tails
Verified every deferred per-subsystem item against current source (8 parallel agents), then resolved all of them: 20 fixed, 14 WONTFIX (recorded with rationale in siftlode-ops/CODE-HYGIENE.md), 2 no-op (already-shipped / already-mitigated). ruff+tsc+knip green; backend runtime-smoke-tested; /code-review (high) findings all addressed; SB2 confirm-dialog E2E-verified. Detail in the 3 branch commits.
This commit is contained in:
commit
f42d8c0c5e
27 changed files with 532 additions and 164 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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("")
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
|
|
|
||||||
|
|
@ -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"] });
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}}?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue