mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-28 21:40:58 +01:00
feat: add expandable table component for displaying Radarr library items with detailed views
feat: implement caching mechanism for library data with TTL feat: enhance Radarr client with methods to fetch movies and quality profiles feat: update library page to support profile changing and improved UI elements fix: update navigation icons and improve layout for better user experience fix: correct cache handling and error management in library loading
This commit is contained in:
111
src/lib/client/ui/table/ExpandableTable.svelte
Normal file
111
src/lib/client/ui/table/ExpandableTable.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" generics="T extends Record<string, any>">
|
||||
import { ChevronDown, ChevronUp } from 'lucide-svelte';
|
||||
import type { Column } from './types';
|
||||
|
||||
export let columns: Column<T>[];
|
||||
export let data: T[];
|
||||
export let getRowId: (row: T) => string | number;
|
||||
export let compact: boolean = false;
|
||||
export let emptyMessage: string = 'No data available';
|
||||
|
||||
let expandedRows: Set<string | number> = new Set();
|
||||
|
||||
function toggleRow(id: string | number) {
|
||||
if (expandedRows.has(id)) {
|
||||
expandedRows.delete(id);
|
||||
} else {
|
||||
expandedRows.add(id);
|
||||
}
|
||||
expandedRows = expandedRows;
|
||||
}
|
||||
|
||||
function getAlignClass(align?: 'left' | 'center' | 'right'): string {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return 'text-center';
|
||||
case 'right':
|
||||
return 'text-right';
|
||||
default:
|
||||
return 'text-left';
|
||||
}
|
||||
}
|
||||
|
||||
function getCellValue(row: T, key: string): unknown {
|
||||
return key.split('.').reduce((obj, k) => obj?.[k], row as Record<string, unknown>);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
|
||||
<tr>
|
||||
<!-- Expand column -->
|
||||
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
|
||||
{#each columns as column}
|
||||
<th
|
||||
class="{compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 {getAlignClass(column.align)} {column.width || ''}"
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if data.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + 1}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data as row}
|
||||
{@const rowId = getRowId(row)}
|
||||
|
||||
<!-- Main Row -->
|
||||
<tr
|
||||
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||
on:click={() => toggleRow(rowId)}
|
||||
>
|
||||
<!-- Expand Icon -->
|
||||
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
{#each columns as column}
|
||||
<td
|
||||
class="{compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 {getAlignClass(column.align)} {column.width || ''}"
|
||||
>
|
||||
<slot name="cell" {row} {column} expanded={expandedRows.has(rowId)}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Expanded Row -->
|
||||
{#if expandedRows.has(rowId)}
|
||||
<tr class="bg-neutral-50 dark:bg-neutral-800/30">
|
||||
<td colspan={columns.length + 1} class="{compact ? 'px-4 py-3' : 'px-6 py-4'}">
|
||||
<div class="ml-6">
|
||||
<slot name="expanded" {row}>
|
||||
<!-- Default expanded content -->
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No additional details
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@
|
||||
$: sortedData = sortData(data);
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border-2 border-neutral-200 dark:border-neutral-800">
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<!-- Header -->
|
||||
<thead
|
||||
|
||||
67
src/lib/server/cache/cache.ts
vendored
Normal file
67
src/lib/server/cache/cache.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Simple in-memory cache with TTL
|
||||
*/
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class Cache {
|
||||
private store = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
/**
|
||||
* Get a cached value
|
||||
*/
|
||||
get<T>(key: string): T | undefined {
|
||||
const entry = this.store.get(key) as CacheEntry<T> | 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<T>(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();
|
||||
@@ -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';
|
||||
|
||||
20
src/lib/server/pcd/queries/qualityProfiles/names.ts
Normal file
20
src/lib/server/pcd/queries/qualityProfiles/names.ts
Normal file
@@ -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<string[]> {
|
||||
const db = cache.kb;
|
||||
|
||||
const profiles = await db
|
||||
.selectFrom('quality_profiles')
|
||||
.select(['name'])
|
||||
.orderBy('name')
|
||||
.execute();
|
||||
|
||||
return profiles.map((p) => p.name);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<RadarrMovie[]> {
|
||||
return this.get<RadarrMovie[]>(`/api/${this.apiVersion}/movie`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all quality profiles
|
||||
*/
|
||||
getQualityProfiles(): Promise<RadarrQualityProfile[]> {
|
||||
return this.get<RadarrQualityProfile[]>(`/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<RadarrMovieFile[]> {
|
||||
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<RadarrMovieFile[]>(`/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<string>): Promise<RadarrLibraryItem[]> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Library, ArrowUpDown, ScrollText } from 'lucide-svelte';
|
||||
import { Library, ArrowUpCircle, ScrollText } from 'lucide-svelte';
|
||||
|
||||
$: instanceId = $page.params.id;
|
||||
$: currentPath = $page.url.pathname;
|
||||
@@ -14,10 +14,10 @@
|
||||
icon: Library
|
||||
},
|
||||
{
|
||||
label: 'Search Priority',
|
||||
label: 'Upgrades',
|
||||
href: `/arr/${instanceId}/search-priority`,
|
||||
active: currentPath.includes('/search-priority'),
|
||||
icon: ArrowUpDown
|
||||
icon: ArrowUpCircle
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { ServerLoad, Actions } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
|
||||
import { cache } from '$cache/cache.ts';
|
||||
import { RadarrClient } from '$utils/arr/clients/radarr.ts';
|
||||
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const LIBRARY_CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get all quality profile names from all enabled Profilarr databases
|
||||
*/
|
||||
async function getProfilarrProfileNames(): Promise<Set<string>> {
|
||||
const profileNames = new Set<string>();
|
||||
const databases = pcdManager.getAll().filter((db) => db.enabled);
|
||||
|
||||
for (const db of databases) {
|
||||
const cache = pcdManager.getCache(db.id);
|
||||
if (!cache?.isBuilt()) continue;
|
||||
|
||||
try {
|
||||
const names = await qualityProfileQueries.names(cache);
|
||||
for (const name of names) {
|
||||
profileNames.add(name);
|
||||
}
|
||||
} catch {
|
||||
// Cache query failed, skip this database
|
||||
}
|
||||
}
|
||||
|
||||
return profileNames;
|
||||
}
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
@@ -15,7 +47,83 @@ export const load: ServerLoad = ({ params }) => {
|
||||
error(404, `Instance not found: ${id}`);
|
||||
}
|
||||
|
||||
// Only fetch library for Radarr for now
|
||||
let library: RadarrLibraryItem[] = [];
|
||||
let libraryError: string | null = null;
|
||||
|
||||
if (instance.type === 'radarr') {
|
||||
const cacheKey = `library:${id}`;
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = cache.get<RadarrLibraryItem[]>(cacheKey);
|
||||
if (cached) {
|
||||
library = cached;
|
||||
} else {
|
||||
try {
|
||||
const profilarrProfileNames = await getProfilarrProfileNames();
|
||||
const client = new RadarrClient(instance.url, instance.api_key);
|
||||
library = await client.getLibrary(profilarrProfileNames);
|
||||
|
||||
// Cache the result
|
||||
cache.set(cacheKey, library, LIBRARY_CACHE_TTL);
|
||||
|
||||
await logger.info(`Fetched library for ${instance.name}`, {
|
||||
source: 'arr/library',
|
||||
meta: { instanceId: id, movieCount: library.length }
|
||||
});
|
||||
} catch (err) {
|
||||
libraryError = err instanceof Error ? err.message : 'Failed to fetch library';
|
||||
|
||||
await logger.error(`Failed to fetch library for ${instance.name}`, {
|
||||
source: 'arr/library',
|
||||
meta: { instanceId: id, error: libraryError }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get profiles from all databases for the "Change Profile" action
|
||||
const profilesByDatabase: { databaseId: number; databaseName: string; profiles: string[] }[] = [];
|
||||
const databases = pcdManager.getAll().filter((db) => db.enabled);
|
||||
|
||||
for (const db of databases) {
|
||||
const cache = pcdManager.getCache(db.id);
|
||||
if (!cache?.isBuilt()) continue;
|
||||
|
||||
try {
|
||||
const names = await qualityProfileQueries.names(cache);
|
||||
profilesByDatabase.push({
|
||||
databaseId: db.id,
|
||||
databaseName: db.name,
|
||||
profiles: names
|
||||
});
|
||||
} catch {
|
||||
// Skip if cache query fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
instance
|
||||
instance,
|
||||
library,
|
||||
libraryError,
|
||||
profilesByDatabase
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
refresh: async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
if (!isNaN(id)) {
|
||||
cache.delete(`library:${id}`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
delete: async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
if (!isNaN(id)) {
|
||||
arrInstancesQueries.delete(id);
|
||||
cache.delete(`library:${id}`);
|
||||
}
|
||||
redirect(303, '/arr');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, Check, Film, ExternalLink, CircleAlert, RefreshCw, HardDrive, CheckCircle, ArrowUpCircle, Pencil, Trash2, FolderSync, Database } from 'lucide-svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { enhance } from '$app/forms';
|
||||
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import type { PageData } from './$types';
|
||||
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
|
||||
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import { createSearchStore } from '$stores/search';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let refreshing = false;
|
||||
|
||||
const searchStore = createSearchStore({ debounceMs: 150 });
|
||||
|
||||
function handleChangeProfile(databaseName: string, profileName: string) {
|
||||
const count = moviesWithFiles.length;
|
||||
// TODO: Implement actual profile change functionality
|
||||
alertStore.add('success', `Changing ${count} movies to "${profileName}" from ${databaseName}`);
|
||||
}
|
||||
|
||||
// Get base URL without trailing slash
|
||||
$: baseUrl = data.instance.url.replace(/\/$/, '');
|
||||
|
||||
// Subscribe to debounced query for reactivity
|
||||
$: debouncedQuery = $searchStore.query;
|
||||
|
||||
// Filter movies by search query
|
||||
$: moviesWithFiles = data.library
|
||||
.filter((m) => m.hasFile)
|
||||
.filter((m) => !debouncedQuery || m.title.toLowerCase().includes(debouncedQuery.toLowerCase()));
|
||||
|
||||
// Get progress bar color
|
||||
function getProgressColor(progress: number, cutoffMet: boolean): string {
|
||||
if (cutoffMet) return 'bg-green-500 dark:bg-green-400';
|
||||
if (progress >= 0.75) return 'bg-yellow-500 dark:bg-yellow-400';
|
||||
if (progress >= 0.5) return 'bg-orange-500 dark:bg-orange-400';
|
||||
return 'bg-red-500 dark:bg-red-400';
|
||||
}
|
||||
|
||||
const columns: Column<RadarrLibraryItem>[] = [
|
||||
{ key: 'title', header: 'Title', align: 'left' },
|
||||
{ key: 'qualityProfileName', header: 'Profile', align: 'left', width: 'w-40' },
|
||||
{ key: 'qualityName', header: 'Quality', align: 'left', width: 'w-32' },
|
||||
{ key: 'customFormatScore', header: 'Score', align: 'right', width: 'w-32' },
|
||||
{ key: 'progress', header: 'Progress', align: 'center', width: 'w-48' },
|
||||
{ key: 'actions', header: 'Actions', align: 'center', width: 'w-20' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.instance.name} - Library - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Library</h2>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
View and manage the library for this {data.instance.type} instance.
|
||||
</p>
|
||||
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Library content coming soon...
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
{#if data.libraryError}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-950/40">
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h3 class="font-medium text-red-800 dark:text-red-200">Failed to load library</h3>
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{data.libraryError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.instance.type !== 'radarr'}
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<Film class="h-5 w-5 text-neutral-400" />
|
||||
<div>
|
||||
<h3 class="font-medium text-neutral-900 dark:text-neutral-50">Library view not yet available</h3>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Library view is currently only supported for Radarr instances.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if moviesWithFiles.length === 0}
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<Film class="h-5 w-5 text-neutral-400" />
|
||||
<div>
|
||||
<h3 class="font-medium text-neutral-900 dark:text-neutral-50">No movies with files</h3>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
This library has {data.library.length} movies but none have downloaded files yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Header with inline stats -->
|
||||
<div class="flex items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{data.instance.name}</h2>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 capitalize">
|
||||
{data.instance.type}
|
||||
</span>
|
||||
<div class="hidden sm:flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Total movies in library">
|
||||
<Film size={12} class="text-blue-500" />
|
||||
{data.library.length} Total
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies with files on disk">
|
||||
<HardDrive size={12} class="text-purple-500" />
|
||||
{moviesWithFiles.length} On Disk
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that have met the quality cutoff">
|
||||
<CheckCircle size={12} class="text-green-500" />
|
||||
{moviesWithFiles.filter((m) => m.cutoffMet).length} Cutoff Met
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that can still be upgraded">
|
||||
<ArrowUpCircle size={12} class="text-orange-500" />
|
||||
{moviesWithFiles.filter((m) => !m.cutoffMet).length} Upgradeable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<code class="text-xs font-mono text-neutral-500 dark:text-neutral-400">{data.instance.url}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/refresh"
|
||||
use:enhance={() => {
|
||||
refreshing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
refreshing = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<RefreshCw size={14} class={refreshing ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
href={baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Open Radarr
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
<a
|
||||
href="/arr/{data.instance.id}/edit"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
title="Edit instance"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
if (!confirm('Are you sure you want to delete this instance?')) {
|
||||
return ({ cancel }) => cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-red-200 bg-red-50 text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
title="Delete instance"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search movies..." />
|
||||
<ActionButton icon={FolderSync} hasDropdown={true} dropdownPosition="right" square={false}>
|
||||
<span class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">Change Profile</span>
|
||||
<span class="ml-1 text-xs text-neutral-500 dark:text-neutral-400">({moviesWithFiles.length})</span>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="16rem">
|
||||
<div class="max-h-80 overflow-y-auto py-1">
|
||||
{#if data.profilesByDatabase.length === 0}
|
||||
<div class="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No databases configured
|
||||
</div>
|
||||
{:else}
|
||||
{#each data.profilesByDatabase as db}
|
||||
<div class="border-b border-neutral-100 dark:border-neutral-700 last:border-b-0">
|
||||
<div class="px-3 py-2 bg-neutral-50 dark:bg-neutral-900 flex items-center gap-2">
|
||||
<Database size={12} class="text-neutral-400" />
|
||||
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
{db.databaseName}
|
||||
</span>
|
||||
</div>
|
||||
{#each db.profiles as profile}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleChangeProfile(db.databaseName, profile)}
|
||||
class="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{profile}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Library Table -->
|
||||
<ExpandableTable {columns} data={moviesWithFiles} getRowId={(row) => row.id} compact={true}>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'title'}
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-50">{row.title}</div>
|
||||
{#if row.year}
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">{row.year}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.key === 'qualityProfileName'}
|
||||
<div class="relative group inline-flex">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium {row.isProfilarrProfile ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}"
|
||||
>
|
||||
{#if !row.isProfilarrProfile}
|
||||
<CircleAlert size={12} />
|
||||
{/if}
|
||||
{row.qualityProfileName}
|
||||
</span>
|
||||
{#if !row.isProfilarrProfile}
|
||||
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-1 px-2 py-1 text-xs text-white bg-neutral-800 dark:bg-neutral-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10">
|
||||
Not managed by Profilarr
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.key === 'qualityName'}
|
||||
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{row.qualityName ?? 'N/A'}
|
||||
</code>
|
||||
{:else if column.key === 'customFormatScore'}
|
||||
<div class="text-right">
|
||||
<span class="font-mono font-medium {row.cutoffMet ? 'text-green-600 dark:text-green-400' : 'text-neutral-900 dark:text-neutral-100'}">
|
||||
{row.customFormatScore.toLocaleString()}
|
||||
</span>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
/ {row.cutoffScore.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{:else if column.key === 'progress'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {getProgressColor(row.progress, row.cutoffMet)}"
|
||||
style="width: {Math.min(row.progress * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if row.cutoffMet}
|
||||
<Check size={16} class="text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
{:else}
|
||||
<span class="text-xs font-mono text-neutral-500 dark:text-neutral-400 w-10 text-right">
|
||||
{Math.round(row.progress * 100)}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex items-center justify-center">
|
||||
{#if row.tmdbId}
|
||||
<a
|
||||
href="{baseUrl}/movie/{row.tmdbId}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
title="Open in Radarr"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- File Name -->
|
||||
{#if row.fileName}
|
||||
<code class="text-xs font-mono text-neutral-600 dark:text-neutral-400 break-all">{row.fileName}</code>
|
||||
{/if}
|
||||
|
||||
<!-- Custom Formats with Scores (sorted by score descending) -->
|
||||
{#if row.scoreBreakdown.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#each row.scoreBreakdown.toSorted((a, b) => b.score - a.score) as item}
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs {item.score > 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : item.score < 0 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'}">
|
||||
<span class="font-medium">{item.name}</span>
|
||||
<span class="font-mono">{item.score >= 0 ? '+' : ''}{item.score.toLocaleString()}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
= <span class="font-mono font-medium">{row.customFormatScore.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">No custom formats matched</div>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ExpandableTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user