Files
plezy/lib/database/tables.dart
T
edde746 841d336cb8 fix(downloads): rederive show rule download links before cleanup
Return initialized show and season rules from the backfill query so their
coverage is recomputed from download ancestry. Rule execution links only the
unwatched episodes it inspected, so the cached flag let a sibling list
cleanup delete episodes the show rule covers.
2026-07-27 17:44:52 +02:00

287 lines
13 KiB
Dart

import 'package:drift/drift.dart';
/// Key-value cache table for media-server API responses (Plex, Jellyfin).
/// Used for offline support - stores raw JSON responses.
class ApiCache extends Table {
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345"
/// for Plex, "abc123:/Users/.../Items/..." for Jellyfin)
TextColumn get cacheKey => text()();
TextColumn get data => text()();
/// Whether this item is pinned for offline access
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
/// Timestamp for cache invalidation (optional future use)
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {cacheKey};
}
@DataClassName('DownloadQueueItem')
class DownloadQueue extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get mediaGlobalKey => text().unique()();
IntColumn get priority => integer().withDefault(const Constant(0))();
IntColumn get addedAt => integer()();
BoolColumn get downloadSubtitles => boolean().withDefault(const Constant(true))();
BoolColumn get downloadArtwork => boolean().withDefault(const Constant(true))();
}
@DataClassName('DownloadedMediaItem')
@TableIndex(name: 'idx_downloaded_media_status', columns: {#status})
@TableIndex(name: 'idx_downloaded_media_server', columns: {#serverId})
@TableIndex(name: 'idx_downloaded_media_parent', columns: {#parentRatingKey})
@TableIndex(name: 'idx_downloaded_media_grandparent', columns: {#grandparentRatingKey})
class DownloadedMedia extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text()();
// Downloads are intentionally app-wide/shared, keyed by the public
// serverId:ratingKey globalKey below. For Jellyfin, clientScopeId records
// which scoped client produced the cached metadata/download request; it is
// not part of download ownership and must not be used to hide or duplicate
// the physical downloaded item per user/profile.
TextColumn get clientScopeId => text().nullable()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get type => text()();
TextColumn get parentRatingKey => text().nullable()();
TextColumn get grandparentRatingKey => text().nullable()();
IntColumn get status => integer()();
IntColumn get progress => integer().withDefault(const Constant(0))();
IntColumn get totalBytes => integer().nullable()();
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
TextColumn get videoFilePath => text().nullable()();
TextColumn get safRootUri => text().nullable()();
TextColumn get thumbPath => text().nullable()();
IntColumn get downloadedAt => integer().nullable()();
TextColumn get errorMessage => text().nullable()();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
TextColumn get bgTaskId => text().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get mediaSourceId => text().nullable()();
}
/// Profile ownership for shared physical downloads.
///
/// [DownloadedMedia] stores one physical row per public serverId:ratingKey so
/// files are deduped across profiles. This table controls which active profile
/// can see/use that shared row.
@DataClassName('DownloadOwnerItem')
@TableIndex(name: 'idx_download_owners_profile', columns: {#profileId})
@TableIndex(name: 'idx_download_owners_global_key', columns: {#globalKey})
class DownloadOwners extends Table {
TextColumn get profileId => text()();
TextColumn get globalKey => text()();
TextColumn get backend => text().nullable()();
TextColumn get clientScopeId => text().nullable()();
IntColumn get createdAt => integer()();
@override
Set<Column> get primaryKey => {profileId, globalKey};
}
/// Persistent sync rules for auto-downloading unwatched episodes.
///
/// Each rule keeps a rolling window of N unwatched episodes for a show/season,
/// or mirrors the current contents of a collection/playlist.
/// Rules are owned by the active top-level profile. Downloads remain app-wide
/// and shared by public serverId:ratingKey identity, but rule ownership must not
/// cross users because Jellyfin permissions and watch state are user-scoped.
@DataClassName('SyncRuleItem')
@TableIndex(name: 'idx_sync_rules_profile', columns: {#profileId})
class SyncRules extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get profileId => text().withDefault(const Constant(''))();
TextColumn get serverId => text()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get targetType => text()(); // 'show', 'season', 'collection', 'playlist'
IntColumn get episodeCount => integer()();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
IntColumn get createdAt => integer()();
IntColumn get lastExecutedAt => integer().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
BoolColumn get includeSpecials => boolean().withDefault(const Constant(true))();
/// Gates collection/playlist backfill into [SyncRuleDownloads] before
/// destructive cleanup. Show/season coverage is re-derived from
/// [DownloadedMedia] ancestry at cleanup time regardless of this value.
BoolColumn get downloadLinksInitialized => boolean().withDefault(const Constant(false))();
}
/// Downloads covered by a sync rule for one profile.
///
/// Links are retained when list membership changes so removing a rule can
/// clean up items it previously synced without re-fetching the list. A
/// download may be linked to multiple rules.
@DataClassName('SyncRuleDownloadItem')
@TableIndex(name: 'idx_sync_rule_downloads_profile_key', columns: {#profileId, #downloadGlobalKey})
class SyncRuleDownloads extends Table {
IntColumn get syncRuleId => integer().references(SyncRules, #id, onDelete: KeyAction.cascade)();
TextColumn get profileId => text()();
TextColumn get downloadGlobalKey => text()();
@override
Set<Column> get primaryKey => {syncRuleId, downloadGlobalKey};
}
/// Persisted media-server connections.
///
/// One row per "connection" the user has added — a Plex account (with its
/// discovered servers and active Home profile) or a single Jellyfin server.
/// The [configJson] payload is backend-specific and parsed by the
/// [Connection] sealed class.
@DataClassName('ConnectionRow')
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
class Connections extends Table {
/// Stable identifier for the connection. For Plex it's a generated UUID
/// (one per account); for Jellyfin it's the server's machineId.
TextColumn get id => text()();
/// Backend kind: `'plex'` or `'jellyfin'`.
TextColumn get kind => text()();
/// User-visible label (account email, server name).
TextColumn get displayName => text()();
/// Backend-specific config payload (token, baseUrl, profile id, …).
TextColumn get configJson => text()();
/// Whether this is the default connection used at app launch when only
/// one connection is present.
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
/// Timestamp this connection was added (milliseconds since epoch).
IntColumn get createdAt => integer()();
/// Timestamp of the most-recent successful auth refresh (milliseconds
/// since epoch). Null until the first successful auth.
IntColumn get lastAuthenticatedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
/// Top-level profiles — see [Profile].
///
/// A profile is the user-facing identity. Plex Home users auto-surface as
/// `kind='plex_home'` rows when their parent Plex account is added; users
/// can also create `kind='local'` rows manually. Each profile owns one or
/// more connections via [ProfileConnections].
@DataClassName('ProfileRow')
@TableIndex(name: 'idx_profiles_kind', columns: {#kind})
class Profiles extends Table {
/// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}`
/// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`.
TextColumn get id => text()();
/// `'local'` | `'plex_home'`.
TextColumn get kind => text()();
TextColumn get displayName => text()();
/// Plex Home users have a thumb URL; locals fall back to initials/colour.
TextColumn get avatarThumbUrl => text().nullable()();
/// Per-kind config:
/// - `local`: `{ "pinHash": "..." }`
/// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }`
TextColumn get configJson => text()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
IntColumn get createdAt => integer()();
IntColumn get lastUsedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
/// Many-to-many join between [Profiles] and [Connections], carrying the
/// per-profile user-level token used when the profile is active.
///
/// For Plex: `userToken` is a Home-user token from `/home/users/{uuid}/switch`,
/// `userIdentifier` is the Plex Home user UUID. An empty `userToken` is a
/// lazy-fetch sentinel — the binder calls `/switch` on first activation.
/// The Dart-side [ProfileConnection] model surfaces empty as `null`; the
/// column stays non-nullable here to avoid a schema migration.
///
/// For Jellyfin: `userToken` mirrors the Connection's accessToken (one user
/// per Jellyfin connection) and `userIdentifier` is the Jellyfin user id.
@DataClassName('ProfileConnectionRow')
@TableIndex(name: 'idx_profile_connections_connection_id', columns: {#connectionId})
@TableIndex(name: 'idx_profile_connections_profile_id', columns: {#profileId})
class ProfileConnections extends Table {
// No FK on profile_id: Plex Home profiles are virtual (built by
// Profile.virtualPlexHome from PlexHomeService's live cache, never
// persisted in `profiles`), so an FK here would reject every join row
// they need. Profile deletion instead cleans up join rows explicitly
// (ProfileConnectionCleanup.removeAllProfileConnections)
// before calling ProfileRegistry.remove.
TextColumn get profileId => text()();
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
TextColumn get userToken => text().withDefault(const Constant(''))();
TextColumn get userIdentifier => text()();
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
IntColumn get tokenAcquiredAt => integer().nullable()();
IntColumn get lastUsedAt => integer().nullable()();
@override
Set<Column> get primaryKey => {profileId, connectionId};
}
/// Queue for offline watch progress and manual watch actions.
///
/// Stores watch progress updates and manual watch/unwatch actions
/// that need to be synced to the originating media server when back online.
@DataClassName('OfflineWatchProgressItem')
@TableIndex(name: 'idx_offline_watch_progress_server', columns: {#serverId})
@TableIndex(name: 'idx_offline_watch_progress_profile', columns: {#profileId})
class OfflineWatchProgress extends Table {
/// Auto-incrementing primary key
IntColumn get id => integer().autoIncrement()();
/// Active Plezy profile that owns this queued action.
TextColumn get profileId => text().nullable()();
/// Server ID this media belongs to
TextColumn get serverId => text()();
/// Optional user-scoped client/cache id for backends where [serverId] is
/// shared by multiple users on the same server.
TextColumn get clientScopeId => text().nullable()();
/// Rating key of the media item
TextColumn get ratingKey => text()();
/// Global key (serverId:ratingKey) for easy lookup
TextColumn get globalKey => text()();
/// Type of action: 'progress', 'watched', 'unwatched'
TextColumn get actionType => text()();
/// Current playback position in milliseconds (for 'progress' actions)
IntColumn get viewOffset => integer().nullable()();
/// Duration of the media in milliseconds (for calculating percentage)
IntColumn get duration => integer().nullable()();
/// Whether this item should be marked as watched (for progress sync)
/// Auto-set to true when viewOffset >= 90% of duration
BoolColumn get shouldMarkWatched => boolean().withDefault(const Constant(false))();
/// Timestamp when this action was recorded (milliseconds since epoch)
IntColumn get createdAt => integer()();
/// Timestamp when this action was last updated (for merging progress updates)
IntColumn get updatedAt => integer()();
/// Number of sync attempts (for retry logic)
IntColumn get syncAttempts => integer().withDefault(const Constant(0))();
/// Last sync error message
TextColumn get lastError => text().nullable()();
}