Files
guangya_flutter/lib/models/media_library.dart
T
2026-07-18 18:18:50 +08:00

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;
}