diff --git a/lib/models/media_library.dart b/lib/models/media_library.dart index 3c069ef..44eccdc 100644 --- a/lib/models/media_library.dart +++ b/lib/models/media_library.dart @@ -487,6 +487,11 @@ class ParsedMediaName { final int? season; final int? episode; final bool isEpisode; + final String? resolution; + final String? source; + final String? videoCodec; + final String? audio; + final String? dynamicRange; const ParsedMediaName({ required this.title, @@ -494,50 +499,82 @@ class ParsedMediaName { this.season, this.episode, this.isEpisode = false, + this.resolution, + this.source, + this.videoCodec, + this.audio, + this.dynamicRange, }); factory ParsedMediaName.parse(String name) { - var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), ''); - stem = stem.replaceAll(RegExp(r'[\._]+'), ' '); - - final episodeMatch = - RegExp( - r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})', - caseSensitive: false, - ).firstMatch(stem) ?? - RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem); - final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem); - - final cutIndex = - [ - episodeMatch?.start, - yearMatch?.start, - RegExp( - r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b', + final stem = name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final normalized = stem.replaceAll(RegExp(r'[._]+'), ' '); + final episodeMatch = RegExp( + r'\bS(\d{1,2})[ ._-]*E(\d{1,3})\b|\b(\d{1,2})x(\d{1,3})\b|第\s*(\d{1,2})\s*季\s*第?\s*(\d{1,3})\s*[集话]', + caseSensitive: false, + ).firstMatch(normalized); + int? season; + int? episode; + if (episodeMatch != null) { + final groups = [ + [1, 2], + [3, 4], + [5, 6], + ]; + for (final pair in groups) { + final a = episodeMatch.group(pair[0]); + final b = episodeMatch.group(pair[1]); + if (a != null && b != null) { + season = int.tryParse(a); + episode = int.tryParse(b); + break; + } + } + } + final episodeOnly = season == null + ? RegExp( + r'\b(?:E|EP|Episode)[ ._-]*(\d{1,3})\b|第\s*(\d{1,3})\s*[集话]', caseSensitive: false, - ).firstMatch(stem)?.start, - ].whereType().fold( - null, - (min, value) => min == null || value < min ? value : min, - ); - - var title = cutIndex == null ? stem : stem.substring(0, cutIndex); + ).firstMatch(normalized) + : null; + if (episodeOnly != null) { + season = 1; + episode = int.tryParse( + episodeOnly.group(1) ?? episodeOnly.group(2) ?? '', + ); + } + String? first(String pattern) => + RegExp(pattern, caseSensitive: false).firstMatch(normalized)?.group(0); + final yearMatch = RegExp(r'\b(19\d{2}|20\d{2})\b').firstMatch(normalized); + final boundary = RegExp( + r'\b(?:19\d{2}|20\d{2}|S\d{1,2}[ ._-]*E\d{1,3}|\d{1,2}x\d{1,3}|2160p|1080p|720p|480p|4k|web[- ]?(?:dl|rip)?|bluray|bdrip|remux|hdtv|dvd|x26[45]|h\.?26[45]|hevc|av1|aac|ac3|eac3|flac|truehd|dts|ddp|atmos|hdr|dv)\b', + caseSensitive: false, + ); + final boundaryMatch = boundary.firstMatch(normalized); + var title = boundaryMatch != null + ? normalized.substring(0, boundaryMatch.start) + : normalized; title = title - .replaceAll(RegExp(r'[\[\]\(\)]'), ' ') + .replaceAll(RegExp(r'[\[\]【】(){}]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); - if (title.isEmpty) title = stem.trim(); + if (title.isEmpty) title = normalized.trim(); return ParsedMediaName( title: title, year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!), - season: episodeMatch == null - ? null - : int.tryParse(episodeMatch.group(1)!), - episode: episodeMatch == null - ? null - : int.tryParse(episodeMatch.group(2)!), - isEpisode: episodeMatch != null, + season: season, + episode: episode, + isEpisode: 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', + ), + videoCodec: first(r'\b(?:x26[45]|h\.?26[45]|HEVC|AV1|VC-1)\b'), + audio: first( + r'\b(?:Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\b', + ), + dynamicRange: first(r'\b(?:HDR10?\+?|Dolby[ .-]?Vision|DV)\b'), ); } } diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 3f4fdb3..e57ca47 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_riverpod/legacy.dart'; @@ -311,21 +312,23 @@ class MediaLibraryNotifier extends StateNotifier { Future pendingPersistence = Future.value(); Future indexBatch(List files) async { - final pending = files - .where((file) => !unique.containsKey(file.id)) - .toList(); + final pending = files.toList(); if (pending.isEmpty || _cancelScan) return; var next = 0; Future worker() async { while (!_cancelScan && next < pending.length) { final file = pending[next++]; final fallback = MediaLibraryItem.fromFile(library.id, file); - final item = await _recognizeMediaItem( - fallback, - tmdbApiKey, - proxyHost: tmdbProxyHost, - proxyPort: tmdbProxyPort, - ); + final existing = unique[file.id]; + var item = existing == null + ? await _recognizeMediaItem( + fallback, + tmdbApiKey, + proxyHost: tmdbProxyHost, + proxyPort: tmdbProxyPort, + ) + : existing.copyWith(file: file); + item = await _renameMatchedMediaFile(item); unique[file.id] = item; completed += 1; final visible = unique.values.toList() @@ -347,7 +350,9 @@ class MediaLibraryNotifier extends StateNotifier { _appendScanLog( item.tmdbID == null ? '已入库:${file.name}(未匹配 TMDB)' - : '已识别并入库:${file.name} → ${item.title}', + : item.file.name == file.name + ? '已识别并入库:${file.name} → ${item.title}' + : '已识别并重命名:${file.name} → ${item.file.name}', ); } } @@ -645,6 +650,114 @@ class MediaLibraryNotifier extends StateNotifier { } } + Future _renameMatchedMediaFile( + MediaLibraryItem item, + ) async { + if (_api == null || item.tmdbID == null || item.mediaKind == null) { + return item; + } + final parsed = ParsedMediaName.parse(item.file.name); + final extension = _extensionOf(item.file.name); + final year = item.year.isEmpty ? '' : '.${item.year}'; + final episode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode + ? '.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}' + : ''; + final targetName = + '${_safeCloudName('${item.title}$year$episode')}${extension.isEmpty ? '' : '.$extension'}'; + if (targetName == item.file.name) return item; + + try { + await _api!.fsRename(item.file.id, targetName); + final updated = item.copyWith(file: item.file.copyWith(name: targetName)); + await _writeNfoForRenamedMedia(updated, parsed); + return updated; + } catch (_) { + return item; + } + } + + Future _writeNfoForRenamedMedia( + MediaLibraryItem item, + ParsedMediaName parsed, + ) async { + try { + final detail = await _api!.fsDetail(item.file.id); + final parentID = _findStringDeep(detail, const [ + 'parentId', + 'parent_id', + 'parentFileId', + ]); + if (parentID == null || parentID.isEmpty) return; + final isEpisode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode; + final nfoName = isEpisode + ? '${_safeCloudName(item.title)}.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}.nfo' + : item.mediaKind == TMDBMediaKind.tv + ? 'tvshow.nfo' + : 'movie.nfo'; + final root = isEpisode + ? 'episodedetails' + : item.mediaKind == TMDBMediaKind.tv + ? 'tvshow' + : 'movie'; + final technical = [ + if (parsed.resolution != null) + '${_xml(parsed.resolution!)}', + if (parsed.source != null) '${_xml(parsed.source!)}', + if (parsed.videoCodec != null) + '${_xml(parsed.videoCodec!)}', + if (parsed.dynamicRange != null) + '${_xml(parsed.dynamicRange!)}', + if (parsed.audio != null) '', + ].join(); + final episodeFields = isEpisode + ? '${parsed.season}${parsed.episode}' + : ''; + final xml = + '' + '<$root>${item.tmdbID}' + '${_xml(item.title)}' + '${_xml(item.originalTitle)}' + '${_xml(item.year)}' + '${_xml(item.overview)}$episodeFields' + '' + ''; + final temp = File('${Directory.systemTemp.path}/$nfoName'); + await temp.writeAsString(xml, flush: true); + try { + await _api!.fileUpload( + temp, + parentID: parentID, + contentType: 'application/xml', + ); + } finally { + if (await temp.exists()) await temp.delete(); + } + } catch (_) { + // File renaming remains successful if sidecar upload is unavailable. + } + } + + String _extensionOf(String value) { + final index = value.lastIndexOf('.'); + return index <= 0 ? '' : value.substring(index + 1).toLowerCase(); + } + + String _safeCloudName(String value) { + final cleaned = value + .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final fallback = cleaned.isEmpty ? 'Untitled' : cleaned; + return fallback.length > 180 ? fallback.substring(0, 180).trim() : fallback; + } + + String _xml(String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + MediaLibraryItem _itemFromTMDBCandidate( MediaLibraryItem fallback, Map candidate, diff --git a/lib/widgets/media_player_dialog.dart b/lib/widgets/media_player_dialog.dart index 4ba04e2..8d696d9 100644 --- a/lib/widgets/media_player_dialog.dart +++ b/lib/widgets/media_player_dialog.dart @@ -18,6 +18,7 @@ Future showMediaPlayerDialog( BuildContext context, CloudFile file, { List episodeCandidates = const [], + CloudFile? initialSubtitle, }) async { Future openExternalPlayer() async { await Future.delayed(Duration.zero); @@ -34,6 +35,7 @@ Future showMediaPlayerDialog( builder: (_) => MediaPlayerDialog( file: file, episodeCandidates: episodeCandidates, + initialSubtitle: initialSubtitle, onPlaybackFailure: openExternalPlayer, ), ); @@ -45,12 +47,14 @@ Future showMediaPlayerDialog( class MediaPlayerDialog extends ConsumerStatefulWidget { final CloudFile file; final List episodeCandidates; + final CloudFile? initialSubtitle; final Future Function()? onPlaybackFailure; const MediaPlayerDialog({ super.key, required this.file, this.episodeCandidates = const [], + this.initialSubtitle, this.onPlaybackFailure, }); @@ -131,6 +135,10 @@ class _MediaPlayerDialogState extends ConsumerState { .read(fileProvider.notifier) .playbackUrl(_currentFile); await _player.open(Media(url.toString()), play: true); + final initialSubtitle = widget.initialSubtitle; + if (initialSubtitle != null) { + await _setDirectorySubtitle(initialSubtitle); + } _videoDimensionFallbackTimer?.cancel(); _videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () { if (mounted && _loading && _error == null) { @@ -236,6 +244,16 @@ class _MediaPlayerDialogState extends ConsumerState { } } + Future _searchDirectorySubtitles() async { + final siblings = await ref + .read(fileProvider.notifier) + .siblingFiles(_currentFile); + if (!mounted) return; + setState(() { + _subtitleCandidates = _matchingSubtitles(_currentFile, siblings); + }); + } + Future _loadLocalSubtitle() async { final result = await FilePicker.pickFiles( type: FileType.custom, @@ -343,6 +361,8 @@ class _MediaPlayerDialogState extends ConsumerState { player: _player, directorySubtitles: _subtitleCandidates, onSelectDirectorySubtitle: _setDirectorySubtitle, + onSearchDirectorySubtitles: + _searchDirectorySubtitles, onLoadLocalSubtitle: _loadLocalSubtitle, onToggleFullscreen: videoState.toggleFullscreen, ), @@ -422,6 +442,7 @@ class _MediaPlaybackControls extends StatefulWidget { final Player player; final List directorySubtitles; final Future Function(CloudFile subtitle) onSelectDirectorySubtitle; + final Future Function() onSearchDirectorySubtitles; final Future Function() onLoadLocalSubtitle; final Future Function() onToggleFullscreen; @@ -429,6 +450,7 @@ class _MediaPlaybackControls extends StatefulWidget { required this.player, required this.directorySubtitles, required this.onSelectDirectorySubtitle, + required this.onSearchDirectorySubtitles, required this.onLoadLocalSubtitle, required this.onToggleFullscreen, }); @@ -524,6 +546,7 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { final tracks = widget.player.state.tracks.subtitle; return ShadPopover( controller: _subtitlePopover, + padding: const EdgeInsets.all(8), popover: (_) => _subtitlePopoverContent(tracks), child: _menuButton('字幕', () async => _subtitlePopover.toggle()), ); @@ -531,13 +554,22 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { Widget _subtitlePopoverContent(List tracks) { final selectedID = widget.player.state.track.subtitle.id; + final cs = ShadTheme.of(context).colorScheme; + final itemStyle = TextStyle(fontSize: 13, color: cs.foreground); return SizedBox( - width: 300, + width: 320, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('字幕', style: TextStyle(fontWeight: FontWeight.w700)), + Text( + '字幕', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: cs.foreground, + ), + ), if (tracks.isNotEmpty) ...[ const SizedBox(height: 6), ConstrainedBox( @@ -550,6 +582,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { final selected = track.id == selectedID; return ShadButton.ghost( size: ShadButtonSize.sm, + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: selected ? cs.primary : cs.foreground, + textStyle: itemStyle, + mainAxisAlignment: MainAxisAlignment.start, onPressed: () async { await widget.player.setSubtitleTrack(track); _subtitlePopover.hide(); @@ -575,10 +612,14 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { ), ], if (widget.directorySubtitles.isNotEmpty) ...[ - const Divider(height: 16), + const Divider(height: 14), Text( '同目录字幕 (${widget.directorySubtitles.length})', - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.mutedForeground, + ), ), const SizedBox(height: 4), ConstrainedBox( @@ -590,6 +631,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { final subtitle = widget.directorySubtitles[index]; return ShadButton.ghost( size: ShadButtonSize.sm, + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: cs.foreground, + textStyle: itemStyle, + mainAxisAlignment: MainAxisAlignment.start, onPressed: () async { await widget.onSelectDirectorySubtitle(subtitle); _subtitlePopover.hide(); @@ -608,9 +654,28 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { ), ), ], - const Divider(height: 16), + const Divider(height: 14), ShadButton.ghost( size: ShadButtonSize.sm, + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: cs.foreground, + textStyle: itemStyle, + mainAxisAlignment: MainAxisAlignment.start, + onPressed: () async { + await widget.onSearchDirectorySubtitles(); + if (mounted) setState(() {}); + }, + leading: const Icon(Icons.search_rounded, size: 15), + child: const Text('搜索同目录字幕'), + ), + ShadButton.ghost( + size: ShadButtonSize.sm, + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: cs.foreground, + textStyle: itemStyle, + mainAxisAlignment: MainAxisAlignment.start, onPressed: () async { _subtitlePopover.hide(); await widget.onLoadLocalSubtitle();