51 Commits
Author SHA1 Message Date
nvrl cf8ee58f6b feat(meta,core,app,android,ios): AniSkip where files have no chapters
Most files that are not fansub releases carry no chapters, so they had no
skip button. AniSkip knows where an episode's opening and ending are, by
MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList
id and enrichment now keeps it on every episode row; schema v14 adds the
column and marks AniList titles un-enriched, deleting nothing, as v13 did.

When a file has loaded with no chapter naming an opening or ending, and
its length is known, each client asks once, with enrichment on only. The
answer lives as long as the file is open. with_skip_ranges cuts it into
the chapters as chapters named Opening and Ending, so the skip button,
auto-skip, the up-next countdown and the seek bar's marks all work
through the code a chaptered file uses. A file's own opening or ending
always wins over AniSkip's.

PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and
the file's length.
2026-10-31 03:13:41 +01:00
nvrl 7bfebc5fd5 feat(meta,core,app,android,ios): cast and trailer
AniList's fragment now asks for the first ten characters, main roles
first, with their Japanese voice actor, and for the trailer's site and
id. Both are stored in the existing details JSON as TitleDetails.cast and
.trailer with serde defaults, so no migration is needed and details
stored before them still read. The details REVISION goes to 2, so a
stored match is refetched by its id on the next metadata run and the
cast fills in without anyone matching the title again.

The title page shows a Cast row of round portraits with the character's
name over the actor's. Clicking a tile opens the Person browse page for
that actor, which now also admits voice actors, and search counts them
under People and studios. A Trailer button opens the YouTube or
Dailymotion page in the browser; the app itself makes no request to
either, and an unknown site or an id with odd characters gives no link.
2026-10-31 01:56:41 +01:00
nvrl 87ccaf26e1 feat(app,android,ios): search across episodes and people
A search now also finds, with posters and descriptions on, the directors
and studios and the genres whose names hold it, as chips that open their
page, and the episodes whose provider names hold it, which play from the
result (pstr_core::discover::find). The titles it found follow under
their own heading.

The last eight searches are kept in recent-searches.json on this device
only (pstr_core::recent), written only when a search led somewhere, and
offered under the empty search box with a way to clear them. The bridge
exposes search_extras, recent_searches, remember_search and
forget_searches.

On Android and iOS the library's titles are now the whole library
whatever is searched for; the grid narrows through the arrangement, as
it already did. A title opened from an episode result can then always be
shown, and History and Downloads no longer shrink to the search.
2026-10-30 23:07:51 +01:00
nvrl fafe0c3087 feat(core,app,android,ios): more like this and browse by genre, studio, person
pstr_core::discover scores every title against another by shared
director, studio, genres, leading tags and format, leaves out its own
franchise and keeps one title per other franchise. It drives a "More
like this" row under a title's episodes and, seeded by the title most
recently watched to the end, a "Because you watched" row on the library
of titles not yet started.

Genre chips on the title page now open a page of every title with that
genre, and the studios and director move from the line of facts onto
chips of their own that do the same. The desktop has a Browse page whose
back link returns to the title; Android and iOS show it over the library
pane. The bridge exposes more_like, browse and facet_kind.

Also moves a doc comment the My List commit left on the wrong function.
2026-10-30 22:46:08 +01:00
nvrl f66faf6b75 feat(core,app,android,ios): My List, synced, and New badges with new-episode rows
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
2026-10-30 22:15:51 +01:00
nvrl cd9e8eea81 feat(app,android,ios): status, season dropdown, watched ticks
A title page now says where the show stands as a chip beside the
quality marks: Airing with the day the next episode comes out, Finished,
Upcoming, On hiatus or Cancelled. The words come from
TitleDetails::status_label, which the bridge hands the phones as
TitleRecord.status; the airing note leaves the line of facts.

