feat(plex): rework player timing (copyts) + player settings & personalization
Streaming / subtitle sync (the core fix):
- stream.py: add -copyts so HLS segments carry the true absolute PTS, and measure
the real keyframe start K from seg_0 (ffprobe) -> return it as the session start.
Fixes the seconds-long subtitle lead caused by using the requested seek offset
instead of the keyframe ffmpeg actually lands on with video stream-copy.
- Add -noaccurate_seek so the re-encoded audio starts at the same keyframe as the
video (was starting (X-K)s later -> seconds of silence after each seek/audio switch).
- Compensate the fixed ~1.0s lag hls.js introduces for non-zero-start copyts streams,
folded into the session start so the clock, seeking and the subtitle shift are all
content-accurate.
- /subtitle gains an offset param; _shift_vtt shifts absolute cues onto the session's
zero-based clock and DROPS fully-past cue blocks (collapsing them to 0->0 made every
past cue active at currentTime 0 on resume -> a pile-up until playback advanced).
Audio:
- /session + stream.py gain an audio A/V-sync offset (-itsoffset, full +/-, second
input only when non-zero).
Player settings & personalization (per-account, persisted -> survive F5):
- storage.ts: useAccountPersistedObject (per-account JSON prefs blob).
- PlexPlayer: volume/mute, audio+subtitle language (index-based match, fixes the F5
audio-revert), sync offsets, seek steps, subtitle style, auto-hide, play intent.
- Hotkeys A (cycle audio) / S (cycle subtitle), mouse-wheel volume, Ctrl+arrow fine
seek, per-user plain/fine seek-step + auto-hide toggle.
- Subtitle appearance: size / colour / vertical position / background via ::cue + line.
- UI: split into a Tracks quick-menu + a tabbed gear panel (Sync | Playback | Subtitle);
both dismiss on outside-click; edge-aware control tooltips.
- i18n en/hu/de for all new strings.
2026-07-08 21:35:38 +02:00
|
|
|
import { useCallback, useState } from "react";
|
2026-06-26 03:30:19 +02:00
|
|
|
|
|
|
|
|
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
|
|
|
|
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
|
|
|
|
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
|
|
|
|
|
export const LS = {
|
|
|
|
|
theme: "siftlode.theme",
|
|
|
|
|
sidebarLayout: "siftlode.sidebarLayout",
|
2026-07-13 05:13:40 +02:00
|
|
|
plexLayout: "siftlode.plexLayout",
|
|
|
|
|
playlistsLayout: "siftlode.playlistsLayout",
|
|
|
|
|
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
2026-06-26 03:30:19 +02:00
|
|
|
hints: "siftlode.hints",
|
|
|
|
|
lang: "siftlode.lang",
|
|
|
|
|
filters: "siftlode.filters",
|
2026-07-01 03:17:36 +02:00
|
|
|
// Mirror of the default saved view's filters, so it can be applied synchronously on load
|
|
|
|
|
// (before the async saved-views query resolves). Kept in sync by SavedViewsWidget.
|
|
|
|
|
defaultViewFilters: "siftlode.defaultViewFilters",
|
2026-06-26 03:30:19 +02:00
|
|
|
page: "siftlode.page",
|
|
|
|
|
perfMode: "siftlode.perfMode",
|
|
|
|
|
channelFilter: "siftlode.channelFilter",
|
|
|
|
|
channelsView: "siftlode.channelsView",
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect
your YouTube account, else fallback toast" logic was duplicated in Channels.tsx
and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the
api ↔ notifications import cycle.)
- Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count
cell was repeated across the subs/videos columns in Channels + ChannelDiscovery.
- Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare
"siftlode.*" string literals.
- Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag,
row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0
refs (createTag matches were the unrelated api.createTag method, not the key).
Behavior-neutral. tsc green, knip no new unused, localdev boots.
2026-07-11 18:11:23 +02:00
|
|
|
channelsTable: "siftlode.channelsTable",
|
|
|
|
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
2026-06-26 03:30:19 +02:00
|
|
|
settingsTab: "siftlode.settingsTab",
|
2026-07-11 19:44:53 +02:00
|
|
|
configTab: "siftlode.configTab",
|
feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).
Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
VALUES never logged, key only (config.py); scheduler interval + maintenance
tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
choke point writes ONE run-summary row per run — manual runs + errors always,
automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
(sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
resolved, System for background) + clear (leaves one tamper-evidence row).
Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
(first admin page to reuse it) — sort/filter/paginate/persist, action select
filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
|
|
|
auditTable: "siftlode.auditTable",
|
2026-06-26 03:30:19 +02:00
|
|
|
statsTab: "siftlode.statsTab",
|
|
|
|
|
adminUsersTab: "siftlode.adminUsersTab",
|
|
|
|
|
navCollapsed: "siftlode.navCollapsed",
|
2026-07-01 23:08:25 +02:00
|
|
|
filterCollapsed: "siftlode.filterCollapsed",
|
2026-06-26 03:30:19 +02:00
|
|
|
playlist: "siftlode.playlist",
|
|
|
|
|
plSort: "siftlode.plSort",
|
2026-07-05 03:29:20 +02:00
|
|
|
plexLibrary: "siftlode.plexLibrary",
|
|
|
|
|
plexShow: "siftlode.plexShow",
|
|
|
|
|
plexSort: "siftlode.plexSort",
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
plexFilters: "siftlode.plexFilters",
|
2026-07-11 01:34:30 +02:00
|
|
|
plexQ: "siftlode.plexQ",
|
2026-07-06 22:14:56 +02:00
|
|
|
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
2026-07-11 23:10:24 +02:00
|
|
|
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
2026-06-26 03:30:19 +02:00
|
|
|
notifHistory: "siftlode.notifications",
|
|
|
|
|
notifSettings: "siftlode.notifSettings",
|
|
|
|
|
onboardingDismissed: "siftlode.onboarding.dismissed",
|
|
|
|
|
seenVersion: "siftlode.seenVersion",
|
|
|
|
|
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
|
|
|
|
} as const;
|
|
|
|
|
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
// --- per-account scoping -----------------------------------------------------------------
|
|
|
|
|
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated
|
|
|
|
|
// here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the
|
|
|
|
|
// low-level helpers below.
|
|
|
|
|
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
2026-06-26 03:30:19 +02:00
|
|
|
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
export function activeAccountId(): number | null {
|
|
|
|
|
try {
|
|
|
|
|
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
const n = Number(raw);
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
|
|
|
|
|
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
|
|
|
|
|
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
|
|
|
|
|
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
|
|
|
|
|
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
|
|
|
|
|
* of touching another account's data. */
|
|
|
|
|
export function accountKey(base: string, id: number | null | undefined = activeAccountId()): string | null {
|
2026-07-02 00:37:23 +02:00
|
|
|
return id != null ? `${base}.${id}` : null;
|
|
|
|
|
}
|
|
|
|
|
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
|
|
|
|
|
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
|
|
|
|
|
export function readAccount<T>(base: string, fallback: T): T {
|
|
|
|
|
const k = accountKey(base);
|
|
|
|
|
return k ? readJSON(k, fallback) : fallback;
|
|
|
|
|
}
|
|
|
|
|
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
|
|
|
|
|
const k = accountKey(base);
|
|
|
|
|
return k ? readMerged(k, defaults) : { ...defaults };
|
|
|
|
|
}
|
|
|
|
|
export function writeAccount(base: string, value: unknown): void {
|
|
|
|
|
const k = accountKey(base);
|
|
|
|
|
if (k) writeJSON(k, value);
|
|
|
|
|
}
|
|
|
|
|
export function getAccountRaw(base: string): string | null {
|
|
|
|
|
const k = accountKey(base);
|
|
|
|
|
return k ? localStorage.getItem(k) : null;
|
|
|
|
|
}
|
|
|
|
|
export function setAccountRaw(base: string, value: string): void {
|
|
|
|
|
const k = accountKey(base);
|
|
|
|
|
if (k) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(k, value);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
|
|
|
|
|
|
2026-06-26 03:30:19 +02:00
|
|
|
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
|
|
|
|
|
* falling back to `defaults` on missing or corrupt data. */
|
|
|
|
|
export function readMerged<T extends object>(key: string, defaults: T): T {
|
|
|
|
|
try {
|
|
|
|
|
return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") };
|
|
|
|
|
} catch {
|
|
|
|
|
return { ...defaults };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */
|
|
|
|
|
export function readJSON<T>(key: string, fallback: T): T {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(key);
|
|
|
|
|
return raw == null ? fallback : (JSON.parse(raw) as T);
|
|
|
|
|
} catch {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Persist a value as JSON, swallowing quota/serialization errors. */
|
|
|
|
|
export function writeJSON(key: string, value: unknown): void {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore quota / serialization errors */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- reactive persisted string state ----------------------------------------------------
|
|
|
|
|
|
2026-07-12 02:15:52 +02:00
|
|
|
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
|
|
|
|
|
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
|
|
|
|
|
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
|
|
|
|
|
* can be used before an async-loaded option list is known, keeping hook order stable. These hooks
|
|
|
|
|
* run in components that only mount once signed in, so the account is known. */
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
export function useAccountPersistedState(
|
|
|
|
|
base: string,
|
|
|
|
|
fallback = "",
|
|
|
|
|
): [string, (value: string) => void] {
|
|
|
|
|
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
|
|
|
|
const set = (next: string) => {
|
|
|
|
|
setValue(next);
|
|
|
|
|
setAccountRaw(base, next);
|
|
|
|
|
};
|
|
|
|
|
return [value, set];
|
|
|
|
|
}
|
feat(plex): rework player timing (copyts) + player settings & personalization
Streaming / subtitle sync (the core fix):
- stream.py: add -copyts so HLS segments carry the true absolute PTS, and measure
the real keyframe start K from seg_0 (ffprobe) -> return it as the session start.
Fixes the seconds-long subtitle lead caused by using the requested seek offset
instead of the keyframe ffmpeg actually lands on with video stream-copy.
- Add -noaccurate_seek so the re-encoded audio starts at the same keyframe as the
video (was starting (X-K)s later -> seconds of silence after each seek/audio switch).
- Compensate the fixed ~1.0s lag hls.js introduces for non-zero-start copyts streams,
folded into the session start so the clock, seeking and the subtitle shift are all
content-accurate.
- /subtitle gains an offset param; _shift_vtt shifts absolute cues onto the session's
zero-based clock and DROPS fully-past cue blocks (collapsing them to 0->0 made every
past cue active at currentTime 0 on resume -> a pile-up until playback advanced).
Audio:
- /session + stream.py gain an audio A/V-sync offset (-itsoffset, full +/-, second
input only when non-zero).
Player settings & personalization (per-account, persisted -> survive F5):
- storage.ts: useAccountPersistedObject (per-account JSON prefs blob).
- PlexPlayer: volume/mute, audio+subtitle language (index-based match, fixes the F5
audio-revert), sync offsets, seek steps, subtitle style, auto-hide, play intent.
- Hotkeys A (cycle audio) / S (cycle subtitle), mouse-wheel volume, Ctrl+arrow fine
seek, per-user plain/fine seek-step + auto-hide toggle.
- Subtitle appearance: size / colour / vertical position / background via ::cue + line.
- UI: split into a Tracks quick-menu + a tabbed gear panel (Sync | Playback | Subtitle);
both dismiss on outside-click; edge-aware control tooltips.
- i18n en/hu/de for all new strings.
2026-07-08 21:35:38 +02:00
|
|
|
|
|
|
|
|
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
|
|
|
|
|
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
|
|
|
|
|
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
|
|
|
|
|
export function useAccountPersistedObject<T extends object>(
|
|
|
|
|
base: string,
|
|
|
|
|
defaults: T,
|
|
|
|
|
): [T, (patch: Partial<T>) => void] {
|
|
|
|
|
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
|
|
|
|
const patch = useCallback(
|
|
|
|
|
(p: Partial<T>) => {
|
|
|
|
|
setValue((prev) => {
|
|
|
|
|
const next = { ...prev, ...p };
|
|
|
|
|
writeAccount(base, next);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[base],
|
|
|
|
|
);
|
|
|
|
|
return [value, patch];
|
|
|
|
|
}
|