Commit Graph
924 Commits
Author SHA1 Message Date
peter 6ae7825a14 refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved
views; channels, files, playlists their provider), and ModuleStores mounts them at the root in
registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's
read side is in reach and that no store's static imports leave its own module, so the order cannot
matter.

Saved views were one provider that switched between the feed and Plex by the active page and reset
on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the
shared chip, popover and modal read the views their page or rail hands down. A feed view applied
and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a
module's manage modal still closes when its page is left. The feed's URL-override check applies
only on the feed page, where its filter params live.

The Channels store's tab type moves from the page into the store's context.
2026-10-23 04:59:10 +02:00
peter cde5a98fd2 refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state
A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for
its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag.
Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width —
handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for
the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds
the registry to the page ids and each definition to its own folder, with no static import of a page.

Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and
shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot
428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag
(lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each
visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell
hooks; App is layout. The registry's three layer suppressions are gone: none is left.
2026-10-23 04:00:58 +02:00
peter 4ef963b005 refactor(frontend): each page fills the floating header itself; the header knows no page
The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
2026-10-23 00:23:38 +02:00
peter 0b24f2d8fa fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects
The guard's logic was a src/lib file the config project also listed. The app project, which
references the config project, then refuses that source on a clean build (TS6305) — while every
local run reused the referenced project's cached output and stayed green; only the Docker image
build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root
(vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's
include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
2026-10-22 23:48:16 +02:00
peter d5867bacca feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
2026-10-22 23:30:27 +02:00
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00
peter 3c6c23507b fix(frontend): a page opened at a //path boots, and the account-switch copy reads right in Hungarian
Every address-bar writer glued `pathname + query`, so a path of `//evil.com/x` named another host:
history refused it (SecurityError) and the first nav mirror threw the app into its crash screen. One
builder, `hrefHere`, now makes the address from the URL itself and stays on this origin.
2026-10-21 02:51:48 +02:00
peter ef99f7b5c7 fix(auth): a Google connect names its tab's account too, and a refused tab says why
- /auth/google/connect is a navigation and carries no headers: the tab names the account it
  was built for in `expect`, checked by the same comparison as the request header
  (built_for_another_account); a mismatch lands on /?account=changed
- the link marker names the OAuth state of its own flow: an older flow coming back no longer
  spends the marker of the connect still open (which then returned as a plain sign-in)
- a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it
  now shows the account signed in
- a refused switch to a suspended or not-yet-approved account says so in the signed-in
  app's words, naming the address a request was recorded for
- the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring
  to the account does not hold the save up; a test pins the lock
- giving up a lost page closes a channel page open over it
- e2e README names the chromium-only specs
2026-10-21 02:05:33 +02:00
peter 94418a087e fix(auth): a tab acts only as the account it was built for, and the review's other findings
A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
2026-10-21 01:05:12 +02:00
peter f21cdfa156 refactor(frontend): every stored key declares its kind, and preferences follow the account
storage.ts's registry gives each key a kind (pref, device, view, cache,
public); a key without one does not compile. A contract test refuses a key
spelled out elsewhere, an account preference read or written straight from
storage instead of through usePref, and an e2e seed of a key nobody reads.
The keys the next account's wipe keeps are the public ones.

The seven preferences kept only in the browser move onto usePref: start on
Home, the onboarding dismissal, the release banner's last version, the
channel manager's layout, the Plex playlist layout, the playlist rail's sort
and channel discovery's sort. usePref reads an older bare-string copy, and a
value only this browser held is saved to the server once, so the account's
other devices get it.

Performance mode is the device's: it is neither read from the account nor
saved to it.
2026-10-20 23:04:48 +02:00
peter d6dada7fe5 refactor(frontend): one account per browser, its local state under plain keys
The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone
says who is asking. localStorage keys lose their account suffix; the browser records whose state it
holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different
account starts from a clean slate (the public /watch keys stay). The first boot moves the account's
old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and
after another tab claimed the browser for someone else, the account helpers read and write nothing.

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
2026-10-20 15:29:41 +02:00
peter 6645caa157 refactor(auth): one Google connect route for a sign-in and for YouTube access
/auth/link and /auth/upgrade become /auth/google/connect?access=identity|read|write: one handler, one
table of what each access asks Google for, the same callback markers. An unknown access is refused
(422) instead of quietly meaning read, and an instance without Google answers ?link=error for a
grant too, not a silent return to the app. The frontend builds the URL in lib/googleConnect, and a
contract test holds it to the backend route and its access values.
2026-10-20 12:49:11 +02:00
peter dc4f4f5143 test(e2e): a plain member's shell is held end to end, and a page it cannot open lands on the feed
The suite ran only as an admin, so nothing end to end held what a member sees. A second seeded
account, a member with its own session, now walks every rail page (no admin entry, no admin call,
no error state) and follows links to the admin pages.

It found one: a member's link to an admin page showed the feed with no rail row current, and the
address bar kept naming the admin page — only the content fell back. The navigation state now
derives the page on screen through each module's gate (reachablePage), so the rail, the content,
the tab title and the address bar agree, on a link, a Back, and a role taken away mid-session.
2026-10-20 04:08:11 +02:00
peter e9c60f72cd refactor(capture): the HTTP layer reads through queries and writes through the service
Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call
the session or set a model's fields: reads are downloads/queries, writes the unit's service (service,
folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes
through userscope (owned_by, owned_row), which owned_or_404 now uses too.

The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes
no other unit's, filters owners through userscope and commits only in its jobs and the upload
ingest. A test per service use case, among them that the tray lists only the caller's files.
2026-10-19 00:19:03 +02:00
peter 3a688fa0f5 refactor(capture): the unit's tables move into downloads/models
The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library
resume points) leave app/models.py for the unit's own module; app.tables registers every unit's
tables for the migrations, the test database and the ownership check. No schema change.
2026-10-18 23:45:53 +02:00
peter b842e4e871 fix(ui): a looping pulse stops for reduced motion; the e2e waits for skeleton bones, not for any pulse
- Seven looping pulses (a running job's dot, the scheduler's progress bar, a queued download, the
  download tray, a playlist upload, a channel's full-history action, the setup wizard's test mail)
  ran regardless of prefers-reduced-motion, which docs/DESIGN.md promises to respect: each is
  motion-safe now, held by a contract test.
- The a11y suite's "settled" wait took every aria-hidden pulse for a loading skeleton, so the
  Scheduler legend's running dot, which pulses for good, kept it waiting until it timed out. A
  skeleton bone now says it is one (data-skeleton) and the wait looks for that.
2026-10-18 21:40:52 +02:00
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
2026-10-18 15:38:32 +02:00
peter 3021be5166 fix(activity): a run's quota reads with its unit, its outcome keeps room at a large text size, a token's audit rows read in Hungarian
The run list showed the quota spend as a bare figure and, at the largest text size on a 1024-wide
screen, its fixed columns left the outcome column no width at all. The outcome now keeps 10rem and
the trigger column gives way; the audit list's summary keeps the same floor. A run that stored no
log line says so instead of linking to an empty list. An API token revoke and a token's file work
rendered the server's English line under the Hungarian UI; both are rebuilt in the reader's
language, and the test holds the server's wording.
2026-10-18 04:22:52 +02:00
peter c288ba147b fix(activity): an old audit/log link opens its tab, times read relative, one name for admin actions
Seen in the browser, not in the page's own tests: the navigation provider rewrites a retired page id
to `activity` before the page's chunk reads the URL, so `?page=audit` landed on the Runs tab. The
retired-page map now carries the params that open the successor's part (`at`), and the provider
writes them as it rewrites the page — on load and on Back alike.

A list time is relative with the exact second on hover (the DESIGN copy rule); a run moved over from
the audit log, whose start and end are one instant, shows no length instead of an invented "1 sec".
The audit log is called "admin actions" everywhere it is named (the clear button and its confirm, the
Configuration group, the cleanup job, which also prunes the run history its description left out).
2026-10-18 04:00:38 +02:00
peter fe6308d844 refactor(activity): the window's hours stay private to its URL state 2026-10-18 03:48:30 +02:00
peter 24448e7c6f feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
2026-10-18 03:45:18 +02:00
peter 1b1e3cdcf2 feat(scheduler): each row shows its last three runs, an interrupted run as such, and the ad-hoc runs beside the jobs
A row's one dot said how the LAST run went, and an interrupted run fell through to "idle" — a grey dot
that read as "no run recorded". The row now leads with its last three runs, newest first, each named
for a screen reader and in its tooltip (how it went, when, who started it); an interrupted run is a
ring, set apart from a skipped run and a failure, and its line says the server restarted under it.

A channel's full-history backfill gets its own "Ad-hoc runs" block: its runs, its channel and what
it fetched, no interval and no run button — it needs its target. AD_HOC_JOBS names every such job;
the backend-id contract holds their names in both languages, and run_ad_hoc refuses any other id.
2026-10-18 01:20:53 +02:00
peter 45c0ee7d58 feat(scheduler): every background run is a durable row
What a job last did lived in the API process's memory and was gone at
every restart; the audit log kept a run only when it was manual, failed
or changed something. Now each run is a job_runs row written at its
start and its end in its own session: who started it, its outcome as
data, what it spent of the YouTube quota (quota_events.job_run_id) and
the lines it logged (log_events.run_id, filterable in the log console).

- a run a stopped process left open is marked interrupted at startup
- "run now" is a one-shot job on the scheduler, which now always starts
  (interval jobs only with SCHEDULER_ENABLED); the manual actor is reset
  after the run, since pool threads are reused
- the dashboard's last run comes from job_runs, so it survives a deploy
- runs leave the audit log: migration 0109 moves the old job_run rows
  over (and back on downgrade); the audit page's run folding goes
- job_run_retention_days (default 30) prunes old runs in audit_gc
2026-10-17 23:08:55 +02:00
peter 3ada34073b fix(theme): text on glass holds 4.5:1 — light --muted kept at 6:1 on the page, tonal fills opaque
Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8%
fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the
current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item
count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract
now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
2026-10-17 20:06:54 +02:00
peter 66181d1394 chore(gate): shellcheck joins the gate — every tracked shell script, the host binary held to a pin 2026-10-17 15:31:34 +02:00
peter 5ba5679239 test: tests that assumed an idle machine wait for what they assert, or carry a budget for their work
Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after.
2026-10-17 01:08:15 +02:00
peter 926e5e9899 chore(gate): check.sh can list its lanes and run one of them
--list prints the lane names, --only "<lane>" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both.
2026-10-16 02:53:30 +02:00
peter 9251918e08 chore(test): the DB lane runs on every core — each pytest-xdist worker gets its own database 2026-10-16 01:13:31 +02:00
peter 011f269152 chore(gate): every lane reports how long it took, and a run can log the times for comparison 2026-10-16 00:13:47 +02:00
peter a248ca6c13 fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so 2026-10-15 05:47:00 +02:00
peter 2efa7d5a75 fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so
- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
  the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
  for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
  dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
  New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
  Links, Tokens and Files rounds.
2026-10-15 04:58:17 +02:00
peter 86712b6b0e feat(files): share a folder by link — its page lists what is in it, plays a video above the list
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id,
exactly one set; a folder's links go with it). The page shows what is in the folder when it is
opened, newest first: a video or a song plays in the same player a file link has, above the list,
each keeping its own resume point; a document only downloads, never opens as a page. A file moved
out stops opening through the link at its next request. Password, expiry and stream-only work as on
a file link; the unfurl names the folder and counts its files, never their names. Source folders
and the Hub cannot be shared.

The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail
marks a folder a live link opens.

Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once,
and the new-link form goes back to downloadable after making a link.
2026-10-15 02:23:24 +02:00
peter 90567718b2 feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited
A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
2026-10-15 01:02:29 +02:00
peter c31d7f9d65 fix(files): an upload is announced once, as an upload, and a finish shows in Files at once
An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".

A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
2026-10-14 23:54:19 +02:00
peter db56f25387 feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.

The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
2026-10-14 23:33:34 +02:00
peter 383d394e32 fix(ui): a picture that fails to load shows the card's own frame, then retries
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
2026-10-14 20:51:08 +02:00
peter 759a7536b9 fix(history): an overlay opened while a back() is in flight waits to push its entry
The Plex player's ⋯ → Settings closed the menu (its entry went back) and opened the panel a task
later, before that back() had landed. The panel's push was the entry the landing back() took, so
the books counted one entry more than the history held, and closing the panel by a click outside
it stepped back over the player's own entry: the player closed.

A push now waits while a back() of ours is in flight; the popstate that lands the last one
reconciles again.
2026-10-14 19:43:51 +02:00
peter 54a6bbf07f fix(files): review round 2, the server — locks the GC and folders read under, the mount check, the old mount name
- GC: the expiry warning marks each file conditionally on the expiry it saw, one commit each (a file
  deleted meanwhile no longer fails the run; one re-timed keeps its fresh expiry unwarned); eviction
  counts the disk again when a candidate was freed meanwhile, so it never evicts for space already free.
- Folders: a retention change locks the folder and its files, and a finishing file reads the retention
  under a share lock, so neither misses the other; a folder delete locks the files it takes home (a
  file moved out meanwhile stays put, one deleted meanwhile no longer fails the delete).
- Upload: the folder is checked before the probe and held only once the job names it.
- Dedup enqueue locks the cached asset, so a release meanwhile yields a fresh one instead of a 500.
- Mount check reads the kernel's mount table instead of comparing devices (the vfs storage driver gave
  a mounted volume the device of /), and only inside a container; the API also logs a fresh, empty
  volume in the library's place.
