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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user