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:
edde746
2026-08-17 20:47:40 +02:00
parent 86498547ba
commit efd33b3a4e
5 changed files with 107 additions and 21 deletions
+6 -5
View File
@@ -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 {
+78 -8
View File
@@ -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,
),
+5
View File
@@ -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))();
+9 -1
View File
@@ -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 {
+9 -7
View File
@@ -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