- Compose files fall back to DOWNLOAD_HOST_PATH; the upgrade notes cover the from-source pull.
- Folder routes call owned_or_404 directly.
2026-10-13 02:32:49 +02:00
peter 8b2260c9a8 fix(files): review round 1, the page — honest states, the keyboard, the tray's words
- A library past the list's cap is searched and sorted on the server; the count says "the newest".
- Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope).
- A file dropped where the page takes none never reaches the browser (which would unload the app).
- The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line;
  a text shows its first 5000 lines and says where the rest is.
- Confirms name the file (no more "your downloads"); the asking menu rows end in "…".
- The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly
  while only paused work waits, and forgets a finished file that was deleted.
- An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top;
  the link box never replaces what was typed.
- Honest states: folders that failed to load, a file no longer there, the storage figure; the rail
  says "nothing downloaded" only when there is nothing.
- Focus after deleting the open folder and when the format form opens; the format name's label.
- ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer.
- Copy fixes, stale comments and the speculative hub key; a contract test for the list's
  parameters; e2e: the Files page and the paste intake.
2026-10-13 00:14:09 +02:00
peter 96509fe356 fix(files): a file is added when it is done, not when it was asked for
A download that failed for days and then went through sat days down the library, under its
request date, and read as missing. A job now records when it became done (`ready_at`), stamped
where the expiry already is (`folders.stamp_done`, `finalize_if_running`) and on an insert that is
already done; a check constraint holds every path to done to it. The library sorts and dates its
"Added" by it; the queue keeps the request order.

