Commit Graph
2724 Commits
Author SHA1 Message Date
peter 7371cdeb94 refactor(downloads): the GIF span helper stays inside lib/gif 2026-11-13 01:42:36 +01:00
peter abb18d4d8c fix(downloads): GIF review round 3 — every length readout, live regions in place
A refused GIF's length reads rounded up everywhere the editor shows it (the segment and the kept summary of one), and the GIF notes and estimates sit in live regions that are there before GIF is picked, so a note already true then is said. Tests hold the clip panel's readout and which segment a note names; DESIGN.md claims only what the contract test holds.
2026-11-13 01:16:21 +01:00
peter 5bf13c8701 test(scheduler): runs started in a row start in that order
The scheduler tests open several runs within microseconds and read them back newest first, by start time. A container's clock on a VM can step back between two readings, which sorted the third of three runs before the second; the tests now run on a clock that never repeats or goes back.
2026-11-13 00:31:57 +01:00
peter fe6af1445f fix(downloads): GIF review round 2 — cap as sent, shown length, retry, Join reason
The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
2026-11-13 00:19:53 +01:00
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
2026-11-12 23:24:25 +01:00
peter 0b92b40db5 fix(downloads): a GIF's size warning judges the size it shows 2026-11-11 02:19:56 +01:00
peter 27ccf253f5 feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself
The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected
to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB).
A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF
per kept segment; Join says why it is unavailable instead of going dead. The share page shows a
picture as itself with its Download, and a picture in a folder opens above the list.
2026-11-11 02:10:07 +01:00
peter c221452a00 feat(downloads): a clip or an edit can be a GIF, shared as a picture
A clip and an editor range can come out as a silent GIF (long side
320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB
is not kept. A shared picture is served inline under the preview's
sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still
for Facebook's crawler.
2026-11-11 01:50:55 +01:00
peter fe38c07e7e test: the sub-view census reads each source once
Read three times, the scan ran past the 5 s test timeout when the gate
loaded the machine.
2026-11-11 01:50:54 +01:00
peter 45b5e19fb8 release: 0.118.0 (improvement/e59-small-residuals) v0.118.0 2026-11-08 01:23:33 +01:00
peter e08278fae2 chore(release): 0.118.0 2026-11-08 01:05:31 +01:00
peter 350913b6c2 test: a watch's weight by age, and each contrast test under its reason
`draw.recency` weighs every watch of both affinities: an undated watch
and a future one now have a test (deleting either branch fails it). The
over-the-picture contrast test moves below the form-on-chrome test, so
each sits under the comment that explains it.
2026-11-08 00:49:22 +01:00
peter 470caacfb0 fix(plex): the slow line says both rates in one unit
Each rate took its own unit, so a link under a megabit read "needs 1.4
Mbps, 800 kbps arrives". `fmtBitrates` picks the unit the largest takes
for all of them. The away-window header now says what ends an absence
outside hls.js (the answer) and after the error (the viewer's retry).
2026-11-08 00:49:22 +01:00
peter e8b9bdd7c1 fix(playlists): "+N without length" counts every video the sum left out
The label said "without length" but the count held only live videos, so
a video not fetched yet, or gone before it was, was left out of the sum
and named nowhere. The server's `Video.without_length` and the client's
`isWithoutLength` are now one rule (no length, whatever the live
status), and the field, the key and the helpers are named for what they
count. The comments that still quoted "+N live" say the current copy.
2026-11-08 00:49:21 +01:00
peter 55cbbfb6e4 fix(channels): the Length column shows the sum alone
The "+N without length" line did not fit the 4.5 rem column (88 px of
text in a 56 px cell) and spilled into Tags. The owner wants no such
line there: the cell shows the summed hours, and the channel list no
longer sends a count nothing reads. A playlist's total keeps it.
2026-11-08 00:10:28 +01:00
peter 4d058cba81 fix(plex): an error over the picture is readable on any frame
The error line kept the theme's red, which a light theme makes dark: 1.6:1 on the 75 % panel over a
white frame. The panel is now black 85 % under media-scope, so the error is the media red in both
themes (5.47:1 at worst, white text too); a contract test measures it on the composited panel.
2026-11-07 22:04:22 +01:00
peter 872b583f06 fix(card): a total's left-out videos are "without length", not "live"
The count holds archives and upcoming streams too, which a card names a stream or upcoming.
2026-11-07 21:58:36 +01:00
peter 90931b4750 fix(plex): a playlist that never answers is asked for again; the slow line reads this stream
A playlist timeout was resumed in the same session, but hls.js reloads no manifest on startLoad: no
request, no error, a spinner for good. It is now asked for again like a server away. An absence
also ended only on a fragment, which native HLS never reports, and a viewer trying again after the
error started with the spent window; both now start a fresh one. The slow-connection meter was
reset only when hls.js was built, so a direct answer showed the previous stream's numbers, and a
replaced session's late fragment still counted; its rates now use the stats overlay's units.
2026-11-07 21:58:36 +01:00
peter a6f1a26658 docs(plex): the transcode comments say the CPU-decode route 2026-11-07 21:48:39 +01:00
peter 4ed07388e8 test(feed): an off-catalog video's length, and async readers of the raw column
The YouTube lookup's length had no test, and the guard on who reads the raw duration skipped async
functions and module-level code.
2026-11-07 21:48:38 +01:00
peter bb91d0a22d refactor(discover): both affinities weigh a watch by one rule
The YouTube channel affinity and the Plex one each had the recency decay, the undated-watch rule and
the scale to the strongest; app.draw now holds them. Tests hold that another user's history leans
neither draw, the rail's blind slot, and that a show leans on show recommendations.
2026-11-07 21:48:38 +01:00
peter e7e8d8236e fix(downloads): the egress filter keeps what yt-dlp fetches itself, and shows no phase
f4m, ism, a bare m3u8 (native under hls_prefer_native) and the DASH generator fetch through the
proxy, but were dropped as if ffmpeg dialled them. The filter also took the job's postprocessor
hook, twice, and painted every guarded job "processing" before a byte arrived. Tests now hold an
allowed tunnel, the worker's own gate, the single-URL branch, and that the pipeline and the
backfill turn the gate on while YouTube does not.
2026-11-07 21:48:38 +01:00
peter fc65b8ab83 fix(playlists): a refused push spends none of the push allowance
The cooldown was checked before the list was found pushable and before its sync claim, so Watch later
or a list another sync held used up the push allowance. It now counts only a push that runs. The
docstring that called a YouTube mirror unpushable is corrected: its local edits sync back.
2026-11-07 21:48:38 +01:00
peter 26ed8855c9 fix(feed): an out-of-range seed or cursor is refused, not a 500
A seed past a float overflowed the shuffle's start point, and a cursor holding Infinity or a 400-digit
key overflowed its int/float read: both answered 500. The routes bound the seed (422), the start
point folds it to 32 bits, and the cursor read counts an overflow as a bad cursor (400). A plan test
holds that the walk reads ix_videos_shuffle_key with no sort over a realistic scope.
2026-11-07 21:48:15 +01:00
peter 9778bdf351 fix(plex): a codec the GPU cannot decode is decoded on the CPU and still encoded on the GPU
The prod iGPU has no VC-1 Advanced decode ("No support for codec vc1 profile 3"), so every VC-1
session died on the GPU and fell back to libx264, which needs ~17 s for the three warm-up segments:
a start or a seek took ~19.5 s. VC-1, XviD and DivX now take CPU decode -> hwupload -> h264_vaapi
(three segments in 5.5 s for a 1080p VC-1 remux, measured on prod hardware with this argv).

The transcode_hw facet now says whether the GPU encodes: VC-1/XviD/DivX count, and so does an HDR
film (the OpenCL tonemap runs on the GPU; the facet said software). Migration 0119 rewrites the
stored facet and re-queues 10-bit items for the HDR probe.
2026-11-07 18:39:15 +01:00
peter 2b7620ded9 fix(plex): text over the picture sits on a dark panel
The player's loading, stall and error text was bare white or red on the video,
unreadable over a bright frame. It now sits on the dark panel the subtitle
notice and the track OSD already used, one constant for all three. DESIGN.md
records it and the slow-connection line.
2026-11-07 18:01:15 +01:00
peter c0682aa572 fix(plex): a link too slow for the stream resumes in place, and says so
A request hls.js got no answer to in time (no status) was taken for a server
away: a new session, the same first segment lost again, a restart every half
minute that never ended, since every session answer reset the away window.
Measured on prod: a work network at ~4 Mbit/s against 11-16 MB remux segments.

A timeout now resumes loading in the same session; the away window ends only
when media arrives (or a direct stream answers), and runs out in a 'too slow'
error. A segment gets 30 s to its first byte. After 5 s of stalling on a link
measured slower than the stream, the player says so with both rates.
2026-11-07 15:09:22 +01:00
peter 25aed6635f perf(feed): a shuffle walks a stored random key instead of sorting the whole scope
Every video carries a random number drawn once (videos.shuffle_key, migration 0118, indexed with
id). A seed picks a point on that ring: the feed's Shuffle sort pages along it and wraps, and the
Home surprise reads a 500-video window of it and shuffles that window by the seed, so no video is
favoured for sitting after a wide gap. The md5(id || seed) order read and sorted the whole feed
scope on every call: ~350 ms on prod per surprise, 381-767 ms per Shuffle page on localdev, now
14 ms and 4 ms.
2026-11-07 04:00:00 +01:00
peter 9310ff85bf feat(discover): Surprise me leans on what you watch, and stays a surprise
The Home hero's surprise tier and the Plex Discover rail were uniform draws over the unseen. They
are now weighted draws (`app.draw`, Efraimidis-Spirakis over the same md5 shuffle): a title's
weight is 1 + its affinity. Plex: the genres of the latest watched titles (half-life 180 days, a
show counts once) and the TMDB recommendations stored on them (`plex.affinity`). YouTube: the
channels the user watches (half-life 90 days). One slot of three stays a blind draw, and with no
history the order is exactly the old one. No visible change.

The YouTube draw builds its pool once (a materialized CTE read twice): the scope is ~200k videos on
a prod-sized catalog, and a second ordered pass doubled the cost. Measured on localdev: a reroll
210 -> 292 ms.
2026-11-07 02:41:30 +01:00
peter 5b846136ad fix(catalog): a 24/7 stream archive's years-long length is unknown, not summed or sorted by
YouTube reports a 24/7 stream's archive with the years it has run (prod: 122,962,914 s), and one
such video made the Watch later total read 44,073 hours. A length past a day is now no length
(`Video.length_seconds`, LENGTH_MAX_S): the API sends none, the playlist and channel totals
leave it out and count it as `live_without_length` ("+N live" beside the sum), and the duration
filter, sort and Home budget never match it. Migration 0117 moves the duration sort indexes onto
the length expression. An AST test holds who may still read the raw column.

The migration 0110 test now ends its read before the way down: 0117 builds an index
CONCURRENTLY there, which waited for that open transaction forever.
2026-11-07 02:20:03 +01:00
peter 1bf3d6a592 fix(downloads): a download that hangs up mid-transfer is not an error at the egress gate
Seen live: yt-dlp closes a connection once it has read what it needed, and the
gate printed a traceback for each. A reset ends the tunnel quietly; anything
else the gate cannot finish is a log line with its code, not bare stderr.
2026-11-06 05:23:02 +01:00
peter 4e4fff87d4 fix(downloads): a user's download is judged at every connection it makes, not only at the URL typed
The URL guard judged the address the user typed; a public host's 3xx into our
network, a page's formats pointing inside, and a name rebound between the check
and the fetch all got past it, and the stored error told a live internal port
from a dead one. Every non-YouTube download now runs through an egress gate on
the worker's loopback: it resolves each host once, refuses any forbidden answer
before dialling, and connects to the address it vetted. Measured on the worker
image: yt-dlp's proxy reaches the Requests and CurlCFFI handlers, native HLS and
ffmpeg alike. Formats fetched over anything but http(s), which ffmpeg would dial
directly, are dropped, and one factory opens every yt-dlp so none skips it.
2026-11-06 04:54:56 +01:00
peter 995a493bd3 fix(playlists): the push side spends its own allowance, so a scripted loop cannot drain the shared YouTube budget
push-plan reads the live list and push reads and writes it, both against the
instance-wide budget, and neither had a limit. They now share one per-user
allowance of 20 calls per 10 minutes, far above a person's plan-then-push,
refused as playlist/push_cooldown with a sentence in both languages.
2026-11-06 04:11:36 +01:00
peter 5a93a9a8e7 release: 0.117.0 (improvement/e57-files-and-small-ideas) v0.117.0 2026-11-06 03:09:58 +01:00
peter 911fea3243 release: 0.117.0 2026-11-06 02:57:39 +01:00
peter 4aece8aff7 docs(design): log the Hungarian unlock button's noun form 2026-11-06 02:46:27 +01:00
peter adb5e94511 fix(watch): the Hungarian unlock button says a noun for a video too, as for a file
"Megnézem" (a first-person verb) and "Megnyitás" (a noun) labelled the same button by link kind;
the app's Hungarian buttons are nouns, so a video's reads "Megtekintés".
2026-11-06 02:46:18 +01:00
peter 673decf4d4 fix(channels): a pick in the +N fold keeps the focus on its chip after the fold closes
The closing popover hands the focus back to "+N" in a microtask it queues as it unmounts, after the
chip was focused (seen in Chrome; jsdom does not show it). A second focus, queued after it, wins.
2026-11-06 02:32:49 +01:00
peter 4d1cf66d96 fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2
- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a
  subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its
  hover list count the same channels as the chip; /api/tags sends both counts under their names.
- A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in
  the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place.
  The fold is a dialog of toggle buttons, not an empty menu.
- Upload: a folder name left by a press is made once the press is over, if the dialog is still
  open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place
  holds the focus and a live region says so.
- Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached
  list, so a reopen makes no second one; the New link form survives the link made on open.
- Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with
  their rail.
- An unsized clip of a rotated file gets the upright size its crop cut.
2026-11-06 02:20:52 +01:00
peter 2baad2ebae fix(files): a folder made from the upload dialog gives the keyboard back the target once it is made 2026-11-06 00:23:09 +01:00
peter 8383459f70 fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim
- Share: a link that expired is never copied (the dialog and one-click Share pick a live one,
  else make one), says it expired, and the copy-on-open claim clears on the next copy.
- A password-protected file link asks to open a file, not to watch a video.
- Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed
  refetch keeps the target; a folder being made says so.
- Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop;
  the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested.
- Auto-skip: the wait gives way on a marker too short for it, so the skip still fires.
- Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus.
- Continue watching's See all sorts by last watched when in progress was already on.
2026-11-06 00:09:39 +01:00
peter c34f6f3297 fix(channels): picked tag chips lead the row, so the +N fold never hides an active filter 2026-11-05 22:34:12 +01:00
peter e4b6d918e4 fix(plex,share): Recently added's See all opens newest first, and a share link says open
See all on Recently added opened Browse in whatever sort was last used, by title on the account it
was seen on. The share dialog said anyone can watch a link that may hold a zip.
2026-11-04 19:43:02 +01:00
peter bf5cf011ce feat: tag chips count their channels, Plex shows a file's size, a skip waits, Continue watching opens whole
The channel manager's tag chips carry a muted count. A Plex item's Info page names the file's size.
An auto-skip's countdown starts 3 s into the marker, with its button there from the start and a delay of
0 still immediate. Continue watching has a See all, opening Browse on in progress, last watched first;
Recently added's sets the watch state back so it opens what it says.
2026-11-04 19:27:46 +01:00
peter 6e6e47652a feat(files): the upload dialog says where the file goes, and sharing copies on open
The upload dialog shows its target folder and can change it, with New folder in the same list; a
file never goes while a folder is being named or made. The share dialog puts the link on the clipboard
when it opens, says so only if the browser let it, and has Open in new tab beside Copy. A file's
format joins the line under its name.
2026-11-04 19:16:20 +01:00
peter 4c09a22add feat(share): a link's address says what it opens
A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token
is the whole key, so every path opens every link and the links already sent keep working. The new
paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a
file a file.
2026-11-04 19:10:09 +01:00
peter 2bdf1ec76f fix(editor): a crop of a video with wide pixels keeps what its frame showed
The browser reports such a video by its shown size and ffmpeg's crop counts stored pixels, so a
frame over the middle half of a 320 stored, 640 shown video kept the whole width. A crop now carries
the size it was measured on and is scaled to the file's own pixels. A spec from before it did is cut
as it always was. Also drops a stale note in downloads/quota.py: edit jobs are held to the cap.
2026-11-04 19:01:49 +01:00
peter f7e036abef merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry) 2026-11-04 00:53:23 +01:00
peter f2de8f73aa test(e2e): a page load the machine had no buffer for is tried again
net::ERR_NO_BUFFER_SPACE stopped a release at its first page with nothing else running, and the
unchanged rerun passed. Every page load of the suite now goes through one helper, held by a test.
2026-11-04 00:26:50 +01:00
peter 643f305814 release: 0.116.3 (bug/lost-stylesheet) v0.116.3 2026-11-04 00:19:42 +01:00