Files

723 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> 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<String>? 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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'name': name,
'mediaKind': mediaKind.name,
'languages': languages,
'isFallback': isFallback,
};
static List<MediaCategoryRule> 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<String, dynamic> json) {
return MediaLibrarySource(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
rootID: json['rootID']?.toString(),
path: json['path']?.toString() ?? '未配置目录',
);
}
Map<String, dynamic> toJson() => {'id': id, 'rootID': rootID, 'path': path};
}
class MediaLibraryDefinition {
final String id;
final String name;
final List<MediaLibrarySource> 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<MediaLibrarySource>? 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<String, dynamic> json) {
final rawSources = json['sources'];
final sources = rawSources is List
? rawSources
.whereType<Map>()
.map(
(item) => MediaLibrarySource.fromJson(
Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<MediaLibraryItem> items) {
var total = 0;
var movies = 0;
var series = 0;
var unmatched = 0;
final collections = <String>{};
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<String, dynamic> json) {
return MediaLibraryScanLog(
createdAt: _parseDate(json['createdAt']) ?? DateTime.now(),
message: json['message']?.toString() ?? '',
isError: json['isError'] == true,
);
}
Map<String, dynamic> 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<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}