feat: persist tmdb artwork urls instead of blobs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user