feat: persist tmdb artwork urls instead of blobs

This commit is contained in:
ngfchl
2026-07-18 15:52:12 +08:00
parent 836ea0e3d3
commit fbebf9468b
3 changed files with 88 additions and 95 deletions
+69 -24
View File
@@ -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<Uint8List?> 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<void> saveLibraries(List<MediaLibraryDefinition> 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<MediaLibraryStorageStats> 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<String, Object?> 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<Set<String>> _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<String>()
.toSet();
}
Future<void> _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);
+17 -56
View File
@@ -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<Uint8List?>(
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<Uint8List?>(
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<Uint8List?>(
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),
+2 -15
View File
@@ -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<MediaLibraryState> {
GuangyaAPI? _api;
final _store = MediaLibraryStore();
final _posterRequests = <String, Future<Uint8List?>>{};
bool _loaded = false;
bool _cancelScan = false;
@@ -251,9 +249,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
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<MediaLibraryState> {
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
}
Future<Uint8List?> 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<MediaLibraryItem> applyTMDBMatch(
MediaLibraryItem item,
Map<String, dynamic> candidate,