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
@@ -1,16 +1,13 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'name_parser.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// Folder scan for the Files source: videos with their parsed names,
|
||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
||||
// synchronous I/O, so it runs in its own isolate.
|
||||
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||
|
||||
const videoExtensions = {
|
||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
||||
};
|
||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
// Folder scan for the Files source: the videos (grouped into shows by
|
||||
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||
|
||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||
const maxScannedFiles = 20000;
|
||||
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
|
||||
/// Samples and trailers under this size are hidden next to the real thing.
|
||||
const _sampleMaxBytes = 300 << 20;
|
||||
|
||||
enum FileKind { video, subtitle, other }
|
||||
|
||||
FileKind kindOf(String path) {
|
||||
final ext = _extension(_baseName(path));
|
||||
if (videoExtensions.contains(ext)) return FileKind.video;
|
||||
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
|
||||
return FileKind.other;
|
||||
}
|
||||
|
||||
class VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final ParsedName parsed;
|
||||
final DateTime modified;
|
||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
||||
|
||||
String get name => _baseName(path);
|
||||
}
|
||||
|
||||
class FolderScan {
|
||||
final List<VideoFile> videos;
|
||||
final List<LocalFile> videos;
|
||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||
final int hiddenCount;
|
||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
||||
|
||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
final videos = <VideoFile>[];
|
||||
final videos = <LocalFile>[];
|
||||
final subs = <String>[];
|
||||
final examples = <String>[];
|
||||
var hidden = 0, seen = 0;
|
||||
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
}
|
||||
}
|
||||
return FolderScan(
|
||||
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
|
||||
/// Subtitles in the same folder whose name is the video's base name,
|
||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
|
||||
final byStem = <String, List<String>>{};
|
||||
for (final s in subs) {
|
||||
final stem = _stripExtension(s);
|
||||
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
};
|
||||
}
|
||||
|
||||
/// One show: its episodes in season/episode order plus its extras
|
||||
/// (NCOP, OVA...) in name order.
|
||||
class SeriesGroup {
|
||||
/// Display title: the most common spelling among the files.
|
||||
final String title;
|
||||
final String key;
|
||||
final List<VideoFile> episodes;
|
||||
final List<VideoFile> extras;
|
||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
||||
|
||||
/// Distinct seasons, ascending; files without one count as season 1.
|
||||
List<int> get seasons =>
|
||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
||||
}
|
||||
|
||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
||||
class GroupedVideos {
|
||||
final List<SeriesGroup> series;
|
||||
final List<VideoFile> loose;
|
||||
const GroupedVideos(this.series, this.loose);
|
||||
}
|
||||
|
||||
/// Groups videos with an episode number (or an extra marker) by
|
||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
||||
/// An extra whose series has no episodes in the list is loose too.
|
||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
||||
final episodes = <String, List<VideoFile>>{};
|
||||
final extras = <String, List<VideoFile>>{};
|
||||
final loose = <VideoFile>[];
|
||||
for (final v in videos) {
|
||||
final p = v.parsed;
|
||||
final key = p.seriesKey;
|
||||
if (p.episode != null && key.isNotEmpty) {
|
||||
episodes.putIfAbsent(key, () => []).add(v);
|
||||
} else if (p.extra != null && key.isNotEmpty) {
|
||||
extras.putIfAbsent(key, () => []).add(v);
|
||||
} else {
|
||||
loose.add(v);
|
||||
}
|
||||
}
|
||||
for (final e in extras.entries) {
|
||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
||||
}
|
||||
int byName(VideoFile a, VideoFile b) =>
|
||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
final groups = [
|
||||
for (final e in episodes.entries)
|
||||
SeriesGroup(
|
||||
_commonTitle(e.value),
|
||||
e.key,
|
||||
e.value..sort(_byEpisode),
|
||||
(extras[e.key] ?? [])..sort(byName),
|
||||
),
|
||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
return GroupedVideos(groups, loose..sort(byName));
|
||||
}
|
||||
|
||||
int _byEpisode(VideoFile a, VideoFile b) {
|
||||
final pa = a.parsed, pb = b.parsed;
|
||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
||||
if (s != 0) return s;
|
||||
final e = pa.episode!.compareTo(pb.episode!);
|
||||
if (e != 0) return e;
|
||||
// Same episode twice (v2, another group): newest release first.
|
||||
return b.name.compareTo(a.name);
|
||||
}
|
||||
|
||||
String _commonTitle(List<VideoFile> files) {
|
||||
final counts = <String, int>{};
|
||||
for (final f in files) {
|
||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts.entries
|
||||
.reduce((a, b) => b.value > a.value ? b : a)
|
||||
.key;
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
String _extension(String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _stripExtension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
|
||||
Reference in new issue
Block a user