598 lines
17 KiB
Dart
598 lines
17 KiB
Dart
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) {
|
|
final parsed = ParsedMediaName.parse(file.name);
|
|
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) {
|
|
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 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'\s+'), ' ')
|
|
.trim();
|
|
if (title.isEmpty) title = normalized.trim();
|
|
|
|
return ParsedMediaName(
|
|
title: title,
|
|
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
|
|
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'),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|