The migration dates a file from before by its "ready to watch" notice where the inbox still holds
one, else by its request date, where it already sat.
2026-10-12 22:19:37 +02:00
peter fb8fb980e5 feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
2026-10-12 15:28:31 +02:00
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
2026-10-11 19:37:13 +02:00
peter 1c4f0042d2 feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one
  app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses
  the box on its history entry.
- The download tray, bottom right on every page: what waits, runs, is paused or failed (reason +
  Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until
  closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the
  work changes or something finishes. Back to top steps up over it, the page end keeps clear.
- A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts
  and notices may carry a second action).
- The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id.
- GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with
  each row's source key.
2026-10-11 16:56:13 +02:00
peter d22c64f4dd feat(files): folders managed in place, a grid per folder, drop to upload, open on one file
- Own folders are made, renamed and deleted from the rail (right click, long press,
  Shift+F10) or the "…" beside an open folder's name; any folder's retention is a
  submenu (7/30/90 days, until deleted). Deleting confirms where its files go.
- List or grid per folder, sharing one sort; the grid is the one media card.
- A file dropped anywhere on the page opens Upload with it, bound for the folder
  on screen.
- A link (?item=) or the upload notice opens Files on the file: its page turned
  to, the keyboard on it, a ring round it.
- An in-place name field left by Enter or Escape hands the focus back to what
  replaced it (the row, the entry, the "…") instead of dropping it on <body>.
