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