test media filename parsing patterns

This commit is contained in:
ngfchl
2026-07-18 18:27:11 +08:00
parent 49fefcfe1f
commit 130fac8259
3 changed files with 91 additions and 9 deletions
+27 -7
View File
@@ -331,8 +331,15 @@ class MediaLibraryItem {
);
}
factory MediaLibraryItem.fromFile(String libraryID, CloudFile file) {
final parsed = ParsedMediaName.parse(file.name);
factory MediaLibraryItem.fromFile(
String libraryID,
CloudFile file, {
String? directoryName,
}) {
final parsed = ParsedMediaName.parse(
file.name,
directoryName: directoryName,
);
final kind = parsed.isEpisode ? TMDBMediaKind.tv : TMDBMediaKind.movie;
return MediaLibraryItem(
libraryID: libraryID,
@@ -506,7 +513,7 @@ class ParsedMediaName {
this.dynamicRange,
});
factory ParsedMediaName.parse(String name) {
factory ParsedMediaName.parse(String name, {String? directoryName}) {
final stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
final normalized = stem.replaceAll(RegExp(r'[._]+'), ' ');
final episodeMatch = RegExp(
@@ -558,14 +565,27 @@ class ParsedMediaName {
.replaceAll(RegExp(r'[\[\]【】(){}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final genericName =
title.isEmpty ||
RegExp(r'^\d+$').hasMatch(title) ||
RegExp(r'^S\d{1,2}E\d{1,3}$', caseSensitive: false).hasMatch(title);
ParsedMediaName? parent;
if (genericName &&
directoryName != null &&
directoryName.trim().isNotEmpty) {
parent = ParsedMediaName.parse(directoryName);
if (parent.title.isNotEmpty) title = parent.title;
}
if (title.isEmpty) title = normalized.trim();
return ParsedMediaName(
title: title,
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
season: season,
year: yearMatch == null
? parent?.year
: int.tryParse(yearMatch.group(1)!),
season: season ?? parent?.season,
episode: episode,
isEpisode: season != null && episode != null,
isEpisode: (season ?? parent?.season) != null && episode != null,
resolution: first(r'\b(?:2160p|1080p|720p|480p|4k)\b'),
source: first(
r'\b(?:WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\b',
@@ -574,7 +594,7 @@ class ParsedMediaName {
audio: first(
r'\b(?:Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\b',
),
dynamicRange: first(r'\b(?:HDR10?\+?|Dolby[ .-]?Vision|DV)\b'),
dynamicRange: first(r'(?:HDR10?\+?|HDR|Dolby[ .-]?Vision|DV)'),
);
}
}
+17 -2
View File
@@ -318,7 +318,11 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Future<void> worker() async {
while (!_cancelScan && next < pending.length) {
final file = pending[next++];
final fallback = MediaLibraryItem.fromFile(library.id, file);
final fallback = MediaLibraryItem.fromFile(
library.id,
file,
directoryName: _parentDirectoryName(file.cloudPath),
);
final existing = unique[file.id];
var item = existing == null
? await _recognizeMediaItem(
@@ -656,7 +660,10 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
if (_api == null || item.tmdbID == null || item.mediaKind == null) {
return item;
}
final parsed = ParsedMediaName.parse(item.file.name);
final parsed = ParsedMediaName.parse(
item.file.name,
directoryName: _parentDirectoryName(item.file.cloudPath),
);
final extension = _extensionOf(item.file.name);
final year = item.year.isEmpty ? '' : '.${item.year}';
final episode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode
@@ -742,6 +749,14 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
return index <= 0 ? '' : value.substring(index + 1).toLowerCase();
}
String? _parentDirectoryName(String cloudPath) {
final values = cloudPath
.split(RegExp(r'[\\/]'))
.where((part) => part.isNotEmpty)
.toList();
return values.length < 2 ? null : values[values.length - 2];
}
String _safeCloudName(String value) {
final cleaned = value
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ')
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:guangya_flutter/models/media_library.dart';
void main() {
group('ParsedMediaName', () {
test('extracts standard season episode and technical tags', () {
final value = ParsedMediaName.parse(
'Pursuit.of.Jade.S01E33.2026.2160p.WEB-DL.H265.HDR.60fps.DDP5.1.Atmos.mkv',
);
expect(value.season, 1);
expect(value.episode, 33);
expect(value.year, 2026);
expect(value.resolution, '2160p');
expect(value.source, 'WEB-DL');
expect(value.videoCodec, 'H265');
expect(value.dynamicRange, 'HDR');
expect(value.audio, 'DDP5');
});
test('accepts episode-only and Chinese episode conventions', () {
final english = ParsedMediaName.parse(
'Immortal.Samsara.2022.E45.WEB-DL.4k.H265.AAC.mp4',
);
final chinese = ParsedMediaName.parse('超能立方 - 第 3 集 - 混战!.2160p.mkv');
expect(english.season, 1);
expect(english.episode, 45);
expect(chinese.season, 1);
expect(chinese.episode, 3);
});
test('keeps film year and ignores unresolvable disc stream names', () {
final movie = ParsedMediaName.parse('加勒比海盗5:死无对证(2017).1080p.mp4');
final stream = ParsedMediaName.parse(
'01076.m2ts',
directoryName: '加勒比海盗5:死无对证(2017)',
);
expect(movie.year, 2017);
expect(movie.resolution, '1080p');
expect(movie.isEpisode, isFalse);
expect(stream.title, '加勒比海盗5:死无对证');
expect(stream.year, 2017);
});
});
}