Restructure step 4: local library and media identity
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s

- 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:
foxyzandClaude Opus 5.5 committed 2026-10-15 23:34:39 +03:00
1 parent 4d00b7455e
commit bf9fc193b3
44 files changed
+4350 -891

No files matched your search

+13 -1
View File
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
@@ -58,6 +57,11 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
return _artOf(match, fetch);
}
Future<ArtBytes> _artOf(AniListMedia match,
Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch) async {
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
return (poster: poster, banner: banner);
}
/// Cover and banner of AniList entry [id] (a file the library matched);
/// null when AniList has none.
Future<ArtBytes?> aniListArtById(AniListClient aniList, int id,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final match = await aniList.mediaById(id);
return match == null ? null : _artOf(match, fetch);
}
/// A web video's thumbnail at [url] as both cover and banner (the same
/// bytes, so the same hash: what tells clients it is a thumbnail, see
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
@@ -1,6 +1,6 @@
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName;
import '../files/name_parser.dart';
import '../library/media_refs.dart' show refEpisodeLine;
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
}
final what = _mediaRef(media['ref']);
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
: what != null && what.title.isNotEmpty
? refTitle(what, name)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
MediaRef? _mediaRef(Object? j) {
try {
return MediaRef.fromJson(j);
} catch (_) {
return null;
}
}
/// Files and links that say what they are (`media.ref`, from the
/// sender's library): its proper title and `S3 E13` / `E108`; an extra
/// keeps the name's own marker (OP1, NCED2).
(String, String?) refTitle(MediaRef ref, String name) =>
(ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null));
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
/// will be its thumbnail: a shared http(s) link that is no media file (the
/// everyone-opens-it mode), or a relayed one (passed on by a member or
@@ -12,6 +12,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/progress.dart';
import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../library/media_refs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../platform/ports/link_resolver.dart';
@@ -118,6 +119,9 @@ class SessionCoordinator {
/// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
/// What a local file is (`media.ref`, from the library: `ShowMatcher`),
/// set by the composition root; null: files go up without one.
MediaRef? Function(String path)? fileRef;
/// The app's yt-dlp and link probes (tests pass their own).
final LinkResolver ytDlp;
/// The link to the server (socket, reconnects, clock offset, its
@@ -1449,8 +1453,11 @@ class SessionCoordinator {
final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : await thumbnailArt(uri);
} else {
// The library's match names the AniList entry; else a title search.
final known = ref == null ? media.ref?.anilistId : null;
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
final aniList = _aniList ??= AniListClient();
bytes = await (known != null ? aniListArtById(aniList, known) : null) ?? await aniListArt(aniList, name);
}
final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return;
@@ -1491,8 +1498,9 @@ class SessionCoordinator {
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name);
if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId);
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId);
return null;
}
if (relay == LinkRelay.me) {
@@ -1526,8 +1534,8 @@ class SessionCoordinator {
await check?.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId);
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
link: url, queueId: queueId);
return null;
}
// The server reads it with its own yt-dlp; only a page this app knows
@@ -1537,7 +1545,12 @@ class SessionCoordinator {
return probe.relayProblem;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)),
name: name,
size: max(0, probe?.size ?? 0),
duration: 0,
url: url,
relay: const MediaRelay(fetch: true),
ref: what),
queueId: queueId);
return null;
}
@@ -1563,18 +1576,23 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path, {String? queueId}) async {
markInput();
final what = fileRef?.call(path);
final host = hostFile;
if (host != null) {
final file = await host(path);
if (file == null) return 'file not found — check the path';
_swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId);
_swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId);
return null;
}
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path);
_swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()),
name: file.uri.pathSegments.last,
size: await file.length(),
duration: 0,
relay: const MediaRelay(),
ref: what),
queueId: queueId);
return null;
}
@@ -8,7 +8,7 @@ import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart' show relayCredentialFor;
import '../jellyfin/session_media.dart' show relayCredentialsFor;
import '../state/connect_prefs.dart';
import '../state/server_isolate.dart';
import 'invite.dart';
@@ -60,7 +60,7 @@ class YourServer {
_interfaces = interfaces ?? machineInterfaces;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay login.
/// Linked Jellyfin servers (default: the app's): the relay logins.
final JellyfinAccounts? jellyfin;
final Future<String> Function() _ytDlpDir;
final String? Function() _logDir;
@@ -156,8 +156,8 @@ class YourServer {
port: want,
secret: form.hostSecret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in.
jellyfin: relayCredentialFor(_accounts),
// server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
shared.value = invite;