diff --git a/src/lib/client/ui/table/ExpandableTable.svelte b/src/lib/client/ui/table/ExpandableTable.svelte new file mode 100644 index 0000000..927f27a --- /dev/null +++ b/src/lib/client/ui/table/ExpandableTable.svelte @@ -0,0 +1,111 @@ + + +
+ + + + + + {#each columns as column} + + {/each} + + + + {#if data.length === 0} + + + + {:else} + {#each data as row} + {@const rowId = getRowId(row)} + + + toggleRow(rowId)} + > + + + + {#each columns as column} + + {/each} + + + + {#if expandedRows.has(rowId)} + + + + {/if} + {/each} + {/if} + +
+ {column.header} +
+ {emptyMessage} +
+ {#if expandedRows.has(rowId)} + + {:else} + + {/if} + + + {getCellValue(row, column.key)} + +
+
+ + +
+ No additional details +
+
+
+
+
diff --git a/src/lib/client/ui/table/Table.svelte b/src/lib/client/ui/table/Table.svelte index 62a7a1e..5e2cf72 100644 --- a/src/lib/client/ui/table/Table.svelte +++ b/src/lib/client/ui/table/Table.svelte @@ -108,7 +108,7 @@ $: sortedData = sortData(data); -
+
{ + data: T; + expiresAt: number; +} + +class Cache { + private store = new Map>(); + + /** + * Get a cached value + */ + get(key: string): T | undefined { + const entry = this.store.get(key) as CacheEntry | undefined; + if (!entry) return undefined; + + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + + return entry.data; + } + + /** + * Set a cached value with TTL in seconds + */ + set(key: string, data: T, ttlSeconds: number): void { + this.store.set(key, { + data, + expiresAt: Date.now() + ttlSeconds * 1000 + }); + } + + /** + * Delete a cached value + */ + delete(key: string): boolean { + return this.store.delete(key); + } + + /** + * Delete all cached values matching a prefix + */ + deleteByPrefix(prefix: string): number { + let count = 0; + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + this.store.delete(key); + count++; + } + } + return count; + } + + /** + * Clear all cached values + */ + clear(): void { + this.store.clear(); + } +} + +export const cache = new Cache(); diff --git a/src/lib/server/pcd/queries/qualityProfiles/index.ts b/src/lib/server/pcd/queries/qualityProfiles/index.ts index ba26723..f318635 100644 --- a/src/lib/server/pcd/queries/qualityProfiles/index.ts +++ b/src/lib/server/pcd/queries/qualityProfiles/index.ts @@ -25,6 +25,7 @@ export type { // Export query functions export { list } from './list.ts'; +export { names } from './names.ts'; export { general } from './general.ts'; export { languages } from './languages.ts'; export { qualities } from './qualities.ts'; diff --git a/src/lib/server/pcd/queries/qualityProfiles/names.ts b/src/lib/server/pcd/queries/qualityProfiles/names.ts new file mode 100644 index 0000000..2891b54 --- /dev/null +++ b/src/lib/server/pcd/queries/qualityProfiles/names.ts @@ -0,0 +1,20 @@ +/** + * Quality profile name queries + */ + +import type { PCDCache } from '../../cache.ts'; + +/** + * Get all quality profile names from a cache + */ +export async function names(cache: PCDCache): Promise { + const db = cache.kb; + + const profiles = await db + .selectFrom('quality_profiles') + .select(['name']) + .orderBy('name') + .execute(); + + return profiles.map((p) => p.name); +} diff --git a/src/lib/server/utils/arr/clients/lidarr.ts b/src/lib/server/utils/arr/clients/lidarr.ts index 3f6bc7a..f25d189 100644 --- a/src/lib/server/utils/arr/clients/lidarr.ts +++ b/src/lib/server/utils/arr/clients/lidarr.ts @@ -6,7 +6,7 @@ import { BaseArrClient } from '../base.ts'; * Note: Lidarr uses API v1, not v3 like other *arr apps */ export class LidarrClient extends BaseArrClient { - protected apiVersion: string = 'v1'; // Lidarr uses v1 API + protected override apiVersion: string = 'v1'; // Lidarr uses v1 API // Specific API methods will be implemented here as needed } diff --git a/src/lib/server/utils/arr/clients/radarr.ts b/src/lib/server/utils/arr/clients/radarr.ts index 41c2b7c..f123f25 100644 --- a/src/lib/server/utils/arr/clients/radarr.ts +++ b/src/lib/server/utils/arr/clients/radarr.ts @@ -1,9 +1,125 @@ import { BaseArrClient } from '../base.ts'; +import type { + RadarrMovie, + RadarrMovieFile, + RadarrQualityProfile, + RadarrLibraryItem, + ScoreBreakdownItem, + CustomFormatRef, + QualityProfileFormatItem +} from '../types.ts'; /** * Radarr API client * Extends BaseArrClient with Radarr-specific API methods */ export class RadarrClient extends BaseArrClient { - // Specific API methods will be implemented here as needed + /** + * Get all movies + */ + getMovies(): Promise { + return this.get(`/api/${this.apiVersion}/movie`); + } + + /** + * Get all quality profiles + */ + getQualityProfiles(): Promise { + return this.get(`/api/${this.apiVersion}/qualityprofile`); + } + + /** + * Get movie files by movie IDs + * Uses movieId params for batching (NOT movieFileIds which can error on stale IDs) + */ + getMovieFiles(movieIds: number[]): Promise { + if (movieIds.length === 0) { + return Promise.resolve([]); + } + + // Build query string with repeated movieId params + const queryString = movieIds.map((id) => `movieId=${id}`).join('&'); + return this.get(`/api/${this.apiVersion}/moviefile?${queryString}`); + } + + /** + * Compute score breakdown for a movie's custom formats against a profile's format items + */ + private computeScoreBreakdown( + movieCustomFormats: CustomFormatRef[], + profileFormatItems: QualityProfileFormatItem[] + ): ScoreBreakdownItem[] { + return movieCustomFormats.map((cf) => { + const profileItem = profileFormatItems.find((fi) => fi.format === cf.id); + return { + name: cf.name, + score: profileItem?.score ?? 0 + }; + }); + } + + /** + * Fetch and compute library data with all joined information + * Makes 3 API calls: movies, quality profiles, and movie files (batched) + * @param profilarrProfileNames - Set of profile names from Profilarr databases + */ + async getLibrary(profilarrProfileNames?: Set): Promise { + // Fetch movies and profiles in parallel + const [movies, profiles] = await Promise.all([this.getMovies(), this.getQualityProfiles()]); + + // Create profile lookup map + const profileMap = new Map(profiles.map((p) => [p.id, p])); + + // Get movie IDs that have files + const movieIdsWithFiles = movies.filter((m) => m.hasFile).map((m) => m.id); + + // Fetch movie files in batch + const movieFiles = await this.getMovieFiles(movieIdsWithFiles); + + // Create movie file lookup by movieId + const movieFileMap = new Map(movieFiles.map((mf) => [mf.movieId, mf])); + + // Build library items + const libraryItems: RadarrLibraryItem[] = movies.map((movie) => { + const profile = profileMap.get(movie.qualityProfileId); + const movieFile = movieFileMap.get(movie.id); + + const customFormats = movieFile?.customFormats ?? []; + const customFormatScore = movieFile?.customFormatScore ?? 0; + const cutoffScore = profile?.cutoffFormatScore ?? 0; + const minScore = profile?.minFormatScore ?? 0; + + // Compute score breakdown + const scoreBreakdown = profile + ? this.computeScoreBreakdown(customFormats, profile.formatItems) + : []; + + // Calculate progress (0 to 1+, where 1 = cutoff met) + const progress = cutoffScore > 0 ? customFormatScore / cutoffScore : 0; + + const profileName = profile?.name ?? 'Unknown'; + + return { + id: movie.id, + tmdbId: movie.tmdbId, + title: movie.title, + year: movie.year, + qualityProfileId: movie.qualityProfileId, + qualityProfileName: profileName, + hasFile: movie.hasFile, + customFormats, + customFormatScore, + qualityName: movieFile?.quality?.quality?.name, + fileName: movieFile?.relativePath?.split('/').pop(), + scoreBreakdown, + cutoffScore, + minScore, + progress, + cutoffMet: customFormatScore >= cutoffScore, + isProfilarrProfile: profilarrProfileNames?.has(profileName) ?? false + }; + }); + + return libraryItems; + } } diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index 85c78db..702e415 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -4,6 +4,159 @@ export type ArrType = 'radarr' | 'sonarr' | 'lidarr' | 'chaptarr'; +// ============================================================================= +// Radarr Types +// ============================================================================= + +/** + * Movie from /api/v3/movie + */ +export interface RadarrMovie { + id: number; + title: string; + originalTitle?: string; + sortTitle?: string; + year?: number; + qualityProfileId: number; + hasFile: boolean; + movieFileId?: number; + monitored?: boolean; + minimumAvailability?: string; + runtime?: number; + tmdbId?: number; + imdbId?: string; + added?: string; + ratings?: { + imdb?: { votes: number; value: number }; + tmdb?: { votes: number; value: number }; + }; + genres?: string[]; + overview?: string; + images?: { coverType: string; url: string; remoteUrl?: string }[]; + path?: string; + studio?: string; + rootFolderPath?: string; + sizeOnDisk?: number; + status?: string; +} + +/** + * Custom format reference (minimal, as returned in movie file) + */ +export interface CustomFormatRef { + id: number; + name: string; +} + +/** + * Movie file from /api/v3/moviefile + */ +export interface RadarrMovieFile { + id: number; + movieId: number; + relativePath?: string; + path?: string; + size?: number; + dateAdded?: string; + quality?: { + quality: { id: number; name: string; source?: string; resolution?: number }; + revision?: { version: number; real: number; isRepack?: boolean }; + }; + customFormats: CustomFormatRef[]; + customFormatScore: number; + mediaInfo?: { + audioBitrate?: number; + audioChannels?: number; + audioCodec?: string; + audioLanguages?: string; + audioStreamCount?: number; + videoBitDepth?: number; + videoBitrate?: number; + videoCodec?: string; + videoFps?: number; + videoDynamicRange?: string; + videoDynamicRangeType?: string; + resolution?: string; + runTime?: string; + scanType?: string; + subtitles?: string; + }; + originalFilePath?: string; + sceneName?: string; + releaseGroup?: string; + edition?: string; + languages?: { id: number; name: string }[]; +} + +/** + * Format item within a quality profile + */ +export interface QualityProfileFormatItem { + format: number; + name: string; + score: number; +} + +/** + * Quality profile from /api/v3/qualityprofile + */ +export interface RadarrQualityProfile { + id: number; + name: string; + upgradeAllowed?: boolean; + cutoff?: number; + cutoffFormatScore: number; + minFormatScore: number; + formatItems: QualityProfileFormatItem[]; + items?: { + id?: number; + name?: string; + quality?: { id: number; name: string; source?: string; resolution?: number }; + items?: unknown[]; + allowed?: boolean; + }[]; +} + +// ============================================================================= +// Library View Types (computed/joined data) +// ============================================================================= + +/** + * Score breakdown showing how each custom format contributes to the total score + */ +export interface ScoreBreakdownItem { + name: string; + score: number; +} + +/** + * Library item with all computed fields for the UI + */ +export interface RadarrLibraryItem { + // From /movie + id: number; + tmdbId?: number; + title: string; + year?: number; + qualityProfileId: number; + qualityProfileName: string; + hasFile: boolean; + + // From /moviefile (only if hasFile) + customFormats: CustomFormatRef[]; + customFormatScore: number; + qualityName?: string; + fileName?: string; + + // Computed + scoreBreakdown: ScoreBreakdownItem[]; + cutoffScore: number; + minScore: number; + progress: number; // customFormatScore / cutoffFormatScore (0-1, can exceed 1) + cutoffMet: boolean; + isProfilarrProfile: boolean; // true if profile name matches a Profilarr database profile +} + /** * System status response from /api/v3/system/status * Based on actual Radarr API response diff --git a/src/routes/arr/[id]/+layout.svelte b/src/routes/arr/[id]/+layout.svelte index 210b255..b8ddc79 100644 --- a/src/routes/arr/[id]/+layout.svelte +++ b/src/routes/arr/[id]/+layout.svelte @@ -1,7 +1,7 @@ {data.instance.name} - Library - Profilarr -
-
-

Library

-

- View and manage the library for this {data.instance.type} instance. -

-
- Library content coming soon... + +
+ {#if data.libraryError} +
+
+ +
+

Failed to load library

+

{data.libraryError}

+
+
-
+ {:else if data.instance.type !== 'radarr'} +
+
+ +
+

Library view not yet available

+

+ Library view is currently only supported for Radarr instances. +

+
+
+
+ {:else if moviesWithFiles.length === 0} +
+
+ +
+

No movies with files

+

+ This library has {data.library.length} movies but none have downloaded files yet. +

+
+
+
+ {:else} + +
+
+
+

{data.instance.name}

+ + {data.instance.type} + + +
+ {data.instance.url} +
+
+
{ + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + > + + + + Open Radarr + + + + + +
{ + if (!confirm('Are you sure you want to delete this instance?')) { + return ({ cancel }) => cancel(); + } + return async ({ update }) => { + await update(); + }; + }} + > + + +
+
+ + + + + + Change Profile + ({moviesWithFiles.length}) + + +
+ {#if data.profilesByDatabase.length === 0} +
+ No databases configured +
+ {:else} + {#each data.profilesByDatabase as db} +
+
+ + + {db.databaseName} + +
+ {#each db.profiles as profile} + + {/each} +
+ {/each} + {/if} +
+
+
+
+
+ + + row.id} compact={true}> + + {#if column.key === 'title'} +
+
{row.title}
+ {#if row.year} +
{row.year}
+ {/if} +
+ {:else if column.key === 'qualityProfileName'} +
+ + {#if !row.isProfilarrProfile} + + {/if} + {row.qualityProfileName} + + {#if !row.isProfilarrProfile} +
+ Not managed by Profilarr +
+ {/if} +
+ {:else if column.key === 'qualityName'} + + {row.qualityName ?? 'N/A'} + + {:else if column.key === 'customFormatScore'} +
+ + {row.customFormatScore.toLocaleString()} + + + / {row.cutoffScore.toLocaleString()} + +
+ {:else if column.key === 'progress'} +
+
+
+
+ {#if row.cutoffMet} + + {:else} + + {Math.round(row.progress * 100)}% + + {/if} +
+ {:else if column.key === 'actions'} +
+ {#if row.tmdbId} + + + + {/if} +
+ {/if} +
+ + +
+ + {#if row.fileName} + {row.fileName} + {/if} + + + {#if row.scoreBreakdown.length > 0} +
+ {#each row.scoreBreakdown.toSorted((a, b) => b.score - a.score) as item} +
+ {item.name} + {item.score >= 0 ? '+' : ''}{item.score.toLocaleString()} +
+ {/each} + + = {row.customFormatScore.toLocaleString()} + +
+ {:else} +
No custom formats matched
+ {/if} +
+
+
+ {/if}
diff --git a/svelte.config.js b/svelte.config.js index f6bb5cc..38aae90 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -27,6 +27,7 @@ const config = { $http: './src/lib/server/utils/http', $utils: './src/lib/server/utils', $notifications: './src/lib/server/notifications', + $cache: './src/lib/server/cache', } } };