A show with more than five seasons picks its season from a drop-down
rather than a row of pills that ran off the page, and a watched
episode's still carries a tick in its corner as well as the mark at the
end of its row.
2026-10-30 21:34:47 +01:00
nvrl c6d303f269 feat(app,android,ios): full-bleed hero carousel with ambient colour
The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
2026-10-30 21:19:37 +01:00
nvrl f4c08d03c7 feat(core,app,android,ios): quality badges from file names
Release names say what their files are — 2160p, HDR, 10-bit, Dual-Audio —
and nothing showed it. naming::quality reads those words, whatever they
are separated by, and Title::quality keeps what more than half a title's
files state, so one odd special does not mark a show down. The facts line
of a title's page ends with them: 4K, 1080p, HDR, 10-bit, Dual audio.
Release groups' names are never read.
2026-10-29 16:56:19 +01:00
nvrl 70952fa7ca feat(core): episode titles from dotted release names
Scene releases name the episode in dotted words before their tags:
Game.of.Thrones.S01E01.Winter.Is.Coming.2160p... Read the words up to the
first quality, source or codec tag; a dotted run that no tag ends is not taken.
2026-10-29 16:33:16 +01:00
nvrl 3ca431feca feat(core,meta,app,android,ios,cli): TVmaze for the shows AniList does not have
With AniList chosen, a title AniList has no match for is now searched on
TVmaze, keyless, before it is given up on. One provider for the whole library
left live-action shows in a share of anime unmatched, and switching to TMDB
would have dropped every AniList match, chain and shelf.

The service holds a chain of providers. A match is stored under the provider
that gave it, and enrichment and refreshes go to that provider; a miss is
stored under the last provider asked, and is_fresh only trusts a miss from the
end of the chain, so AniList misses stored before this are asked again at once.
A provider failure still ends the lookup instead of falling through.

The hand matcher searches every provider in the chain and labels each result
with its source. TVmaze is never offered in the settings and is not asked
about titles known to be films.
2026-10-29 16:33:16 +01:00
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00
nvrl 648c35e92a fix(app): the subtitle preview is drawn in the chosen font
The preview drew every font in the app's own Inter, so picking one changed
nothing on screen. On Linux it now asks fontconfig for the face libass would
use — the family with the weight and slant wanted, or fontconfig's fallback
when it is not installed — loads it on a thread of its own and draws the
sample in it. A bold or italic the face lacks is made up, as libass does.
Windows and macOS have no fc-match and keep the app's font, as the hint there
still says.

The CJK fallback and the subtitle face are now both kept, so installing one
no longer drops the other.
2026-10-27 23:08:43 +01:00
nvrl ffd6e959bb fix(core,player,app): styled subtitles keep their size and place unless restyled
mpv's default sub-ass-override, `scale`, hands an ASS track sub-scale,
and sub-pos reaches it under every mode but `no`. So with "Restyle
styled subtitles" off, the subtitle size and position settings still
resized and moved typeset dialogue and signs.

The override is now `yes` when restyling is off, which keeps sub-scale
off ASS. A styled track also ignores the style's position, and moves
only to stay clear of the player's controls. A frame from an ASS track
rendered with every other option changed now matches the untouched one
pixel for pixel. Plain (SRT) tracks still take the whole style.
2026-10-27 22:44:35 +01:00
nvrl 69d961d7b8 feat(core,player,app): full subtitle styling, applied live, with a preview
The subtitle style was three fixed choices. It is now size, position,
font, bold and italic, free colours with opacity for the text, outline,
box and shadow, outline thickness, shadow distance and blur, and an
opt-in "Restyle styled subtitles" that repaints ASS dialogue too
(sub-ass-override=force). The defaults are mpv's own, so a viewer who
never opens the section sees no change.

Settings has a section of its own with a sample line drawn on a bright
and a dark picture. Every change also reaches the playing file at once,
and is written when the mouse is let go rather than once per frame of a
drag. The player's subtitle menu has size, position and a per-file
delay; Z and X shift subtitles by 0.1 s, as in mpv.

Every option is sent every time, so a change back to a default lands on
a live player. A refused sub-border-style still skips the border options
that depend on it, so an older libmpv loses the box rather than drawing
a smear.
2026-10-27 22:25:53 +01:00
nvrl 238ecd8220 fix(core,meta): a folder of films is one title per film
The four Rebuild of Evangelion films in one folder were one series:
`- 1.11` read as episode 1, and every file took the folder's name, so
the whole folder matched a single film. A dash followed by a decimal is
now part of the film's name, and a folder whose files are all
unnumbered, outside any grouping folder and each named differently
gives each file its own title.