- The table's saved page size is per account.
2026-10-11 15:59:12 +02:00
peter 85aa917b38 feat(files): the Files page — the library in one aligned list, its folders in a rail
Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.

The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
2026-10-11 05:34:25 +02:00
peter bf99444ded feat(files): every file sits in a library folder, and keeps its own expiry
The Files library starts here. A user's every download, clip and upload now
lands in a source folder made per origin (YouTube, Plex clips, uploads, one per
other site), decided once in downloads/folders.py at every place a job is made.
How long a file stays moves from the shared asset to the user's job: stamped
from its folder when it is done, never for a file the user keeps. The GC warns
each user once, then takes the expired file out of their library; the cache cap
never evicts a kept file, and an evicted one leaves every holder's library.

The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume
siftlode_files); docs/self-hosting.md says how to carry an old volume over.
Migration 0102 places every existing job and carries each done file's expiry
over from its asset.

safe_abs_path takes a missing path and answers None, which drops one baselined
type error.
2026-10-11 03:30:34 +02:00
peter b9eb8458b3 test(e2e): a localhost connect the machine dropped is tried again on the test side
On a Windows host a burst of test traffic can drop a loopback connect now and then (every port,
any program — measured on the dev desktop). The browser resends it and only waits; Node (libuv)
sends a loopback SYN once and fails at ~300 ms with "connect ETIMEDOUT". `e2e/retryConnect.ts` wraps
Playwright's request context so that one error is tried again (0.5 / 1 / 2 / 4 / 8 s, the system's
own retry waits) while every other failure goes straight to the test; global.setup's health check,
login and /api/me and speed.measure's share probe go through it. Unit-tested; e2e 61/61.
2026-10-11 00:45:13 +02:00
peter 663a7e8012 fix(player): the Plex player's fills hold on its chrome: an amber head on a white played part
A light theme's accent as a fill on the always-dark chrome was ~2.8:1 (Slate), under the 3:1 a
control needs. A native checkbox and slider tint with `accent-chosen` (the accent elsewhere, the
media amber under `media-marks`), and so does the skip button's countdown. The progress bar plays
white with an amber head: amber there marks a place, as the intro / credits markers do, and an amber
played part made a marker ahead of the head read as already played (owner's choice of three).
2026-10-10 22:56:09 +02:00
peter 93192dadd8 fix(player): a chosen tab's underline and an open or pressed icon read the scope's chosen colour
The underline named `--color-chosen`, which is resolved once on :root, so it stayed the accent under
`media-marks` (seen in the browser: fuchsia on Slate light); it reads `--chosen` where it is used
now. An icon button pressed or with its menu open tinted its glyph with the accent; it takes the
chosen colour — the accent everywhere but the player chrome, where it is the media amber.
2026-10-10 22:26:22 +02:00
peter 8312c6d592 fix(player): what is chosen on the player chrome is marked amber, not written in the accent
A light theme's accent as text on the always-dark chrome was 2.84:1 (Slate). `--chosen` is the
accent unless a scope names its own; `media-marks` (was media-focus) points it, with the focus
ring, at the media amber. A chosen track stays white with an amber tick (MenuCheck reads
`text-chosen`); a chosen settings tab or toggle is white with an amber underline (MEDIA_CHOSEN).
The theme contract test holds the token, the tick and the player components free of accent text.
2026-10-10 21:56:47 +02:00