test media filename parsing patterns
This commit is contained in:
@@ -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)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]'), ' ')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user