AniList needs every query word in a title, and a film's version number
is not in it, so a search that finds nothing is retried without it.
All four films now match their own entries.
2026-10-27 19:50:26 +01:00
nvrl 65a669e231 feat(core,player,app): subtitle size, background and colour settings
Plain-text subtitles (SRT) were drawn in mpv's bare default, thin white
text that bright scenes swallow. Settings → Playback now picks a size,
an outline or a dark box, and white or yellow text. Styled (ASS) tracks
keep their own look and take only the size.

The box options are mpv 0.38's, so they are set best effort: an older
libmpv logs and plays on. The style syncs with the other playback
settings, and the Android bridge keeps the stored style instead of
resetting it on save.
2026-10-27 19:50:21 +01:00
nvrl 4a3a7291b8 chore(deps): proton-sdk and proton-drive-rs 0.9.0
Adapt sign-in to the 0.8 account API and use what it adds:

- Key passphrases are KeyPassphrases now, wiped on drop. The stored
  session keeps its JSON shape and converts on unlock.
- The mailbox password is checked with AccountClient::unlock(), one
  core/v4/users request instead of loading every address, and a wrong
  one is recognised as ProtonError::KeysLocked instead of any
  InvalidOperation.
- KeysLocked from a resumed session counts as signed out: the mailbox
  password changed, and only signing in again derives passphrases that
  unlock the keys.
- A sign-in Proton answers with code 2028 (Blocked) says so, instead of
  showing the server text that claims the app version is outdated.

Also picked up without code changes: a token refresh that outlives its
caller and is never replayed (0.8.2).
2026-10-15 19:56:56 +02:00
nvrl b1ea076ea3 feat(core,app,android,ios): play the dialogue, not the signs and songs
Anime releases often carry two subtitle tracks in one language, the
dialogue and a "Signs & Songs" track, and the second is as often as not
the one marked default or forced. mpv, picking by language, played it.

pstr_core::subtitles reads a track's name and forced flag for whether it
is the dialogue, and every client asks it once per file, after mpv has
made its own pick, whether to switch to a track in the same language
that is. A subtitle track the viewer chose by name stays, and subtitles
that are off stay off.
2026-10-14 18:18:40 +02:00
nvrl f0fe90f0fe feat(core,app,cli): pair subtitle files with their video and play them
A crawl now keeps .srt/.ass/.ssa/.vtt files and pairs each with a video by
name: the video's name followed by a dot or nothing, the same inside a
Subs folder, the Subs/<video>/ layout, and failing those the only video in
the folder. The words past the video's name give the language and the
picker's title. They go in a new sidecars table (schema v12), rebuilt by
every crawl.

The desktop opens them while mpv opens the video and adds them with
sub-add through the stream registry once the file has loaded. One in the
preferred language is turned on when subtitles are on and the file has
none of its own playing; the rest wait in the subtitle menu.
2026-10-13 21:11:20 +02:00
nvrl 51454b5a6e refactor(core,player): move the language table into pstr-core
The crawl needs it to read a subtitle file's language from its name
(en, eng, English), and pstr-player re-exports it unchanged.
2026-10-13 21:11:20 +02:00
nvrl 85bc975f5f fix(core,android,app): say why a crawl failed, not only which URL
A transport failure's text is reqwest's "error sending request for url
(…)", and the cause sits further down its source chain. Error::describe
appends the causes and drops the URL, so the reason fits on a share row.
2026-10-13 20:45:14 +02:00
nvrl 4f130d1d6e fix(core): list a folder again when its connection drops mid-crawl
A crawl is hundreds of listings and the first failure ended it. The SDK
replays a timeout or a refused connection, not a request whose connection
dropped, which is what a phone sees after the OS tears down its sockets or
hands over between networks. A listing is read-only, so the crawl retries
it after 1, 3 and 9 seconds.
2026-10-13 20:45:14 +02:00
nvrl abb3e3aaf8 feat(core,app,android,ios): rename a share
ShareStore::rename changes only the name; the id that the catalog, watch
state and offline files are keyed by stays the same. Sync carries the
change as it does any other change to the share's register. The desktop
renames in the share's row. The phones rename from the share's menu.
Before, the name given at add time was permanent short of removing the
share.
2026-10-13 16:01:11 +02:00
nvrl eb67df0aa0 feat(core,app,android,ios): filter the library on every client, including by download
The library filter moves from the desktop into pstr_core::browse and
gains a Downloaded filter: titles with at least one episode kept on the
device. The bridge's arrangement() takes the filter. Android and iOS get
a row of filter chips above the grid. The choice is remembered per
device, and shelves stay hidden under a filter, as on the desktop.
2026-10-13 15:36:29 +02:00
nvrl 89e55aac12 feat(core,app,android,ios): finish adding a first share where it pays off
- A link with a password of its own, added without one, now fails as
  "This link has a password of its own" instead of the handshake's
  refusal. The link's public flags are only asked for after a refusal.
  The desktop form turns its password toggle on.
