forked from Foxyz/sync_replay_project
Restructure step 4: local library and media identity
- L1 (core, offline): release names with kinds (episode, movie, extra: OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from parentheticals, ranges), one show across folders, gaps and next with skipped numbers, junk ignored, stable keys. - L2 (app, device-owned cache): AniList match with synonyms and sequel walking, episode mapping across cours (AnimeFlow runs when signed in), art and episode titles, "Wrong show?" pins per show or folder, episode offsets; lazy, spaced requests, works offline. - Files tab as a library: poster cards, show pages with banner, gap prompt, extras, movies, Wrong show? and Episode numbers. - media.ref produced for local files, Jellyfin (provider ids) and links, carried in swaps and Up next; receivers name media from it. - Hosting relays each Jellyfin item through its own server's login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d00b7455e
commit
bf9fc193b3
44 files changed
+4350
-891
No files matched your search
+112
-23
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| Path | What | Rule |
|
||||
|---|---|---|
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
@@ -48,9 +48,10 @@ attach and detach the network listener (below).
|
||||
- **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a
|
||||
second listener on every IPv4 interface at the port from Settings →
|
||||
Party & network (default 8080) to the running server (Relic serves the
|
||||
same routes on both), sets the password and the Jellyfin relay login
|
||||
(the default account's, so guests without an account watch Jellyfin
|
||||
items through the server); `unshare` closes it. Nothing restarts for
|
||||
same routes on both), sets the password and the Jellyfin relay logins
|
||||
(one per linked account, `relayCredentialsFor`, so guests without an
|
||||
account watch Jellyfin items through the server with your sign-in on
|
||||
the item's own Jellyfin); `unshare` closes it. Nothing restarts for
|
||||
either: members on the loopback listener keep their sockets, the media
|
||||
and Up next stay. Unsharing sends everyone but the host out: each
|
||||
leaves at once (feed `leave`, reason `closed`) and its socket closes
|
||||
@@ -133,7 +134,9 @@ only stopping sharing sends guests out.
|
||||
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
||||
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
||||
`state/connect_prefs.dart`), `YourServer` and `Navigation`; it wires them
|
||||
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
|
||||
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the
|
||||
coordinator as `fileRef`); it wires them
|
||||
through their constructors (and gives `Profile` the form and the push) and
|
||||
has no logic or signals of its own. Widgets reach it through one
|
||||
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in
|
||||
@@ -653,20 +656,22 @@ Gotchas (Jellyfin 10.11):
|
||||
## Jellyfin relay
|
||||
|
||||
For viewers without an account on the item's Jellyfin
|
||||
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
|
||||
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
|
||||
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
|
||||
isolate boundary in the `serve()` map). The app sends it when your server
|
||||
is shared (`share`): its own app-obtained token and that token's device
|
||||
fields; the dedicated server
|
||||
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
|
||||
(`packages/server/lib/src/jellyfin_relay.dart`). The server holds its
|
||||
logins as `JellyfinCredential`s (`ServerHub.jellyfinLogins`, at most one
|
||||
per Jellyfin `serverId`; each: `baseUrl`, `serverId`, `token`, `client`,
|
||||
`device`, `deviceId`, `version`; primitives, so they cross the isolate
|
||||
boundary). The app sends one per linked account (not known expired, the
|
||||
default first) when your server is shared (`share`): its own
|
||||
app-obtained tokens and their device fields; the dedicated server has one,
|
||||
read from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
|
||||
token off the command line) or a `jellyfin:` map in `--config`. An empty
|
||||
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
|
||||
relay stays off. The token is never logged (`toString` and config parse
|
||||
errors leave it out).
|
||||
`serverId` is looked up at start (`/System/Info/Public`); if that fails
|
||||
that login stays unused. Tokens are never logged (`toString` and config
|
||||
parse errors leave them out).
|
||||
|
||||
- Available (`state.relay`) only while the current media is a ref whose
|
||||
`serverId` equals the credential's; `state.relayNext` likewise for Up
|
||||
- Available (`state.relay`) only while the current media is a ref on a
|
||||
server one of the logins is for (`loginFor(serverId)`, which `/j` then
|
||||
uses); `state.relayNext` likewise for Up
|
||||
next's first item, which `/j` also serves so guests can preload it
|
||||
(see "Getting ready" under "Up next"). Only those two items are
|
||||
relayed, only to a valid viewer token: no open proxy.
|
||||
@@ -1282,6 +1287,73 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
in order, a link in the Link page's mode). All go through
|
||||
`QueueController.add`. None of it offline.
|
||||
|
||||
## Library
|
||||
|
||||
Local videos as shows, device-owned (the three-way truth: library, match
|
||||
corrections and watch history live on the device). Two layers:
|
||||
|
||||
**L1, local identity** (`packages/core/lib/src/library/`, pure and
|
||||
offline): `parseReleaseName` reads a file name (title, group, season,
|
||||
episode, year, extras: OP/ED, Opening/Ending, NCOP/NCED, OVA, specials,
|
||||
PV, Menu, numbered; movies: `Movie N`, `The Movie`, `Gekijouban`, a
|
||||
title with a year), `parseFolderName` a folder name (title, season and
|
||||
its name from a parenthetical read before brackets go, `(Season 3 -
|
||||
Alicization)`, an episode range `001-055`, a season-only or extras
|
||||
folder). `groupLocal(files, roots:)` keeps the videos (anything else,
|
||||
`.lnk` shortcuts included, is counted as ignored) and reads each
|
||||
(`readLocalFile`): the file's own name wins, the folders between it and
|
||||
its watched folder are hints, nearest first (a bare `01.mkv` takes the
|
||||
folder's title, a file under `Season 2` its season). Files group into a
|
||||
`LocalShow` by `normalizeSeries` of their title, across folders (Bleach
|
||||
in three folders is one show), with `LocalSeason`s as numbered (a null
|
||||
number is an absolute run, kept as found), extras and the show's movies;
|
||||
a movie of no show here stands alone, anything else is "other". Each
|
||||
season knows its holes (`EpisodeGap`: before the first episode, between
|
||||
episodes, and up to a folder range's end), so `LocalShow.nextAfter(key)`
|
||||
gives the next episode and the numbers skipped on the way. Keys are
|
||||
stable across rescans (`show|s<season>|e<n>`, extras and movies alike);
|
||||
ordering is deterministic (titles, then numbers, then names). The app's
|
||||
scan (`files/scanner.dart`, `FolderAccess.scan`) only walks and pairs
|
||||
subtitles; the Files page groups its `LocalFile`s.
|
||||
|
||||
**L2, match** (`apps/desktop/lib/src/library/`, `ShowMatcher`, made by
|
||||
the composition root, cached in the Hive box `library`, `LibraryStore`):
|
||||
a show's season (by show key, season and season name) is searched once
|
||||
on AniList (`AniListClient.searchAnime`: titles, synonyms, format,
|
||||
episodes, art, the SEQUEL / PREQUEL TV entries and episode titles) with
|
||||
the season's name or "season N" as hints; `scoreMatch` weighs titles and
|
||||
synonyms (`Re.Zero` finds `Re:ZERO -Starting Life in Another World-`),
|
||||
the season marker in a name (`4th Season`), the format (TV for episodes,
|
||||
MOVIE for movies) and AniList's own order; below a threshold nothing is
|
||||
kept ("none", asked again after 3 days). A season the search misses is
|
||||
the show's first entry walked along its sequels. Episode numbers
|
||||
(plus the user's offset) map onto AniList along the run: past an
|
||||
entry's episode count, its sequel's (a TVDB season AniList splits into
|
||||
cours, SAO S3 25 = War of Underworld 1), or, when signed in to
|
||||
AnimeFlow, its `anilist_map` runs for that season (which also give the
|
||||
TVDB id). Corrections win over everything and survive rescans: a pin of
|
||||
the show everywhere (`pin:show:<key>`) or of a folder and what is below
|
||||
it (`pin:folder:<path>`, more specific), and an episode offset per show
|
||||
and season ("these files are episodes N.."). Requests are lazy (cards
|
||||
and pages on screen ask, `want`), one at a time, 800 ms apart, paused a
|
||||
minute after a 429; failures are retried after 5 minutes; everything
|
||||
answers from the cache offline. Episode and show names, cover and banner
|
||||
come from the matched entry.
|
||||
|
||||
**Media identity** (`media.ref`, [PROTOCOL.md](PROTOCOL.md) "Media
|
||||
identity"): what a media is, independent of where its bytes come from.
|
||||
Producers: a local file, `ShowMatcher.refFor` (L1 kind and numbers, the
|
||||
match's title, AniList entry and episode, the TVDB id from AnimeFlow;
|
||||
L1 only before a match); a Jellyfin item, `jellyfinMediaRef` (kind,
|
||||
series title, season and episode, the AniList / Tvdb provider ids of the
|
||||
show or movie, `ProviderIds` in the item fields; from a pick's lines,
|
||||
`jellyfinPickRef`, when the item was not at hand); a link,
|
||||
`linkMediaRef` (its name read as a release name, else a movie of that
|
||||
title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds
|
||||
carry it; Up next names a file item by it. Consumers: receivers' now
|
||||
playing title and episode line (`refTitle`), the swapper's art (by the
|
||||
AniList id); later the watch tracking keys on it.
|
||||
|
||||
## App events and platform ports
|
||||
|
||||
**Status.** What the session is like right now is one computed value,
|
||||
@@ -1558,10 +1630,24 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
that fails although the kept preview looked fine, the preview is
|
||||
probed again and shows why, with "Put it up anyway" when it is a
|
||||
problem. Files
|
||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
click opens one and open ones stay open for the app session
|
||||
(`showFilesFolder` opens a folder from outside). With nobody else
|
||||
is `FilesPage` (`files/`), a library over the watched folders
|
||||
(`FolderStore`, each scanned in an isolate when first needed): places
|
||||
on the left (chips when narrow): Library (every folder together, the
|
||||
default), Recent, then each folder (`showFilesFolder` opens one from
|
||||
outside). A place is poster cards (2:3, like the Jellyfin browser's;
|
||||
the AniList cover, else a frame of the first episode) for its Shows and
|
||||
Movies, then the Other videos as 16:9 cards; a show card says how many
|
||||
episodes, or the next one after the last played here. A click on a show
|
||||
opens its page (`files/ui/library_view.dart`, kept open per place for
|
||||
the app session, Back returns): a banner header (AniList banner, else a
|
||||
frame) with the proper name, what the files are (seasons, episodes,
|
||||
extras, group, folders), the match ("AniList: …", "files say …",
|
||||
pinned), the gap prompt ("66–107 aren't in your folders — skip to
|
||||
108?") above the next-episode button, Wrong show? and Episode
|
||||
numbers…; then season chips (more than one season), the season's
|
||||
episodes as rows ("12. Name" with AniList's episode title, else
|
||||
"Episode 12"; one click plays, right-click queues) with the holes as
|
||||
muted rows, Extras and the show's Movies. With nobody else
|
||||
in the server (`alone` on the Library, Files and Schedule, D9) the
|
||||
buttons say Play, Link has no relay modes and a link goes up direct,
|
||||
Files has no relay notice; a put-up before your server is up waits for
|
||||
@@ -2220,10 +2306,13 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
||||
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
||||
its own account; a web link's yt-dlp thumbnail, the one the Library's
|
||||
Recent grid shows, as poster and banner both, shrunk to a JPEG under the
|
||||
poster cap when larger; else an AniList title search on the parsed
|
||||
poster cap when larger; else the AniList entry `media.ref` names (a
|
||||
matched local file), else an AniList title search on the parsed
|
||||
release name) and sends `art`, so guests without Jellyfin get covers too.
|
||||
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||
`state.media` (`now_playing_info.dart`). A web video
|
||||
`state.media` (`now_playing_info.dart`); a non-Jellyfin media with a
|
||||
`media.ref` is named by it (its title, `S3 E13` / `E108`, an extra's
|
||||
own marker), see "Library". A web video
|
||||
(`NowPlaying.thumbnail`) shows the media name as is, no episode line, a
|
||||
16:9 cover in the controls (a neutral placeholder until the art is up)
|
||||
and none on the start card. Art up: poster and banner of one hash mark
|
||||
|
||||
+5
-2
@@ -79,7 +79,9 @@ Three suites, run from each package's folder:
|
||||
|
||||
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||
the sync engine (clock, gate, presence and grace, correction, end of
|
||||
file), the `PresencePolicy` table, queue model,
|
||||
file), the `PresencePolicy` table, queue model, the library's L1
|
||||
(release and folder names, `groupLocal` over a real anime folder's
|
||||
names: shows across folders, holes, extras, a movie),
|
||||
relay pieces, updater, logging redaction, watch rule, yt-dlp and
|
||||
Jellyfin client parsing against captured fixtures.
|
||||
- `cd packages/server && dart test` (well under a minute): live servers
|
||||
@@ -90,7 +92,8 @@ Three suites, run from each package's folder:
|
||||
under the same members and Up next, the host key standing in for the
|
||||
password).
|
||||
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate), `singletons_test` (the `.instance` count, see
|
||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||
|
||||
+4
-2
@@ -354,8 +354,10 @@ media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6,
|
||||
is (the server drops non-integer ids and cuts `title` to 200
|
||||
characters). `kind` `episode`, `movie` or `extra` (else no ref); every
|
||||
other field optional; `season` absent for absolute numbering. At most
|
||||
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet
|
||||
(step 4).
|
||||
2 KiB of JSON, else `err` `bad`. Apps send it with swaps and Up next
|
||||
adds of files (the sender's library match), Jellyfin items (their
|
||||
provider ids) and links (kind and title only); receivers name the
|
||||
media by it (ARCHITECTURE.md "Library").
|
||||
|
||||
## Avatars
|
||||
|
||||
|
||||
+20
-12
@@ -61,9 +61,6 @@ Problems are numbered; delete a line when its fix ships.
|
||||
the `close:` log lines.
|
||||
- P33 No volume readout on wheel: likely fixed by the controls rework
|
||||
(1.2.1-dev.1; a test covers both skins); confirm in real use.
|
||||
- P34 Local anime folders are opened as plain files (no banner, names,
|
||||
next episode, tracking); test library: the user's
|
||||
`/mnt/torrents/Torrents/Binux/` (several shows and a movie).
|
||||
|
||||
**Restructure** (in order; reviewed against the code). Truth lives in
|
||||
three places: the server (members, presence, gate, transport, queue incl.
|
||||
@@ -71,9 +68,6 @@ the automatic continuation and countdown, relay state), the device
|
||||
(client id, library and folders, match corrections, watch history,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
integrations (Jellyfin played/resume, AniList list).
|
||||
4. Library + Media identity: L1 local grouping (kinds, folder hints,
|
||||
gaps, merge across folders) and L2 match (AniList/TVDB, art, names,
|
||||
manual pin, cache); `MediaRef` in swap/queue media. Fixes P34.
|
||||
5. Media pipeline + player host port: plan → resolve → open → ready,
|
||||
commands queued during a switch, viewer-side recovery when a source
|
||||
drops (no mpv seek loops, play what's buffered), resolver and source
|
||||
@@ -127,12 +121,26 @@ that the invite skips the proxy's 198.18.x here) and of the update band
|
||||
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
|
||||
members card only says friends elsewhere need the port forwarded. Your
|
||||
server's `/health` name is the profile name at start (a rename shows
|
||||
after a restart). Guests' Jellyfin relay uses the default account (no
|
||||
longer the put-up item's server). Joining a friend while others are on
|
||||
after a restart). Joining a friend while others are on
|
||||
your server leaves them there without you, unwarned; a join refused
|
||||
after dialling (wrong password) costs your own member there (`bye`, back
|
||||
as a new member). Your server writes its session log even when you are
|
||||
alone.
|
||||
Left of step 4 (library): a human check of the Files library on the
|
||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||
Kimetsu movie) and of the AniList matches it gets. A Jellyfin swap
|
||||
carries AniList/TVDB ids only when the picked item has them (movies); an
|
||||
episode's show ids need its series item, which picks do not fetch.
|
||||
Receivers name the media by `media.ref` but still get art only as the
|
||||
swapper's bytes (now chosen by the ref's AniList id). Relay logins are
|
||||
sent at Invite: an account linked or signed in again later relays only
|
||||
after Stop sharing and Invite. "Season N" without a name walks AniList's
|
||||
sequels, which can land on the wrong cour where AniList splits seasons;
|
||||
Wrong show? fixes it. The next episode of a local show lives on its page
|
||||
(from Recent); no countdown or "Continues with" for files yet (backlog
|
||||
2). The `library` box keeps every AniList entry seen (no pruning);
|
||||
AniList's episode titles exist only where it lists streaming episodes.
|
||||
Left of step 2: system notifications need a human check (Linux: the
|
||||
notification shows and a click brings the window up, also on Wayland,
|
||||
where focus may be refused without an activation token; Windows: toasts
|
||||
@@ -164,7 +172,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
holds it while the item's performer or source is `gone`. Today it is
|
||||
parsed and broadcast but never started; `auto` items and `cancel` are
|
||||
live.
|
||||
- `media.ref` producers (step 4); the server carries it already.
|
||||
- `hello.client` optionally derived per server (HMAC of an install
|
||||
secret and the dialled host:port).
|
||||
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid
|
||||
@@ -178,9 +185,10 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
seeks are shared, and only real ranges (segments, OP/ED chapters) should
|
||||
ever be skipped.
|
||||
|
||||
2. **Files**: next episode from Files (by parsed episode, with the
|
||||
countdown, and under Up next's "Continues with"). Later: tracking for
|
||||
file series.
|
||||
2. **Files**: the next episode of a local show (the library's
|
||||
`nextAfter`, gaps included) at the end of one, with the countdown, and
|
||||
under Up next's "Continues with". Tracking for file series comes with
|
||||
step 6.
|
||||
|
||||
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
|
||||
|
||||
Reference in new issue
Block a user