- 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>
161 lines
6.3 KiB
Dart
161 lines
6.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:starsync/src/files/scanner.dart';
|
|
import 'package:sync_relay_core/core.dart' show groupLocal, readLocalFile, EpisodeGap, LocalKind;
|
|
|
|
// The Files scan on a real (generated) tree, and the library's L1
|
|
// grouping over what it found (core's own tests cover the grouping
|
|
// rules on paths alone).
|
|
|
|
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
|
|
|
late Directory _root;
|
|
|
|
String _p(String rel) => '${_root.path}/$rel';
|
|
|
|
void _file(String rel, {int size = 10}) {
|
|
final f = File(_p(rel))..createSync(recursive: true);
|
|
if (size > 0) {
|
|
final raf = f.openSync(mode: FileMode.write);
|
|
raf.truncateSync(size);
|
|
raf.closeSync();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
setUp(() => _root = Directory(_tmp).createTempSync('files_scan_'));
|
|
tearDown(() => _root.deleteSync(recursive: true));
|
|
|
|
test('classifies, skips hidden and samples, pairs sidecar subtitles',
|
|
() async {
|
|
_file('Show/[Grp] Show - 01 [1080p].mkv');
|
|
_file('Show/[Grp] Show - 01 [1080p].ass');
|
|
_file('Show/[Grp] Show - 02 [1080p].mkv');
|
|
_file('Show/[Grp] Show - 02 [1080p].en.srt');
|
|
_file('Show/[Grp] Show - 02 [1080p].en.forced.srt');
|
|
_file('Show/unrelated.srt');
|
|
_file('Show/sample-show.mkv');
|
|
_file('Show/info.nfo');
|
|
_file('Show/cover.jpg');
|
|
_file('Movie.2020.1080p.BluRay.mp4');
|
|
_file('Movie.2020.1080p.BluRay.nfo');
|
|
_file('.hidden.mkv');
|
|
_file('.cache/inside.mkv');
|
|
_file('deep/a/b/c/clip.webm');
|
|
_file('Trailer big.mkv', size: 301 << 20); // sparse
|
|
Link(_p('link-to-show')).createSync(_p('Show'));
|
|
Link(_p('link.mkv')).createSync(_p('Movie.2020.1080p.BluRay.mp4'));
|
|
|
|
final scan = await scanFolder(_root.path);
|
|
final names = scan.videos.map((v) => v.name).toList()..sort();
|
|
expect(names, [
|
|
'Movie.2020.1080p.BluRay.mp4',
|
|
'Trailer big.mkv',
|
|
'[Grp] Show - 01 [1080p].mkv',
|
|
'[Grp] Show - 02 [1080p].mkv',
|
|
'clip.webm',
|
|
]);
|
|
expect(scan.hiddenCount, 4); // sample, nfo, jpg, nfo
|
|
expect(scan.hiddenExamples, hasLength(4));
|
|
expect(scan.hiddenExamples, contains('sample-show.mkv'));
|
|
expect(scan.truncated, isFalse);
|
|
expect(scan.subtitles[_p('Show/[Grp] Show - 01 [1080p].mkv')],
|
|
[_p('Show/[Grp] Show - 01 [1080p].ass')]);
|
|
expect(scan.subtitles[_p('Show/[Grp] Show - 02 [1080p].mkv')], [
|
|
_p('Show/[Grp] Show - 02 [1080p].en.forced.srt'),
|
|
_p('Show/[Grp] Show - 02 [1080p].en.srt'),
|
|
]);
|
|
expect(scan.subtitles.containsKey(_p('Movie.2020.1080p.BluRay.mp4')),
|
|
isFalse);
|
|
|
|
final ep = scan.videos.firstWhere((v) => v.name.contains('- 02'));
|
|
expect(readLocalFile(ep).episode, 2);
|
|
expect(ep.size, 10);
|
|
expect(ep.modified.year, DateTime.now().year);
|
|
});
|
|
|
|
test('only language/flag suffixes pair a subtitle with a shorter name',
|
|
() async {
|
|
_file('Show.mkv');
|
|
_file('Show.pt-BR.srt');
|
|
_file('Show.S01E01.srt');
|
|
final scan = await scanFolder(_root.path);
|
|
expect(scan.subtitles[_p('Show.mkv')], [_p('Show.pt-BR.srt')]);
|
|
});
|
|
|
|
test('stops after the file limit and says so', () async {
|
|
for (var i = 0; i < 30; i++) {
|
|
_file('f$i.mkv', size: 0);
|
|
}
|
|
final scan = await scanFolder(_root.path, maxFiles: 12);
|
|
expect(scan.truncated, isTrue);
|
|
expect(scan.videos, hasLength(12));
|
|
});
|
|
|
|
test('hidden examples stop at 6', () async {
|
|
for (var i = 0; i < 9; i++) {
|
|
_file('note$i.txt', size: 0);
|
|
}
|
|
final scan = await scanFolder(_root.path);
|
|
expect(scan.hiddenCount, 9);
|
|
expect(scan.hiddenExamples, hasLength(6));
|
|
});
|
|
|
|
test('a missing folder is an empty scan', () async {
|
|
final scan = await scanFolder(_p('nope'));
|
|
expect(scan.videos, isEmpty);
|
|
expect(scan.truncated, isFalse);
|
|
});
|
|
|
|
test('kindOf', () {
|
|
expect(kindOf('/a/b.MKV'), FileKind.video);
|
|
expect(kindOf('b.m2ts'), FileKind.video);
|
|
expect(kindOf('b.SUP'), FileKind.subtitle);
|
|
expect(kindOf('b.nfo'), FileKind.other);
|
|
expect(kindOf('mkv'), FileKind.other);
|
|
});
|
|
|
|
test('a generated tree of the Binux names, scanned and grouped (L1)', () async {
|
|
const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
|
|
const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
|
|
const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
|
|
const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
|
|
String n3(int n) => n.toString().padLeft(3, '0');
|
|
// The first Bleach folder holds 1-byte shortcuts only.
|
|
for (final j in ['Amine', 'App', 'Audio', 'Data', 'Document', 'Download', 'Image', 'Internal storage', 'Recent', 'Video']) {
|
|
_file('$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk', size: 1);
|
|
}
|
|
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) {
|
|
_file('$b2/[Judas] Bleach - ${n3(n)}.mkv');
|
|
}
|
|
for (var n = 112; n <= 139; n++) {
|
|
_file('$b3/[Judas] Bleach - ${n3(n)}.mkv');
|
|
}
|
|
for (var n = 1; n <= 24; n++) {
|
|
_file('$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv');
|
|
}
|
|
for (final x in ['Opening 1', 'Opening 2', 'Opening 3', 'Ending 1', 'Ending 2']) {
|
|
_file('$sao/[Judas] Sword Art Online S3 - $x.mkv');
|
|
}
|
|
_file('[Judas] Re.Zero - S04E17.mkv');
|
|
_file('[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv');
|
|
|
|
final scan = await scanFolder(_root.path);
|
|
expect(scan.videos, hasLength(14 + 28 + 24 + 5 + 2));
|
|
expect(scan.hiddenCount, 10, reason: 'the shortcuts are not videos');
|
|
final lib = groupLocal(scan.videos, roots: [_root.path]);
|
|
expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']);
|
|
final bleach = lib.show('bleach')!;
|
|
expect(bleach.folders, [_p(b2), _p(b3)]);
|
|
expect(bleach.seasons.single.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]);
|
|
final s = lib.show('sword art online')!;
|
|
expect([s.seasons.single.number, s.seasons.single.name], [3, 'Alicization']);
|
|
expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']);
|
|
expect(lib.show('re zero')!.seasons.single.number, 4);
|
|
expect(lib.movies.single.kind, LocalKind.movie);
|
|
expect(lib.movies.single.title, 'Kimetsu no Yaiba - Mugenjou Hen');
|
|
expect(lib.other, isEmpty);
|
|
});
|
|
}
|