- After the first share is added, all three clients go to the library.
  While that share is being listed, the empty library says so instead of
  offering to add a share.
- The desktop library offers posters and synopses once there is a
  library to put them on. The offer says what is sent, links to
  Settings, never turns anything on, and "Not now" is remembered.
2026-10-13 15:07:41 +02:00
nvrl d154002109 feat(core,app): open the library the way it was last arranged
Filter, sort and franchise folding reset on every launch, so someone who
browses by Recently added chose it again each time. They are kept in the
desktop's own settings file, alongside the cache budget.
2026-10-12 22:57:12 +02:00
nvrl 603cc0c1a9 feat(core,app,android,ios): add a first share without a detour or a name to invent
The empty library said to add a link "under Shares, then refresh" with
nothing to press on the phones, and on the desktop only switched page.
All three now open the add form from there, the desktop with the caret
in the link field; and adding already crawls, so there is nothing to
refresh.

A share's name was required before the viewer had seen anything to name.
It is optional now: without one, ShareStore::add_verified takes the
shared folder's own name from the link it has just opened. The link
comes first in each form. The desktop form submits on Enter, and the
Android link field has an eye to check what was pasted.
2026-10-12 22:37:17 +02:00
nvrl 5807493a4a feat(core,app,android,ios): look at shares again on their own, and say when they were last looked at
No client crawled a share unless someone pressed Refresh, so a new
episode stayed out of the library until then, and nothing said how stale
the library was.

Catalog schema v11 adds share_crawls: when each share's last crawl
finished and was attempted, how many files it stored and how many were
new, and why the last attempt failed. A failure keeps the last good
crawl's numbers.

Every client now crawls, quietly, any share not looked at for six hours:
the desktop at launch and every half hour while open, the phones at
launch and on their existing catch-up timer. A quiet crawl says only
news ("anime: 3 new files") and leaves failures on the share's row
rather than in a toast.

Share rows on all three show "120 files - crawled 2 h ago", or the last
failure until a crawl succeeds.
2026-10-12 22:22:25 +02:00
nvrl a306d02cf1 feat(core,app,android,ios): keep a show in Continue watching after an episode ends
Continue watching listed only part-watched episodes, so finishing one -
the most ordinary moment to want the next - dropped the show from the
shelf. Title::continue_with now reads the episode touched last: a
part-watched one is resumed, a finished one offers the episode after it
("Next: S01E04"), and one reset to the start (what leaving the shelf
does) keeps the title off. A few seconds of an episode are passed over.

The phones computed their own shelf from episode records. The bridge now
exports the engine's choice as TitleRecord.continue_index, and Play on a
title lands there on every client.

Undo on the phones re-saved a watch state with a fresh time, which both
reordered the shelf and could not express "never played". A new
restore_watch_state puts the state back as it was read.
2026-10-12 22:05:05 +02:00
nvrl 9e53a9bbdb fix(core,app,android): refresh an expired visitor session on the desktop too
The age check and the one retry behind a refresh (B22) lived only in the
Android bridge. The desktop opened streams, offline copies and crawls
against whatever session the share was opened with, so a window left open
for a few hours failed to play or recrawl until it was restarted.

The logic moves to pstr-core as SessionAges, owned by SharedLibrary and
reached through SharedLibrary::reauthenticating. Both engines go through
it for play, warm, offline copy and (desktop) crawl.
2026-10-12 21:32:05 +02:00
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00
nvrl 8e42906a7f feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it
Sync carried only watch positions, so a second device started empty. The
per-installation history file now also carries per-key registers
(pstr-core::profile): every share with its link and custom password, the
playback, appearance and metadata settings, the TMDB key, hand-picked
matches and per-title track choices. Newer updated_at wins per key, a
removal travels as an empty register, and a first sync stamps at 0 so a
fresh install takes the library the other devices have instead of
overwriting it with defaults. Volume, mute and the device-only settings
(cache budget, Wi-Fi only, background audio, hardware decoding) stay out.
The secrets live only in the end-to-end encrypted Drive file, never in
shares.json. FORMAT_VERSION stays 1; an older build ignores the registers.

