From 130fac8259ec13e809fc4379f6c1b99f369bca0e Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 18:27:11 +0800 Subject: [PATCH] test media filename parsing patterns --- lib/models/media_library.dart | 34 ++++++++++++---- lib/providers/media_library_provider.dart | 19 ++++++++- test/media_name_parser_test.dart | 47 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 test/media_name_parser_test.dart diff --git a/lib/models/media_library.dart b/lib/models/media_library.dart index 44eccdc..ff3e435 100644 --- a/lib/models/media_library.dart +++ b/lib/models/media_library.dart @@ -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)'), ); } } diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index e57ca47..0f0b009 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -318,7 +318,11 @@ class MediaLibraryNotifier extends StateNotifier { Future 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 { 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 { 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]'), ' ') diff --git a/test/media_name_parser_test.dart b/test/media_name_parser_test.dart new file mode 100644 index 0000000..e57c599 --- /dev/null +++ b/test/media_name_parser_test.dart @@ -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); + }); + }); +}