chore: initialize Guangya Flutter project

This commit is contained in:
ngfchl
2026-07-18 10:23:34 +08:00
commit 6bcc3fdf18
143 changed files with 13939 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
import 'package:intl/intl.dart';
/// Cloud file model representing a file or folder in the cloud drive.
class CloudFile {
static const supportedVideoExtensions = {
'mp4',
'm4v',
'mkv',
'mov',
'avi',
'ts',
'm2ts',
'mts',
'webm',
'flv',
'f4v',
'wmv',
'asf',
'mpg',
'mpeg',
'vob',
'iso',
'rm',
'rmvb',
'3gp',
'ogv',
};
final String id;
final String name;
final bool isDirectory;
final int? size;
final String? gcid;
final int? subDirectoryCount;
final int? subFileCount;
final String modifiedAt;
final String cloudPath;
final int fileType;
const CloudFile({
required this.id,
required this.name,
required this.isDirectory,
this.size,
this.gcid,
this.subDirectoryCount,
this.subFileCount,
this.modifiedAt = '',
this.cloudPath = '',
this.fileType = 0,
});
bool get isVideo {
if (isDirectory) return false;
if (fileType == 2) return true;
final ext = name.split('.').last.toLowerCase();
return supportedVideoExtensions.contains(ext);
}
bool get isImage => fileType == 1;
bool get isAudio => fileType == 3;
bool get isDocument => fileType == 4;
String get icon {
if (isDirectory) return 'folder';
if (isVideo) return 'movie';
switch (fileType) {
case 1:
return 'image';
case 2:
return 'movie';
case 3:
return 'music_note';
case 4:
return 'description';
case 5:
case 9:
return 'archive';
default:
return 'insert_drive_file';
}
}
String get typeName {
if (isDirectory) return '文件夹';
if (isVideo) return '视频';
const names = {1: '图片', 2: '视频', 3: '音频', 4: '文档', 5: '压缩包', 9: 'BT种子'};
return names[fileType] ?? '文件';
}
String get formattedSize {
if (size == null) return '--';
return _formatBytes(size!);
}
static String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
factory CloudFile.fromJson(Map<String, dynamic> json) {
final name =
json['name'] ??
json['fileName'] ??
json['file_name'] ??
json['resName'] ??
json['res_name'] ??
json['dirName'] ??
json['dir_name'] ??
'';
final id = _extractId(json);
if (id.isEmpty) throw FormatException('Missing file ID');
final resourceType = _extractInt(json, ['resType']);
final type = _extractInt(json, ['fileType', 'type']) ?? 0;
bool isDir;
final explicitDir = json['isDir'] ?? json['dir'] ?? json['directoryType'];
if (explicitDir != null) {
isDir = _truthyDirectoryFlag(explicitDir);
} else if (resourceType != null) {
isDir = resourceType == 2;
} else {
isDir =
type == 0 && (json['dirName'] != null || json['children'] != null);
}
int? fileSize = _extractIntDeep(json, [
'size',
'fileSize',
'resSize',
'totalSize',
'dirSize',
'folderSize',
]);
if (isDir && fileSize == null) fileSize = 0;
final epoch = _extractIntDeep(json, ['utime', 'ctime']);
return CloudFile(
id: id,
name: name.toString(),
isDirectory: isDir,
size: fileSize,
gcid: _extractStringDeep(json, ['gcid', 'gcId', 'gcidValue', 'hash']),
subDirectoryCount: _extractIntDeep(json, ['subDirCount']),
subFileCount: _extractIntDeep(json, ['subFileCount']),
modifiedAt:
json['updateTime']?.toString() ??
json['updatedAt']?.toString() ??
json['modifyTime']?.toString() ??
json['createTime']?.toString() ??
(epoch == null ? null : _formatEpoch(epoch)) ??
'',
cloudPath:
_extractString(json, ['location', 'path', 'fullPath']) ??
name.toString(),
fileType: type,
);
}
static String _extractId(Map<String, dynamic> json) {
for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
return '';
}
static String? _extractString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null) return v.toString();
}
return null;
}
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
final value = _toInt(v);
if (value != null) return value;
}
return null;
}
static String? _extractStringDeep(
Map<String, dynamic> json,
List<String> keys,
) {
final direct = _extractString(json, keys);
if (direct != null && direct.isNotEmpty) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractStringDeep(value, keys);
if (found != null && found.isNotEmpty) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractStringDeep(item, keys);
if (found != null && found.isNotEmpty) return found;
}
}
}
}
return null;
}
static int? _extractIntDeep(Map<String, dynamic> json, List<String> keys) {
final direct = _extractInt(json, keys);
if (direct != null) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractIntDeep(value, keys);
if (found != null) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractIntDeep(item, keys);
if (found != null) return found;
}
}
}
}
return null;
}
static 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());
}
static bool _truthyDirectoryFlag(dynamic value) {
if (value is bool) return value;
if (value is num) return value == 1;
final text = value.toString().toLowerCase();
return text == '1' || text == 'true' || text == 'folder' || text == 'dir';
}
static String _formatEpoch(int epoch) {
final seconds = epoch > 9999999999 ? epoch ~/ 1000 : epoch;
return DateFormat(
'yyyy-MM-dd HH:mm',
).format(DateTime.fromMillisecondsSinceEpoch(seconds * 1000));
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is CloudFile && id == other.id;
@override
int get hashCode => id.hashCode;
}
+387
View File
@@ -0,0 +1,387 @@
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 '剧集';
}
}
}
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;
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 ParsedMediaName {
final String title;
final int? year;
final int? season;
final int? episode;
final bool isEpisode;
const ParsedMediaName({
required this.title,
this.year,
this.season,
this.episode,
this.isEpisode = false,
});
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',
caseSensitive: false,
).firstMatch(stem)?.start,
].whereType<int>().fold<int?>(
null,
(min, value) => min == null || value < min ? value : min,
);
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
title = title
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (title.isEmpty) title = stem.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,
);
}
}
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;
}