fix(database): restore api_cache.cached_at read by the playback freshness gate
Main stopped compiling: the v21 column drop treated cached_at as never-read, but the fresh-cache-first playback metadata gate (ApiCacheSingleton.getIfFresh, from the playback start-latency fix) reads it on every Plex and Jellyfin playback start, and a later cleanup removed the put() stamp the gate depends on. Reinstate the column, keep the v21 migration to the connections half only (it never shipped in a release), restore the explicit put() timestamp - on conflict the upsert only updates the companion's columns, so without it a refreshed row would keep its original write time and read as permanently stale - and copy cached_at through the pinned-metadata rescope statement again. The v21 migration test now pins that api_cache is left untouched.
This commit is contained in:
@@ -732,9 +732,8 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
}
|
||||
if (from < 21) {
|
||||
appLogger.i('Dropping unused Connections.isDefault and ApiCache.cachedAt columns (v21 migration)');
|
||||
appLogger.i('Dropping unused Connections.isDefault column (v21 migration)');
|
||||
await m.alterTable(TableMigration(connections));
|
||||
await m.alterTable(TableMigration(apiCache));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1324,13 +1323,14 @@ String _rescopePinnedPlexMetadataStatement({
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| $namespaceExpression
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
$ownerJoin
|
||||
JOIN api_cache AS source
|
||||
@@ -1342,7 +1342,8 @@ String _rescopePinnedPlexMetadataStatement({
|
||||
$ownerFilter
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''';
|
||||
|
||||
Future<File> _resolveProductionDatabaseFile() async {
|
||||
|
||||
@@ -2023,6 +2023,18 @@ class $ApiCacheTable extends ApiCache
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _cachedAtMeta = const VerificationMeta(
|
||||
'cachedAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> cachedAt = GeneratedColumn<DateTime>(
|
||||
'cached_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: currentDateAndTime,
|
||||
);
|
||||
static const VerificationMeta _pinnedMeta = const VerificationMeta('pinned');
|
||||
@override
|
||||
late final GeneratedColumn<bool> pinned = GeneratedColumn<bool>(
|
||||
@@ -2037,7 +2049,7 @@ class $ApiCacheTable extends ApiCache
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [cacheKey, data, pinned];
|
||||
List<GeneratedColumn> get $columns => [cacheKey, data, cachedAt, pinned];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -2066,6 +2078,12 @@ class $ApiCacheTable extends ApiCache
|
||||
} else if (isInserting) {
|
||||
context.missing(_dataMeta);
|
||||
}
|
||||
if (data.containsKey('cached_at')) {
|
||||
context.handle(
|
||||
_cachedAtMeta,
|
||||
cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('pinned')) {
|
||||
context.handle(
|
||||
_pinnedMeta,
|
||||
@@ -2089,6 +2107,10 @@ class $ApiCacheTable extends ApiCache
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}data'],
|
||||
)!,
|
||||
cachedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}cached_at'],
|
||||
)!,
|
||||
pinned: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}pinned'],
|
||||
@@ -2108,11 +2130,17 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
final String cacheKey;
|
||||
final String data;
|
||||
|
||||
/// When the row was last written ([ApiCacheSingleton.put] stamps it on
|
||||
/// every store). Read by the fresh-cache-first playback metadata gate,
|
||||
/// [ApiCacheSingleton.getIfFresh].
|
||||
final DateTime cachedAt;
|
||||
|
||||
/// Whether this item is pinned for offline access
|
||||
final bool pinned;
|
||||
const ApiCacheData({
|
||||
required this.cacheKey,
|
||||
required this.data,
|
||||
required this.cachedAt,
|
||||
required this.pinned,
|
||||
});
|
||||
@override
|
||||
@@ -2120,6 +2148,7 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
final map = <String, Expression>{};
|
||||
map['cache_key'] = Variable<String>(cacheKey);
|
||||
map['data'] = Variable<String>(data);
|
||||
map['cached_at'] = Variable<DateTime>(cachedAt);
|
||||
map['pinned'] = Variable<bool>(pinned);
|
||||
return map;
|
||||
}
|
||||
@@ -2128,6 +2157,7 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
return ApiCacheCompanion(
|
||||
cacheKey: Value(cacheKey),
|
||||
data: Value(data),
|
||||
cachedAt: Value(cachedAt),
|
||||
pinned: Value(pinned),
|
||||
);
|
||||
}
|
||||
@@ -2140,6 +2170,7 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
return ApiCacheData(
|
||||
cacheKey: serializer.fromJson<String>(json['cacheKey']),
|
||||
data: serializer.fromJson<String>(json['data']),
|
||||
cachedAt: serializer.fromJson<DateTime>(json['cachedAt']),
|
||||
pinned: serializer.fromJson<bool>(json['pinned']),
|
||||
);
|
||||
}
|
||||
@@ -2149,20 +2180,27 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
return <String, dynamic>{
|
||||
'cacheKey': serializer.toJson<String>(cacheKey),
|
||||
'data': serializer.toJson<String>(data),
|
||||
'cachedAt': serializer.toJson<DateTime>(cachedAt),
|
||||
'pinned': serializer.toJson<bool>(pinned),
|
||||
};
|
||||
}
|
||||
|
||||
ApiCacheData copyWith({String? cacheKey, String? data, bool? pinned}) =>
|
||||
ApiCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
data: data ?? this.data,
|
||||
pinned: pinned ?? this.pinned,
|
||||
);
|
||||
ApiCacheData copyWith({
|
||||
String? cacheKey,
|
||||
String? data,
|
||||
DateTime? cachedAt,
|
||||
bool? pinned,
|
||||
}) => ApiCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
data: data ?? this.data,
|
||||
cachedAt: cachedAt ?? this.cachedAt,
|
||||
pinned: pinned ?? this.pinned,
|
||||
);
|
||||
ApiCacheData copyWithCompanion(ApiCacheCompanion data) {
|
||||
return ApiCacheData(
|
||||
cacheKey: data.cacheKey.present ? data.cacheKey.value : this.cacheKey,
|
||||
data: data.data.present ? data.data.value : this.data,
|
||||
cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt,
|
||||
pinned: data.pinned.present ? data.pinned.value : this.pinned,
|
||||
);
|
||||
}
|
||||
@@ -2172,36 +2210,41 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
|
||||
return (StringBuffer('ApiCacheData(')
|
||||
..write('cacheKey: $cacheKey, ')
|
||||
..write('data: $data, ')
|
||||
..write('cachedAt: $cachedAt, ')
|
||||
..write('pinned: $pinned')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(cacheKey, data, pinned);
|
||||
int get hashCode => Object.hash(cacheKey, data, cachedAt, pinned);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ApiCacheData &&
|
||||
other.cacheKey == this.cacheKey &&
|
||||
other.data == this.data &&
|
||||
other.cachedAt == this.cachedAt &&
|
||||
other.pinned == this.pinned);
|
||||
}
|
||||
|
||||
class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
|
||||
final Value<String> cacheKey;
|
||||
final Value<String> data;
|
||||
final Value<DateTime> cachedAt;
|
||||
final Value<bool> pinned;
|
||||
final Value<int> rowid;
|
||||
const ApiCacheCompanion({
|
||||
this.cacheKey = const Value.absent(),
|
||||
this.data = const Value.absent(),
|
||||
this.cachedAt = const Value.absent(),
|
||||
this.pinned = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
ApiCacheCompanion.insert({
|
||||
required String cacheKey,
|
||||
required String data,
|
||||
this.cachedAt = const Value.absent(),
|
||||
this.pinned = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : cacheKey = Value(cacheKey),
|
||||
@@ -2209,12 +2252,14 @@ class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
|
||||
static Insertable<ApiCacheData> custom({
|
||||
Expression<String>? cacheKey,
|
||||
Expression<String>? data,
|
||||
Expression<DateTime>? cachedAt,
|
||||
Expression<bool>? pinned,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (cacheKey != null) 'cache_key': cacheKey,
|
||||
if (data != null) 'data': data,
|
||||
if (cachedAt != null) 'cached_at': cachedAt,
|
||||
if (pinned != null) 'pinned': pinned,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
@@ -2223,12 +2268,14 @@ class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
|
||||
ApiCacheCompanion copyWith({
|
||||
Value<String>? cacheKey,
|
||||
Value<String>? data,
|
||||
Value<DateTime>? cachedAt,
|
||||
Value<bool>? pinned,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return ApiCacheCompanion(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
data: data ?? this.data,
|
||||
cachedAt: cachedAt ?? this.cachedAt,
|
||||
pinned: pinned ?? this.pinned,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
@@ -2243,6 +2290,9 @@ class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
|
||||
if (data.present) {
|
||||
map['data'] = Variable<String>(data.value);
|
||||
}
|
||||
if (cachedAt.present) {
|
||||
map['cached_at'] = Variable<DateTime>(cachedAt.value);
|
||||
}
|
||||
if (pinned.present) {
|
||||
map['pinned'] = Variable<bool>(pinned.value);
|
||||
}
|
||||
@@ -2257,6 +2307,7 @@ class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
|
||||
return (StringBuffer('ApiCacheCompanion(')
|
||||
..write('cacheKey: $cacheKey, ')
|
||||
..write('data: $data, ')
|
||||
..write('cachedAt: $cachedAt, ')
|
||||
..write('pinned: $pinned, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
@@ -6808,6 +6859,7 @@ typedef $$ApiCacheTableCreateCompanionBuilder =
|
||||
ApiCacheCompanion Function({
|
||||
required String cacheKey,
|
||||
required String data,
|
||||
Value<DateTime> cachedAt,
|
||||
Value<bool> pinned,
|
||||
Value<int> rowid,
|
||||
});
|
||||
@@ -6815,6 +6867,7 @@ typedef $$ApiCacheTableUpdateCompanionBuilder =
|
||||
ApiCacheCompanion Function({
|
||||
Value<String> cacheKey,
|
||||
Value<String> data,
|
||||
Value<DateTime> cachedAt,
|
||||
Value<bool> pinned,
|
||||
Value<int> rowid,
|
||||
});
|
||||
@@ -6838,6 +6891,11 @@ class $$ApiCacheTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get cachedAt => $composableBuilder(
|
||||
column: $table.cachedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get pinned => $composableBuilder(
|
||||
column: $table.pinned,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -6863,6 +6921,11 @@ class $$ApiCacheTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get cachedAt => $composableBuilder(
|
||||
column: $table.cachedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get pinned => $composableBuilder(
|
||||
column: $table.pinned,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -6884,6 +6947,9 @@ class $$ApiCacheTableAnnotationComposer
|
||||
GeneratedColumn<String> get data =>
|
||||
$composableBuilder(column: $table.data, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get cachedAt =>
|
||||
$composableBuilder(column: $table.cachedAt, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<bool> get pinned =>
|
||||
$composableBuilder(column: $table.pinned, builder: (column) => column);
|
||||
}
|
||||
@@ -6921,11 +6987,13 @@ class $$ApiCacheTableTableManager
|
||||
({
|
||||
Value<String> cacheKey = const Value.absent(),
|
||||
Value<String> data = const Value.absent(),
|
||||
Value<DateTime> cachedAt = const Value.absent(),
|
||||
Value<bool> pinned = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => ApiCacheCompanion(
|
||||
cacheKey: cacheKey,
|
||||
data: data,
|
||||
cachedAt: cachedAt,
|
||||
pinned: pinned,
|
||||
rowid: rowid,
|
||||
),
|
||||
@@ -6933,11 +7001,13 @@ class $$ApiCacheTableTableManager
|
||||
({
|
||||
required String cacheKey,
|
||||
required String data,
|
||||
Value<DateTime> cachedAt = const Value.absent(),
|
||||
Value<bool> pinned = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => ApiCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
data: data,
|
||||
cachedAt: cachedAt,
|
||||
pinned: pinned,
|
||||
rowid: rowid,
|
||||
),
|
||||
|
||||
@@ -9,6 +9,11 @@ class ApiCache extends Table {
|
||||
|
||||
TextColumn get data => text()();
|
||||
|
||||
/// When the row was last written ([ApiCacheSingleton.put] stamps it on
|
||||
/// every store). Read by the fresh-cache-first playback metadata gate,
|
||||
/// [ApiCacheSingleton.getIfFresh].
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
/// Whether this item is pinned for offline access
|
||||
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
|
||||
|
||||
|
||||
@@ -141,7 +141,15 @@ abstract class ApiCache {
|
||||
Future<void> put(ServerId serverId, String endpoint, Map<String, dynamic> data) async {
|
||||
final key = _buildKey(serverId, endpoint);
|
||||
final encoded = await tryIsolateRun(() => jsonEncode(data));
|
||||
await _db.into(_db.apiCache).insertOnConflictUpdate(ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded)));
|
||||
// The explicit stamp matters: on conflict the upsert only updates the
|
||||
// companion's columns, so relying on the column default would leave a
|
||||
// refreshed row carrying its original write time and [getIfFresh] would
|
||||
// treat just-refetched data as stale.
|
||||
await _db
|
||||
.into(_db.apiCache)
|
||||
.insertOnConflictUpdate(
|
||||
ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteForServer(ServerId serverId) async {
|
||||
|
||||
@@ -926,7 +926,7 @@ class _AppDatabaseTestSuite {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
}
|
||||
});
|
||||
test('v21 migration drops connections.is_default and api_cache.cached_at without losing rows', () async {
|
||||
test('v21 migration drops connections.is_default without touching api_cache.cached_at', () async {
|
||||
await db.close();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_db_v21_migration_test_');
|
||||
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||
@@ -947,12 +947,11 @@ class _AppDatabaseTestSuite {
|
||||
createdAt: 1000,
|
||||
),
|
||||
);
|
||||
await seeded.customStatement('INSERT INTO api_cache (cache_key, data, pinned) VALUES (?, ?, 1)', [
|
||||
'srv:/library/metadata/1',
|
||||
'{}',
|
||||
]);
|
||||
await seeded.customStatement(
|
||||
'INSERT INTO api_cache (cache_key, data, pinned, cached_at) VALUES (?, ?, 1, 12345)',
|
||||
['srv:/library/metadata/1', '{}'],
|
||||
);
|
||||
await seeded.customStatement('ALTER TABLE connections ADD COLUMN is_default INTEGER NOT NULL DEFAULT 1');
|
||||
await seeded.customStatement('ALTER TABLE api_cache ADD COLUMN cached_at INTEGER NOT NULL DEFAULT 0');
|
||||
await seeded.customStatement('PRAGMA user_version = 20');
|
||||
await seeded.close();
|
||||
seeded = null;
|
||||
@@ -965,7 +964,9 @@ class _AppDatabaseTestSuite {
|
||||
.map((row) => row.read<String>('name'))
|
||||
.toSet();
|
||||
expect(connectionColumns, isNot(contains('is_default')));
|
||||
expect(cacheColumns, isNot(contains('cached_at')));
|
||||
// cached_at is load-bearing (ApiCacheSingleton.getIfFresh); the
|
||||
// migration must leave it, and its values, alone.
|
||||
expect(cacheColumns, contains('cached_at'));
|
||||
|
||||
final connection = await reopened.select(reopened.connections).getSingle();
|
||||
expect(connection.id, 'c1');
|
||||
@@ -973,6 +974,7 @@ class _AppDatabaseTestSuite {
|
||||
final cacheRow = await reopened.select(reopened.apiCache).getSingle();
|
||||
expect(cacheRow.cacheKey, 'srv:/library/metadata/1');
|
||||
expect(cacheRow.pinned, isTrue);
|
||||
expect(cacheRow.cachedAt.millisecondsSinceEpoch ~/ 1000, 12345);
|
||||
|
||||
// The drift table recreation must restore the kind index.
|
||||
final indexRows = await reopened
|
||||
|
||||
Reference in New Issue
Block a user