Adding a share stored it before opening the link, so a first failure left a
broken entry and every retry said "already configured". add_verified opens
the link first and stores nothing on failure, and only the new share is
crawled afterwards, so another broken share cannot fail the add.

Android drops picture-in-picture: the window sized the video wrongly and
background audio already covers leaving the app.

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
2026-10-09 03:15:39 +02:00
nvrl da16ab0575 fix(core,app,android): keep the password out of storage and refuse key-only 2FA
Moves to proton-sdk / proton-drive-rs 0.7.3.

The stored Proton session now holds, per account key, the passphrase derived
from the mailbox password at sign-in, and resumes through
`ProtonDriveClient::with_key_passphrases`. The mailbox password — on a
single-password account, the login password — and the key salts are no longer
stored. A session in the earlier shape, which only development builds of
1.2.0 wrote, reads as signed out: signing in again replaces it.

An account whose only second factor is a security key is refused at sign-in
with a reason, rather than asked for a code that can only fail as a wrong one.
The code prompts now say plainly that security keys are not supported.
2026-10-06 15:01:37 +02:00
nvrl d9ca8f8d60 fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
2026-10-06 14:30:15 +02:00
nvrl 0b2813cd42 feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

Watch history syncs through a `proton-stream` device in that Drive: one
`watch-history/<installation>.json` per installation, so no two devices ever
write the same file. Each sync reads the others and merges per episode, newer
`updated_at` wins, with a deterministic tie-break. It runs at launch, after
sign-in, whenever the player closes and every five minutes. A session that can
no longer refresh signs the app out instead of failing on every launch.

The CLI gains `login`, `logout`, `account`, `browse` and `sync`.

Also in this change:
- Android offline downloads no longer look stuck (B64): two transfer at once,
  through a copy stream with no read-ahead, with a smoothed transfer rate.
- Choosing a metadata match by hand now enriches the title too, instead of
  keeping the old match's episodes and artwork.

The catalog schema is unchanged.
2026-10-06 13:06:54 +02:00
nvrl 3638e0e5df feat(core,app,android): sort, fold and shelve the Android library
Bring the phase 2 library to Android. Search, sorting and franchise folding
move from the desktop app into pstr_core::browse, so both clients find,
fold and order the same titles the same way.

The bridge gains AndroidEngine::arrangement, which returns the grid's tiles
and up to three shelves as keys into the library's titles. Each TitleRecord
now carries its display name, wide art, format, season, studios, directors,
tags, airing state and franchise. The library bar has a sort menu with the
franchise toggle, folded tiles show a +N badge, and shelves sit above the
grid. The title page shows the same facts, tags and franchise row as the
desktop. Settings -> Appearance gains Title names.

The default title name is now English, which is what Android always showed.
A folded franchise now sorts by its lead's displayed name rather than by its
alphabetically first member.
2026-10-04 02:04:15 +02:00
nvrl 6853725ca0 feat(core,meta,app,android,cli): sort, group and shelve the library
The AniList query now keeps the start date, season, banner, popularity,
status, next airing episode, tags, main studios, director and story
relations, as TitleDetails in one JSON column (schema v10). Matches
stored without them are fetched again by id, never searched, so
hand-picked matches stay; refreshing the same entry keeps its episodes
and fanart. Enrichment records the entries it walked. node_first_seen
remembers when each file appeared, across recrawls.

pstr_core::franchise groups titles that share a provider id. The grid
folds each franchise into its lead with a +N badge, and the title page
lists the whole franchise in release order. pstr_core::shelves picks
rows by airing status, director, studio and genre. A row needs three
franchises, so one franchise's titles never make a row on their own.

Desktop:
- Covers are the default grid (Settings > Appearance > Library tiles).
- The hero and title band use fanart or banner decoded at 1920 px.
- New sorts: recently added, newest, highest rated, most popular.
- Titles can show the share's, English or romaji name; search matches
  all of them.

