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:
Sam Chau
2025-12-26 07:41:04 +10:30
parent 85b594cdf1
commit 119131bab6
12 changed files with 902 additions and 19 deletions

View 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>

View File

@@ -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
View 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();

View File

@@ -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';

View 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);
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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');
}
};

View File

@@ -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>

View File

@@ -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',
}
}
};