From fbebf9468b9408b96280a4079cba7cb4a13034f7 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 15:52:12 +0800 Subject: [PATCH] feat: persist tmdb artwork urls instead of blobs --- lib/core/storage/media_library_store.dart | 93 +++++++++++++++++------ lib/pages/media_library_page.dart | 73 +++++------------- lib/providers/media_library_provider.dart | 17 +---- 3 files changed, 88 insertions(+), 95 deletions(-) diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index b877c51..177ccee 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:path/path.dart' as path; import 'package:sqflite/sqflite.dart'; @@ -22,7 +21,7 @@ class MediaLibraryStore { ); _database = await openDatabase( databasePath, - version: 3, + version: 4, onCreate: (db, _) async { await _createSchema(db); }, @@ -104,18 +103,6 @@ class MediaLibraryStore { return rows.map(_itemFromRow).toList(); } - Future posterBytes(String libraryID, String fileID) async { - final rows = await (await _db).query( - 'media_items', - columns: const ['poster'], - where: 'library_id = ? AND file_id = ?', - whereArgs: [libraryID, fileID], - limit: 1, - ); - final value = rows.isEmpty ? null : rows.first['poster']; - return value is Uint8List ? value : null; - } - Future saveLibraries(List libraries) async { final db = await _db; await db.transaction((txn) async { @@ -209,9 +196,33 @@ class MediaLibraryStore { await txn.execute( 'INSERT OR REPLACE INTO media_library_sources SELECT * FROM imported_backup.media_library_sources', ); - await txn.execute( - 'INSERT OR REPLACE INTO media_items SELECT * FROM imported_backup.media_items', + final importedColumns = await _tableColumns( + txn, + 'imported_backup', + 'media_items', ); + final importedPosterPath = importedColumns.contains('poster_path') + ? 'poster_path' + : 'NULL'; + final importedBackdropPath = importedColumns.contains('backdrop_path') + ? 'backdrop_path' + : 'NULL'; + await txn.execute(''' + INSERT OR REPLACE INTO media_items ( + library_id, file_id, resource_path, cloud_name, file_size, gcid, + file_type, tmdb_id, media_kind, title, original_title, + release_date, overview, poster_path, backdrop_path, poster, backdrop, + has_chinese_audio, has_chinese_subtitle, collection_id, + collection_name, updated_at + ) + SELECT + library_id, file_id, resource_path, cloud_name, file_size, gcid, + file_type, tmdb_id, media_kind, title, original_title, + release_date, overview, $importedPosterPath, $importedBackdropPath, + NULL, NULL, has_chinese_audio, has_chinese_subtitle, collection_id, + collection_name, updated_at + FROM imported_backup.media_items + '''); }); } finally { await db.execute('DETACH DATABASE imported_backup'); @@ -219,14 +230,14 @@ class MediaLibraryStore { return optimizeStorage(); } - /// Reclaims space used by imported backdrop BLOBs. Posters remain available - /// for offline poster walls; backdrops can always be fetched from TMDB again. + /// TMDB artwork is stored by address. This removes legacy binary artwork + /// from imported databases, while retaining every media and scrape record. Future optimizeStorage() async { final db = await _db; final before = await _databaseBytes(db.path); - final removedBackdrops = await db.rawUpdate( - 'UPDATE media_items SET backdrop = NULL WHERE backdrop IS NOT NULL', - ); + final removedArtwork = await db.rawUpdate('''UPDATE media_items + SET poster = NULL, backdrop = NULL + WHERE poster IS NOT NULL OR backdrop IS NOT NULL'''); await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); await db.execute('PRAGMA optimize'); await db.execute('VACUUM'); @@ -234,7 +245,7 @@ class MediaLibraryStore { return MediaLibraryStorageStats( beforeBytes: before, afterBytes: after, - removedBackdropCount: removedBackdrops, + removedArtworkCount: removedArtwork, ); } @@ -468,6 +479,8 @@ class MediaLibraryStore { original_title TEXT NOT NULL, release_date TEXT NOT NULL, overview TEXT NOT NULL, + poster_path TEXT, + backdrop_path TEXT, poster BLOB, backdrop BLOB, has_chinese_audio INTEGER NOT NULL DEFAULT 0, @@ -508,6 +521,8 @@ class MediaLibraryStore { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_file_index_gcid ON file_index(gcid)', ); + await _ensureColumn(db, 'media_items', 'poster_path', 'TEXT'); + await _ensureColumn(db, 'media_items', 'backdrop_path', 'TEXT'); } MediaLibraryItem _itemFromRow(Map row) { @@ -525,6 +540,8 @@ class MediaLibraryStore { 'originalTitle': row['original_title'], 'releaseDate': row['release_date'], 'overview': row['overview'], + 'posterPath': row['poster_path'], + 'backdropPath': row['backdrop_path'], 'hasChineseAudio': row['has_chinese_audio'] == 1, 'hasChineseSubtitle': row['has_chinese_subtitle'] == 1, 'collectionID': row['collection_id'], @@ -547,6 +564,8 @@ class MediaLibraryStore { 'original_title': item.originalTitle, 'release_date': item.releaseDate, 'overview': item.overview, + 'poster_path': item.posterPath, + 'backdrop_path': item.backdropPath, 'has_chinese_audio': item.hasChineseAudio ? 1 : 0, 'has_chinese_subtitle': item.hasChineseSubtitle ? 1 : 0, 'collection_id': item.collectionID, @@ -595,6 +614,8 @@ class MediaLibraryStore { 'original_title', 'release_date', 'overview', + 'poster_path', + 'backdrop_path', 'has_chinese_audio', 'has_chinese_subtitle', 'collection_id', @@ -611,17 +632,41 @@ class MediaLibraryStore { } return bytes; } + + Future> _tableColumns( + DatabaseExecutor db, + String schema, + String table, + ) async { + final rows = await db.rawQuery('PRAGMA $schema.table_info($table)'); + return rows + .map((row) => row['name']?.toString()) + .whereType() + .toSet(); + } + + Future _ensureColumn( + DatabaseExecutor db, + String table, + String column, + String definition, + ) async { + final columns = await _tableColumns(db, 'main', table); + if (!columns.contains(column)) { + await db.execute('ALTER TABLE $table ADD COLUMN $column $definition'); + } + } } class MediaLibraryStorageStats { final int beforeBytes; final int afterBytes; - final int removedBackdropCount; + final int removedArtworkCount; const MediaLibraryStorageStats({ required this.beforeBytes, required this.afterBytes, - required this.removedBackdropCount, + required this.removedArtworkCount, }); int get reclaimedBytes => (beforeBytes - afterBytes).clamp(0, beforeBytes); diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 8d30ca1..0a35428 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -1570,19 +1568,15 @@ class _MediaCollectionTile extends ConsumerWidget { border: Border.all(color: cs.border), ), clipBehavior: Clip.antiAlias, - child: FutureBuilder( - future: ref - .read(mediaLibraryProvider.notifier) - .posterBytes(item), - builder: (context, snapshot) { - if (snapshot.data != null) { - return Image.memory( - snapshot.data!, - fit: BoxFit.cover, - ); - } - if (posterURL != null) { - return Image.network( + child: posterURL == null + ? Center( + child: Icon( + Icons.collections_bookmark_rounded, + color: cs.mutedForeground, + size: 34, + ), + ) + : Image.network( posterURL, fit: BoxFit.cover, errorBuilder: (_, _, _) => Center( @@ -1592,17 +1586,7 @@ class _MediaCollectionTile extends ConsumerWidget { size: 34, ), ), - ); - } - return Center( - child: Icon( - Icons.collections_bookmark_rounded, - color: cs.mutedForeground, - size: 34, ), - ); - }, - ), ), Positioned( right: 7, @@ -1715,28 +1699,14 @@ class _MediaPosterTile extends ConsumerWidget { border: Border.all(color: cs.border), ), clipBehavior: Clip.antiAlias, - child: FutureBuilder( - future: ref - .read(mediaLibraryProvider.notifier) - .posterBytes(item), - builder: (context, snapshot) { - if (snapshot.data != null) { - return Image.memory( - snapshot.data!, - fit: BoxFit.cover, - ); - } - if (posterURL != null) { - return Image.network( + child: posterURL == null + ? _posterFallback(cs, isSeries) + : Image.network( posterURL, fit: BoxFit.cover, errorBuilder: (_, _, _) => _posterFallback(cs, isSeries), - ); - } - return _posterFallback(cs, isSeries); - }, - ), + ), ), if (work.resources.length > 1) Positioned( @@ -1992,23 +1962,14 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { height: 270, child: ClipRRect( borderRadius: BorderRadius.circular(6), - child: FutureBuilder( - future: ref.read(mediaLibraryProvider.notifier).posterBytes(item), - builder: (context, snapshot) { - if (snapshot.data != null) { - return Image.memory(snapshot.data!, fit: BoxFit.cover); - } - if (posterURL != null) { - return Image.network( + child: posterURL == null + ? _detailPosterFallback(cs, isSeries) + : Image.network( posterURL, fit: BoxFit.cover, errorBuilder: (_, _, _) => _detailPosterFallback(cs, isSeries), - ); - } - return _detailPosterFallback(cs, isSeries); - }, - ), + ), ), ), const SizedBox(width: 18), diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index bcdde44..028ee01 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter_riverpod/legacy.dart'; @@ -86,7 +85,6 @@ class MediaLibraryState { class MediaLibraryNotifier extends StateNotifier { GuangyaAPI? _api; final _store = MediaLibraryStore(); - final _posterRequests = >{}; bool _loaded = false; bool _cancelScan = false; @@ -251,9 +249,9 @@ class MediaLibraryNotifier extends StateNotifier { try { final stats = await _store.optimizeStorage(); state = state.copyWith( - statusMessage: stats.removedBackdropCount == 0 + statusMessage: stats.removedArtworkCount == 0 ? '本地刮削数据库已是最优状态' - : '已清理 ${stats.removedBackdropCount} 张背景图,回收 ${_formatBytes(stats.reclaimedBytes)}', + : '已清理 ${stats.removedArtworkCount} 条本地图片缓存,回收 ${_formatBytes(stats.reclaimedBytes)}', ); } catch (error) { state = state.copyWith(errorMessage: '数据库优化失败:$error'); @@ -408,17 +406,6 @@ class MediaLibraryNotifier extends StateNotifier { ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); } - Future posterBytes(MediaLibraryItem item) { - final key = '${item.libraryID}:${item.file.id}'; - return _posterRequests.putIfAbsent(key, () async { - final bytes = await _store.posterBytes(item.libraryID, item.file.id); - if (_posterRequests.length > 48) { - _posterRequests.remove(_posterRequests.keys.first); - } - return bytes; - }); - } - Future applyTMDBMatch( MediaLibraryItem item, Map candidate,