Android carries TitleDetails through a hand-picked match; its screens
are unchanged.
2026-10-04 01:26:21 +02:00
nvrl 4c80076762 fix(core,meta,app,android,cli): number episodes the way releases do
AniList's per-season lookup searched for "<title> 2nd Season" and the
scorer picked the base entry again, so Attack on Titan seasons 2-4 and
Jigokuraku season 2 were captioned with season one's names. Episode
names came from streamingEpisodes, which is partial and numbered however
a streaming site counted. Bleach S17 and Mushishi S02/S03 had none.

AniList now walks SEQUEL relations from the match and takes each
entry's episodes from ani.zip, which carries TVDB's season, number and
absolute number. An episode row keeps all three plus its place in the
AniList entry. EpisodeGuide::get matches whichever numbering a file
uses and never answers from another season.

Episodes and fanart are stored as one enrichment. The episode list is
replaced even when empty, and the enrichment is recorded as asked, so a
film is not re-asked every run. Storing a different match drops the old
entry's episode rows. AniList's 4.75:1 bannerImage is no longer stored
as the 16:9 backdrop; ani.zip's 1920x1080 fanart is used instead.

Schema v9 adds the columns and deletes the AniList episode rows and
banners. Matches are kept. Desktop, Android and the CLI now share
service::plan and MetadataService::work. The settings text names
ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
2026-10-04 00:47:41 +02:00
nvrl e7eeaa518f feat(core,app): a history page
Every played episode, newest first, under Today, Yesterday, a weekday or a
date. A row shows the still (which plays it), the title (which opens it),
the episode, and where it stopped or that it was finished. Removing a row
resets that file to never played, with an Undo. History sits second in the
top bar, so Ctrl+1 to Ctrl+5 now run Library, History, Shares, Downloads,
Settings.
2026-10-02 23:17:36 +02:00
nvrl a432a93cf9 feat(app,stream): storage and hardware-decoding settings
- the streaming cache's budget (1-16 GiB) is chosen in Settings, shows
  what it holds, and can be emptied; a lower budget evicts at once
- hardware decoding can be turned off for a driver that shows green
  frames, from the next file
- both live in desktop.json rather than the playback prefs shared with
  Android, whose bridge record would reset them on every save. A file
  that will not parse is never overwritten
2026-10-02 20:16:27 +02:00
nvrl c60ffc7ab1 feat(app): up-next card with the next still and a countdown ring, remember the window size
The up-next card shows the next episode's still with the seconds left
running round it, and the still itself plays it. The window opens at
the size and maximized state it closed at; position is left to the
window manager.
2026-10-02 20:16:27 +02:00
nvrl ff362d0d08 perf(app,core): bounded image cache, shrunk poster cache, in-place watch updates
- ImageCache drops the picture asked for longest ago past 256 MiB and
  uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
  a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
  it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
2026-10-02 19:34:45 +02:00
nvrl 8f78f5c1b1 perf(stream,core): take the disk cache off the read path, warm file edges, report crawl progress
A block the player waits for no longer waits on its disk-cache write and
two fsyncs; the put runs in a spawned task and only the block itself is
synced before the rename. Opening a stream can fetch the first and last
block together, where MKV cues and the MP4 moov live. Downloads open a
copying stream that reads the disk cache but never fills it.

crawl_reporting reports the running file count after each listing.
2026-10-02 19:27:27 +02:00
nvrl 6f28a9c377 chore(deps) bump proton-sdk/proton-drive-rs to 0.6.3, version 0.4.2
Adds the new Node fields (direct_role, share_id) to the catalog test fixture and applies rustfmt to the track-prefs test.
2026-09-15 14:10:00 +02:00
nvrl d3c5f071fb feat(player,core): remember the audio/subtitle track by name across episodes
A track picked on one episode now carries over to the next by its title as
well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same
language stick. Catalog migration V8 adds the title columns; the Android
bridge keeps a stored title only while its language is unchanged.
2026-09-15 13:54:07 +02:00
nvrl ad24557588 feature(gui) themes + chore(deps) version bump 2026-08-15 14:49:04 +02:00
nvrl 46bb68e3a5 improved android ui 2026-08-09 19:12:41 +02:00
nvrl f1b3ad9637 improved android client, feature parity with desktop 2026-08-09 16:40:37 +02:00
nvrl 6db6909824 added android client 2026-08-05 13:07:56 +02:00
nvrl 14c48428a3 bug fixes, windows build 2026-08-03 09:09:40 +02:00