import 'cloud_file.dart'; enum TMDBMediaKind { automatic, movie, tv } extension TMDBMediaKindX on TMDBMediaKind { String get title { switch (this) { case TMDBMediaKind.automatic: return '自动识别'; case TMDBMediaKind.movie: return '电影'; case TMDBMediaKind.tv: return '剧集'; } } } class MediaCategoryRule { final String id; final String name; final TMDBMediaKind mediaKind; final List languages; final bool isFallback; const MediaCategoryRule({ required this.id, required this.name, required this.mediaKind, this.languages = const [], this.isFallback = false, }); MediaCategoryRule copyWith({ String? name, TMDBMediaKind? mediaKind, List? languages, bool? isFallback, }) { return MediaCategoryRule( id: id, name: name ?? this.name, mediaKind: mediaKind ?? this.mediaKind, languages: languages ?? this.languages, isFallback: isFallback ?? this.isFallback, ); } factory MediaCategoryRule.fromJson(Map json) { return MediaCategoryRule( id: json['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), name: json['name']?.toString() ?? '未命名分类', mediaKind: TMDBMediaKind.values.firstWhere( (kind) => kind.name == json['mediaKind']?.toString(), orElse: () => TMDBMediaKind.movie, ), languages: (json['languages'] as List?) ?.map((value) => value.toString().trim()) .where((value) => value.isNotEmpty) .toList() ?? const [], isFallback: json['isFallback'] == true, ); } Map toJson() => { 'id': id, 'name': name, 'mediaKind': mediaKind.name, 'languages': languages, 'isFallback': isFallback, }; static List presets() => const [ MediaCategoryRule( id: 'preset-movie-cn', name: '国产电影', mediaKind: TMDBMediaKind.movie, languages: ['zh', 'cn', 'yue'], ), MediaCategoryRule( id: 'preset-movie-jpkr', name: '日韩电影', mediaKind: TMDBMediaKind.movie, languages: ['ja', 'ko', 'th'], ), MediaCategoryRule( id: 'preset-movie-west', name: '欧美电影', mediaKind: TMDBMediaKind.movie, languages: ['en'], ), MediaCategoryRule( id: 'preset-movie-other', name: '其他电影', mediaKind: TMDBMediaKind.movie, isFallback: true, ), MediaCategoryRule( id: 'preset-tv-cn', name: '国产剧集', mediaKind: TMDBMediaKind.tv, languages: ['zh', 'cn', 'yue'], ), MediaCategoryRule( id: 'preset-tv-jpkr', name: '日韩剧集', mediaKind: TMDBMediaKind.tv, languages: ['ja', 'ko', 'th'], ), MediaCategoryRule( id: 'preset-tv-west', name: '欧美剧集', mediaKind: TMDBMediaKind.tv, languages: ['en'], ), MediaCategoryRule( id: 'preset-tv-other', name: '其他剧集', mediaKind: TMDBMediaKind.tv, isFallback: true, ), ]; } enum MediaLibraryKind { movies, series, mixed } extension MediaLibraryKindX on MediaLibraryKind { String get title { switch (this) { case MediaLibraryKind.movies: return '电影'; case MediaLibraryKind.series: return '电视剧'; case MediaLibraryKind.mixed: return '混合内容'; } } } class MediaLibrarySource { final String id; final String? rootID; final String path; const MediaLibrarySource({ required this.id, required this.rootID, required this.path, }); factory MediaLibrarySource.fromJson(Map json) { return MediaLibrarySource( id: json['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), rootID: json['rootID']?.toString(), path: json['path']?.toString() ?? '未配置目录', ); } Map toJson() => {'id': id, 'rootID': rootID, 'path': path}; } class MediaLibraryDefinition { final String id; final String name; final List sources; final MediaLibraryKind kind; final bool recursive; final int minimumSizeMB; final DateTime? updatedAt; const MediaLibraryDefinition({ required this.id, required this.name, required this.sources, this.kind = MediaLibraryKind.mixed, this.recursive = true, this.minimumSizeMB = 50, this.updatedAt, }); String? get rootID => sources.isEmpty ? null : sources.first.rootID; String get rootPath { if (sources.length == 1) return sources.first.path; if (sources.isEmpty) return '未配置目录'; return '${sources.length} 个媒体目录'; } MediaLibraryDefinition copyWith({ String? name, List? sources, MediaLibraryKind? kind, bool? recursive, int? minimumSizeMB, DateTime? updatedAt, }) { return MediaLibraryDefinition( id: id, name: name ?? this.name, sources: sources ?? this.sources, kind: kind ?? this.kind, recursive: recursive ?? this.recursive, minimumSizeMB: minimumSizeMB ?? this.minimumSizeMB, updatedAt: updatedAt ?? this.updatedAt, ); } factory MediaLibraryDefinition.fromJson(Map json) { final rawSources = json['sources']; final sources = rawSources is List ? rawSources .whereType() .map( (item) => MediaLibrarySource.fromJson( Map.from(item), ), ) .toList() : [ MediaLibrarySource( id: '${json['id'] ?? DateTime.now().microsecondsSinceEpoch}-legacy', rootID: json['rootID']?.toString(), path: json['rootPath']?.toString() ?? '未配置目录', ), ]; return MediaLibraryDefinition( id: json['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), name: json['name']?.toString() ?? '未命名媒体库', sources: sources, kind: MediaLibraryKind.values.firstWhere( (kind) => kind.name == json['kind']?.toString(), orElse: () => MediaLibraryKind.mixed, ), recursive: json['recursive'] != false, minimumSizeMB: _toInt(json['minimumSizeMB']) ?? 50, updatedAt: _parseDate(json['updatedAt']), ); } Map toJson() => { 'id': id, 'name': name, 'sources': sources.map((source) => source.toJson()).toList(), 'kind': kind.name, 'recursive': recursive, 'minimumSizeMB': minimumSizeMB, 'updatedAt': updatedAt?.toIso8601String(), }; } class MediaLibraryItem { final String libraryID; final CloudFile file; final int? tmdbID; final String title; final String originalTitle; final TMDBMediaKind? mediaKind; final String releaseDate; final String overview; final String? posterPath; final String? backdropPath; final bool hasChineseAudio; final bool hasChineseSubtitle; final int? collectionID; final String? collectionName; final DateTime updatedAt; const MediaLibraryItem({ required this.libraryID, required this.file, this.tmdbID, required this.title, required this.originalTitle, this.mediaKind, this.releaseDate = '', this.overview = '', this.posterPath, this.backdropPath, this.hasChineseAudio = false, this.hasChineseSubtitle = false, this.collectionID, this.collectionName, required this.updatedAt, }); String get id => file.id; String get year => releaseDate.length >= 4 ? releaseDate.substring(0, 4) : ''; bool get isMatched => mediaKind != null && tmdbID != null; MediaLibraryItem copyWith({ CloudFile? file, int? tmdbID, bool clearTMDBID = false, String? title, String? originalTitle, TMDBMediaKind? mediaKind, bool clearMediaKind = false, String? releaseDate, String? overview, String? posterPath, String? backdropPath, bool? hasChineseAudio, bool? hasChineseSubtitle, int? collectionID, String? collectionName, DateTime? updatedAt, }) { return MediaLibraryItem( libraryID: libraryID, file: file ?? this.file, tmdbID: clearTMDBID ? null : (tmdbID ?? this.tmdbID), title: title ?? this.title, originalTitle: originalTitle ?? this.originalTitle, mediaKind: clearMediaKind ? null : (mediaKind ?? this.mediaKind), releaseDate: releaseDate ?? this.releaseDate, overview: overview ?? this.overview, posterPath: posterPath ?? this.posterPath, backdropPath: backdropPath ?? this.backdropPath, hasChineseAudio: hasChineseAudio ?? this.hasChineseAudio, hasChineseSubtitle: hasChineseSubtitle ?? this.hasChineseSubtitle, collectionID: collectionID ?? this.collectionID, collectionName: collectionName ?? this.collectionName, updatedAt: updatedAt ?? this.updatedAt, ); } 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, file: file, title: parsed.title, originalTitle: parsed.title, mediaKind: kind, releaseDate: parsed.year == null ? '' : '${parsed.year}-01-01', updatedAt: DateTime.now(), ); } factory MediaLibraryItem.fromJson(Map json) { return MediaLibraryItem( libraryID: json['libraryID']?.toString() ?? '', file: CloudFile( id: json['fileID']?.toString() ?? '', name: json['cloudName']?.toString() ?? '', isDirectory: false, size: _toInt(json['fileSize']), gcid: json['gcid']?.toString(), modifiedAt: json['modifiedAt']?.toString() ?? '', cloudPath: json['resourcePath']?.toString() ?? '', fileType: _toInt(json['fileType']) ?? 2, ), tmdbID: _toInt(json['tmdbID']), title: json['title']?.toString() ?? '', originalTitle: json['originalTitle']?.toString() ?? '', mediaKind: TMDBMediaKind.values .where((kind) => kind.name == json['mediaKind']) .firstOrNull, releaseDate: json['releaseDate']?.toString() ?? '', overview: json['overview']?.toString() ?? '', posterPath: json['posterPath']?.toString(), backdropPath: json['backdropPath']?.toString(), hasChineseAudio: json['hasChineseAudio'] == true, hasChineseSubtitle: json['hasChineseSubtitle'] == true, collectionID: _toInt(json['collectionID']), collectionName: json['collectionName']?.toString(), updatedAt: _parseDate(json['updatedAt']) ?? DateTime.now(), ); } Map toJson() => { 'libraryID': libraryID, 'fileID': file.id, 'resourcePath': file.cloudPath, 'cloudName': file.name, 'fileSize': file.size, 'gcid': file.gcid, 'fileType': file.fileType, 'modifiedAt': file.modifiedAt, 'tmdbID': tmdbID, 'mediaKind': mediaKind?.name, 'title': title, 'originalTitle': originalTitle, 'releaseDate': releaseDate, 'overview': overview, 'posterPath': posterPath, 'backdropPath': backdropPath, 'hasChineseAudio': hasChineseAudio, 'hasChineseSubtitle': hasChineseSubtitle, 'collectionID': collectionID, 'collectionName': collectionName, 'updatedAt': updatedAt.toIso8601String(), }; } class MediaLibraryStatistics { final int total; final int movies; final int series; final int unmatched; final int collections; const MediaLibraryStatistics({ this.total = 0, this.movies = 0, this.series = 0, this.unmatched = 0, this.collections = 0, }); factory MediaLibraryStatistics.fromItems(Iterable items) { var total = 0; var movies = 0; var series = 0; var unmatched = 0; final collections = {}; for (final item in items) { total++; if (item.mediaKind == TMDBMediaKind.movie) movies++; if (item.mediaKind == TMDBMediaKind.tv) series++; if (!item.isMatched) unmatched++; final collectionKey = item.collectionID?.toString() ?? item.collectionName; if (collectionKey != null && collectionKey.isNotEmpty) { collections.add(collectionKey); } } return MediaLibraryStatistics( total: total, movies: movies, series: series, unmatched: unmatched, collections: collections.length, ); } } class MediaLibraryScanProgress { final String phase; final int completed; final int total; const MediaLibraryScanProgress({ this.phase = '', this.completed = 0, this.total = 0, }); } class MediaLibraryScanLog { final DateTime createdAt; final String message; final bool isError; const MediaLibraryScanLog({ required this.createdAt, required this.message, this.isError = false, }); factory MediaLibraryScanLog.fromJson(Map json) { return MediaLibraryScanLog( createdAt: _parseDate(json['createdAt']) ?? DateTime.now(), message: json['message']?.toString() ?? '', isError: json['isError'] == true, ); } Map toJson() => { 'createdAt': createdAt.toIso8601String(), 'message': message, 'isError': isError, }; } class ParsedMediaName { final String title; final int? year; 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, this.year, this.season, this.episode, this.isEpisode = false, this.resolution, this.source, this.videoCodec, this.audio, this.dynamicRange, }); factory ParsedMediaName.parse(String name, {String? directoryName}) { 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(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 yearMatches = RegExp( r'\b(19\d{2}|20\d{2})\b', ).allMatches(normalized).toList(); // Some release names carry an upload year before the real title and // release year. Prefer the later year in that specific form. final yearMatch = yearMatches.length > 1 && yearMatches.first.start == 0 ? yearMatches.last : yearMatches.firstOrNull; 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}|\d{3,4}x\d{3,4}|2160p|1080p|720p|480p|4k|web[- ]?(?:dl|rip)?|bluray|bdrip|remux|hdtv|dvd|bd|x26[45]|h\.?26[45]|hevc|av1|aac|ac3|eac3|flac|truehd|dts|ddp|atmos|hdr|dv|国语|粤语|国粤(?:双语)?|中(?:英|日|韩)?(?:双语|字幕)|中文字幕|简繁(?:字幕)?)\b', caseSensitive: false, ); final boundaryMatches = boundary.allMatches(normalized).toList(); final boundaryMatch = boundaryMatches.length > 1 && boundaryMatches.first.start == 0 && RegExp( r'^(?:19\d{2}|20\d{2})$', ).hasMatch(boundaryMatches.first.group(0) ?? '') ? boundaryMatches[1] : boundaryMatches.firstOrNull; var title = boundaryMatch != null ? normalized.substring(0, boundaryMatch.start) : normalized; if (yearMatches.length > 1 && yearMatches.first.start == 0) { title = title.replaceFirst(RegExp(r'^\s*\d{4}[ ._-]+'), ''); } title = _cleanTitle(title); 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; } final seasonOnly = RegExp( r'\b(?:Season|S)\s*0?(\d{1,2})\b', caseSensitive: false, ).firstMatch(normalized); if (season == null && seasonOnly != null) { season = int.tryParse(seasonOnly.group(1)!); } // Disc/episode files are frequently named only "0102" or "02". Use an // explicit season from the parent directory first; otherwise only infer // the unambiguous SS EE form to avoid treating years as episode numbers. final numericOnly = RegExp(r'^\d{1,4}$').hasMatch(stem.trim()); if (episode == null && numericOnly && directoryName != null) { final digits = stem.trim(); final parentSeason = parent?.season; if (parentSeason != null) { season = parentSeason; episode = int.tryParse(digits); } else if (digits.length == 3 || digits.length == 4) { final split = digits.length - 2; final inferredSeason = int.tryParse(digits.substring(0, split)); final inferredEpisode = int.tryParse(digits.substring(split)); if (inferredSeason != null && inferredSeason > 0 && inferredSeason <= 99 && inferredEpisode != null && inferredEpisode > 0) { season = inferredSeason; episode = inferredEpisode; } } } if (title.isEmpty) title = normalized.trim(); return ParsedMediaName( title: title, year: yearMatch == null ? parent?.year : int.tryParse(yearMatch.group(1)!), season: season ?? parent?.season, episode: episode, isEpisode: (season ?? parent?.season) != null && episode != null, resolution: first(r'\b(?:2160p|1080p|720p|480p|4k|\d{3,4}x\d{3,4})\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'(?:HDR10?\+?|HDR|Dolby[ .-]?Vision|DV)'), ); } static String _cleanTitle(String value) { var title = value.trim(); // Release collectors often prepend their collection date. It has no // relation to the movie year and degrades a TMDB query substantially. title = title.replaceFirst( RegExp(r'^\s*(?:19|20)\d{2}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]?\s*'), '', ); // Release groups frequently put all disc/menu/audio/subtitle information // in a bracket between the Chinese and original titles. Keep the leading // title and discard the whole release block instead of joining both sides. final bracket = RegExp(r'[\[【]([^\]】]*)[\]】]').firstMatch(title); if (bracket != null && RegExp( r'UHD|ULTRAHD|Blu[- ]?ray|BDJ|BDMV|原盘|DIY|菜单|音轨|国语|国配|字幕|SGNB|CHDBits', caseSensitive: false, ).hasMatch(bracket.group(1) ?? '')) { title = title.substring(0, bracket.start); } // A few release tags are written without brackets. They are never part of // a searchable title and should terminate the human-readable name. final releaseBoundary = RegExp( r'(?:\b(?:ULTRA[ .-]?HD|UHD|Blu[- ]?ray|BDRip|REMUX|BDJ|BDMV)\b|原盘|DIY|菜单|音轨|国语|国配|字幕|次时代|SGNB|CHDBits)', caseSensitive: false, ).firstMatch(title); if (releaseBoundary != null) { title = title.substring(0, releaseBoundary.start); } // Prefer the localized title when an English original title follows it. // TMDB receives a compact Chinese query first; the original title remains // available from TMDB after matching. final chineseEnglishBoundary = RegExp( r'[\u4e00-\u9fff\))]\s*(?=[A-Z][A-Za-z])', ).firstMatch(title); if (chineseEnglishBoundary != null) { title = title.substring(0, chineseEnglishBoundary.start + 1); } title = title .replaceFirst(RegExp(r'^\s*[\[【((]\s*\d{1,3}\s*[\]】))][ ._-]*'), '') .replaceFirst(RegExp(r'^\s*\d{1,3}[ ._-]+'), '') .replaceAll(RegExp(r'[\((](?:港台|港版?|台版?|国配|国语|简繁?|中字?)[\))]'), ' ') .replaceAll( RegExp( r'(?:[ ._-]+|^)(?:国[粤英日韩][语字]?|国粤(?:双语)?(?:中字|中英(?:字幕|双字)?)?|国语|粤语|(?:中英|中日|中韩)(?:双语|字幕)?|中文字幕|中字|简繁(?:字幕)?|内封(?:特效)?中英(?:双字|字幕)?|CHINESE|CHN)(?=$|[ ._-])', caseSensitive: false, ), ' ', ) .replaceAll(RegExp(r'[\[\]【】{}()()]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); return title; } } int? _toInt(dynamic value) { if (value == null) return null; if (value is int) return value; if (value is double) return value.toInt(); return int.tryParse(value.toString()); } DateTime? _parseDate(dynamic value) { if (value == null) return null; if (value is DateTime) return value; return DateTime.tryParse(value.toString()); } extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; }