Files
guangya_flutter/lib/providers/media_library_provider.dart
T

732 lines
22 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/legacy.dart';
import '../api/guangya_api.dart';
import '../core/storage/file_metadata_cache.dart';
import '../core/storage/storage_manager.dart';
import '../models/cloud_file.dart';
import '../models/media_library.dart';
class MediaLibraryState {
final List<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
final List<MediaLibraryItem> items;
final bool isLoading;
final bool isScanning;
final MediaLibraryScanProgress progress;
final String searchQuery;
final String? errorMessage;
final String? statusMessage;
const MediaLibraryState({
this.libraries = const [],
this.selectedLibraryID,
this.items = const [],
this.isLoading = false,
this.isScanning = false,
this.progress = const MediaLibraryScanProgress(),
this.searchQuery = '',
this.errorMessage,
this.statusMessage,
});
MediaLibraryDefinition? get selectedLibrary {
for (final library in libraries) {
if (library.id == selectedLibraryID) return library;
}
return libraries.isEmpty ? null : libraries.first;
}
List<MediaLibraryItem> get visibleItems {
final query = searchQuery.trim().toLowerCase();
if (query.isEmpty) return items;
return items.where((item) {
return item.title.toLowerCase().contains(query) ||
item.file.name.toLowerCase().contains(query) ||
item.file.cloudPath.toLowerCase().contains(query);
}).toList();
}
MediaLibraryStatistics get statistics =>
MediaLibraryStatistics.fromItems(items);
MediaLibraryState copyWith({
List<MediaLibraryDefinition>? libraries,
String? selectedLibraryID,
bool clearSelectedLibrary = false,
List<MediaLibraryItem>? items,
bool? isLoading,
bool? isScanning,
MediaLibraryScanProgress? progress,
String? searchQuery,
String? errorMessage,
bool clearError = false,
String? statusMessage,
bool clearStatus = false,
}) {
return MediaLibraryState(
libraries: libraries ?? this.libraries,
selectedLibraryID: clearSelectedLibrary
? null
: (selectedLibraryID ?? this.selectedLibraryID),
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
isScanning: isScanning ?? this.isScanning,
progress: progress ?? this.progress,
searchQuery: searchQuery ?? this.searchQuery,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
);
}
}
class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
GuangyaAPI? _api;
bool _loaded = false;
bool _cancelScan = false;
MediaLibraryNotifier() : super(const MediaLibraryState());
set api(GuangyaAPI value) => _api = value;
Future<void> load() async {
if (_loaded) return;
_loaded = true;
state = state.copyWith(isLoading: true, clearError: true);
try {
final libraries = _loadLibraries();
final selectedID = libraries.isEmpty ? null : libraries.first.id;
final items = selectedID == null
? <MediaLibraryItem>[]
: _loadItems(selectedID);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
items: items,
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<void> selectLibrary(String id) async {
state = state.copyWith(
selectedLibraryID: id,
items: _loadItems(id),
searchQuery: '',
clearError: true,
);
}
Future<void> createLibrary({
required String name,
required String? rootID,
required String rootPath,
List<MediaLibrarySource>? sources,
MediaLibraryKind kind = MediaLibraryKind.mixed,
bool recursive = true,
int minimumSizeMB = 50,
}) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
final now = DateTime.now();
final id = now.microsecondsSinceEpoch.toString();
final library = MediaLibraryDefinition(
id: id,
name: trimmed,
sources:
sources ??
[
MediaLibrarySource(
id: '$id-source-0',
rootID: rootID,
path: rootPath,
),
],
kind: kind,
recursive: recursive,
minimumSizeMB: minimumSizeMB,
updatedAt: now,
);
final libraries = [...state.libraries, library];
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: library.id,
items: const [],
statusMessage: '已创建媒体库「${library.name}」',
);
}
Future<void> deleteLibrary(String id) async {
final libraries = state.libraries
.where((library) => library.id != id)
.toList();
final allItems = _loadAllItems()
..removeWhere((item) => item.libraryID == id);
await _saveLibraries(libraries);
await _saveAllItems(allItems);
final selectedID = libraries.isEmpty ? null : libraries.first.id;
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
clearSelectedLibrary: selectedID == null,
items: selectedID == null ? const [] : _loadItems(selectedID),
statusMessage: '媒体库已删除',
);
}
Future<void> scanSelectedLibrary() async {
final library = state.selectedLibrary;
if (library == null || _api == null || state.isScanning) return;
_cancelScan = false;
state = state.copyWith(
isScanning: true,
progress: const MediaLibraryScanProgress(phase: '准备扫描'),
clearError: true,
clearStatus: true,
);
try {
final initialItems = _loadAllItems()
..removeWhere((item) => item.libraryID == library.id);
final unique = <String, MediaLibraryItem>{
for (final item in _loadItems(library.id)) item.file.id: item,
};
state = state.copyWith(items: unique.values.toList());
final tmdbApiKey =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
final tmdbProxyHost =
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
final tmdbProxyPort =
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '';
var completed = 0;
Future<void> pendingPersistence = Future.value();
Future<void> indexBatch(List<CloudFile> files) async {
final pending = files
.where((file) => !unique.containsKey(file.id))
.toList();
if (pending.isEmpty || _cancelScan) return;
var next = 0;
Future<void> worker() async {
while (!_cancelScan && next < pending.length) {
final file = pending[next++];
final fallback = MediaLibraryItem.fromFile(library.id, file);
final item = await _recognizeMediaItem(
fallback,
tmdbApiKey,
proxyHost: tmdbProxyHost,
proxyPort: tmdbProxyPort,
);
unique[file.id] = item;
completed += 1;
final visible = unique.values.toList()
..sort(
(a, b) =>
a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
pendingPersistence = pendingPersistence.then(
(_) => _saveAllItems([...initialItems, ...unique.values]),
);
await pendingPersistence;
state = state.copyWith(
items: visible,
progress: MediaLibraryScanProgress(
phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}',
completed: completed,
),
);
}
}
final concurrency =
(int.tryParse(
StorageManager.get<String>(
StorageKeys.mediaScanConcurrency,
) ??
'3',
) ??
3)
.clamp(1, 20);
await Future.wait(List.generate(concurrency, (_) => worker()));
}
for (final source in library.sources) {
if (_cancelScan) break;
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${source.path}',
completed: completed,
),
);
await _scanSource(
source.rootID,
source.path,
recursive: library.recursive,
minimumSizeBytes: library.minimumSizeMB * 1024 * 1024,
onMediaFiles: indexBatch,
);
}
await pendingPersistence;
final items = unique.values.toList()
..sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final allItems = [...initialItems, ...items];
final updatedLibrary = library.copyWith(updatedAt: DateTime.now());
final libraries = state.libraries
.map((item) => item.id == library.id ? updatedLibrary : item)
.toList();
await _saveAllItems(allItems);
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
items: items,
statusMessage: _cancelScan
? '扫描已停止,已保留 ${items.length} 个项目'
: '扫描完成:${items.length} 个视频文件',
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(
isScanning: false,
progress: const MediaLibraryScanProgress(),
);
}
}
void cancelScan() {
_cancelScan = true;
state = state.copyWith(
progress: const MediaLibraryScanProgress(phase: '正在停止扫描'),
);
}
void setSearchQuery(String query) {
state = state.copyWith(searchQuery: query);
}
Future<List<MediaLibraryItem>> searchAllItems(String query) async {
final normalized = query.trim().toLowerCase();
if (normalized.isEmpty) return const [];
return _loadAllItems().where((item) {
return item.title.toLowerCase().contains(normalized) ||
item.file.name.toLowerCase().contains(normalized) ||
item.file.cloudPath.toLowerCase().contains(normalized);
}).toList()
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
}
void clearError() {
state = state.copyWith(clearError: true);
}
Future<MediaLibraryItem> _recognizeMediaItem(
MediaLibraryItem fallback,
String apiKey, {
required String proxyHost,
required String proxyPort,
}) async {
if (_api == null || apiKey.trim().isEmpty) return fallback;
try {
final result = await _api!.tmdbSearch(
fallback.title,
apiKey: apiKey,
proxyHost: proxyHost,
proxyPort: proxyPort,
);
final values = result['results'];
if (values is! List) return fallback;
Map<String, dynamic>? candidate;
for (final value in values) {
if (value is! Map) continue;
final map = Map<String, dynamic>.from(value);
final type = map['media_type']?.toString();
if (type == 'movie' || type == 'tv') {
candidate = map;
break;
}
}
if (candidate == null) return fallback;
final type = candidate['media_type']?.toString();
final title = (candidate['title'] ?? candidate['name'])
?.toString()
.trim();
final originalTitle =
(candidate['original_title'] ?? candidate['original_name'])
?.toString()
.trim();
final releaseDate =
(candidate['release_date'] ?? candidate['first_air_date'])
?.toString() ??
'';
return MediaLibraryItem(
libraryID: fallback.libraryID,
file: fallback.file,
tmdbID: _toInt(candidate['id']),
title: title == null || title.isEmpty ? fallback.title : title,
originalTitle: originalTitle == null || originalTitle.isEmpty
? fallback.originalTitle
: originalTitle,
mediaKind: type == 'tv' ? TMDBMediaKind.tv : TMDBMediaKind.movie,
releaseDate: releaseDate,
overview: candidate['overview']?.toString() ?? '',
posterPath: candidate['poster_path']?.toString(),
backdropPath: candidate['backdrop_path']?.toString(),
updatedAt: DateTime.now(),
);
} catch (_) {
return fallback;
}
}
int? _toInt(dynamic value) {
if (value is int) return value;
return int.tryParse(value?.toString() ?? '');
}
Future<void> _scanSource(
String? rootID,
String rootPath, {
required bool recursive,
required int minimumSizeBytes,
required Future<void> Function(List<CloudFile> files) onMediaFiles,
}) async {
final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)];
final visited = <String>{};
var discovered = 0;
while (folders.isNotEmpty && !_cancelScan) {
final folder = folders.removeAt(0);
final visitKey = folder.id ?? 'root';
if (!visited.add(visitKey)) continue;
var page = 0;
final cachedFolder = FileMetadataCache.folderChildren(folder.id);
final folderSnapshot = <CloudFile>[];
while (!_cancelScan) {
late List<CloudFile> files;
if (cachedFolder != null) {
files = cachedFolder;
} else {
final response = await _api!.fsFiles(
parentID: folder.id,
page: page,
pageSize: 200,
orderBy: 0,
sortType: 0,
);
files = _extractFiles(response);
files = await _enrichAndCacheFiles(files);
folderSnapshot.addAll(files);
}
final mediaBatch = <CloudFile>[];
for (final file in files) {
if (file.isDirectory) {
if (recursive) {
folders.add(_ScanFolder(file.id, '${folder.path}/${file.name}'));
}
} else if (file.isVideo && (file.size ?? 0) >= minimumSizeBytes) {
mediaBatch.add(_withPath(file, '${folder.path}/${file.name}'));
}
}
discovered += mediaBatch.length;
if (mediaBatch.isNotEmpty) await onMediaFiles(mediaBatch);
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${folder.path}',
completed: discovered,
total: folders.length + visited.length,
),
);
if (cachedFolder != null || files.length < 200) {
if (cachedFolder == null) {
await FileMetadataCache.cacheFolderChildren(
folder.id,
folderSnapshot,
);
}
break;
}
page += 1;
}
}
}
Future<List<CloudFile>> _enrichAndCacheFiles(List<CloudFile> files) async {
final resolved = <CloudFile>[];
final pending = <CloudFile>[];
for (final file in files) {
final cached = FileMetadataCache.file(file.id);
if (cached != null) {
resolved.add(
file.copyWith(
size: cached.size,
gcid: cached.gcid,
modifiedAt: cached.modifiedAt,
cloudPath: file.cloudPath,
),
);
} else if (!file.isDirectory &&
(file.gcid == null || file.gcid!.isEmpty)) {
pending.add(file);
} else {
resolved.add(file);
}
}
if (pending.isEmpty) {
await FileMetadataCache.cacheFiles(resolved);
return resolved;
}
final enriched = <String, CloudFile>{
for (final file in resolved) file.id: file,
};
var next = 0;
Future<void> worker() async {
while (next < pending.length) {
final file = pending[next++];
try {
final detail = await _api!.fsDetail(file.id);
final detailFile = _extractFiles(
detail,
).where((candidate) => candidate.id == file.id).firstOrNull;
final gcid =
detailFile?.gcid ??
_findStringDeep(detail, const [
'gcid',
'gcId',
'gcidValue',
'hash',
]);
final size =
detailFile?.size ??
_findIntDeep(detail, const [
'size',
'fileSize',
'resSize',
'totalSize',
]);
enriched[file.id] = file.copyWith(gcid: gcid, size: size);
} catch (_) {
enriched[file.id] = file;
}
}
}
await Future.wait(List.generate(6, (_) => worker()));
final values = [for (final file in files) enriched[file.id] ?? file];
await FileMetadataCache.cacheFiles(values);
return values;
}
String? _findStringDeep(Map<String, dynamic> value, List<String> keys) {
for (final entry in value.entries) {
if (keys.contains(entry.key) && entry.value != null) {
final text = entry.value.toString().trim();
if (text.isNotEmpty) return text;
}
if (entry.value is Map) {
final found = _findStringDeep(
Map<String, dynamic>.from(entry.value),
keys,
);
if (found != null) return found;
} else if (entry.value is List) {
for (final child in entry.value as List) {
if (child is Map) {
final found = _findStringDeep(
Map<String, dynamic>.from(child),
keys,
);
if (found != null) return found;
}
}
}
}
return null;
}
int? _findIntDeep(Map<String, dynamic> value, List<String> keys) {
for (final entry in value.entries) {
if (keys.contains(entry.key)) {
final parsed = int.tryParse(entry.value?.toString() ?? '');
if (parsed != null) return parsed;
}
if (entry.value is Map) {
final found = _findIntDeep(
Map<String, dynamic>.from(entry.value),
keys,
);
if (found != null) return found;
} else if (entry.value is List) {
for (final child in entry.value as List) {
if (child is Map) {
final found = _findIntDeep(Map<String, dynamic>.from(child), keys);
if (found != null) return found;
}
}
}
}
return null;
}
CloudFile _withPath(CloudFile file, String path) {
return CloudFile(
id: file.id,
name: file.name,
isDirectory: file.isDirectory,
size: file.size,
gcid: file.gcid,
subDirectoryCount: file.subDirectoryCount,
subFileCount: file.subFileCount,
modifiedAt: file.modifiedAt,
cloudPath: path,
fileType: file.fileType,
);
}
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void appendList(List<dynamic> values) {
for (final value in values) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
}
}
}
final preferred = _findArrayDeep(json, const [
'list',
'files',
'fileList',
'items',
'records',
'rows',
'resList',
'resourceList',
]);
if (preferred != null) {
appendList(preferred);
return result;
}
void visit(dynamic value) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
for (final child in value.values) {
visit(child);
}
} else if (value is List) {
for (final child in value) {
visit(child);
}
}
}
visit(json);
return result;
}
List<MediaLibraryDefinition> _loadLibraries() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraries);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) =>
MediaLibraryDefinition.fromJson(Map<String, dynamic>.from(item)),
)
.toList();
}
List<MediaLibraryItem> _loadItems(String libraryID) {
return _loadAllItems()
.where((item) => item.libraryID == libraryID)
.toList();
}
List<MediaLibraryItem> _loadAllItems() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraryItems);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) => MediaLibraryItem.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.file.id.isNotEmpty)
.toList();
}
Future<void> _saveLibraries(List<MediaLibraryDefinition> libraries) {
return StorageManager.set(
StorageKeys.mediaLibraries,
libraries.map((library) => library.toJson()).toList(),
);
}
Future<void> _saveAllItems(List<MediaLibraryItem> items) {
return StorageManager.set(
StorageKeys.mediaLibraryItems,
items.map((item) => item.toJson()).toList(),
);
}
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value is List) return value;
}
const preferredKeys = ['data', 'result', 'payload'];
for (final key in preferredKeys) {
final value = json[key];
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
for (final entry in json.entries) {
if (preferredKeys.contains(entry.key)) continue;
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
return null;
}
}
class _ScanFolder {
final String? id;
final String path;
const _ScanFolder(this.id, this.path);
}
final mediaLibraryProvider =
StateNotifierProvider<MediaLibraryNotifier, MediaLibraryState>(
(ref) => MediaLibraryNotifier(),
);