Files
sync_replay_project/apps/desktop/lib/src/files/name_parser.dart
T
foxyzandClaude Opus 5.5 c76fee70dc Files backend: release-name parser, video-only scanner, folder store, mpv thumbnails
lib/src/files/ for the 1.0 Files source (UI comes with the library
round; ARCHITECTURE gets its section then):
- parseReleaseName: series/season/episode/group/year/extras from release
  names (fansub, scene, batch, NCOP/OVA markers); seriesKey for grouping.
- scanFolder (isolate): videos only, samples/trailers and other files
  counted as hidden, sidecar subtitles paired by name (+ language/flag
  suffixes), 20 000-file cap; groupBySeries -> series + loose videos.
- FolderStore: watched folders and recent files in Hive, as signals.
- thumbnailFor: a 480 px JPEG at 25% via a hidden mpv player
  (screenshot-to-file, vo=null), cached by path+size+mtime, 2 at a time,
  cancellable, never throws. No ffmpeg needed (Windows ships only libmpv).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:46:01 +03:00

197 lines
7.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Release file names -> series / season / episode, anitomy-style but small:
// enough to group a downloads folder into shows. Unknown shapes fall back to
// the cleaned name with no episode, never to a wrong number.
/// What a release file name says.
class ParsedName {
/// Title without group, season marker, episode, year or release tags.
final String series;
final int? season;
final int? episode;
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
final String? group;
/// Release year, taken out of [series] (movies, `Title (2019)`).
final int? year;
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
/// carry no [episode] so they never sort among the real ones.
final String? extra;
const ParsedName(
this.series, {
this.season,
this.episode,
this.group,
this.year,
this.extra,
});
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
String get label {
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
return extra ?? s;
}
/// Grouping key: case, punctuation and spacing do not split a series.
String get seriesKey => normalizeSeries(series);
@override
String toString() => 'ParsedName($series, s=$season, e=$episode, '
'group=$group, year=$year, extra=$extra)';
}
/// Lowercase letters and digits of [s], single spaces between words.
String normalizeSeries(String s) => s
.toLowerCase()
.replaceAll(RegExp(r"['’`]"), '')
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
.trim();
// Release tags: the title ends where the first of these starts.
final _junk = RegExp(
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
r'Batch|Complete)\b',
caseSensitive: false,
);
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
caseSensitive: false);
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
final _episodeWord = RegExp(
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
caseSensitive: false,
);
// Extras: unambiguous words anywhere, short ones only right after the dash.
final _extraAnywhere = RegExp(
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _extraAfterDash = RegExp(
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
r'(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _seasonInTitle = RegExp(
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
caseSensitive: false,
);
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
/// Parses a file name (with or without the extension, no directories).
ParsedName parseReleaseName(String fileName) {
var s = fileName.replaceFirst(_ext, '').trim();
final original = s;
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
int? year;
final by = _bracketYear.firstMatch(s);
if (by != null) year = int.parse(by.group(1)!);
s = s.replaceAll(_brackets, ' ');
// Dots and underscores as separators (scene style), unless the name
// already has spaces and only the odd `Dr. Stone` dot.
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) {
// `...H264-GRP`, but not `...WEB-DL`.
final tags = _junk.allMatches(s);
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
final g = _sceneGroup.firstMatch(s);
if (g != null) {
group = g.group(1);
s = s.substring(0, g.start);
}
}
s = s.replaceAll('.', ' ');
}
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
// Release tags end the interesting part.
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
int? season, episode;
String? extra;
var cut = s.length;
final extraMatch =
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
final sxe = _sxe.firstMatch(s);
if (extraMatch != null) {
final word = extraMatch.group(1)!;
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
// `OVA - 02`: the number may follow the dash instead.
final n = extraMatch.group(2) ??
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
extra = n == null
? upper
: (upper.startsWith('NC') || upper.length <= 2)
? '$upper${int.parse(n)}'
: '$upper ${int.parse(n)}';
cut = extraMatch.start;
if (sxe != null && sxe.start < cut) {
season = int.parse(sxe.group(1)!);
cut = sxe.start;
}
} else if (sxe != null) {
season = int.parse(sxe.group(1)!);
episode = int.parse(sxe.group(2)!);
cut = sxe.start;
} else {
final nxn = _nxn.firstMatch(s);
if (nxn != null) {
season = int.parse(nxn.group(1)!);
episode = int.parse(nxn.group(2)!);
cut = nxn.start;
} else {
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
}
var series = s.substring(0, cut);
// Season pack or season marker kept in the title: `Title S2`,
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
final sIn = _seasonInTitle.firstMatch(series);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
series = series.substring(0, sIn.start);
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
// A trailing year is the release year, unless it is the whole title or
// too far ahead to be one (`Blade Runner 2049`).
final ty = _trailingYear.firstMatch(series);
if (ty != null) {
final y = int.parse(ty.group(1)!);
if (y <= DateTime.now().year + 1) {
year ??= y;
series = series.substring(0, ty.start).trim();
}
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
if (series.isEmpty) {
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
}
return ParsedName(
series,
season: season,
episode: episode,
group: group,
year: year,
extra: extra,
);
}
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();