>();
+
+ state.libraryItems.forEach((item) => {
+ if (!item.genres.length) {
+ return;
+ }
+
+ const rootItem = rootAncestorForItem(item, itemsById);
+ const root = rootsById.get(rootItem.id) ?? rootItem;
+ item.genres.forEach((genre) => {
+ const normalizedGenre = genre.trim();
+ if (!normalizedGenre) {
+ return;
+ }
+
+ if (!categories.has(normalizedGenre)) {
+ categories.set(normalizedGenre, new Map());
+ }
+ categories.get(normalizedGenre)!.set(root.id, root);
+ });
+ });
+
+ return [...categories.entries()]
+ .map(([genre, items]) => ({ genre, count: items.size, items: [...items.values()] }))
+ .sort((left, right) => right.count - left.count || left.genre.localeCompare(right.genre));
+}
+
+function collectionSummaries(): MediaCollectionSummary[] {
+ return state.home?.collections ?? [];
+}
+
+function filteredTopLevelLibraryItems(): MediaItemSummary[] {
+ const items = topLevelLibraryItems();
+ if (!state.browseFilter) {
+ return items;
+ }
+
+ const allowedIds = new Set(state.browseFilter.itemIds);
+ return items.filter((item) => allowedIds.has(item.id));
+}
+
+function applyBrowseFilter(filter: BrowseFilter): void {
+ state.browseFilter = filter;
+ state.homeTab = 'library';
+ render();
+}
+
+function metadataBadgeMarkup(item: MediaItemSummary): string {
+ if (item.metadata_refresh_state === 'pending') {
+ return ' ';
+ }
+
+ if (item.has_metadata) {
+ return '';
+ }
+
+ return `${renderIcon('triangle-alert', 'status-icon')} `;
+}
+
+function itemCardSubtitle(item: MediaItemSummary): string | undefined {
+ if (item.item_type === 'episode' && typeof item.episode_number === 'number') {
+ return `Episode ${item.episode_number}`;
+ }
+
+ if (item.item_type === 'season' && typeof item.season_number === 'number') {
+ return `Season ${item.season_number}`;
+ }
+
+ return undefined;
+}
+
+function renderItemCard(item: MediaItemSummary): string {
+ const library = state.libraries.find((entry) => entry.id === item.library_id);
+ const artworkUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at);
+ const cardSubtitle = itemCardSubtitle(item);
+ const isSeasonEpisodeCard = state.route.page === 'item'
+ && state.selectedItem?.item_type === 'season'
+ && item.item_type === 'episode';
+ const secondaryMeta = isSeasonEpisodeCard
+ ? undefined
+ : state.route.page === 'home' && typeof state.route.libraryId === 'number'
+ ? humanizeItemType(item.item_type)
+ : `${library?.name ?? 'Library'} · ${humanizeItemType(item.item_type)}`;
+ const badgeMarkup = metadataBadgeMarkup(item);
+
+ return `
+
+
+
+ ${badgeMarkup}
+ ${renderIcon(selectedLibraryIcon(library?.kind), 'card-icon')}
+
+ ${escapeHtml(formatChildCount(item))}
+
+ ${escapeHtml(item.display_title)}
+ ${cardSubtitle ? `${escapeHtml(cardSubtitle)} ` : ''}
+ ${secondaryMeta ? `${escapeHtml(secondaryMeta)} ` : ''}
+
+ `;
+}
+
+function renderShelfStack(): string {
+ if (state.searchQuery.trim()) {
+ if (!state.searchResults.length) {
+ return 'No media items matched the current search.
';
+ }
+
+ return `
+
+
+ ${state.searchResults.map(renderItemCard).join('')}
+
+ `;
+ }
+
+ const shelves = state.home?.shelves ?? [];
+ if (!shelves.length) {
+ return 'No shelves are available yet. Add a library to get started.
';
+ }
+
+ return shelves
+ .map((shelf) => `
+
+
+ ${shelf.items.length
+ ? `${shelf.items.map(renderItemCard).join('')}
`
+ : 'Nothing here yet.
'}
+
+ `)
+ .join('');
+}
+
+function renderHomeTabs(): string {
+ const tabs: Array<{ id: HomeBrowseTab; label: string }> = [
+ { id: 'recommended', label: 'Recommended' },
+ { id: 'library', label: 'Library' },
+ { id: 'collections', label: 'Collections' },
+ { id: 'playlists', label: 'Playlists' },
+ { id: 'categories', label: 'Categories' },
+ ];
+
+ return `
+
+ ${tabs.map((tab) => `
+
+ ${escapeHtml(tab.label)}
+
+ `).join('')}
+
+ `;
+}
+
+function renderLibraryOverview(): string {
+ const library = activeLibrary();
+ const refreshProgress = library ? libraryRefreshProgress(library) : undefined;
+ const stalePending = library ? Math.max(0, library.metadata_refresh_pending - activeLibraryPendingRefreshCount(library.id)) : 0;
+
+ if (!library) {
+ return `
+
+
+
+ Libraries
+ ${state.libraries.length}
+
+
+ Items
+ ${topLevelLibraryItems().length}
+
+
+ Status
+ ${state.libraries.some((entry) => entry.status === 'never_scanned') ? 'Scanning' : 'Ready'}
+
+
+
+ `;
+ }
+
+ return `
+
+
+
+
+ Top-level items
+ ${topLevelLibraryItems().length}
+
+
+ Video files
+ ${library.video_files}
+
+
+ Folders
+ ${library.paths.length}
+
+
+ Last scanned
+ ${escapeHtml(formatTimestamp(library.last_scanned_at))}
+
+
+ ${library.error ? `${escapeHtml(library.error)}
` : ''}
+ ${library.status === 'never_scanned' ? 'Koko is scanning this library in the background. New items will appear automatically.
' : ''}
+ ${refreshProgress
+ ? `Metadata refresh progress: ${refreshProgress.completed}/${refreshProgress.total}${refreshProgress.failed ? ` (${refreshProgress.failed} failed)` : ''}. Artwork and item cards update automatically as each item completes.
`
+ : ''}
+ ${stalePending > 0
+ ? `${stalePending} item${stalePending === 1 ? ' is' : 's are'} still marked pending without an active refresh worker. Open Settings → Metadata dashboard to inspect the affected items and errors.
`
+ : ''}
+
+ `;
+}
+
+function renderLibraryTab(): string {
+ const items = filteredTopLevelLibraryItems();
+ const library = activeLibrary();
+ const isSpecificLibrary = state.route.page === 'home' && typeof state.route.libraryId === 'number';
+
+ if (!items.length) {
+ if (state.browseFilter) {
+ return `No items matched the current ${escapeHtml(state.browseFilter.kind)} filter.
`;
+ }
+
+ if (library?.status === 'never_scanned') {
+ return 'Koko is scanning this library right now. The show, season, and episode hierarchy will appear when the scan completes.
';
+ }
+
+ if (library?.status && library.status !== 'available') {
+ return `This library is not ready yet: ${escapeHtml(libraryStatusLabel(library.status))}.
`;
+ }
+
+ return 'No browseable items are available yet for this library.
';
+ }
+
+ return `
+
+
+ ${state.browseFilter ? `
+
+ ${escapeHtml(state.browseFilter.kind === 'category' ? 'Category' : 'Collection')}
+ ${escapeHtml(state.browseFilter.label)}
+ ${renderButtonContent('Clear filter', 'x')}
+
+ ` : ''}
+ ${items.map(renderItemCard).join('')}
+
+ `;
+}
+
+function renderCollectionsTab(): string {
+ const collections = collectionSummaries();
+ if (!collections.length) {
+ return 'No linked collection data is available yet for this library.
';
+ }
+
+ return `
+
+ ${collections.map((collection) => `
+
+
+ ${escapeHtml(collection.overview ?? 'Open this collection to filter the library view.')}
+
+ `).join('')}
+
+ `;
+}
+
+function renderPlaylistsTab(): string {
+ return `
+
+ Playlist creation is planned. This tab will eventually let you build reusable watch queues and listening sessions.
+
+ `;
+}
+
+function renderCategoriesTab(): string {
+ const categories = categorySummaries();
+ if (!categories.length) {
+ return 'No genre metadata is available yet for the current library.
';
+ }
+
+ return `
+
+ ${categories.map((category) => `
+
+
+ ${escapeHtml(category.items.slice(0, 3).map((item) => item.display_title).join(' · ') || 'No titles yet')}
+
+ `).join('')}
+
+ `;
+}
+
+function renderHomeTabContent(): string {
+ if (state.searchQuery.trim()) {
+ return renderShelfStack();
+ }
+
+ switch (state.homeTab) {
+ case 'library':
+ return renderLibraryTab();
+ case 'collections':
+ return renderCollectionsTab();
+ case 'playlists':
+ return renderPlaylistsTab();
+ case 'categories':
+ return renderCategoriesTab();
+ default:
+ return renderShelfStack();
+ }
+}
+
+function renderPageNavbar(eyebrow: string, title: string, description: string, actions = ''): string {
+ return `
+
+
+
${escapeHtml(eyebrow)}
+
${escapeHtml(title)}
+
${escapeHtml(description)}
+
+ ${actions ? `${actions}
` : ''}
+
+ `;
+}
+
+function renderHomePage(): string {
+ const activeLibraryName = selectedLibraryName();
+ const library = activeLibrary();
+ const activeLibraryPaths = library?.paths ?? [];
+ const libraryRefreshPending = library ? Boolean(libraryRefreshProgress(library)) : false;
+
+ return `
+ ${renderPageNavbar(
+ 'Browse',
+ activeLibraryName,
+ activeLibraryPaths.length ? `${activeLibraryPaths.length} folder${activeLibraryPaths.length === 1 ? '' : 's'} connected for this library.` : 'Browse every configured library from one place.',
+ `
+
+
+ ${library
+ ? `${renderButtonContent(libraryRefreshPending ? 'Refreshing library metadata' : 'Refresh library metadata', 'refresh-cw')} `
+ : ''}
+
+ `,
+ )}
+ ${renderHomeTabs()}
+ ${renderLibraryOverview()}
+ ${renderHomeTabContent()}
+ `;
+}
+
+function renderMetadataSearchResults(): string {
+ const selectedItem = state.selectedItem;
+ if (!selectedItem) {
+ return '';
+ }
+
+ if (!state.metadataSearchResults.length) {
+ return 'Run a TMDB search to link rich metadata and artwork.
';
+ }
+
+ return state.metadataSearchResults
+ .map((result) => `
+
+
+
${escapeHtml(result.title)}
+
${escapeHtml(result.overview ?? 'No overview available.')}
+
+ ${result.release_year ?? 'Unknown year'}
+ ${escapeHtml(result.media_type)}
+
+
+
+ ${renderButtonContent('Link', 'link-2')}
+
+
+ `)
+ .join('');
+}
+
+function renderLinkedMetadataSummary(): string {
+ const linkedMatch = state.selectedItemMetadata?.matches[0];
+ if (!linkedMatch) {
+ return 'No external metadata is linked yet.
';
+ }
+
+ const metadataRefreshPending = itemIsMetadataPending(state.selectedItem);
+ const refreshStateLabel = metadataRefreshPending || linkedMatch.refresh_state === 'pending'
+ ? 'Refreshing'
+ : linkedMatch.refresh_state === 'error'
+ ? 'Refresh failed'
+ : 'Up to date';
+
+ return `
+
+ ${escapeHtml(linkedMatch.provider_id)}
+ ${escapeHtml(linkedMatch.media_type ?? 'linked')}
+ ${escapeHtml(refreshStateLabel)}
+ ${linkedMatch.release_year ? `${linkedMatch.release_year} ` : ''}
+
+ ${escapeHtml(linkedMatch.title ?? linkedMatch.external_id)}
+ Last refreshed ${escapeHtml(formatTimestamp(linkedMatch.last_refreshed_at ?? linkedMatch.updated_at))}
+ ${linkedMatch.refresh_error ? `${escapeHtml(linkedMatch.refresh_error)} ` : ''}
+
+
+ `;
+}
+
+function subtitleLanguage(trackLabel: string): string {
+ const normalized = trackLabel.trim().toLowerCase();
+ if (/^[a-z]{2,3}$/.test(normalized)) {
+ return normalized;
+ }
+
+ return 'en';
+}
+
+function renderMetadataDashboard(): string {
+ const filteredItems = filteredMetadataDashboardItems();
+ const summary = metadataDashboardSummary(state.dashboardItems);
+ const itemTypes = [...new Set(state.dashboardItems.map((item) => item.item_type))].sort((left, right) => left.localeCompare(right));
+
+ return `
+
+
+
+
Metadata dashboard
+
Browse every item, identify failed refreshes, and spot pending items that no longer have an active worker.
+
+
+ ${state.dashboardItems.length} total
+ ${summary.failed} failed
+ ${summary.pending} active
+ ${summary.stalled} stalled
+ ${summary.unmatched} unmatched
+
+
+
+ ${filteredItems.length
+ ? ``
+ : 'No items matched the current dashboard filters.
'}
+
+ `;
+}
+
+function renderSystemActivitiesPanel(): string {
+ const activities = state.systemActivities.filter((activity) => activity.state !== 'completed' && activity.state !== 'failed');
+ return `
+
+
+
+
Backend activities
+
Active background work that the browser is polling.
+
+
${activities.length} active
+
+ ${activities.length
+ ? `${activities.map((activity) => {
+ const progress = activityProgress(activity);
+ return `
+
+
+
+
+
+
+
${progress.completed}/${progress.total}${progress.failed ? ` · ${progress.failed} failed` : ''}
+
+
+ `;
+ }).join('')}
`
+ : 'No background activities are running right now.
'}
+
+ `;
+}
+
+function renderLogViewer(): string {
+ const logEntries = state.logsResponse?.entries ?? [];
+
+ return `
+
+
+
+
Logs
+
Structured logs from ${escapeHtml(state.logsResponse?.log_path ?? 'the current log file')}.
+
+
${renderButtonContent('Refresh logs', 'refresh-cw')}
+
+
+ ${logEntries.length
+ ? `${logEntries.map((entry) => `
+
+
+ ${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}
+ ${escapeHtml(entry.message)}
+
+ `).join('')}
`
+ : 'No log entries matched the current filters.
'}
+
+ `;
+}
+
+function renderItemPage(): string {
+ if (!state.selectedItem) {
+ return '';
+ }
+
+ const posterUrl = state.selectedItem.poster_url
+ ? getArtworkUrl(state.selectedItem.id, 'poster', state.selectedItem.artwork_updated_at)
+ : undefined;
+ const themeSongUrl = state.selectedItem.theme_song_url ? resolveApiUrl(state.selectedItem.theme_song_url) : undefined;
+ const themeSongYouTubeUrl = state.selectedItem.theme_song_youtube_url
+ ? buildYouTubeEmbedUrl(state.selectedItem.theme_song_youtube_url, { autoplay: true, controls: false })
+ : undefined;
+ const trailerOptions = currentTrailerOptions();
+ const preferredTrailer = trailerOptions[0];
+ const hasMultipleTrailers = trailerOptions.length > 1;
+ const trailerButtonTitle = hasMultipleTrailers
+ ? 'Click to play the first trailer. Right-click or press and hold to choose another trailer.'
+ : 'Play Trailer';
+ const playback = state.selectedPlayback;
+ const library = state.libraries.find((entry) => entry.id === state.selectedItem?.library_id);
+ const linkedMatch = state.selectedItemMetadata?.matches[0];
+ const overview = state.selectedItem.overview
+ ?? linkedMatch?.overview
+ ?? 'No description is stored for this item yet.';
+ const genres = state.selectedItem.genres.length
+ ? state.selectedItem.genres
+ : [];
+ const technicalFacts = [
+ { label: 'Duration', value: formatDuration(state.selectedItem.duration_ms) },
+ {
+ label: 'Format',
+ value: [state.selectedItem.container?.toUpperCase(), state.selectedItem.media_kind.toUpperCase()].filter(Boolean).join(' • ') || 'Unknown',
+ },
+ {
+ label: 'Codecs',
+ value: [state.selectedItem.video_codec, state.selectedItem.audio_codec].filter(Boolean).join(' / ') || 'Unknown',
+ },
+ {
+ label: 'Resolution',
+ value: state.selectedItem.width && state.selectedItem.height ? `${state.selectedItem.width}×${state.selectedItem.height}` : 'Unknown',
+ },
+ { label: 'Bitrate', value: formatBitRate(state.selectedItem.bit_rate) },
+ { label: 'Size', value: formatFileSize(state.selectedItem.file_size) },
+ ];
+ const hierarchy = state.selectedItem.hierarchy;
+ const children = state.selectedItem.children;
+ const backTarget = backNavigationTarget();
+ const supportsManualLinking = canManuallyLinkMetadata(state.selectedItem);
+ const metadataRefreshPending = itemIsMetadataPending(state.selectedItem);
+ const childSectionTitle = state.selectedItem.item_type === 'show'
+ ? 'Seasons'
+ : state.selectedItem.item_type === 'season'
+ ? 'Episodes'
+ : 'Contained items';
+ const themeSongMarkup = !state.isPlayerOpen && !state.activeTrailer
+ ? themeSongUrl
+ ? ` `
+ : themeSongYouTubeUrl
+ ? `
+
+ `
+ : ''
+ : '';
+
+ return `
+
+ ${themeSongMarkup}
+ ${hierarchy.length ? `
+
+ ${hierarchy.map((item) => `
+ ${escapeHtml(item.display_title)}
+ `).join('/ ')}
+ /
+ ${escapeHtml(state.selectedItem.display_title)}
+
+ ` : ''}
+
+
+ ${posterUrl ? `
` : `
${escapeHtml(state.selectedItem.display_title.slice(0, 1).toUpperCase())} `}
+
+
+
${escapeHtml(state.selectedItem.display_title)}
+ ${state.selectedItem.tagline ? `
${escapeHtml(state.selectedItem.tagline)}
` : ''}
+
+ ${state.selectedItem.release_year ? `${state.selectedItem.release_year} ` : ''}
+ ${genres.map((genre) => `${escapeHtml(genre)} `).join('')}
+
+
${escapeHtml(overview)}
+
+ ${state.selectedItem.playable ? `${renderButtonContent(playback?.can_direct_play ? 'Play now' : 'Transcode planned', 'play')} ` : ''}
+ ${preferredTrailer ? `${renderButtonContent('Play Trailer', 'play')} ` : ''}
+ ${renderButtonContent(backTarget.label, 'arrow-left')}
+
+ ${hasMultipleTrailers && state.isTrailerMenuOpen ? `
+
+
+
Choose a trailer
+ ${renderButtonContent('Close', 'x')}
+
+
+ ${trailerOptions.map((option, index) => `
+ ${escapeHtml(option.title)}
+ `).join('')}
+
+
+ ` : ''}
+
${escapeHtml(playback?.reason ?? 'Loading playback capabilities…')}
+
+ ${technicalFacts.map((fact) => `
+
+ ${escapeHtml(fact.label)}
+ ${escapeHtml(fact.value)}
+
+ `).join('')}
+
+
+
+
+ ${children.length ? `
+
+
+
${escapeHtml(childSectionTitle)}
+ ${children.length} item${children.length === 1 ? '' : 's'}
+
+ ${children.map(renderItemCard).join('')}
+
+ ` : ''}
+
+
+
+
+
File and library
+
+
+
+ Library
+ ${escapeHtml(library?.name ?? 'Unknown')}
+
+
+ Folders
+ ${escapeHtml(String(library?.paths.length ?? 0))}
+
+
+ Source
+ ${escapeHtml(state.selectedItem.relative_path)}
+
+
+ Updated
+ ${escapeHtml(formatTimestamp(state.selectedItem.modified_at))}
+
+
+
+
+
+
+
${supportsManualLinking ? 'Link metadata' : 'Metadata'}
+ ${supportsManualLinking
+ ? `${renderButtonContent(metadataRefreshPending ? 'Refreshing metadata' : 'Force refresh metadata', 'refresh-cw')} `
+ : ''}
+
+ ${renderLinkedMetadataSummary()}
+ ${supportsManualLinking
+ ? `
+
+ ${renderMetadataSearchResults()}
+ `
+ : 'Season and episode metadata is inherited and refreshed automatically from the linked show.
'}
+
+
+
+ `;
+}
+
+function metadataProviderCheckboxes(prefix: string, selectedProviders: string[]): string {
+ const allProviders = ['tmdb', 'musicbrainz', 'open_library', 'local_nfo'];
+ return allProviders
+ .map((providerId) => `
+
+
+ ${providerId}
+
+ `)
+ .join('');
+}
+
+function renderExistingLibrariesSettings(settings: SettingsSnapshot): string {
+ if (!settings.media.libraries.length) {
+ return 'No libraries are configured yet.
';
+ }
+
+ return settings.media.libraries
+ .map((library, index) => {
+ const persistedLibrary = persistedLibraryForSettings(library);
+ const refreshPending = persistedLibrary ? Boolean(libraryRefreshProgress(persistedLibrary)) : false;
+ const refreshLabel = refreshPending
+ ? 'Refreshing metadata'
+ : 'Refresh metadata';
+
+ return `
+
+ `;
+ })
+ .join('');
+}
+
+function libraryKindOptions(selectedKind: string): string {
+ return [
+ ['mixed', 'Mixed'],
+ ['movies', 'Movies'],
+ ['shows', 'Shows'],
+ ['music', 'Music'],
+ ['photos', 'Photos'],
+ ['books', 'Books'],
+ ['home_videos', 'Home videos'],
+ ]
+ .map(([value, label]) => `${label} `)
+ .join('');
+}
+
+function renderSettingsPage(): string {
+ const settings = state.settingsResponse?.settings;
+ if (!settings) {
+ return 'Settings are still loading…
';
+ }
+
+ const tmdb = settings.metadata.providers.find((provider) => provider.id === 'tmdb');
+
+ return `
+ ${renderPageNavbar(
+ 'Settings',
+ 'Program configuration',
+ `Saved to ${state.settingsResponse?.settings_path ?? ''}`,
+ )}
+
+
+
+
+
+ ${renderUserManagement()}
+
+ ${renderMetadataDashboard()}
+ ${renderSystemActivitiesPanel()}
+ ${renderLogViewer()}
+ `;
+}
+
+function renderCurrentPage(): string {
+ switch (state.route.page) {
+ case 'item':
+ return renderItemPage();
+ case 'settings':
+ return renderSettingsPage();
+ default:
+ return renderHomePage();
+ }
+}
+
+function renderPlayerOverlay(): string {
+ if (state.activeTrailer) {
+ const trailerUrl = buildYouTubeEmbedUrl(state.activeTrailer.url, { autoplay: true, controls: true })
+ ?? state.activeTrailer.url;
+ return `
+
+ `;
+ }
+
+ if (!state.isPlayerOpen || !state.selectedItem || !state.selectedPlayback?.can_direct_play) {
+ return '';
+ }
+
+ const tag = state.selectedItem.media_kind === 'audio' ? 'audio' : 'video';
+ const source = getStreamUrl(state.selectedItem.id);
+ const trackMarkup = tag === 'video'
+ ? state.selectedItem.subtitle_tracks
+ .map((track) => ` `)
+ .join('')
+ : '';
+
+ return `
+
+
+
+ ${tag === 'audio'
+ ? `
`
+ : `
${trackMarkup} `}
+
+
+ `;
+}
+
+function renderRail(): string {
+ const activeLibraryIdValue = activeLibraryId();
+ const collapsed = isRailCollapsed();
+
+ return `
+
+ `;
+}
+
+function render(preserveScroll = true): void {
+ const previousScrollTop = preserveScroll
+ ? document.querySelector('.main-shell')?.scrollTop ?? 0
+ : 0;
+
+ if (!state.bootstrap && state.isLoading) {
+ appRoot.innerHTML = renderAuthShell('Loading Koko', 'Checking server state and account access.', '');
+ createIcons({ icons });
+ return;
+ }
+
+ if (requiresSetup()) {
+ appRoot.innerHTML = renderWelcomeScreen();
+ createIcons({ icons });
+ bindEvents();
+ return;
+ }
+
+ if (requiresLogin()) {
+ appRoot.innerHTML = renderLoginScreen();
+ createIcons({ icons });
+ bindEvents();
+ return;
+ }
+
+ const pageBackdropUrl = state.route.page === 'item' && state.selectedItem
+ && (state.selectedItem.backdrop_url || state.selectedItemMetadata?.matches.some((match) => Boolean(match.backdrop_url || match.cached_backdrop_path)))
+ ? getArtworkUrl(state.selectedItem.id, 'backdrop', state.selectedItem.artwork_updated_at)
+ : undefined;
+ const railCollapsed = isRailCollapsed();
+
+ appRoot.innerHTML = `
+
+ ${pageBackdropUrl ? `
` : ''}
+ ${renderRail()}
+
+
+ ${state.error ? `${escapeHtml(state.error)} ` : ''}
+ ${renderCurrentPage()}
+
+
+ ${renderPlayerOverlay()}
+
+ `;
+
+ createIcons({ icons });
+ bindEvents();
+ bindThemeSongPlayer();
+ if (preserveScroll) {
+ window.requestAnimationFrame(() => {
+ const shell = document.querySelector('.main-shell');
+ if (shell) {
+ shell.scrollTop = previousScrollTop;
+ }
+ });
+ }
+}
+
+async function refreshData(): Promise {
+ state.route = parseRoute();
+ state.isLoading = true;
+ state.error = undefined;
+ state.apiMode = getApiMode();
+ render(false);
+
+ try {
+ state.bootstrap = await getAppBootstrap().catch(async (error) => {
+ if (!getStoredAuthToken()) {
+ return Promise.reject(error);
+ }
+
+ clearStoredAuthToken();
+ return getAppBootstrap();
+ });
+
+ if (requiresSetup() || requiresLogin()) {
+ clearPendingLibraryRefresh();
+ clearPendingMetadataRefresh();
+ state.capabilities = undefined;
+ state.libraries = [];
+ state.home = undefined;
+ state.libraryItems = [];
+ state.searchResults = [];
+ state.metadataProviders = [];
+ state.systemActivities = [];
+ state.dashboardItems = [];
+ state.settingsResponse = undefined;
+ state.logsResponse = undefined;
+ state.selectedItem = undefined;
+ state.selectedItemMetadata = undefined;
+ state.selectedPlayback = undefined;
+ state.metadataSearchResults = [];
+ state.users = [];
+ state.hasDeferredAutoRefreshRender = false;
+ return;
+ }
+
+ const [capabilities, libraries, metadataProviders, settingsResponse, systemActivitiesResponse] = await Promise.all([
+ getCapabilities(),
+ getLibraries(),
+ getMetadataProviders(),
+ getSettings(),
+ getSystemActivities(),
+ ]);
+
+ state.capabilities = capabilities;
+ state.libraries = libraries;
+ state.metadataProviders = metadataProviders;
+ state.settingsResponse = settingsResponse;
+ state.systemActivities = systemActivitiesResponse.activities;
+ state.users = canManageUsers() ? await getUsers() : [];
+
+ if (state.route.page === 'home') {
+ const [home, libraryItems, searchResults] = await Promise.all([
+ getHome(state.route.libraryId),
+ getItems(state.route.libraryId),
+ state.searchQuery.trim()
+ ? searchItems(state.searchQuery, state.route.libraryId)
+ : Promise.resolve([]),
+ ]);
+ state.home = home;
+ state.libraryItems = libraryItems;
+ state.searchResults = searchResults;
+ state.selectedItem = undefined;
+ state.selectedItemMetadata = undefined;
+ state.selectedPlayback = undefined;
+ state.metadataSearchResults = [];
+ state.metadataSearchQuery = '';
+ state.isPlayerOpen = false;
+ state.isTrailerMenuOpen = false;
+ state.activeTrailer = undefined;
+ state.hasDeferredAutoRefreshRender = false;
+ state.dashboardItems = [];
+ state.logsResponse = undefined;
+ } else if (state.route.page === 'item') {
+ state.home = undefined;
+ state.libraryItems = [];
+ state.searchResults = [];
+ state.metadataSearchResults = [];
+ state.metadataSearchQuery = '';
+ state.isTrailerMenuOpen = false;
+ state.activeTrailer = undefined;
+ state.dashboardItems = [];
+ state.logsResponse = undefined;
+ const [item, metadata, playback] = await Promise.all([
+ getItem(state.route.itemId),
+ getItemMetadata(state.route.itemId),
+ getPlaybackDecision(state.route.itemId),
+ ]);
+ state.selectedItem = item;
+ state.selectedItemMetadata = metadata;
+ state.selectedPlayback = playback;
+ } else {
+ state.home = undefined;
+ state.libraryItems = [];
+ state.searchResults = [];
+ state.selectedItem = undefined;
+ state.selectedItemMetadata = undefined;
+ state.selectedPlayback = undefined;
+ state.metadataSearchResults = [];
+ state.metadataSearchQuery = '';
+ state.isPlayerOpen = false;
+ state.isTrailerMenuOpen = false;
+ state.activeTrailer = undefined;
+ state.hasDeferredAutoRefreshRender = false;
+ const [logsResponse, dashboardItems] = await Promise.all([
+ getLogs(currentLogFilterRequest()),
+ getItems(),
+ ]);
+ state.logsResponse = logsResponse;
+ state.dashboardItems = dashboardItems;
+ }
+
+ state.apiMode = getApiMode();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to load server data.';
+ state.apiMode = getApiMode();
+ } finally {
+ state.isLoading = false;
+ schedulePendingLibraryRefresh();
+ schedulePendingMetadataRefresh();
+ render(false);
+ }
+}
+
+async function refreshPendingMetadataData(): Promise {
+ const route = parseRoute();
+ let shouldRender = false;
+ const previousError = state.error;
+
+ try {
+ if (route.page === 'item') {
+ const itemId = route.itemId;
+ const previousSnapshot = snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ selectedItem: state.selectedItem,
+ selectedItemMetadata: state.selectedItemMetadata,
+ });
+ const [activitiesResponse, libraries, item, metadata] = await Promise.all([
+ getSystemActivities(),
+ getLibraries(),
+ getItem(itemId),
+ getItemMetadata(itemId),
+ ]);
+ if (state.route.page !== 'item' || state.route.itemId !== itemId) {
+ return;
+ }
+
+ state.systemActivities = activitiesResponse.activities;
+ state.libraries = libraries;
+ state.selectedItem = item;
+ state.selectedItemMetadata = metadata;
+ state.error = undefined;
+ shouldRender = previousSnapshot !== snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ selectedItem: state.selectedItem,
+ selectedItemMetadata: state.selectedItemMetadata,
+ }) || previousError !== state.error;
+ } else if (route.page === 'home') {
+ const libraryId = route.libraryId;
+ const searchQuery = state.searchQuery.trim();
+ const previousSnapshot = snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ home: state.home,
+ libraryItems: state.libraryItems,
+ searchResults: state.searchResults,
+ });
+ const [activitiesResponse, libraries, home, libraryItems, searchResults] = await Promise.all([
+ getSystemActivities(),
+ getLibraries(),
+ getHome(libraryId),
+ getItems(libraryId),
+ searchQuery
+ ? searchItems(searchQuery, libraryId)
+ : Promise.resolve([]),
+ ]);
+ if (state.route.page !== 'home' || state.route.libraryId !== libraryId) {
+ return;
+ }
+
+ state.systemActivities = activitiesResponse.activities;
+ state.libraries = libraries;
+ state.home = home;
+ state.libraryItems = libraryItems;
+ state.searchResults = searchResults;
+ state.error = undefined;
+ shouldRender = previousSnapshot !== snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ home: state.home,
+ libraryItems: state.libraryItems,
+ searchResults: state.searchResults,
+ }) || previousError !== state.error;
+ } else {
+ const previousSnapshot = snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ logsResponse: state.logsResponse,
+ dashboardItems: state.dashboardItems,
+ });
+ const [activitiesResponse, libraries, logsResponse, dashboardItems] = await Promise.all([
+ getSystemActivities(),
+ getLibraries(),
+ getLogs(currentLogFilterRequest()),
+ getItems(),
+ ]);
+ state.systemActivities = activitiesResponse.activities;
+ state.libraries = libraries;
+ state.logsResponse = logsResponse;
+ state.dashboardItems = dashboardItems;
+ state.error = undefined;
+ shouldRender = previousSnapshot !== snapshotJson({
+ systemActivities: state.systemActivities,
+ libraries: state.libraries,
+ logsResponse: state.logsResponse,
+ dashboardItems: state.dashboardItems,
+ }) || previousError !== state.error;
+ }
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to refresh media metadata.';
+ shouldRender = previousError !== state.error;
+ } finally {
+ schedulePendingMetadataRefresh();
+ maybeRenderAfterAutoRefresh(shouldRender);
+ }
+}
+
+function buildSettingsFromForm(formData: FormData): SettingsSnapshot | undefined {
+ const current = state.settingsResponse?.settings;
+ if (!current) {
+ return undefined;
+ }
+
+ return {
+ general: {
+ data_dir: String(formData.get('data_dir') ?? current.general.data_dir),
+ },
+ media: {
+ libraries: current.media.libraries.map((library, index) => {
+ const paths = parsePathsInput(formData.get(`existing_library_paths_${index}`));
+ return {
+ name: String(formData.get(`existing_library_name_${index}`) ?? library.name),
+ path: paths[0] ?? library.path,
+ paths,
+ recursive: formData.get(`existing_library_recursive_${index}`) === 'on',
+ kind: String(formData.get(`existing_library_kind_${index}`) ?? library.kind),
+ metadata_providers: formData
+ .getAll(`existing_library_metadata_provider_${index}`)
+ .map((value) => String(value)),
+ };
+ }),
+ },
+ metadata: {
+ providers: current.metadata.providers.map((provider) => {
+ if (provider.id !== 'tmdb') {
+ return provider;
+ }
+
+ return {
+ ...provider,
+ enabled: formData.get('tmdb_enabled') === 'on',
+ api_key: String(formData.get('tmdb_api_key') ?? ''),
+ language: String(formData.get('tmdb_language') ?? 'en-US'),
+ rate_limit_per_second: Math.max(1, Number(formData.get('tmdb_rate_limit_per_second') ?? provider.rate_limit_per_second)),
+ retry_attempts: Math.max(0, Number(formData.get('tmdb_retry_attempts') ?? provider.retry_attempts)),
+ retry_backoff_ms: Math.max(1, Number(formData.get('tmdb_retry_backoff_ms') ?? provider.retry_backoff_ms)),
+ };
+ }),
+ },
+ server: {
+ use_https: formData.get('use_https') === 'on',
+ address: String(formData.get('address') ?? current.server.address),
+ port: Number(formData.get('port') ?? current.server.port),
+ cert_path: String(formData.get('cert_path') ?? current.server.cert_path),
+ key_path: String(formData.get('key_path') ?? current.server.key_path),
+ use_custom_certs: formData.get('use_custom_certs') === 'on',
+ },
+ ffmpeg: {
+ strategy: String(formData.get('ffmpeg_strategy') ?? current.ffmpeg.strategy),
+ ffmpeg_path: String(formData.get('ffmpeg_path') ?? current.ffmpeg.ffmpeg_path),
+ ffprobe_path: String(formData.get('ffprobe_path') ?? current.ffmpeg.ffprobe_path),
+ },
+ };
+}
+
+function bindThemeSongPlayer(): void {
+ const themePlayer = document.querySelector('#theme-song-player');
+ if (!themePlayer) {
+ return;
+ }
+
+ themePlayer.volume = 0.45;
+ themePlayer.loop = false;
+ themePlayer.addEventListener('ended', () => {
+ if (state.hasDeferredAutoRefreshRender) {
+ state.hasDeferredAutoRefreshRender = false;
+ render();
+ }
+ }, { once: true });
+ void themePlayer.play().catch(() => {
+ // Autoplay can be blocked by the browser, so the page quietly falls back without looping.
+ });
+}
+
+async function refreshLogsView(): Promise {
+ if (state.route.page !== 'settings') {
+ return;
+ }
+
+ try {
+ state.logsResponse = await getLogs(currentLogFilterRequest());
+ state.error = undefined;
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to load logs.';
+ } finally {
+ render();
+ }
+}
+
+function bindPlayerProgress(): void {
+ const player = document.querySelector('#media-player');
+ if (!player || !state.selectedItem) {
+ return;
+ }
+
+ let lastSentSeconds = -1;
+ player.addEventListener('timeupdate', () => {
+ const currentSeconds = Math.floor(player.currentTime);
+ if (currentSeconds === lastSentSeconds || currentSeconds % 15 !== 0) {
+ return;
+ }
+
+ lastSentSeconds = currentSeconds;
+ void updatePlaybackProgress(state.selectedItem!.id, {
+ position_ms: Math.floor(player.currentTime * 1000),
+ duration_ms: Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : state.selectedItem?.duration_ms,
+ completed: false,
+ });
+ });
+
+ player.addEventListener('ended', () => {
+ void updatePlaybackProgress(state.selectedItem!.id, {
+ position_ms: Math.floor((Number.isFinite(player.duration) ? player.duration : 0) * 1000),
+ duration_ms: Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : state.selectedItem?.duration_ms,
+ completed: true,
+ });
+ });
+}
+
+function bindEvents(): void {
+ document.querySelector('#welcome-user-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ const request: CreateUserRequest = {
+ username: String(formData.get('username') ?? '').trim(),
+ password: String(formData.get('password') ?? ''),
+ pin: String(formData.get('pin') ?? '').trim() || undefined,
+ admin: true,
+ };
+
+ try {
+ await createUser(request);
+ const token = await loginUser({ username: request.username, password: request.password });
+ setStoredAuthToken(token.token);
+ await refreshData();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to create the first user.';
+ render();
+ }
+ });
+
+ document.querySelector('#login-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ const request: LoginRequest = {
+ username: String(formData.get('username') ?? '').trim(),
+ password: String(formData.get('password') ?? ''),
+ };
+
+ try {
+ const token = await loginUser(request);
+ setStoredAuthToken(token.token);
+ await refreshData();
+ } catch (error) {
+ clearStoredAuthToken();
+ state.error = error instanceof Error ? error.message : 'Failed to sign in.';
+ render();
+ }
+ });
+
+ document.querySelector('[data-sign-out]')?.addEventListener('click', () => {
+ clearStoredAuthToken();
+ state.bootstrap = state.bootstrap ? { ...state.bootstrap, current_user: undefined } : undefined;
+ void refreshData();
+ });
+
+ document.querySelector('[data-nav-home]')?.addEventListener('click', () => {
+ navigateTo('/');
+ });
+
+ document.querySelectorAll('[data-nav-library-id]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const libraryId = Number(button.dataset.navLibraryId);
+ if (!Number.isFinite(libraryId)) {
+ return;
+ }
+
+ navigateTo(`/libraries/${libraryId}`);
+ });
+ });
+
+ document.querySelector('[data-nav-settings]')?.addEventListener('click', () => {
+ navigateTo('/settings');
+ });
+
+ document.querySelector('#search-form')?.addEventListener('submit', (event) => {
+ event.preventDefault();
+ const input = document.querySelector('#search-input');
+ state.searchQuery = input?.value.trim() ?? '';
+ void refreshData();
+ });
+
+ document.querySelector('#reset-search')?.addEventListener('click', () => {
+ state.searchQuery = '';
+ void refreshData();
+ });
+
+ document.querySelector('#refresh-active-library-metadata')?.addEventListener('click', async () => {
+ const library = activeLibrary();
+ if (!library || libraryRefreshProgress(library)) {
+ return;
+ }
+
+ try {
+ const refreshedLibrary = await refreshLibraryMetadata(library.id);
+ state.libraries = state.libraries.map((entry) => entry.id === refreshedLibrary.id ? refreshedLibrary : entry);
+ await refreshPendingMetadataData();
+ schedulePendingMetadataRefresh();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.';
+ render();
+ }
+ });
+
+ document.querySelectorAll('[data-home-tab]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const nextTab = button.dataset.homeTab as HomeBrowseTab | undefined;
+ if (!nextTab || state.homeTab === nextTab) {
+ return;
+ }
+
+ state.homeTab = nextTab;
+ render();
+ });
+ });
+
+ document.querySelectorAll('[data-category-filter]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const genre = button.dataset.categoryFilter;
+ if (!genre) {
+ return;
+ }
+
+ const category = categorySummaries().find((entry) => entry.genre === genre);
+ if (!category) {
+ return;
+ }
+
+ applyBrowseFilter({
+ kind: 'category',
+ label: category.genre,
+ itemIds: category.items.map((item) => item.id),
+ });
+ });
+ });
+
+ document.querySelectorAll('[data-collection-filter]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const collectionId = button.dataset.collectionFilter;
+ if (!collectionId) {
+ return;
+ }
+
+ const collection = collectionSummaries().find((entry) => entry.id === collectionId);
+ if (!collection) {
+ return;
+ }
+
+ applyBrowseFilter({
+ kind: 'collection',
+ label: collection.name,
+ itemIds: collection.item_ids,
+ });
+ });
+ });
+
+ document.querySelector('#clear-browse-filter')?.addEventListener('click', () => {
+ state.browseFilter = undefined;
+ render();
+ });
+
+ document.querySelectorAll('[data-item-id]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const itemId = Number(button.dataset.itemId);
+ if (!Number.isFinite(itemId)) {
+ return;
+ }
+
+ navigateTo(`/items/${itemId}`);
+ });
+ });
+
+ document.querySelector('#back-to-library')?.addEventListener('click', () => {
+ navigateTo(backNavigationTarget().path);
+ });
+
+ document.querySelector('#metadata-search-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ if (!state.selectedItem) {
+ return;
+ }
+
+ const input = document.querySelector('#metadata-search-input');
+ state.metadataSearchQuery = input?.value.trim() ?? '';
+ try {
+ state.metadataSearchResults = await searchItemMetadata(state.selectedItem.id, state.metadataSearchQuery);
+ render();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to search metadata.';
+ render();
+ }
+ });
+
+ document.querySelectorAll('[data-link-metadata]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const encoded = button.dataset.linkMetadata;
+ if (!encoded || !state.selectedItem) {
+ return;
+ }
+
+ const [itemId, providerId, externalId, mediaType] = encoded.split(':');
+ try {
+ await linkItemMetadata(Number(itemId), {
+ provider_id: providerId,
+ external_id: externalId,
+ media_type: mediaType,
+ });
+ state.selectedItemMetadata = await getItemMetadata(state.selectedItem.id);
+ state.metadataSearchResults = [];
+ await refreshPendingMetadataData();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to link metadata.';
+ render();
+ }
+ });
+ });
+
+ document.querySelector('#refresh-item-metadata')?.addEventListener('click', async () => {
+ if (!state.selectedItem) {
+ return;
+ }
+
+ try {
+ await refreshItemMetadata(state.selectedItem.id);
+ await refreshPendingMetadataData();
+ schedulePendingMetadataRefresh();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to refresh item metadata.';
+ render();
+ }
+ });
+
+ const trailerButton = document.querySelector('#play-item-trailer');
+ if (trailerButton) {
+ let trailerHoldHandle: number | undefined;
+ let suppressNextTrailerClick = false;
+
+ const clearTrailerHoldHandle = (): void => {
+ if (trailerHoldHandle !== undefined) {
+ window.clearTimeout(trailerHoldHandle);
+ trailerHoldHandle = undefined;
+ }
+ };
+
+ const openTrailerChooser = (): void => {
+ if (currentTrailerOptions().length <= 1) {
+ return;
+ }
+
+ suppressNextTrailerClick = true;
+ state.isTrailerMenuOpen = true;
+ render();
+ };
+
+ trailerButton.addEventListener('click', () => {
+ if (suppressNextTrailerClick) {
+ suppressNextTrailerClick = false;
+ return;
+ }
+
+ openTrailer(currentTrailerOptions()[0]);
+ });
+ trailerButton.addEventListener('contextmenu', (event) => {
+ if (currentTrailerOptions().length <= 1) {
+ return;
+ }
+
+ event.preventDefault();
+ clearTrailerHoldHandle();
+ openTrailerChooser();
+ });
+ trailerButton.addEventListener('mousedown', () => {
+ clearTrailerHoldHandle();
+ if (currentTrailerOptions().length <= 1) {
+ return;
+ }
+
+ trailerHoldHandle = window.setTimeout(() => {
+ trailerHoldHandle = undefined;
+ openTrailerChooser();
+ }, 450);
+ });
+ trailerButton.addEventListener('mouseup', clearTrailerHoldHandle);
+ trailerButton.addEventListener('mouseleave', clearTrailerHoldHandle);
+ trailerButton.addEventListener('touchstart', () => {
+ clearTrailerHoldHandle();
+ if (currentTrailerOptions().length <= 1) {
+ return;
+ }
+
+ trailerHoldHandle = window.setTimeout(() => {
+ trailerHoldHandle = undefined;
+ openTrailerChooser();
+ }, 500);
+ }, { passive: true });
+ trailerButton.addEventListener('touchend', clearTrailerHoldHandle);
+ trailerButton.addEventListener('touchcancel', clearTrailerHoldHandle);
+ }
+
+ document.querySelector('#close-trailer-picker')?.addEventListener('click', () => {
+ state.isTrailerMenuOpen = false;
+ render();
+ });
+
+ document.querySelectorAll('[data-play-trailer-index]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const trailerIndex = Number(button.dataset.playTrailerIndex);
+ if (!Number.isFinite(trailerIndex)) {
+ return;
+ }
+
+ openTrailer(currentTrailerOptions()[trailerIndex]);
+ });
+ });
+
+ document.querySelector('#close-trailer')?.addEventListener('click', () => {
+ state.activeTrailer = undefined;
+ render();
+ });
+
+ document.querySelector('#play-selected-item')?.addEventListener('click', () => {
+ if (!state.selectedPlayback?.can_direct_play) {
+ return;
+ }
+
+ state.isPlayerOpen = true;
+ render();
+ });
+
+ document.querySelector('#close-player')?.addEventListener('click', () => {
+ state.isPlayerOpen = false;
+ render();
+ });
+
+ document.querySelector('#settings-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const nextSettings = buildSettingsFromForm(new FormData(form));
+ if (!nextSettings) {
+ return;
+ }
+
+ try {
+ state.settingsResponse = await updateSettings(nextSettings);
+ await refreshData();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to save settings.';
+ render();
+ }
+ });
+
+ document.querySelector('#go-home-from-settings')?.addEventListener('click', () => {
+ navigateTo('/');
+ });
+
+ document.querySelector('#metadata-dashboard-filter-form')?.addEventListener('submit', (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ state.metadataDashboardFilters = {
+ libraryId: String(formData.get('dashboard_library_id') ?? '').trim(),
+ itemType: String(formData.get('dashboard_item_type') ?? '').trim(),
+ refreshState: String(formData.get('dashboard_refresh_state') ?? '').trim(),
+ search: String(formData.get('dashboard_search') ?? '').trim(),
+ };
+ render();
+ });
+
+ document.querySelector('#clear-metadata-dashboard-filters')?.addEventListener('click', () => {
+ state.metadataDashboardFilters = {
+ libraryId: '',
+ itemType: '',
+ refreshState: '',
+ search: '',
+ };
+ render();
+ });
+
+ document.querySelector('#log-filter-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ state.logFilters = {
+ level: String(formData.get('log_level') ?? '').trim().toUpperCase(),
+ module: String(formData.get('log_module') ?? '').trim(),
+ search: String(formData.get('log_search') ?? '').trim(),
+ since: String(formData.get('log_since') ?? '').trim(),
+ until: String(formData.get('log_until') ?? '').trim(),
+ };
+ await refreshLogsView();
+ });
+
+ document.querySelector('#clear-log-filters')?.addEventListener('click', async () => {
+ state.logFilters = {
+ level: '',
+ module: '',
+ search: '',
+ since: '',
+ until: '',
+ };
+ await refreshLogsView();
+ });
+
+ document.querySelector('#refresh-log-viewer')?.addEventListener('click', async () => {
+ await refreshLogsView();
+ });
+
+ document.querySelector('#create-user-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ const request: CreateUserRequest = {
+ username: String(formData.get('username') ?? '').trim(),
+ password: String(formData.get('password') ?? ''),
+ pin: String(formData.get('pin') ?? '').trim() || undefined,
+ admin: formData.get('admin') === 'on',
+ };
+
+ try {
+ await createUser(request);
+ form.reset();
+ state.users = await getUsers();
+ render();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to create the user.';
+ render();
+ }
+ });
+
+ document.querySelectorAll('[data-remove-library-index]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const libraryIndex = Number(button.dataset.removeLibraryIndex);
+ if (!Number.isFinite(libraryIndex)) {
+ return;
+ }
+
+ const confirmed = window.confirm('Remove this library from settings? This only removes the configuration, not the media files on disk.');
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ state.settingsResponse = await deleteLibrary(libraryIndex);
+ await refreshData();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to remove library.';
+ render();
+ }
+ });
+ });
+
+ document.querySelectorAll('[data-refresh-library-id]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const libraryId = Number(button.dataset.refreshLibraryId);
+ if (!Number.isFinite(libraryId)) {
+ return;
+ }
+
+ try {
+ const library = await refreshLibraryMetadata(libraryId);
+ state.libraries = state.libraries.map((entry) => entry.id === library.id ? library : entry);
+ await refreshPendingMetadataData();
+ schedulePendingMetadataRefresh();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.';
+ render();
+ }
+ });
+ });
+
+ document.querySelector('#add-library-form')?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement | null;
+ if (!form) {
+ return;
+ }
+
+ const formData = new FormData(form);
+ const paths = parsePathsInput(formData.get('library_paths'));
+ const library: MediaLibrarySettings = {
+ name: String(formData.get('library_name') ?? ''),
+ path: paths[0] ?? '',
+ paths,
+ recursive: formData.get('library_recursive') === 'on',
+ kind: String(formData.get('library_kind') ?? 'mixed'),
+ metadata_providers: formData.getAll('library_metadata_provider').map((value) => String(value)),
+ };
+
+ try {
+ state.settingsResponse = await addLibrary(library);
+ form.reset();
+ await refreshData();
+ } catch (error) {
+ state.error = error instanceof Error ? error.message : 'Failed to add library.';
+ render();
+ }
+ });
+
+ bindPlayerProgress();
+}
+
+window.addEventListener('popstate', () => {
+ state.route = parseRoute();
+ if (state.route.page === 'home') {
+ state.homeTab = defaultHomeTab(state.route);
+ state.browseFilter = undefined;
+ }
+ state.isTrailerMenuOpen = false;
+ void refreshData();
+});
+
+render();
+void refreshData();
+
diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts
new file mode 100644
index 00000000..3cb52c90
--- /dev/null
+++ b/crates/client-web/src/mockApi.ts
@@ -0,0 +1,1051 @@
+import type {
+ AppBootstrapResponse,
+ BootstrapUser,
+ CreateUserRequest,
+ ItemMetadataMatch,
+ ItemMetadataResponse,
+ LoginRequest,
+ LinkMetadataRequest,
+ MediaCollectionSummary,
+ MediaHome,
+ MediaItemDetail,
+ MediaItemSummary,
+ MediaLibrary,
+ MediaLibrarySettings,
+ MetadataProviderStatus,
+ MetadataSearchResult,
+ LogEntriesResponse,
+ PlaybackDecision,
+ PlaybackProgressRequest,
+ ServerCapabilities,
+ SettingsResponse,
+ SettingsSnapshot,
+ SystemActivity,
+ SystemActivitiesResponse,
+ TokenResponse,
+} from './api';
+
+let nextLibraryId = 4;
+let nextUserId = 2;
+const AUTH_TOKEN_STORAGE_KEY = 'koko-client-web-auth-token';
+
+interface MockUserRecord extends BootstrapUser {
+ password: string;
+ pin?: string;
+}
+
+const libraries: MediaLibrary[] = [
+ {
+ id: 1,
+ name: 'Movies',
+ path: 'C:/Media/Movies',
+ paths: ['C:/Media/Movies', 'D:/Overflow/Movies'],
+ recursive: true,
+ kind: 'movies',
+ status: 'available',
+ scan_revision: 6,
+ last_scanned_at: 1760923200,
+ total_files: 2,
+ video_files: 2,
+ audio_files: 0,
+ image_files: 0,
+ book_files: 0,
+ other_files: 0,
+ metadata_refresh_total: 0,
+ metadata_refresh_pending: 0,
+ metadata_refresh_completed: 0,
+ metadata_refresh_failed: 0,
+ },
+ {
+ id: 2,
+ name: 'Shows',
+ path: 'C:/Media/Shows',
+ paths: ['C:/Media/Shows'],
+ recursive: true,
+ kind: 'shows',
+ status: 'available',
+ scan_revision: 5,
+ last_scanned_at: 1760923150,
+ total_files: 1,
+ video_files: 1,
+ audio_files: 0,
+ image_files: 0,
+ book_files: 0,
+ other_files: 0,
+ metadata_refresh_total: 0,
+ metadata_refresh_pending: 0,
+ metadata_refresh_completed: 0,
+ metadata_refresh_failed: 0,
+ },
+ {
+ id: 3,
+ name: 'Music',
+ path: 'C:/Media/Music',
+ paths: ['C:/Media/Music'],
+ recursive: true,
+ kind: 'music',
+ status: 'available',
+ scan_revision: 4,
+ last_scanned_at: 1760923100,
+ total_files: 2,
+ video_files: 0,
+ audio_files: 2,
+ image_files: 0,
+ book_files: 0,
+ other_files: 0,
+ metadata_refresh_total: 0,
+ metadata_refresh_pending: 0,
+ metadata_refresh_completed: 0,
+ metadata_refresh_failed: 0,
+ },
+];
+
+const items: MediaItemDetail[] = [
+ {
+ id: 101,
+ library_id: 1,
+ item_type: 'movie',
+ display_title: 'Mock Movie',
+ relative_path: 'Action/mock-movie.mp4',
+ media_kind: 'video',
+ playable: true,
+ child_count: 0,
+ duration_ms: 5_400_000,
+ width: 1920,
+ height: 1080,
+ modified_at: 1760923200,
+ file_size: 1_610_612_736,
+ container: 'mp4',
+ bit_rate: 2_400_000,
+ video_codec: 'h264',
+ audio_codec: 'aac',
+ metadata_json: JSON.stringify({ format: { format_name: 'mp4', duration: '5400.0' } }, null, 2),
+ metadata_updated_at: 1760923200,
+ poster_url: '/api/v1/items/101/artwork?kind=poster',
+ backdrop_url: '/api/v1/items/101/artwork?kind=backdrop',
+ theme_song_url: '/api/v1/items/101/theme',
+ theme_song_youtube_url: 'https://www.youtube.com/watch?v=SLBACEP6LsI',
+ tagline: 'Welcome to the real world.',
+ overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.',
+ genres: ['Action', 'Science Fiction'],
+ release_year: 1999,
+ linked_media_type: 'movie',
+ has_metadata: true,
+ metadata_refresh_state: 'fresh',
+ artwork_updated_at: 1760923200,
+ trailer_title: 'Official Trailer',
+ trailer_url: 'https://www.youtube.com/embed/vKQi3bBA1y8?autoplay=1&rel=0',
+ subtitle_tracks: [
+ {
+ index: 0,
+ label: 'EN',
+ format: 'SRT',
+ url: '/api/v1/items/101/subtitles/0',
+ },
+ ],
+ hierarchy: [],
+ children: [],
+ },
+ {
+ id: 201,
+ library_id: 2,
+ item_type: 'show',
+ display_title: 'Mock Show',
+ relative_path: 'Mock Show',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ duration_ms: 2_700_000,
+ modified_at: 1760923150,
+ genres: ['Drama', 'Fantasy'],
+ has_metadata: true,
+ metadata_refresh_state: 'fresh',
+ theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0',
+ subtitle_tracks: [],
+ hierarchy: [],
+ children: [
+ {
+ id: 202,
+ library_id: 2,
+ parent_id: 201,
+ item_type: 'season',
+ display_title: 'Season 1',
+ relative_path: 'Mock Show/Season 1',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ season_number: 1,
+ duration_ms: 2_700_000,
+ genres: ['Drama', 'Fantasy'],
+ modified_at: 1760923150,
+ },
+ ],
+ },
+ {
+ id: 202,
+ library_id: 2,
+ parent_id: 201,
+ item_type: 'season',
+ display_title: 'Season 1',
+ relative_path: 'Mock Show/Season 1',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ season_number: 1,
+ duration_ms: 2_700_000,
+ modified_at: 1760923150,
+ genres: ['Drama', 'Fantasy'],
+ has_metadata: true,
+ metadata_refresh_state: 'fresh',
+ theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0',
+ subtitle_tracks: [],
+ hierarchy: [
+ {
+ id: 201,
+ library_id: 2,
+ item_type: 'show',
+ display_title: 'Mock Show',
+ relative_path: 'Mock Show',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ duration_ms: 2_700_000,
+ genres: ['Drama', 'Fantasy'],
+ modified_at: 1760923150,
+ },
+ ],
+ children: [
+ {
+ id: 203,
+ library_id: 2,
+ parent_id: 202,
+ item_type: 'episode',
+ display_title: 'Mock Episode',
+ relative_path: 'Mock Show/Season 1/episode-01.mp4',
+ media_kind: 'video',
+ playable: true,
+ child_count: 0,
+ season_number: 1,
+ episode_number: 1,
+ duration_ms: 2_700_000,
+ width: 1280,
+ height: 720,
+ genres: ['Drama', 'Fantasy'],
+ modified_at: 1760923100,
+ },
+ ],
+ },
+ {
+ id: 203,
+ library_id: 2,
+ parent_id: 202,
+ item_type: 'episode',
+ display_title: 'Mock Episode',
+ relative_path: 'Mock Show/Season 1/episode-01.mp4',
+ media_kind: 'video',
+ playable: true,
+ child_count: 0,
+ season_number: 1,
+ episode_number: 1,
+ duration_ms: 2_700_000,
+ width: 1280,
+ height: 720,
+ modified_at: 1760923100,
+ file_size: 810_612_736,
+ container: 'mp4',
+ bit_rate: 1_800_000,
+ video_codec: 'h264',
+ audio_codec: 'aac',
+ metadata_json: JSON.stringify({ format: { format_name: 'mp4', duration: '2700.0' } }, null, 2),
+ metadata_updated_at: 1760923100,
+ poster_url: '/api/v1/items/203/artwork?kind=poster',
+ tagline: 'Winter is coming.',
+ overview: 'A major fantasy series entry used as mock TV metadata for the browser client.',
+ genres: ['Drama', 'Fantasy'],
+ release_year: 2011,
+ linked_media_type: 'tv',
+ has_metadata: true,
+ metadata_refresh_state: 'fresh',
+ theme_song_youtube_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0',
+ subtitle_tracks: [],
+ hierarchy: [
+ {
+ id: 201,
+ library_id: 2,
+ item_type: 'show',
+ display_title: 'Mock Show',
+ relative_path: 'Mock Show',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ duration_ms: 2_700_000,
+ genres: ['Drama', 'Fantasy'],
+ modified_at: 1760923150,
+ },
+ {
+ id: 202,
+ library_id: 2,
+ parent_id: 201,
+ item_type: 'season',
+ display_title: 'Season 1',
+ relative_path: 'Mock Show/Season 1',
+ media_kind: 'video',
+ playable: false,
+ child_count: 1,
+ season_number: 1,
+ duration_ms: 2_700_000,
+ genres: ['Drama', 'Fantasy'],
+ modified_at: 1760923150,
+ },
+ ],
+ children: [],
+ },
+ {
+ id: 103,
+ library_id: 3,
+ item_type: 'track',
+ display_title: 'Mock Song',
+ relative_path: 'mock-artist/mock-song.flac',
+ media_kind: 'audio',
+ playable: true,
+ child_count: 0,
+ duration_ms: 215_000,
+ modified_at: 1760923000,
+ file_size: 35_610_736,
+ container: 'flac',
+ bit_rate: 970_000,
+ audio_codec: 'flac',
+ metadata_json: JSON.stringify({ format: { format_name: 'flac', duration: '215.0' } }, null, 2),
+ metadata_updated_at: 1760923000,
+ genres: [],
+ subtitle_tracks: [],
+ hierarchy: [],
+ children: [],
+ },
+ {
+ id: 104,
+ library_id: 3,
+ item_type: 'track',
+ display_title: 'Roadtrip Mix',
+ relative_path: 'mock-artist/roadtrip-mix.mp3',
+ media_kind: 'audio',
+ playable: true,
+ child_count: 0,
+ duration_ms: 198_000,
+ modified_at: 1760922900,
+ file_size: 8_610_736,
+ container: 'mp3',
+ bit_rate: 320_000,
+ audio_codec: 'mp3',
+ metadata_json: JSON.stringify({ format: { format_name: 'mp3', duration: '198.0' } }, null, 2),
+ metadata_updated_at: 1760922900,
+ genres: [],
+ subtitle_tracks: [],
+ hierarchy: [],
+ children: [],
+ },
+];
+
+const metadataProviders: MetadataProviderStatus[] = [
+ {
+ id: 'tmdb',
+ display_name: 'TheMovieDB',
+ description: 'Primary movie and television metadata provider for Koko.',
+ supported_kinds: ['movies', 'shows'],
+ requires_api_key: true,
+ implemented: true,
+ enabled: true,
+ configured: true,
+ language: 'en-US',
+ },
+ {
+ id: 'musicbrainz',
+ display_name: 'MusicBrainz',
+ description: 'Planned music metadata provider for albums, artists, and tracks.',
+ supported_kinds: ['music'],
+ requires_api_key: false,
+ implemented: false,
+ enabled: false,
+ configured: true,
+ language: 'en-US',
+ },
+];
+
+const metadataSearchResults: Record = {
+ 101: [
+ {
+ provider_id: 'tmdb',
+ external_id: '603',
+ media_type: 'movie',
+ title: 'The Matrix',
+ overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.',
+ artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg',
+ backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg',
+ release_year: 1999,
+ },
+ ],
+ 203: [
+ {
+ provider_id: 'tmdb',
+ external_id: '1399',
+ media_type: 'tv',
+ title: 'Game of Thrones',
+ overview: 'Nine noble families wage war against each other in order to gain control over the mythical land of Westeros.',
+ artwork_url: 'https://image.tmdb.org/t/p/w500/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg',
+ backdrop_url: 'https://image.tmdb.org/t/p/w1280/suopoADq0k8YZr4dQXcU6pToj6s.jpg',
+ release_year: 2011,
+ },
+ ],
+};
+
+const users: MockUserRecord[] = [
+ {
+ id: 1,
+ username: 'admin',
+ password: 'adminpass',
+ admin: true,
+ },
+];
+
+function activeMockUserId(): number | undefined {
+ const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim();
+ if (!token?.startsWith('mock-token-')) {
+ return undefined;
+ }
+
+ const parsed = Number(token.slice('mock-token-'.length));
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+const itemMetadata: Record = {
+ 101: {
+ item_id: 101,
+ providers: metadataProviders,
+ matches: [
+ {
+ id: 1,
+ provider_id: 'tmdb',
+ external_id: '603',
+ title: 'The Matrix',
+ overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.',
+ artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg',
+ backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg',
+ release_year: 1999,
+ media_type: 'movie',
+ match_state: 'linked',
+ provider_payload_json: JSON.stringify({
+ videos: {
+ results: [
+ {
+ site: 'YouTube',
+ type: 'Trailer',
+ official: true,
+ name: 'Official Trailer',
+ key: 'vKQi3bBA1y8',
+ },
+ {
+ site: 'YouTube',
+ type: 'Teaser',
+ official: false,
+ name: 'Legacy Teaser',
+ key: 'm8e-FF8MsqU',
+ },
+ ],
+ },
+ }),
+ refresh_state: 'fresh',
+ last_refreshed_at: 1760923200,
+ updated_at: 1760923200,
+ },
+ ],
+ },
+ 201: {
+ item_id: 201,
+ providers: metadataProviders,
+ matches: [],
+ },
+ 202: {
+ item_id: 202,
+ providers: metadataProviders,
+ matches: [],
+ },
+ 203: {
+ item_id: 203,
+ providers: metadataProviders,
+ matches: [],
+ },
+ 103: {
+ item_id: 103,
+ providers: metadataProviders,
+ matches: [],
+ },
+ 104: {
+ item_id: 104,
+ providers: metadataProviders,
+ matches: [],
+ },
+};
+
+const playbackProgress = new Map();
+playbackProgress.set('1:101', { position_ms: 1_260_000, duration_ms: 5_400_000, completed: false });
+playbackProgress.set('1:103', { position_ms: 74_000, duration_ms: 215_000, completed: false });
+
+const collections: MediaCollectionSummary[] = [
+ {
+ id: 'tmdb:2344',
+ provider_id: 'tmdb',
+ external_id: '2344',
+ name: 'The Matrix Collection',
+ overview: 'A cyberpunk science-fiction collection centered around Neo, Zion, and the war against the machines.',
+ artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg',
+ backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg',
+ item_ids: [101],
+ item_count: 1,
+ },
+];
+
+let settings: SettingsSnapshot = {
+ general: {
+ data_dir: 'C:/Users/Mock/AppData/Local/Koko/data',
+ },
+ media: {
+ libraries: [
+ {
+ name: 'Movies',
+ path: 'C:/Media/Movies',
+ paths: ['C:/Media/Movies', 'D:/Overflow/Movies'],
+ recursive: true,
+ kind: 'movies',
+ metadata_providers: ['tmdb'],
+ },
+ {
+ name: 'Shows',
+ path: 'C:/Media/Shows',
+ paths: ['C:/Media/Shows'],
+ recursive: true,
+ kind: 'shows',
+ metadata_providers: ['tmdb'],
+ },
+ {
+ name: 'Music',
+ path: 'C:/Media/Music',
+ paths: ['C:/Media/Music'],
+ recursive: true,
+ kind: 'music',
+ metadata_providers: [],
+ },
+ ],
+ },
+ metadata: {
+ providers: [
+ {
+ id: 'tmdb',
+ enabled: true,
+ api_key: 'mock-key',
+ language: 'en-US',
+ rate_limit_per_second: 4,
+ retry_attempts: 3,
+ retry_backoff_ms: 1000,
+ },
+ ],
+ },
+ server: {
+ use_https: false,
+ address: '127.0.0.1',
+ port: 9191,
+ cert_path: 'cert.pem',
+ key_path: 'key.pem',
+ use_custom_certs: false,
+ },
+ ffmpeg: {
+ strategy: 'external_binaries',
+ ffmpeg_path: 'ffmpeg',
+ ffprobe_path: 'ffprobe',
+ },
+};
+
+export function getMockCapabilities(): ServerCapabilities {
+ return {
+ app_name: 'Koko',
+ version: '0.0.0-dev',
+ server_url: 'http://127.0.0.1:9191',
+ https_enabled: false,
+ libraries_configured: libraries.length,
+ ffmpeg_strategy: 'external_binaries',
+ api_versions: ['v1'],
+ transcoding: {
+ ffmpeg: {
+ available: true,
+ version: 'ffmpeg mock build',
+ },
+ ffprobe: {
+ available: true,
+ version: 'ffprobe mock build',
+ },
+ },
+ };
+}
+
+export function getMockBootstrap(): AppBootstrapResponse {
+ const currentUser = users.find((user) => user.id === activeMockUserId());
+ return {
+ has_users: users.length > 0,
+ current_user: currentUser ? { id: currentUser.id, username: currentUser.username, admin: currentUser.admin } : undefined,
+ };
+}
+
+export function loginMockUser(request: LoginRequest): TokenResponse {
+ const user = users.find((candidate) => {
+ return candidate.username === request.username && candidate.password === request.password;
+ });
+ if (!user) {
+ throw new Error('401 Unauthorized');
+ }
+ return { token: `mock-token-${user.id}` };
+}
+
+export function createMockUser(request: CreateUserRequest): string {
+ const currentUser = users.find((user) => user.id === activeMockUserId());
+ if (users.length > 0 && currentUser === undefined) {
+ throw new Error('401 Unauthorized');
+ }
+
+ if (users.length > 0 && !currentUser?.admin) {
+ throw new Error('403 Forbidden');
+ }
+
+ if (users.some((user) => user.username.toLowerCase() === request.username.trim().toLowerCase())) {
+ throw new Error('409 Conflict');
+ }
+
+ users.push({
+ id: nextUserId,
+ username: request.username.trim(),
+ password: request.password,
+ pin: request.pin,
+ admin: users.length === 0 || request.admin,
+ });
+ nextUserId += 1;
+ return 'User created';
+}
+
+export function getMockUsers(): BootstrapUser[] {
+ return users.map(({ id, username, admin }) => ({ id, username, admin }));
+}
+
+export function getMockLibraries(): MediaLibrary[] {
+ syncAllMockLibraryRefreshProgress();
+ return [...libraries];
+}
+
+function syncMockLibraryRefreshProgress(libraryId: number): void {
+ const library = libraries.find((candidate) => candidate.id === libraryId);
+ if (!library) {
+ return;
+ }
+
+ const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata);
+ library.metadata_refresh_total = refreshableItems.length;
+ library.metadata_refresh_pending = refreshableItems.filter((item) => item.metadata_refresh_state === 'pending').length;
+ library.metadata_refresh_failed = refreshableItems.filter((item) => item.metadata_refresh_state === 'error').length;
+ library.metadata_refresh_completed = Math.max(0, library.metadata_refresh_total - library.metadata_refresh_pending);
+}
+
+function syncAllMockLibraryRefreshProgress(): void {
+ libraries.forEach((library) => {
+ syncMockLibraryRefreshProgress(library.id);
+ });
+}
+
+export function getMockMetadataProviders(): MetadataProviderStatus[] {
+ return metadataProviders.map((provider) => ({ ...provider }));
+}
+
+export function getMockSystemActivities(): SystemActivitiesResponse {
+ const now = Math.floor(Date.now() / 1000);
+ const activities = libraries.reduce((entries, library) => {
+ const pendingItems = items.filter((item) => item.library_id === library.id && item.metadata_refresh_state === 'pending');
+ if (!pendingItems.length) {
+ return entries;
+ }
+
+ entries.push({
+ id: `mock-activity-library-${library.id}`,
+ category: 'metadata_refresh',
+ scope: 'library',
+ source: 'mock_refresh',
+ state: 'running',
+ label: `Refresh metadata for ${library.name}`,
+ provider_id: 'tmdb',
+ library_id: library.id,
+ item_ids: pendingItems.map((item) => item.id),
+ total_items: pendingItems.length,
+ completed_items: 0,
+ failed_items: 0,
+ queued_at: now,
+ started_at: now,
+ updated_at: now,
+ });
+
+ return entries;
+ }, []);
+
+ return {
+ generated_at: now,
+ activities,
+ };
+}
+
+export function getMockLogs(
+ level?: string,
+ moduleFilter?: string,
+ search?: string,
+ since?: string,
+ until?: string,
+ limit = 200,
+): LogEntriesResponse {
+ const sinceTime = since ? new Date(since).getTime() : Number.NaN;
+ const untilTime = until ? new Date(until).getTime() : Number.NaN;
+ const entries = [
+ {
+ timestamp: '2026-04-22T09:12:35.853-04:00',
+ level: 'INFO',
+ module: 'koko::web::routes::media',
+ source_file_path: 'src/web/routes/media.rs',
+ line_number: 540,
+ message: 'Completed TMDB metadata refresh for media item 201 "Mock Show" (show) in library 2 [Mock Show]',
+ },
+ {
+ timestamp: '2026-04-22T09:12:00.810-04:00',
+ level: 'WARN',
+ module: 'koko::web::routes::media',
+ source_file_path: 'src/web/routes/media.rs',
+ line_number: 589,
+ message: 'Failed to fetch refreshed TMDB metadata snapshot for media item 417 "Season 1" (season) in library 2 [The Simpsons/Season 1] using target tv:456:season:1 (tv_season): TMDB season lookup failed with status 404 Not Found',
+ },
+ {
+ timestamp: '2026-04-22T09:10:49.079-04:00',
+ level: 'DEBUG',
+ module: 'reqwest::connect',
+ source_file_path: 'src/connect.rs',
+ line_number: 118,
+ message: 'starting new connection: https://api.themoviedb.org/',
+ },
+ ].filter((entry) => {
+ const levelMatches = level ? entry.level.toLowerCase() === level.toLowerCase() : true;
+ const moduleMatches = moduleFilter ? entry.module.toLowerCase().includes(moduleFilter.toLowerCase()) : true;
+ const searchMatches = search
+ ? `${entry.message} ${entry.module} ${entry.source_file_path}`.toLowerCase().includes(search.toLowerCase())
+ : true;
+ const timestamp = new Date(entry.timestamp).getTime();
+ const sinceMatches = Number.isNaN(sinceTime) || timestamp >= sinceTime;
+ const untilMatches = Number.isNaN(untilTime) || timestamp <= untilTime;
+ return levelMatches && moduleMatches && searchMatches && sinceMatches && untilMatches;
+ });
+
+ return {
+ log_path: 'C:/Users/Mock/AppData/Local/Koko/data/koko.log',
+ entries: entries.slice(0, Math.max(1, limit)),
+ };
+}
+
+export function getMockItem(itemId: number): MediaItemDetail | undefined {
+ return items.find((item) => item.id === itemId);
+}
+
+export function getMockItemMetadata(itemId: number): ItemMetadataResponse | undefined {
+ return itemMetadata[itemId];
+}
+
+export function searchMockItemMetadata(itemId: number, query?: string): MetadataSearchResult[] {
+ const results = metadataSearchResults[itemId] ?? [];
+ const normalized = query?.trim().toLowerCase();
+ if (!normalized) {
+ return [...results];
+ }
+
+ return results.filter((result) => {
+ return result.title.toLowerCase().includes(normalized)
+ || result.overview?.toLowerCase().includes(normalized);
+ });
+}
+
+export function getMockPlayback(itemId: number): PlaybackDecision {
+ const item = getMockItem(itemId);
+ if (!item) {
+ throw new Error('404 Not Found');
+ }
+
+ if (!item.playable) {
+ return {
+ item_id: itemId,
+ can_direct_play: false,
+ transcode_required: false,
+ reason: 'This item is a container and cannot be played directly.',
+ stream_url: undefined,
+ mime_type: undefined,
+ };
+ }
+
+ const canDirectPlay = item.container === 'mp4' || item.container === 'mp3' || item.container === 'flac';
+ return {
+ item_id: itemId,
+ can_direct_play: canDirectPlay,
+ transcode_required: !canDirectPlay,
+ reason: canDirectPlay
+ ? 'Browser direct play is supported for this item.'
+ : 'A future FFmpeg-backed transcode path will be required for browser playback.',
+ stream_url: canDirectPlay ? `/api/v1/items/${itemId}/stream` : undefined,
+ mime_type: item.media_kind === 'video' ? 'video/mp4' : 'audio/mpeg',
+ };
+}
+
+export function getMockHome(libraryId?: number): MediaHome {
+ const filteredItems = getMockItems(libraryId);
+ const continueWatching = filteredItems.filter((item) => {
+ const userId = activeMockUserId();
+ const progress = userId === undefined ? undefined : playbackProgress.get(`${userId}:${item.id}`);
+ return Boolean(progress && !progress.completed && progress.position_ms > 0);
+ });
+ const recentlyAdded = [...filteredItems].sort((left, right) => (right.modified_at ?? 0) - (left.modified_at ?? 0));
+ const recommended = filteredItems.filter((item) => !continueWatching.some((candidate) => candidate.id === item.id));
+
+ return {
+ library_id: libraryId,
+ shelves: [
+ { id: 'continue_watching', title: 'Continue watching', items: continueWatching.slice(0, 12) },
+ { id: 'recently_added', title: 'Recently added', items: recentlyAdded.slice(0, 12) },
+ { id: 'recommended', title: 'Recommended', items: recommended.slice(0, 12) },
+ ],
+ collections: collections.filter((collection) => collection.item_ids.some((itemId) => filteredItems.some((item) => item.id === itemId))),
+ };
+}
+
+export function getMockItems(libraryId?: number): MediaItemSummary[] {
+ return items
+ .filter((item) => (typeof libraryId === 'number' ? item.library_id === libraryId : true))
+ .map(({ file_size: _fileSize, container: _container, bit_rate: _bitRate, video_codec: _videoCodec, audio_codec: _audioCodec, metadata_json: _metadataJson, metadata_updated_at: _metadataUpdatedAt, ...summary }) => summary);
+}
+
+export function searchMockItems(query: string, libraryId?: number): MediaItemSummary[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) {
+ return [];
+ }
+
+ return getMockItems(libraryId).filter((item) => {
+ return item.display_title.toLowerCase().includes(normalizedQuery)
+ || item.relative_path.toLowerCase().includes(normalizedQuery)
+ || item.media_kind.toLowerCase().includes(normalizedQuery);
+ });
+}
+
+export function getMockSettings(): SettingsResponse {
+ return {
+ settings: structuredClone(settings),
+ settings_path: 'C:/Users/Mock/AppData/Local/Koko/settings.yml',
+ };
+}
+
+export function updateMockSettings(nextSettings: SettingsSnapshot): SettingsResponse {
+ settings = structuredClone(nextSettings);
+ return getMockSettings();
+}
+
+export function addMockLibrary(request: { library: MediaLibrarySettings }): SettingsResponse {
+ const normalizedLibrary = structuredClone(request.library);
+ normalizedLibrary.paths = normalizedLibrary.paths.length ? normalizedLibrary.paths : [normalizedLibrary.path].filter(Boolean);
+ normalizedLibrary.path = normalizedLibrary.paths[0] ?? normalizedLibrary.path;
+ settings.media.libraries.push(normalizedLibrary);
+ libraries.push({
+ id: nextLibraryId,
+ name: normalizedLibrary.name,
+ path: normalizedLibrary.path,
+ paths: [...normalizedLibrary.paths],
+ recursive: normalizedLibrary.recursive,
+ kind: normalizedLibrary.kind,
+ status: 'available',
+ scan_revision: 1,
+ last_scanned_at: Math.floor(Date.now() / 1000),
+ total_files: 0,
+ video_files: 0,
+ audio_files: 0,
+ image_files: 0,
+ book_files: 0,
+ other_files: 0,
+ metadata_refresh_total: 0,
+ metadata_refresh_pending: 0,
+ metadata_refresh_completed: 0,
+ metadata_refresh_failed: 0,
+ });
+ nextLibraryId += 1;
+ return getMockSettings();
+}
+
+export function removeMockLibrary(libraryIndex: number): SettingsResponse {
+ if (libraryIndex < 0 || libraryIndex >= settings.media.libraries.length) {
+ throw new Error('404 Not Found');
+ }
+
+ const [removedLibrary] = settings.media.libraries.splice(libraryIndex, 1);
+ const libraryMatchIndex = libraries.findIndex((library) => {
+ return library.name === removedLibrary.name && library.path === removedLibrary.path;
+ });
+ if (libraryMatchIndex >= 0) {
+ libraries.splice(libraryMatchIndex, 1);
+ }
+
+ return getMockSettings();
+}
+
+export function updateMockPlaybackProgress(itemId: number, payload: PlaybackProgressRequest): void {
+ const userId = activeMockUserId();
+ if (userId !== undefined) {
+ playbackProgress.set(`${userId}:${itemId}`, payload);
+ }
+}
+
+export function linkMockItemMetadata(itemId: number, request: LinkMetadataRequest): ItemMetadataMatch {
+ const candidate = (metadataSearchResults[itemId] ?? []).find((result) => {
+ return result.provider_id === request.provider_id
+ && result.external_id === request.external_id
+ && result.media_type === request.media_type;
+ });
+ if (!candidate) {
+ throw new Error('404 Not Found');
+ }
+
+ const linkedMatch: ItemMetadataMatch = {
+ id: Date.now(),
+ provider_id: candidate.provider_id,
+ external_id: candidate.external_id,
+ title: candidate.title,
+ overview: candidate.overview,
+ artwork_url: candidate.artwork_url,
+ backdrop_url: candidate.backdrop_url,
+ release_year: candidate.release_year,
+ media_type: candidate.media_type,
+ match_state: 'linked',
+ provider_payload_json: JSON.stringify(candidate, null, 2),
+ updated_at: Math.floor(Date.now() / 1000),
+ };
+
+ itemMetadata[itemId] = {
+ item_id: itemId,
+ providers: metadataProviders,
+ matches: [linkedMatch],
+ };
+ const item = items.find((candidate) => candidate.id === itemId);
+ if (item) {
+ item.display_title = candidate.title;
+ }
+
+ return linkedMatch;
+}
+
+export function refreshMockItemMetadata(itemId: number): ItemMetadataMatch {
+ const response = itemMetadata[itemId];
+ const existingMatch = response?.matches[0];
+ if (!existingMatch) {
+ throw new Error('404 Not Found');
+ }
+
+ const pendingMatch: ItemMetadataMatch = {
+ ...existingMatch,
+ refresh_state: 'pending',
+ updated_at: Math.floor(Date.now() / 1000),
+ };
+
+ itemMetadata[itemId] = {
+ ...response,
+ matches: [pendingMatch],
+ };
+
+ const item = items.find((candidate) => candidate.id === itemId);
+ if (item) {
+ item.metadata_refresh_state = 'pending';
+ syncMockLibraryRefreshProgress(item.library_id);
+
+ window.setTimeout(() => {
+ const source = (metadataSearchResults[itemId] ?? []).find((candidate) => {
+ return candidate.provider_id === existingMatch.provider_id
+ && candidate.external_id === existingMatch.external_id
+ && candidate.media_type === existingMatch.media_type;
+ });
+ const refreshedAt = Math.floor(Date.now() / 1000);
+ const refreshedMatch: ItemMetadataMatch = {
+ ...pendingMatch,
+ title: source?.title ?? existingMatch.title,
+ overview: source?.overview ?? existingMatch.overview,
+ artwork_url: source?.artwork_url ?? existingMatch.artwork_url,
+ backdrop_url: source?.backdrop_url ?? existingMatch.backdrop_url,
+ release_year: source?.release_year ?? existingMatch.release_year,
+ refresh_state: 'fresh',
+ updated_at: refreshedAt,
+ };
+
+ itemMetadata[itemId] = {
+ ...response,
+ matches: [refreshedMatch],
+ };
+ item.display_title = refreshedMatch.title ?? item.display_title;
+ item.overview = refreshedMatch.overview ?? item.overview;
+ item.release_year = refreshedMatch.release_year ?? item.release_year;
+ item.linked_media_type = refreshedMatch.media_type ?? item.linked_media_type;
+ item.metadata_refresh_state = 'fresh';
+ item.artwork_updated_at = refreshedAt;
+ syncMockLibraryRefreshProgress(item.library_id);
+ }, 900);
+ }
+
+ return pendingMatch;
+}
+
+export function refreshMockLibraryMetadata(libraryId: number): MediaLibrary {
+ const library = libraries.find((candidate) => candidate.id === libraryId);
+ if (!library) {
+ throw new Error('404 Not Found');
+ }
+
+ const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata);
+ refreshableItems.forEach((item) => {
+ item.metadata_refresh_state = 'pending';
+ const response = itemMetadata[item.id];
+ const existingMatch = response?.matches[0];
+ if (existingMatch && response) {
+ itemMetadata[item.id] = {
+ ...response,
+ matches: [{
+ ...existingMatch,
+ refresh_state: 'pending',
+ updated_at: Math.floor(Date.now() / 1000),
+ }],
+ };
+ }
+ });
+ syncMockLibraryRefreshProgress(libraryId);
+
+ window.setTimeout(() => {
+ const refreshedAt = Math.floor(Date.now() / 1000);
+ refreshableItems.forEach((item) => {
+ item.metadata_refresh_state = 'fresh';
+ item.artwork_updated_at = refreshedAt;
+ const response = itemMetadata[item.id];
+ const existingMatch = response?.matches[0];
+ if (existingMatch && response) {
+ itemMetadata[item.id] = {
+ ...response,
+ matches: [{
+ ...existingMatch,
+ refresh_state: 'fresh',
+ updated_at: refreshedAt,
+ }],
+ };
+ }
+ });
+ syncMockLibraryRefreshProgress(libraryId);
+ }, 1200);
+
+ return { ...library };
+}
+
diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css
new file mode 100644
index 00000000..01e9485a
--- /dev/null
+++ b/crates/client-web/src/style.css
@@ -0,0 +1,1581 @@
+:root {
+ --page-backdrop-image: none;
+ color-scheme: dark;
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ background: #0c111d;
+ color: #f4f7fb;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top left, rgba(89, 124, 255, 0.18), transparent 24%),
+ radial-gradient(circle at bottom right, rgba(67, 214, 158, 0.16), transparent 22%),
+ #0c111d;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #5d7bff, #7c5cff);
+ color: #fff;
+ padding: 0.8rem 1rem;
+ cursor: pointer;
+ transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
+ box-shadow: 0 12px 30px rgba(93, 123, 255, 0.24);
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.secondary-button {
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: none;
+ color: #dbe7ff;
+}
+
+.button-content {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.55rem;
+}
+
+.button-content.icon-end {
+ flex-direction: row-reverse;
+}
+
+.button-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.button-icon svg {
+ width: 1rem;
+ height: 1rem;
+ stroke-width: 2.1;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.05);
+ color: inherit;
+ padding: 0.8rem 0.9rem;
+}
+
+textarea {
+ resize: vertical;
+ min-height: 6rem;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+legend {
+ font-weight: 600;
+ margin-bottom: 0.6rem;
+}
+
+#app {
+ min-height: 100vh;
+}
+
+.auth-shell {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+}
+
+.auth-panel {
+ width: min(480px, 100%);
+ padding: 1.4rem;
+}
+
+.auth-header {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.auth-header h1,
+.auth-copy h2 {
+ margin: 0;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.auth-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+
+.auth-error-panel {
+ margin-bottom: 1rem;
+}
+
+.app-shell {
+ position: relative;
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ min-height: 100vh;
+ height: 100vh;
+ align-items: stretch;
+ overflow: hidden;
+}
+
+.app-shell.rail-collapsed {
+ grid-template-columns: 88px minmax(0, 1fr);
+}
+
+.page-backdrop {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.page-backdrop::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: min(76vw, 1240px);
+ height: min(68vh, 840px);
+ background-image: var(--page-backdrop-image, none);
+ background-position: center 18%;
+ background-repeat: no-repeat;
+ background-size: cover;
+ opacity: 0.88;
+ transform: scale(1.04);
+ transform-origin: top right;
+ filter: saturate(1.04) contrast(1.02);
+ mask-image: radial-gradient(ellipse at 58% 34%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 34%, rgba(0, 0, 0, 0.82) 56%, rgba(0, 0, 0, 0.46) 74%, transparent 100%);
+ -webkit-mask-image: radial-gradient(ellipse at 58% 34%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 34%, rgba(0, 0, 0, 0.82) 56%, rgba(0, 0, 0, 0.46) 74%, transparent 100%);
+}
+
+.page-backdrop::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 66% 22%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.16) 34%, rgba(12, 17, 29, 0.62) 72%, #0c111d 100%),
+ linear-gradient(180deg, rgba(12, 17, 29, 0.06) 0%, rgba(12, 17, 29, 0.18) 26%, rgba(12, 17, 29, 0.54) 54%, rgba(12, 17, 29, 0.84) 76%, #0c111d 100%),
+ linear-gradient(270deg, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.18) 18%, rgba(12, 17, 29, 0.78) 54%, #0c111d 100%);
+}
+
+.app-shell > :not(.page-backdrop) {
+ position: relative;
+ z-index: 1;
+}
+
+.library-rail {
+ grid-column: 1;
+ grid-row: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1.2rem;
+ width: 100%;
+ min-width: 0;
+ height: 100vh;
+ padding: 1.2rem 0.9rem;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(7, 11, 21, 0.92);
+ backdrop-filter: blur(18px);
+ overflow: hidden;
+}
+
+.app-shell.rail-collapsed .library-rail {
+ max-width: 88px;
+}
+
+.library-rail-top,
+.library-rail-bottom {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.library-rail-top {
+ min-height: 0;
+ flex: 1;
+}
+
+.library-rail-bottom {
+ margin-top: auto;
+ padding-top: 0.2rem;
+}
+
+.rail-user-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ padding: 0.85rem 0.9rem;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.05);
+ color: #dbe7ff;
+}
+
+.rail-user-card span {
+ color: #9ab1d1;
+ font-size: 0.82rem;
+}
+
+.brand-block {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ padding: 0.25rem 0.4rem;
+}
+
+.brand-block h1 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.brand-block p {
+ margin: 0.1rem 0 0;
+ font-size: 0.78rem;
+ color: #9ab1d1;
+}
+
+.brand-mark {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border-radius: 14px;
+ background: linear-gradient(135deg, #5d7bff, #42d69e);
+}
+
+.brand-icon,
+.rail-icon,
+.card-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.brand-icon svg,
+.rail-icon svg,
+.card-icon svg {
+ width: 1.1rem;
+ height: 1.1rem;
+ stroke-width: 2;
+}
+
+.rail-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 0.2rem;
+}
+
+.rail-button {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.7rem;
+ width: 100%;
+ padding: 0.85rem 0.9rem;
+ border-radius: 16px;
+ background: transparent;
+ box-shadow: none;
+ color: #b6c4d8;
+}
+
+.rail-button.active,
+.rail-button:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+
+.rail-icon {
+ width: 1.5rem;
+ min-width: 1.5rem;
+ text-align: center;
+}
+
+.library-rail.collapsed {
+ padding-inline: 0.7rem;
+}
+
+.library-rail.collapsed .brand-block {
+ justify-content: center;
+}
+
+.library-rail.collapsed .brand-block > div:last-child,
+.library-rail.collapsed .rail-label,
+.library-rail.collapsed .rail-user-card {
+ display: none;
+}
+
+.library-rail.collapsed .rail-library-copy {
+ display: none;
+}
+
+.library-rail.collapsed .rail-button {
+ justify-content: center;
+ padding-inline: 0.75rem;
+}
+
+.rail-label {
+ max-width: 100%;
+ font-size: 0.92rem;
+ line-height: 1.2;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.rail-library-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-width: 0;
+}
+
+.library-refresh-indicator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 0.95rem;
+ height: 0.95rem;
+ flex: 0 0 auto;
+}
+
+.library-refresh-ring {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 999px;
+ background: conic-gradient(#5d7bff var(--library-refresh-progress, 0%), rgba(255, 255, 255, 0.14) 0);
+}
+
+.library-refresh-ring::after {
+ content: '';
+ position: absolute;
+ inset: 2px;
+ border-radius: inherit;
+ background: rgba(14, 20, 35, 0.96);
+}
+
+.rail-settings {
+ width: 100%;
+}
+
+.main-shell {
+ grid-column: 2;
+ grid-row: 1;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ padding: 1.2rem 1.4rem;
+ height: 100vh;
+ min-width: 0;
+ overflow-y: auto;
+}
+
+.panel {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(14, 20, 35, 0.82);
+ backdrop-filter: blur(18px);
+ border-radius: 24px;
+}
+
+.main-shell-inner {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.content-navbar {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: end;
+ padding: 1rem 1.2rem;
+}
+
+.content-navbar h2,
+.player-header h2 {
+ margin: 0.1rem 0;
+}
+
+.content-navbar-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ min-width: 0;
+}
+
+.content-navbar-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 1;
+ min-width: 0;
+}
+
+.content-navbar-actions-stack {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ width: 100%;
+}
+
+.content-navbar-actions .search-form {
+ justify-content: flex-end;
+}
+
+.browse-tabs {
+ display: flex;
+ gap: 0.7rem;
+ padding: 0.8rem;
+ overflow-x: auto;
+}
+
+.browse-tab-button {
+ flex: 0 0 auto;
+ background: transparent;
+ box-shadow: none;
+ color: #9ab1d1;
+}
+
+.browse-tab-button.active,
+.browse-tab-button:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+
+.page-panel {
+ width: 100%;
+}
+
+.eyebrow,
+.label {
+ display: block;
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #86a0c7;
+}
+
+.error-panel {
+ padding: 1rem 1.2rem;
+ border-color: rgba(255, 132, 132, 0.35);
+ background: rgba(86, 24, 24, 0.38);
+ color: #ffd7d7;
+}
+
+.workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 380px;
+ gap: 1rem;
+ min-height: 0;
+}
+
+.content-column {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.shelf-stack,
+.detail-panel {
+ padding: 1.2rem;
+}
+
+.search-form {
+ display: flex;
+ gap: 0.7rem;
+ width: min(100%, 640px);
+}
+
+.library-overview-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem 1.2rem;
+}
+
+.library-overview-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.library-overview-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.65rem;
+ align-items: center;
+}
+
+.library-overview-header h3,
+.category-card-header strong {
+ margin: 0;
+}
+
+.library-status-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.library-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 0.8rem;
+}
+
+.library-stat-card,
+.category-card {
+ padding: 0.95rem 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.library-stat-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.library-stat-card strong {
+ font-size: 1.15rem;
+}
+
+.library-overview-note {
+ margin: 0;
+}
+
+.browse-section,
+.placeholder-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+.active-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.7rem;
+ align-items: center;
+ padding: 0.85rem 0.95rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.browse-section-header {
+ margin-bottom: 0.1rem;
+}
+
+.category-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.9rem;
+}
+
+.category-card-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.8rem;
+ align-items: center;
+}
+
+.filter-card-button {
+ text-align: left;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow: none;
+ color: inherit;
+}
+
+.filter-card-button[style*='--collection-card-image'] {
+ background-image: var(--collection-card-image);
+ background-size: cover;
+ background-position: center;
+}
+
+.filter-card-button:hover {
+ border-color: rgba(93, 123, 255, 0.35);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.shelf-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.shelf {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.shelf-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+}
+
+.shelf-header h3,
+.section-heading h3 {
+ margin: 0;
+}
+
+.shelf-header span,
+.metadata-match-meta,
+.media-card-meta,
+.detail-subtitle,
+.muted,
+.metadata-search-card p {
+ color: #9ab1d1;
+}
+
+.shelf-row {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 184px;
+ gap: 0.9rem;
+ overflow-x: auto;
+ padding-bottom: 0.2rem;
+ align-items: start;
+}
+
+.shelf-row .media-card {
+ width: 184px;
+}
+
+.item-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 1rem;
+}
+
+.media-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ align-items: stretch;
+ text-align: left;
+ padding: 0;
+ border-radius: 18px;
+ background: transparent;
+ box-shadow: none;
+}
+
+.episode-card {
+ gap: 0.45rem;
+}
+
+.media-card-art {
+ position: relative;
+ aspect-ratio: 2 / 3;
+ border-radius: 18px;
+ padding: 0.9rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
+ background: linear-gradient(180deg, rgba(93, 123, 255, 0.8), rgba(22, 31, 54, 0.92));
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.media-card-art.episode {
+ aspect-ratio: 16 / 9;
+ align-items: end;
+}
+
+.media-card-art.audio {
+ background: linear-gradient(180deg, rgba(66, 214, 158, 0.78), rgba(17, 44, 40, 0.94));
+}
+
+.media-card-kind-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: start;
+ width: 100%;
+}
+
+.media-card-kind,
+.media-card-duration {
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ background: rgba(10, 14, 24, 0.36);
+ font-size: 0.76rem;
+ white-space: nowrap;
+}
+
+.media-card-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ background: rgba(10, 14, 24, 0.52);
+ font-size: 0.72rem;
+}
+
+.media-card-status.icon-only {
+ min-width: 2rem;
+ min-height: 2rem;
+ justify-content: center;
+ padding: 0.35rem;
+}
+
+.media-card-status.is-unmatched {
+ color: #ffe5b5;
+}
+
+.media-card-status.is-loading {
+ color: #dce6ff;
+}
+
+.loading-spinner {
+ width: 0.82rem;
+ height: 0.82rem;
+ border-radius: 999px;
+ border: 2px solid rgba(255, 255, 255, 0.25);
+ border-top-color: currentColor;
+ animation: spin 0.85s linear infinite;
+}
+
+.status-icon svg {
+ width: 0.95rem;
+ height: 0.95rem;
+ stroke-width: 2.2;
+}
+
+.media-card-title {
+ font-weight: 700;
+}
+
+.media-card-subtitle {
+ font-size: 0.82rem;
+ color: #d8e5ff;
+}
+
+.detail-panel {
+ position: sticky;
+ top: 1.2rem;
+ align-self: start;
+ max-height: calc(100vh - 2.4rem);
+ overflow: auto;
+}
+
+.panel-placeholder,
+.empty-state {
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ color: #afc0db;
+}
+
+.empty-state.tight {
+ padding: 0.8rem;
+}
+
+.detail-card {
+ display: flex;
+ flex-direction: column;
+ gap: 1.2rem;
+}
+
+.detail-hero {
+ display: grid;
+ grid-template-columns: 120px minmax(0, 1fr);
+ gap: 1rem;
+ width: 100%;
+}
+
+.detail-art {
+ aspect-ratio: 2 / 3;
+ border-radius: 20px;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: linear-gradient(180deg, rgba(93, 123, 255, 0.9), rgba(27, 37, 62, 0.96));
+ font-size: 2.2rem;
+ font-weight: 800;
+}
+
+.detail-art img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.detail-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: 100%;
+ min-width: 0;
+}
+
+.hero-tagline {
+ margin: 0;
+ font-size: 1.05rem;
+ color: #d6e5ff;
+}
+
+.hero-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+}
+
+.hero-description {
+ margin: 0.2rem 0 0;
+ max-width: 70ch;
+ color: #dbe7ff;
+}
+
+.metadata-refresh-error {
+ display: block;
+ margin-top: 0.25rem;
+ color: #ffd0d0;
+}
+
+.detail-summary h2,
+.metadata-search-card strong {
+ margin: 0;
+}
+
+.detail-actions {
+ display: flex;
+ gap: 0.7rem;
+ flex-wrap: wrap;
+}
+
+.trailer-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ padding: 0.9rem 1rem;
+ max-width: 760px;
+}
+
+.trailer-picker-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem;
+}
+
+.trailer-option-button {
+ text-align: left;
+}
+
+.detail-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+}
+
+.metadata-search-list,
+.item-page {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.item-page {
+ padding-top: 1rem;
+ padding-bottom: 1.2rem;
+}
+
+.item-breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ padding: 0.85rem 1rem;
+}
+
+.breadcrumb-button {
+ padding: 0;
+ background: transparent;
+ box-shadow: none;
+ color: #b7cae6;
+}
+
+.breadcrumb-button:hover {
+ color: #fff;
+}
+
+.breadcrumb-separator,
+.breadcrumb-current {
+ color: #86a0c7;
+}
+
+.item-hero {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ gap: 1.5rem;
+ align-items: start;
+ min-height: min(58vh, 720px);
+ padding: 1.3rem 0 0.75rem;
+}
+
+.item-hero.episode-hero {
+ grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+}
+
+.item-poster {
+ width: min(100%, 220px);
+ box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34);
+}
+
+.item-thumbnail {
+ width: min(100%, 360px);
+ aspect-ratio: 16 / 9;
+}
+
+.item-summary {
+ align-self: start;
+ padding: 0.35rem 0 1rem;
+}
+
+.item-summary h2 {
+ font-size: clamp(2.2rem, 3vw, 3.6rem);
+ line-height: 1.04;
+ margin-top: 0;
+}
+
+.item-fact-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 0.8rem;
+ margin-top: 0.5rem;
+}
+
+.item-fact {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding: 0.75rem 0.9rem;
+ border-radius: 18px;
+ background: rgba(8, 11, 18, 0.28);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ backdrop-filter: blur(16px);
+}
+
+.item-support-grid {
+ display: grid;
+ grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.hierarchy-item-grid {
+ margin-top: 0.85rem;
+}
+
+.hierarchy-item-grid.season-episodes-grid {
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 1.1rem;
+}
+
+.hierarchy-item-grid.season-episodes-grid .episode-card {
+ gap: 0.55rem;
+}
+
+.hierarchy-item-grid.season-episodes-grid .media-card-art.episode {
+ padding: 1rem;
+}
+
+.item-section {
+ padding: 1.1rem;
+}
+
+.item-info-list {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.item-info-list > div {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.section-heading-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.metadata-search-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+}
+
+.metadata-search-card {
+ padding: 0.9rem 1rem;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.metadata-search-card p {
+ margin: 0.35rem 0 0;
+}
+
+.metadata-match-meta {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.metadata-current-link {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.9rem 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.metadata-current-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-width: 0;
+}
+
+.metadata-search-card {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ background: rgba(255, 255, 255, 0.07);
+}
+
+.tag.success {
+ background: rgba(66, 214, 158, 0.18);
+ color: #8bf3ca;
+}
+
+.tag.warning {
+ background: rgba(255, 191, 84, 0.16);
+ color: #ffd78a;
+}
+
+.metadata-search-form,
+.settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.settings-activity-panel,
+.metadata-dashboard-panel,
+.settings-log-panel {
+ padding: 1.2rem;
+}
+
+.metadata-dashboard-filter-grid {
+ align-items: end;
+}
+
+.metadata-dashboard-table {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.metadata-dashboard-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.metadata-dashboard-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.metadata-dashboard-title-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.metadata-dashboard-path,
+.metadata-dashboard-meta,
+.metadata-dashboard-error {
+ margin: 0;
+}
+
+.metadata-dashboard-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+}
+
+.metadata-dashboard-error {
+ color: #ffd0d0;
+}
+
+.settings-system-activity-list,
+.log-entry-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.settings-system-activity,
+.log-entry-card {
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.settings-system-activity {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.settings-system-activity-header,
+.log-entry-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.activity-progress-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.activity-progress-bar {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ height: 0.6rem;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.activity-progress-fill {
+ display: block;
+ width: var(--activity-progress, 0%);
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #5d7bff 0%, #8bf3ca 100%);
+}
+
+.log-filter-form {
+ margin-bottom: 1rem;
+}
+
+.log-filter-row {
+ align-items: end;
+}
+
+.log-entry-source,
+.log-entry-message {
+ margin: 0;
+}
+
+.log-entry-message {
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: 'Cascadia Mono', 'Fira Code', Consolas, monospace;
+ font-size: 0.86rem;
+ line-height: 1.5;
+ color: #dbe8ff;
+}
+
+@media (max-width: 1320px) {
+ .page-backdrop::before {
+ inset: 0;
+ width: auto;
+ height: auto;
+ background-image: var(--page-backdrop-image, none);
+ background-position: center top;
+ opacity: 0.42;
+ transform: none;
+ filter: saturate(1.02) contrast(1.02);
+ mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%);
+ -webkit-mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%);
+ }
+
+ .page-backdrop::after {
+ background:
+ radial-gradient(circle at center 16%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.24) 30%, rgba(12, 17, 29, 0.7) 72%, #0c111d 100%),
+ linear-gradient(180deg, rgba(12, 17, 29, 0.12) 0%, rgba(12, 17, 29, 0.28) 32%, rgba(12, 17, 29, 0.72) 64%, #0c111d 100%);
+ }
+
+ .item-hero,
+ .item-support-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.settings-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: min(520px, 100vw);
+ height: 100vh;
+ padding: 1.2rem;
+ overflow: auto;
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(9, 13, 24, 0.96);
+ backdrop-filter: blur(20px);
+ z-index: 20;
+}
+
+.settings-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+ margin-bottom: 1rem;
+}
+
+.settings-form section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.settings-page-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.settings-library-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+.user-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.settings-library-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.settings-library-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.settings-library-actions {
+ display: flex;
+ gap: 0.65rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.danger-button {
+ background: rgba(255, 107, 107, 0.14);
+ color: #ffb9b9;
+}
+
+.page-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.settings-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.checkbox-row,
+.checkbox-inline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.9rem;
+ align-items: center;
+}
+
+.checkbox-inline {
+ flex-direction: row;
+}
+
+.checkbox-inline input,
+.checkbox-row input {
+ width: auto;
+}
+
+.add-library-form {
+ margin-top: 1rem;
+}
+
+.player-overlay {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 1rem;
+ background: rgba(5, 8, 16, 0.86);
+ z-index: 30;
+}
+
+.trailer-shell {
+ width: min(100%, 1200px);
+}
+
+.trailer-frame-shell {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: 20px;
+ overflow: hidden;
+ background: #000;
+}
+
+.trailer-frame-shell iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: block;
+}
+
+.theme-song-iframe {
+ position: fixed;
+ right: -8px;
+ bottom: -8px;
+ width: 1px;
+ height: 1px;
+ border: 0;
+ opacity: 0.01;
+ pointer-events: none;
+}
+
+.danger-tag {
+ color: #ffd0d0;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.player-shell {
+ width: min(960px, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(11, 16, 27, 0.96);
+}
+
+.player-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+#media-player {
+ width: 100%;
+ max-height: 72vh;
+ border-radius: 18px;
+ background: #000;
+}
+
+#theme-song-player {
+ display: none;
+}
+
+@media (max-width: 1280px) {
+ .workspace-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .detail-panel {
+ position: static;
+ max-height: none;
+ }
+}
+
+@media (max-width: 960px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ height: auto;
+ min-height: 100vh;
+ overflow: visible;
+ }
+
+ .library-rail {
+ grid-column: auto;
+ grid-row: auto;
+ height: auto;
+ max-width: none;
+ flex-direction: row;
+ align-items: center;
+ overflow: auto;
+ border-right: 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ }
+
+ .library-rail-top,
+ .library-rail-bottom {
+ flex-direction: row;
+ align-items: center;
+ min-height: auto;
+ }
+
+ .rail-nav {
+ flex-direction: row;
+ overflow: visible;
+ }
+
+ .rail-button {
+ min-width: 110px;
+ }
+
+ .main-shell {
+ grid-column: auto;
+ grid-row: auto;
+ height: auto;
+ overflow: visible;
+ padding: 0.9rem;
+ }
+
+ .content-navbar,
+ .library-overview-header,
+ .section-heading-actions,
+ .settings-library-header,
+ .player-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .search-form,
+ .form-row,
+ .item-hero,
+ .item-support-grid {
+ grid-template-columns: 1fr;
+ min-width: 0;
+ }
+
+ .content-navbar-actions-stack,
+ .search-form {
+ flex-direction: column;
+ }
+
+ .metadata-dashboard-row,
+ .metadata-dashboard-title-row,
+ .activity-progress-row,
+ .settings-system-activity-header,
+ .log-entry-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .item-poster {
+ width: min(220px, 100%);
+ }
+}
diff --git a/crates/client-web/tsconfig.json b/crates/client-web/tsconfig.json
new file mode 100644
index 00000000..fd8dc9d3
--- /dev/null
+++ b/crates/client-web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
+
diff --git a/crates/client-web/vite.config.ts b/crates/client-web/vite.config.ts
new file mode 100644
index 00000000..ff923a07
--- /dev/null
+++ b/crates/client-web/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ server: {
+ host: '127.0.0.1',
+ port: 4173,
+ },
+ preview: {
+ host: '127.0.0.1',
+ port: 4173,
+ },
+});
+
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index d54d0dc7..523ff2aa 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -62,12 +62,15 @@ once_cell = "1.20.3"
rand = "0.9.0"
rcgen = "0.13.2"
regex = "1.11.1"
+reqwest = { version = "0.13.2", default-features = false, features = ["json", "query", "rustls"] }
rocket = { version = "0.5.1", features = ["tls"] }
rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] }
rocket_sync_db_pools = { version = "0.1.0", features = ["diesel_sqlite_pool"] }
schemars = "0.8.1"
serde = "1.0.217"
serde_json = "1.0.138"
+serde_yaml = "0.9.34"
+strsim = "0.11.1"
tao = "0.35.0"
tokio = { version = "1.0", features = ["full"] }
tray-icon = "0.22.0"
diff --git a/crates/server/sql/migrations/0_create_users/down.sql b/crates/server/sql/migrations/0000001_create_users/down.sql
similarity index 100%
rename from crates/server/sql/migrations/0_create_users/down.sql
rename to crates/server/sql/migrations/0000001_create_users/down.sql
diff --git a/crates/server/sql/migrations/0_create_users/up.sql b/crates/server/sql/migrations/0000001_create_users/up.sql
similarity index 100%
rename from crates/server/sql/migrations/0_create_users/up.sql
rename to crates/server/sql/migrations/0000001_create_users/up.sql
diff --git a/crates/server/sql/migrations/0000002_create_media_catalog/down.sql b/crates/server/sql/migrations/0000002_create_media_catalog/down.sql
new file mode 100644
index 00000000..bdf93ab9
--- /dev/null
+++ b/crates/server/sql/migrations/0000002_create_media_catalog/down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS media_files;
+DROP TABLE IF EXISTS scan_state;
+DROP TABLE IF EXISTS media_libraries;
+
diff --git a/crates/server/sql/migrations/0000002_create_media_catalog/up.sql b/crates/server/sql/migrations/0000002_create_media_catalog/up.sql
new file mode 100644
index 00000000..9a8f3fe9
--- /dev/null
+++ b/crates/server/sql/migrations/0000002_create_media_catalog/up.sql
@@ -0,0 +1,34 @@
+CREATE TABLE IF NOT EXISTS media_libraries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ recursive BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE TABLE IF NOT EXISTS scan_state (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL UNIQUE,
+ last_status TEXT NOT NULL DEFAULT 'never_scanned',
+ last_error TEXT DEFAULT NULL,
+ total_files BIGINT NOT NULL DEFAULT 0,
+ video_files BIGINT NOT NULL DEFAULT 0,
+ audio_files BIGINT NOT NULL DEFAULT 0,
+ image_files BIGINT NOT NULL DEFAULT 0,
+ book_files BIGINT NOT NULL DEFAULT 0,
+ other_files BIGINT NOT NULL DEFAULT 0,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS media_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ UNIQUE (library_id, relative_path)
+);
+
diff --git a/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql b/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql
new file mode 100644
index 00000000..92d32c80
--- /dev/null
+++ b/crates/server/sql/migrations/0000003_enhance_media_catalog/down.sql
@@ -0,0 +1,79 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_files_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ UNIQUE (library_id, relative_path)
+);
+
+INSERT INTO media_files_previous (
+ id,
+ library_id,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed
+)
+SELECT
+ id,
+ library_id,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_previous RENAME TO media_files;
+
+CREATE TABLE scan_state_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL UNIQUE,
+ last_status TEXT NOT NULL DEFAULT 'never_scanned',
+ last_error TEXT DEFAULT NULL,
+ total_files BIGINT NOT NULL DEFAULT 0,
+ video_files BIGINT NOT NULL DEFAULT 0,
+ audio_files BIGINT NOT NULL DEFAULT 0,
+ image_files BIGINT NOT NULL DEFAULT 0,
+ book_files BIGINT NOT NULL DEFAULT 0,
+ other_files BIGINT NOT NULL DEFAULT 0,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
+);
+
+INSERT INTO scan_state_previous (
+ id,
+ library_id,
+ last_status,
+ last_error,
+ total_files,
+ video_files,
+ audio_files,
+ image_files,
+ book_files,
+ other_files
+)
+SELECT
+ id,
+ library_id,
+ last_status,
+ last_error,
+ total_files,
+ video_files,
+ audio_files,
+ image_files,
+ book_files,
+ other_files
+FROM scan_state;
+
+DROP TABLE scan_state;
+ALTER TABLE scan_state_previous RENAME TO scan_state;
+
+PRAGMA foreign_keys=on;
diff --git a/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql b/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql
new file mode 100644
index 00000000..7e7cad1d
--- /dev/null
+++ b/crates/server/sql/migrations/0000003_enhance_media_catalog/up.sql
@@ -0,0 +1,14 @@
+ALTER TABLE scan_state ADD COLUMN scan_revision BIGINT NOT NULL DEFAULT 0;
+ALTER TABLE scan_state ADD COLUMN last_scanned_at BIGINT DEFAULT NULL;
+
+ALTER TABLE media_files ADD COLUMN display_title TEXT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN container TEXT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN duration_ms BIGINT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN bit_rate BIGINT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN width INTEGER DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN height INTEGER DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN video_codec TEXT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN audio_codec TEXT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN metadata_json TEXT DEFAULT NULL;
+ALTER TABLE media_files ADD COLUMN metadata_updated_at BIGINT DEFAULT NULL;
+
diff --git a/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql b/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql
new file mode 100644
index 00000000..14c9b068
--- /dev/null
+++ b/crates/server/sql/migrations/0000004_create_item_metadata_links/down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS item_metadata_links;
+
diff --git a/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql b/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql
new file mode 100644
index 00000000..a20316ba
--- /dev/null
+++ b/crates/server/sql/migrations/0000004_create_item_metadata_links/up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS item_metadata_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_file_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ title TEXT DEFAULT NULL,
+ overview TEXT DEFAULT NULL,
+ artwork_url TEXT DEFAULT NULL,
+ backdrop_url TEXT DEFAULT NULL,
+ release_year INTEGER DEFAULT NULL,
+ match_state TEXT NOT NULL DEFAULT 'unmatched',
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE,
+ UNIQUE (media_file_id, provider_id)
+);
+
diff --git a/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql
new file mode 100644
index 00000000..f38ed09d
--- /dev/null
+++ b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/down.sql
@@ -0,0 +1,52 @@
+PRAGMA foreign_keys=off;
+
+DROP TABLE IF EXISTS playback_progress;
+
+CREATE TABLE item_metadata_links_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_file_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ title TEXT DEFAULT NULL,
+ overview TEXT DEFAULT NULL,
+ artwork_url TEXT DEFAULT NULL,
+ backdrop_url TEXT DEFAULT NULL,
+ release_year INTEGER DEFAULT NULL,
+ match_state TEXT NOT NULL DEFAULT 'unmatched',
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE,
+ UNIQUE (media_file_id, provider_id)
+);
+
+INSERT INTO item_metadata_links_previous (
+ id,
+ media_file_id,
+ provider_id,
+ external_id,
+ title,
+ overview,
+ artwork_url,
+ backdrop_url,
+ release_year,
+ match_state,
+ updated_at
+)
+SELECT
+ id,
+ media_file_id,
+ provider_id,
+ external_id,
+ title,
+ overview,
+ artwork_url,
+ backdrop_url,
+ release_year,
+ match_state,
+ updated_at
+FROM item_metadata_links;
+
+DROP TABLE item_metadata_links;
+ALTER TABLE item_metadata_links_previous RENAME TO item_metadata_links;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql
new file mode 100644
index 00000000..111c7908
--- /dev/null
+++ b/crates/server/sql/migrations/0000005_extend_metadata_and_playback/up.sql
@@ -0,0 +1,15 @@
+ALTER TABLE item_metadata_links ADD COLUMN media_type TEXT DEFAULT NULL;
+ALTER TABLE item_metadata_links ADD COLUMN provider_payload_json TEXT DEFAULT NULL;
+ALTER TABLE item_metadata_links ADD COLUMN cached_artwork_path TEXT DEFAULT NULL;
+ALTER TABLE item_metadata_links ADD COLUMN cached_backdrop_path TEXT DEFAULT NULL;
+
+CREATE TABLE IF NOT EXISTS playback_progress (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_file_id INTEGER NOT NULL UNIQUE,
+ position_ms BIGINT NOT NULL DEFAULT 0,
+ duration_ms BIGINT DEFAULT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE
+);
+
diff --git a/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql b/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql
new file mode 100644
index 00000000..cc75ebf2
--- /dev/null
+++ b/crates/server/sql/migrations/0000006_add_media_source_roots/down.sql
@@ -0,0 +1,68 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_files_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ display_title TEXT DEFAULT NULL,
+ container TEXT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ bit_rate BIGINT DEFAULT NULL,
+ width INTEGER DEFAULT NULL,
+ height INTEGER DEFAULT NULL,
+ video_codec TEXT DEFAULT NULL,
+ audio_codec TEXT DEFAULT NULL,
+ metadata_json TEXT DEFAULT NULL,
+ metadata_updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ UNIQUE (library_id, relative_path)
+);
+
+INSERT INTO media_files_previous (
+ id,
+ library_id,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+)
+SELECT
+ id,
+ library_id,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_previous RENAME TO media_files;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql b/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql
new file mode 100644
index 00000000..c8c9d236
--- /dev/null
+++ b/crates/server/sql/migrations/0000006_add_media_source_roots/up.sql
@@ -0,0 +1,71 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_files_next (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ source_root_path TEXT NOT NULL DEFAULT '',
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ display_title TEXT DEFAULT NULL,
+ container TEXT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ bit_rate BIGINT DEFAULT NULL,
+ width INTEGER DEFAULT NULL,
+ height INTEGER DEFAULT NULL,
+ video_codec TEXT DEFAULT NULL,
+ audio_codec TEXT DEFAULT NULL,
+ metadata_json TEXT DEFAULT NULL,
+ metadata_updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ UNIQUE (library_id, source_root_path, relative_path)
+);
+
+INSERT INTO media_files_next (
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+)
+SELECT
+ id,
+ library_id,
+ '',
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_next RENAME TO media_files;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql b/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql
new file mode 100644
index 00000000..fe4c9840
--- /dev/null
+++ b/crates/server/sql/migrations/0000007_store_library_settings_in_db/down.sql
@@ -0,0 +1,30 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_libraries_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ recursive BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+INSERT INTO media_libraries_previous (
+ id,
+ name,
+ path,
+ kind,
+ recursive
+)
+SELECT
+ id,
+ name,
+ path,
+ kind,
+ recursive
+FROM media_libraries;
+
+DROP TABLE media_libraries;
+ALTER TABLE media_libraries_previous RENAME TO media_libraries;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql b/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql
new file mode 100644
index 00000000..c9fc55cb
--- /dev/null
+++ b/crates/server/sql/migrations/0000007_store_library_settings_in_db/up.sql
@@ -0,0 +1,11 @@
+ALTER TABLE media_libraries ADD COLUMN paths_json TEXT NOT NULL DEFAULT '[]';
+ALTER TABLE media_libraries ADD COLUMN metadata_providers_json TEXT NOT NULL DEFAULT '["tmdb"]';
+
+UPDATE media_libraries
+SET paths_json = json_array(path)
+WHERE TRIM(COALESCE(paths_json, '')) = '' OR paths_json = '[]';
+
+UPDATE media_libraries
+SET metadata_providers_json = '["tmdb"]'
+WHERE TRIM(COALESCE(metadata_providers_json, '')) = '';
+
diff --git a/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql
new file mode 100644
index 00000000..5348575c
--- /dev/null
+++ b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/down.sql
@@ -0,0 +1,8 @@
+UPDATE item_metadata_links
+SET provider_id = 'music_brainz'
+WHERE provider_id = 'musicbrainz';
+
+UPDATE media_libraries
+SET metadata_providers_json = REPLACE(metadata_providers_json, 'musicbrainz', 'music_brainz')
+WHERE metadata_providers_json LIKE '%musicbrainz%';
+
diff --git a/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql
new file mode 100644
index 00000000..fbd93c8b
--- /dev/null
+++ b/crates/server/sql/migrations/0000008_normalize_legacy_metadata_provider_ids/up.sql
@@ -0,0 +1,8 @@
+UPDATE item_metadata_links
+SET provider_id = 'musicbrainz'
+WHERE provider_id = 'music_brainz';
+
+UPDATE media_libraries
+SET metadata_providers_json = REPLACE(metadata_providers_json, 'music_brainz', 'musicbrainz')
+WHERE metadata_providers_json LIKE '%music_brainz%';
+
diff --git a/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql
new file mode 100644
index 00000000..3569768f
--- /dev/null
+++ b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/down.sql
@@ -0,0 +1,71 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_files_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ source_root_path TEXT NOT NULL DEFAULT '',
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ display_title TEXT DEFAULT NULL,
+ container TEXT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ bit_rate BIGINT DEFAULT NULL,
+ width INTEGER DEFAULT NULL,
+ height INTEGER DEFAULT NULL,
+ video_codec TEXT DEFAULT NULL,
+ audio_codec TEXT DEFAULT NULL,
+ metadata_json TEXT DEFAULT NULL,
+ metadata_updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ UNIQUE (library_id, source_root_path, relative_path)
+);
+
+INSERT INTO media_files_previous (
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+)
+SELECT
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_previous RENAME TO media_files;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql
new file mode 100644
index 00000000..54b8ca63
--- /dev/null
+++ b/crates/server/sql/migrations/0000009_track_metadata_match_attempts/up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE media_files ADD COLUMN metadata_match_attempted_at BIGINT DEFAULT NULL;
+
diff --git a/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql
new file mode 100644
index 00000000..2498351d
--- /dev/null
+++ b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/down.sql
@@ -0,0 +1,35 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE playback_progress_previous (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_file_id INTEGER NOT NULL UNIQUE,
+ position_ms BIGINT NOT NULL DEFAULT 0,
+ duration_ms BIGINT DEFAULT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE
+);
+
+INSERT INTO playback_progress_previous (
+ id,
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+)
+SELECT
+ MIN(id),
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+FROM playback_progress
+GROUP BY media_file_id;
+
+DROP TABLE playback_progress;
+ALTER TABLE playback_progress_previous RENAME TO playback_progress;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql
new file mode 100644
index 00000000..2b2a0703
--- /dev/null
+++ b/crates/server/sql/migrations/0000010_scope_playback_progress_to_user/up.sql
@@ -0,0 +1,39 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE playback_progress_next (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER DEFAULT NULL,
+ media_file_id INTEGER NOT NULL,
+ position_ms BIGINT NOT NULL DEFAULT 0,
+ duration_ms BIGINT DEFAULT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE,
+ UNIQUE (user_id, media_file_id)
+);
+
+INSERT INTO playback_progress_next (
+ id,
+ user_id,
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+)
+SELECT
+ id,
+ NULL,
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+FROM playback_progress;
+
+DROP TABLE playback_progress;
+ALTER TABLE playback_progress_next RENAME TO playback_progress;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql
new file mode 100644
index 00000000..1937f31a
--- /dev/null
+++ b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/down.sql
@@ -0,0 +1,172 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE playback_progress_prev (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER DEFAULT NULL,
+ media_file_id INTEGER NOT NULL,
+ position_ms BIGINT NOT NULL DEFAULT 0,
+ duration_ms BIGINT DEFAULT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE,
+ UNIQUE (user_id, media_file_id)
+);
+
+INSERT INTO playback_progress_prev (
+ id,
+ user_id,
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+)
+SELECT
+ progress.id,
+ progress.user_id,
+ COALESCE(files.id, progress.media_item_id),
+ progress.position_ms,
+ progress.duration_ms,
+ progress.completed,
+ progress.updated_at
+FROM playback_progress AS progress
+LEFT JOIN media_files AS files ON files.media_item_id = progress.media_item_id;
+
+DROP TABLE playback_progress;
+ALTER TABLE playback_progress_prev RENAME TO playback_progress;
+
+DROP TABLE item_metadata_people;
+DROP TABLE item_metadata_collections;
+
+CREATE TABLE item_metadata_links_prev (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_file_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ title TEXT DEFAULT NULL,
+ overview TEXT DEFAULT NULL,
+ artwork_url TEXT DEFAULT NULL,
+ backdrop_url TEXT DEFAULT NULL,
+ release_year INTEGER DEFAULT NULL,
+ media_type TEXT DEFAULT NULL,
+ match_state TEXT NOT NULL,
+ provider_payload_json TEXT DEFAULT NULL,
+ cached_artwork_path TEXT DEFAULT NULL,
+ cached_backdrop_path TEXT DEFAULT NULL,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE
+);
+
+INSERT INTO item_metadata_links_prev (
+ id,
+ media_file_id,
+ provider_id,
+ external_id,
+ title,
+ overview,
+ artwork_url,
+ backdrop_url,
+ release_year,
+ media_type,
+ match_state,
+ provider_payload_json,
+ cached_artwork_path,
+ cached_backdrop_path,
+ updated_at
+)
+SELECT
+ links.id,
+ COALESCE(files.id, links.media_item_id),
+ links.provider_id,
+ links.external_id,
+ links.title,
+ links.overview,
+ links.artwork_url,
+ links.backdrop_url,
+ links.release_year,
+ links.media_type,
+ links.match_state,
+ links.provider_payload_json,
+ links.cached_artwork_path,
+ links.cached_backdrop_path,
+ links.updated_at
+FROM item_metadata_links AS links
+LEFT JOIN media_files AS files ON files.media_item_id = links.media_item_id;
+
+DROP TABLE item_metadata_links;
+ALTER TABLE item_metadata_links_prev RENAME TO item_metadata_links;
+
+CREATE TABLE media_files_prev (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ source_root_path TEXT NOT NULL,
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ display_title TEXT DEFAULT NULL,
+ container TEXT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ bit_rate BIGINT DEFAULT NULL,
+ width INTEGER DEFAULT NULL,
+ height INTEGER DEFAULT NULL,
+ video_codec TEXT DEFAULT NULL,
+ audio_codec TEXT DEFAULT NULL,
+ metadata_json TEXT DEFAULT NULL,
+ metadata_updated_at BIGINT DEFAULT NULL,
+ metadata_match_attempted_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
+);
+
+INSERT INTO media_files_prev (
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at,
+ metadata_match_attempted_at
+)
+SELECT
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at,
+ metadata_match_attempted_at
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_prev RENAME TO media_files;
+
+DROP TABLE media_items;
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql
new file mode 100644
index 00000000..0395bf44
--- /dev/null
+++ b/crates/server/sql/migrations/0000011_logical_media_items_and_metadata_hierarchy/up.sql
@@ -0,0 +1,300 @@
+PRAGMA foreign_keys=off;
+
+CREATE TABLE media_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ parent_id INTEGER DEFAULT NULL,
+ identity_key TEXT NOT NULL UNIQUE,
+ item_type TEXT NOT NULL,
+ display_title TEXT NOT NULL,
+ relative_path TEXT DEFAULT NULL,
+ media_kind TEXT DEFAULT NULL,
+ season_number INTEGER DEFAULT NULL,
+ episode_number INTEGER DEFAULT NULL,
+ child_count INTEGER NOT NULL DEFAULT 0,
+ playable BOOLEAN NOT NULL DEFAULT FALSE,
+ file_size BIGINT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ created_at BIGINT DEFAULT NULL,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ FOREIGN KEY (parent_id) REFERENCES media_items(id) ON DELETE CASCADE
+);
+
+INSERT INTO media_items (
+ id,
+ library_id,
+ parent_id,
+ identity_key,
+ item_type,
+ display_title,
+ relative_path,
+ media_kind,
+ season_number,
+ episode_number,
+ child_count,
+ playable,
+ file_size,
+ duration_ms,
+ modified_at,
+ created_at,
+ updated_at
+)
+SELECT
+ id,
+ library_id,
+ NULL,
+ 'legacy-file-' || id,
+ CASE
+ WHEN media_kind = 'audio' THEN 'track'
+ WHEN media_kind = 'image' THEN 'photo'
+ WHEN media_kind = 'book' THEN 'book'
+ ELSE 'movie'
+ END,
+ COALESCE(display_title, relative_path),
+ relative_path,
+ media_kind,
+ NULL,
+ NULL,
+ 0,
+ CASE
+ WHEN media_kind IN ('video', 'audio') THEN TRUE
+ ELSE FALSE
+ END,
+ file_size,
+ duration_ms,
+ modified_at,
+ modified_at,
+ modified_at
+FROM media_files;
+
+CREATE TABLE media_files_next (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ library_id INTEGER NOT NULL,
+ source_root_path TEXT NOT NULL,
+ relative_path TEXT NOT NULL,
+ file_size BIGINT NOT NULL,
+ modified_at BIGINT DEFAULT NULL,
+ media_kind TEXT NOT NULL,
+ fingerprint_seed TEXT NOT NULL,
+ display_title TEXT DEFAULT NULL,
+ container TEXT DEFAULT NULL,
+ duration_ms BIGINT DEFAULT NULL,
+ bit_rate BIGINT DEFAULT NULL,
+ width INTEGER DEFAULT NULL,
+ height INTEGER DEFAULT NULL,
+ video_codec TEXT DEFAULT NULL,
+ audio_codec TEXT DEFAULT NULL,
+ metadata_json TEXT DEFAULT NULL,
+ metadata_updated_at BIGINT DEFAULT NULL,
+ metadata_match_attempted_at BIGINT DEFAULT NULL,
+ media_item_id INTEGER DEFAULT NULL,
+ FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE,
+ FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE SET NULL
+);
+
+INSERT INTO media_files_next (
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at,
+ metadata_match_attempted_at,
+ media_item_id
+)
+SELECT
+ id,
+ library_id,
+ source_root_path,
+ relative_path,
+ file_size,
+ modified_at,
+ media_kind,
+ fingerprint_seed,
+ display_title,
+ container,
+ duration_ms,
+ bit_rate,
+ width,
+ height,
+ video_codec,
+ audio_codec,
+ metadata_json,
+ metadata_updated_at,
+ metadata_match_attempted_at,
+ id
+FROM media_files;
+
+DROP TABLE media_files;
+ALTER TABLE media_files_next RENAME TO media_files;
+
+CREATE TABLE item_metadata_links_next (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_item_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ title TEXT DEFAULT NULL,
+ overview TEXT DEFAULT NULL,
+ tagline TEXT DEFAULT NULL,
+ artwork_url TEXT DEFAULT NULL,
+ backdrop_url TEXT DEFAULT NULL,
+ release_year INTEGER DEFAULT NULL,
+ media_type TEXT DEFAULT NULL,
+ relation_kind TEXT NOT NULL DEFAULT 'primary',
+ match_state TEXT NOT NULL,
+ provider_payload_json TEXT DEFAULT NULL,
+ cached_artwork_path TEXT DEFAULT NULL,
+ cached_backdrop_path TEXT DEFAULT NULL,
+ refresh_state TEXT NOT NULL DEFAULT 'fresh',
+ refresh_interval_seconds BIGINT NOT NULL DEFAULT 604800,
+ last_refreshed_at BIGINT DEFAULT NULL,
+ next_refresh_at BIGINT DEFAULT NULL,
+ refresh_error TEXT DEFAULT NULL,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE,
+ UNIQUE (media_item_id, provider_id, relation_kind)
+);
+
+INSERT INTO item_metadata_links_next (
+ id,
+ media_item_id,
+ provider_id,
+ external_id,
+ title,
+ overview,
+ tagline,
+ artwork_url,
+ backdrop_url,
+ release_year,
+ media_type,
+ relation_kind,
+ match_state,
+ provider_payload_json,
+ cached_artwork_path,
+ cached_backdrop_path,
+ refresh_state,
+ refresh_interval_seconds,
+ last_refreshed_at,
+ next_refresh_at,
+ refresh_error,
+ updated_at
+)
+SELECT
+ id,
+ media_file_id,
+ provider_id,
+ external_id,
+ title,
+ overview,
+ NULL,
+ artwork_url,
+ backdrop_url,
+ release_year,
+ media_type,
+ 'primary',
+ match_state,
+ provider_payload_json,
+ cached_artwork_path,
+ cached_backdrop_path,
+ 'fresh',
+ 604800,
+ updated_at,
+ CASE
+ WHEN updated_at IS NULL THEN NULL
+ ELSE updated_at + 604800
+ END,
+ NULL,
+ updated_at
+FROM item_metadata_links;
+
+DROP TABLE item_metadata_links;
+ALTER TABLE item_metadata_links_next RENAME TO item_metadata_links;
+
+CREATE TABLE item_metadata_people (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ metadata_link_id INTEGER NOT NULL,
+ external_id TEXT DEFAULT NULL,
+ name TEXT NOT NULL,
+ role TEXT DEFAULT NULL,
+ department TEXT DEFAULT NULL,
+ character_name TEXT DEFAULT NULL,
+ profile_url TEXT DEFAULT NULL,
+ image_url TEXT DEFAULT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE
+);
+
+CREATE TABLE item_metadata_collections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ metadata_link_id INTEGER NOT NULL,
+ provider_id TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ overview TEXT DEFAULT NULL,
+ artwork_url TEXT DEFAULT NULL,
+ backdrop_url TEXT DEFAULT NULL,
+ provider_payload_json TEXT DEFAULT NULL,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE,
+ UNIQUE (metadata_link_id, provider_id, external_id)
+);
+
+CREATE TABLE playback_progress_next (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER DEFAULT NULL,
+ media_item_id INTEGER NOT NULL,
+ position_ms BIGINT NOT NULL DEFAULT 0,
+ duration_ms BIGINT DEFAULT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at BIGINT DEFAULT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE,
+ UNIQUE (user_id, media_item_id)
+);
+
+INSERT INTO playback_progress_next (
+ id,
+ user_id,
+ media_item_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+)
+SELECT
+ id,
+ user_id,
+ media_file_id,
+ position_ms,
+ duration_ms,
+ completed,
+ updated_at
+FROM playback_progress;
+
+DROP TABLE playback_progress;
+ALTER TABLE playback_progress_next RENAME TO playback_progress;
+
+CREATE INDEX idx_media_items_library_parent ON media_items (library_id, parent_id);
+CREATE INDEX idx_media_items_identity_key ON media_items (identity_key);
+CREATE INDEX idx_media_files_media_item_id ON media_files (media_item_id);
+CREATE INDEX idx_item_metadata_links_media_item_id ON item_metadata_links (media_item_id);
+CREATE INDEX idx_item_metadata_people_link_id ON item_metadata_people (metadata_link_id);
+CREATE INDEX idx_item_metadata_collections_link_id ON item_metadata_collections (metadata_link_id);
+CREATE INDEX idx_playback_progress_media_item_id ON playback_progress (media_item_id);
+
+PRAGMA foreign_keys=on;
+
diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs
index 621f7148..0eac18e6 100644
--- a/crates/server/src/config.rs
+++ b/crates/server/src/config.rs
@@ -1,24 +1,268 @@
//! Configuration module for the application.
+// standard imports
+use std::fs;
+use std::path::PathBuf;
+use std::sync::RwLock;
+
// lib imports
use config::{Config, ConfigError, Environment, File};
use dirs::config_local_dir;
use once_cell::sync::Lazy;
+use schemars::JsonSchema;
use serde::Deserialize;
+use serde::Serialize;
// local imports
use crate::globals::GLOBAL_APP_NAME;
/// General settings.
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
pub struct GeneralSettings {
/// The directory where application data is stored.
#[serde(default)]
pub data_dir: String,
}
+/// Supported library categories for configured media roots.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum MediaLibraryKind {
+ /// Mixed content when the library is not limited to a single media type.
+ #[default]
+ Mixed,
+ /// Feature films and similar long-form video content.
+ Movies,
+ /// Episodic television or serialized video content.
+ Shows,
+ /// Music, albums, and other audio-focused content.
+ Music,
+ /// Photos and other image collections.
+ Photos,
+ /// Books, comics, PDFs, and other reading material.
+ Books,
+ /// Home videos and other personal recordings.
+ HomeVideos,
+}
+
+fn default_recursive_scan() -> bool {
+ true
+}
+
+fn default_ffmpeg_path() -> String {
+ "ffmpeg".into()
+}
+
+fn default_ffprobe_path() -> String {
+ "ffprobe".into()
+}
+
+fn default_metadata_language() -> String {
+ "en-US".into()
+}
+
+fn default_provider_enabled() -> bool {
+ true
+}
+
+fn default_provider_rate_limit_per_second() -> u32 {
+ 4
+}
+
+fn default_provider_retry_attempts() -> u32 {
+ 3
+}
+
+fn default_provider_retry_backoff_ms() -> u32 {
+ 1_000
+}
+
+fn default_library_metadata_providers() -> Vec {
+ vec![MetadataProviderId::Tmdb]
+}
+
+fn normalized_unique_strings(values: impl IntoIterator- ) -> Vec
{
+ let mut seen = std::collections::HashSet::new();
+ let mut normalized = Vec::new();
+
+ for value in values {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+
+ let owned = trimmed.to_string();
+ if seen.insert(owned.clone()) {
+ normalized.push(owned);
+ }
+ }
+
+ normalized
+}
+
+/// A configured media-library root.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)]
+pub struct MediaLibrarySettings {
+ /// Human-friendly library name.
+ #[serde(default)]
+ pub name: String,
+ /// Filesystem path to the media-library root.
+ #[serde(default)]
+ pub path: String,
+ /// Filesystem paths for one logical library when multiple roots are configured.
+ #[serde(default)]
+ pub paths: Vec,
+ /// Whether the scanner should recurse into subdirectories.
+ #[serde(default = "default_recursive_scan")]
+ pub recursive: bool,
+ /// The intended media category for the library.
+ #[serde(default)]
+ pub kind: MediaLibraryKind,
+ /// Ordered metadata providers to use for this library.
+ #[serde(default = "default_library_metadata_providers")]
+ pub metadata_providers: Vec,
+}
+
+impl MediaLibrarySettings {
+ /// Return all configured filesystem roots for this logical library.
+ pub fn configured_paths(&self) -> Vec {
+ normalized_unique_strings(
+ std::iter::once(self.path.clone()).chain(self.paths.iter().cloned()),
+ )
+ }
+
+ /// Return the first configured filesystem root for this library, when present.
+ pub fn primary_path(&self) -> String {
+ self.configured_paths().into_iter().next().unwrap_or_default()
+ }
+
+ /// Normalize path and provider settings for persistence.
+ pub fn normalize(&mut self) {
+ let normalized_paths = self.configured_paths();
+ self.path = normalized_paths.first().cloned().unwrap_or_default();
+ self.paths = normalized_paths;
+ self.metadata_providers = normalized_unique_strings(
+ self.metadata_providers
+ .iter()
+ .map(|provider| provider.as_storage_value().to_string()),
+ )
+ .into_iter()
+ .filter_map(|value| MetadataProviderId::from_storage_value(&value))
+ .collect();
+ if self.metadata_providers.is_empty() {
+ self.metadata_providers = default_library_metadata_providers();
+ }
+ }
+}
+
+/// Media scanning settings.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)]
+pub struct MediaSettings {
+ /// Configured media-library roots.
+ #[serde(default)]
+ pub libraries: Vec,
+}
+
+/// Supported external metadata providers.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Hash, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum MetadataProviderId {
+ /// TheMovieDB for movie and television metadata.
+ #[default]
+ Tmdb,
+ /// MusicBrainz for music-oriented metadata.
+ #[serde(rename = "musicbrainz")]
+ MusicBrainz,
+ /// Open Library for book metadata.
+ OpenLibrary,
+ /// Local NFO files and sidecar metadata.
+ LocalNfo,
+}
+
+impl MetadataProviderId {
+ /// Return the stable storage value for this provider identifier.
+ pub fn as_storage_value(&self) -> &'static str {
+ match self {
+ MetadataProviderId::Tmdb => "tmdb",
+ MetadataProviderId::MusicBrainz => "musicbrainz",
+ MetadataProviderId::OpenLibrary => "open_library",
+ MetadataProviderId::LocalNfo => "local_nfo",
+ }
+ }
+
+ /// Parse a provider identifier from a stored string value.
+ pub fn from_storage_value(value: &str) -> Option {
+ match value.trim() {
+ "tmdb" => Some(MetadataProviderId::Tmdb),
+ "musicbrainz" => Some(MetadataProviderId::MusicBrainz),
+ "open_library" => Some(MetadataProviderId::OpenLibrary),
+ "local_nfo" => Some(MetadataProviderId::LocalNfo),
+ _ => None,
+ }
+ }
+}
+
+/// Configuration for one metadata provider.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
+pub struct MetadataProviderSettings {
+ /// Provider identifier.
+ #[serde(default)]
+ pub id: MetadataProviderId,
+ /// Whether this provider is enabled.
+ #[serde(default = "default_provider_enabled")]
+ pub enabled: bool,
+ /// Provider-specific API key or token, when required.
+ #[serde(default)]
+ pub api_key: Option,
+ /// Preferred language for metadata results.
+ #[serde(default = "default_metadata_language")]
+ pub language: String,
+ /// Maximum request rate the provider should use when making API calls.
+ #[serde(default = "default_provider_rate_limit_per_second")]
+ pub rate_limit_per_second: u32,
+ /// Maximum number of retry attempts after transient provider failures.
+ #[serde(default = "default_provider_retry_attempts")]
+ pub retry_attempts: u32,
+ /// Base retry backoff in milliseconds.
+ #[serde(default = "default_provider_retry_backoff_ms")]
+ pub retry_backoff_ms: u32,
+}
+
+/// Metadata acquisition settings.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
+pub struct MetadataSettings {
+ /// Ordered list of enabled and optional providers.
+ #[serde(default)]
+ pub providers: Vec,
+}
+
+/// FFmpeg integration strategy.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum FfmpegStrategy {
+ /// Use external FFmpeg and ffprobe executables.
+ #[default]
+ ExternalBinaries,
+ /// Reserve room for future embedded-library support if licensing allows.
+ EmbeddedLibrariesPlanned,
+}
+
+/// FFmpeg-related tooling settings.
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
+pub struct FfmpegSettings {
+ /// Licensing-safe FFmpeg integration strategy.
+ #[serde(default)]
+ pub strategy: FfmpegStrategy,
+ /// Path or command name for the FFmpeg executable.
+ #[serde(default = "default_ffmpeg_path")]
+ pub ffmpeg_path: String,
+ /// Path or command name for the ffprobe executable.
+ #[serde(default = "default_ffprobe_path")]
+ pub ffprobe_path: String,
+}
+
/// Server settings.
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
pub struct ServerSettings {
/// Whether to use HTTPS.
#[serde(default)]
@@ -41,14 +285,23 @@ pub struct ServerSettings {
}
/// Application settings.
-#[derive(Debug, Deserialize, Default)]
+#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)]
pub struct Settings {
/// General settings.
#[serde(default)]
pub general: GeneralSettings,
+ /// Media settings.
+ #[serde(default)]
+ pub media: MediaSettings,
+ /// Metadata-provider settings.
+ #[serde(default)]
+ pub metadata: MetadataSettings,
/// Server settings.
#[serde(default)]
pub server: ServerSettings,
+ /// FFmpeg tooling settings.
+ #[serde(default)]
+ pub ffmpeg: FfmpegSettings,
}
impl Default for GeneralSettings {
@@ -78,11 +331,41 @@ impl Default for ServerSettings {
}
}
+impl Default for FfmpegSettings {
+ fn default() -> Self {
+ Self {
+ strategy: FfmpegStrategy::ExternalBinaries,
+ ffmpeg_path: default_ffmpeg_path(),
+ ffprobe_path: default_ffprobe_path(),
+ }
+ }
+}
+
+impl Default for MetadataProviderSettings {
+ fn default() -> Self {
+ Self {
+ id: MetadataProviderId::Tmdb,
+ enabled: default_provider_enabled(),
+ api_key: None,
+ language: default_metadata_language(),
+ rate_limit_per_second: default_provider_rate_limit_per_second(),
+ retry_attempts: default_provider_retry_attempts(),
+ retry_backoff_ms: default_provider_retry_backoff_ms(),
+ }
+ }
+}
+
+impl Default for MetadataSettings {
+ fn default() -> Self {
+ Self {
+ providers: vec![MetadataProviderSettings::default()],
+ }
+ }
+}
+
impl Settings {
/// Create a new instance of `Settings`.
pub fn new() -> Result {
- // Start with defaults provided via set_default and then merge in any provided config file
- // or environment variables.
let config = Config::builder()
.set_default("general.data_dir", GeneralSettings::default().data_dir)?
.set_default("server.use_https", ServerSettings::default().use_https)?
@@ -94,24 +377,12 @@ impl Settings {
"server.use_custom_certs",
ServerSettings::default().use_custom_certs,
)?
- // Add other configuration sources; values here will override the defaults.
- .add_source(
- File::with_name(
- config_local_dir()
- .unwrap()
- .join(GLOBAL_APP_NAME)
- .join("settings")
- .to_str()
- .unwrap(),
- )
- .required(false),
- )
+ .add_source(File::with_name(settings_base_path().to_str().unwrap()).required(false))
.add_source(Environment::with_prefix(
GLOBAL_APP_NAME.to_uppercase().as_str(),
))
.build()?;
- // Deserialize the configuration into our Settings struct.
config.try_deserialize()
}
@@ -121,5 +392,74 @@ impl Settings {
}
}
-/// Global settings for the application.
-pub static GLOBAL_SETTINGS: Lazy = Lazy::new(Settings::load);
+/// Normalize settings values before persistence or runtime replacement.
+pub fn normalize_settings(settings: &mut Settings) {
+ for library in &mut settings.media.libraries {
+ library.normalize();
+ }
+}
+
+/// Return a settings snapshot suitable for YAML persistence.
+pub fn settings_for_persistence(settings: &Settings) -> Settings {
+ let mut normalized = settings.clone();
+ normalize_settings(&mut normalized);
+ normalized.media.libraries.clear();
+ normalized
+}
+
+fn settings_base_path() -> PathBuf {
+ settings_directory_path().join("settings")
+}
+
+/// Return the settings directory path.
+pub fn settings_directory_path() -> PathBuf {
+ if let Ok(path) = std::env::var("KOKO_SETTINGS_DIR") {
+ let path = path.trim();
+ if !path.is_empty() {
+ return PathBuf::from(path);
+ }
+ }
+
+ config_local_dir().unwrap().join(GLOBAL_APP_NAME)
+}
+
+/// Return the YAML settings file path.
+pub fn settings_file_path() -> PathBuf {
+ if let Ok(path) = std::env::var("KOKO_SETTINGS_PATH") {
+ let path = path.trim();
+ if !path.is_empty() {
+ return PathBuf::from(path);
+ }
+ }
+
+ settings_directory_path().join("settings.yml")
+}
+
+/// Save settings to disk.
+pub fn save_settings(settings: &Settings) -> Result<(), String> {
+ let normalized = settings_for_persistence(settings);
+ let settings_path = settings_file_path();
+ if let Some(parent) = settings_path.parent() {
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+
+ let yaml = serde_yaml::to_string(&normalized).map_err(|error| error.to_string())?;
+ fs::write(settings_path, yaml).map_err(|error| error.to_string())
+}
+
+/// Global mutable settings state for the application.
+pub static CURRENT_SETTINGS: Lazy> = Lazy::new(|| RwLock::new(Settings::load()));
+
+/// Return a clone of the current in-memory settings.
+pub fn current_settings() -> Settings {
+ let mut settings = CURRENT_SETTINGS.read().unwrap().clone();
+ normalize_settings(&mut settings);
+ settings
+}
+
+/// Replace the in-memory settings state.
+pub fn replace_current_settings(settings: Settings) {
+ let mut normalized = settings;
+ normalize_settings(&mut normalized);
+ *CURRENT_SETTINGS.write().unwrap() = normalized;
+}
diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs
index 4719bf22..955f684f 100644
--- a/crates/server/src/db/mod.rs
+++ b/crates/server/src/db/mod.rs
@@ -4,10 +4,10 @@ pub(crate) mod models;
pub(crate) mod schema;
// lib imports
+use diesel::connection::SimpleConnection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use rocket::{
- Build,
- Rocket,
+ Build, Rocket,
fairing::{Fairing, Info, Kind},
};
use rocket_sync_db_pools::{database, diesel};
@@ -15,6 +15,16 @@ use rocket_sync_db_pools::{database, diesel};
/// Embedded migrations for the SQLite database.
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations");
+/// Apply SQLite pragmas that improve concurrency and reduce lock contention.
+pub fn configure_sqlite_connection(conn: &mut diesel::SqliteConnection) -> diesel::result::QueryResult<()> {
+ conn.batch_execute(
+ "PRAGMA foreign_keys = ON;\
+ PRAGMA journal_mode = WAL;\
+ PRAGMA synchronous = NORMAL;\
+ PRAGMA busy_timeout = 5000;",
+ )
+}
+
/// Database connection fairing.
#[database("sqlite_db")]
pub struct DbConn(diesel::SqliteConnection);
@@ -38,6 +48,7 @@ impl Fairing for Migrate {
if let Some(conn) = DbConn::get_one(&rocket).await {
let _ = conn
.run(|c| {
+ configure_sqlite_connection(c).expect("Failed to configure SQLite connection");
c.run_pending_migrations(MIGRATIONS)
.expect("Failed to run migrations");
})
diff --git a/crates/server/src/db/models.rs b/crates/server/src/db/models.rs
index dcea4d07..ffd8e2c7 100644
--- a/crates/server/src/db/models.rs
+++ b/crates/server/src/db/models.rs
@@ -4,7 +4,10 @@
use diesel::prelude::*;
// local imports
-use crate::db::schema::users;
+use crate::db::schema::{
+ item_metadata_collections, item_metadata_links, item_metadata_people, media_files,
+ media_items, media_libraries, playback_progress, scan_state, users,
+};
#[derive(Queryable, Selectable, Insertable, Debug)]
#[diesel(table_name = users)]
@@ -16,3 +19,300 @@ pub struct User {
pub pin: Option,
pub admin: bool,
}
+
+#[derive(Queryable, Selectable, Identifiable, Debug, Clone)]
+#[diesel(table_name = media_libraries)]
+pub struct MediaLibrary {
+ pub id: i32,
+ pub name: String,
+ pub path: String,
+ pub paths_json: String,
+ pub kind: String,
+ pub recursive: bool,
+ pub metadata_providers_json: String,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = media_libraries)]
+pub struct NewMediaLibrary {
+ pub name: String,
+ pub path: String,
+ pub paths_json: String,
+ pub kind: String,
+ pub recursive: bool,
+ pub metadata_providers_json: String,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug)]
+#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))]
+#[diesel(table_name = scan_state)]
+pub struct ScanState {
+ pub id: i32,
+ pub library_id: i32,
+ pub last_status: String,
+ pub last_error: Option,
+ pub scan_revision: i64,
+ pub last_scanned_at: Option,
+ pub total_files: i64,
+ pub video_files: i64,
+ pub audio_files: i64,
+ pub image_files: i64,
+ pub book_files: i64,
+ pub other_files: i64,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = scan_state)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewScanState {
+ pub library_id: i32,
+ pub last_status: String,
+ pub last_error: Option,
+ pub scan_revision: i64,
+ pub last_scanned_at: Option,
+ pub total_files: i64,
+ pub video_files: i64,
+ pub audio_files: i64,
+ pub image_files: i64,
+ pub book_files: i64,
+ pub other_files: i64,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug)]
+#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))]
+#[diesel(table_name = media_files)]
+pub struct MediaFile {
+ pub id: i32,
+ pub library_id: i32,
+ pub source_root_path: String,
+ pub relative_path: String,
+ pub file_size: i64,
+ pub modified_at: Option,
+ pub media_kind: String,
+ pub fingerprint_seed: String,
+ pub display_title: Option,
+ pub container: Option,
+ pub duration_ms: Option,
+ pub bit_rate: Option,
+ pub width: Option,
+ pub height: Option,
+ pub video_codec: Option,
+ pub audio_codec: Option,
+ pub metadata_json: Option,
+ pub metadata_updated_at: Option,
+ pub metadata_match_attempted_at: Option,
+ pub media_item_id: Option,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)]
+#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))]
+#[diesel(belongs_to(MediaItem, foreign_key = parent_id))]
+#[diesel(table_name = media_items)]
+pub struct MediaItem {
+ pub id: i32,
+ pub library_id: i32,
+ pub parent_id: Option,
+ pub identity_key: String,
+ pub item_type: String,
+ pub display_title: String,
+ pub relative_path: Option,
+ pub media_kind: Option,
+ pub season_number: Option,
+ pub episode_number: Option,
+ pub child_count: i32,
+ pub playable: bool,
+ pub file_size: Option,
+ pub duration_ms: Option,
+ pub modified_at: Option,
+ pub created_at: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = media_items)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewMediaItem {
+ pub library_id: i32,
+ pub parent_id: Option,
+ pub identity_key: String,
+ pub item_type: String,
+ pub display_title: String,
+ pub relative_path: Option,
+ pub media_kind: Option,
+ pub season_number: Option,
+ pub episode_number: Option,
+ pub child_count: i32,
+ pub playable: bool,
+ pub file_size: Option,
+ pub duration_ms: Option,
+ pub modified_at: Option,
+ pub created_at: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug)]
+#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))]
+#[diesel(table_name = item_metadata_links)]
+pub struct ItemMetadataLink {
+ pub id: i32,
+ pub media_item_id: i32,
+ pub provider_id: String,
+ pub external_id: String,
+ pub title: Option,
+ pub overview: Option,
+ pub tagline: Option,
+ pub artwork_url: Option,
+ pub backdrop_url: Option,
+ pub release_year: Option,
+ pub media_type: Option,
+ pub relation_kind: String,
+ pub match_state: String,
+ pub provider_payload_json: Option,
+ pub cached_artwork_path: Option,
+ pub cached_backdrop_path: Option,
+ pub refresh_state: String,
+ pub refresh_interval_seconds: i64,
+ pub last_refreshed_at: Option,
+ pub next_refresh_at: Option,
+ pub refresh_error: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = item_metadata_links)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewItemMetadataLink {
+ pub media_item_id: i32,
+ pub provider_id: String,
+ pub external_id: String,
+ pub title: Option,
+ pub overview: Option,
+ pub tagline: Option,
+ pub artwork_url: Option,
+ pub backdrop_url: Option,
+ pub release_year: Option,
+ pub media_type: Option,
+ pub relation_kind: String,
+ pub match_state: String,
+ pub provider_payload_json: Option,
+ pub cached_artwork_path: Option,
+ pub cached_backdrop_path: Option,
+ pub refresh_state: String,
+ pub refresh_interval_seconds: i64,
+ pub last_refreshed_at: Option,
+ pub next_refresh_at: Option,
+ pub refresh_error: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)]
+#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))]
+#[diesel(table_name = item_metadata_people)]
+pub struct ItemMetadataPerson {
+ pub id: i32,
+ pub metadata_link_id: i32,
+ pub external_id: Option,
+ pub name: String,
+ pub role: Option,
+ pub department: Option,
+ pub character_name: Option,
+ pub profile_url: Option,
+ pub image_url: Option,
+ pub sort_order: i32,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = item_metadata_people)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewItemMetadataPerson {
+ pub metadata_link_id: i32,
+ pub external_id: Option,
+ pub name: String,
+ pub role: Option,
+ pub department: Option,
+ pub character_name: Option,
+ pub profile_url: Option,
+ pub image_url: Option,
+ pub sort_order: i32,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)]
+#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))]
+#[diesel(table_name = item_metadata_collections)]
+pub struct ItemMetadataCollection {
+ pub id: i32,
+ pub metadata_link_id: i32,
+ pub provider_id: String,
+ pub external_id: String,
+ pub name: String,
+ pub overview: Option,
+ pub artwork_url: Option,
+ pub backdrop_url: Option,
+ pub provider_payload_json: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = item_metadata_collections)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewItemMetadataCollection {
+ pub metadata_link_id: i32,
+ pub provider_id: String,
+ pub external_id: String,
+ pub name: String,
+ pub overview: Option,
+ pub artwork_url: Option,
+ pub backdrop_url: Option,
+ pub provider_payload_json: Option,
+ pub updated_at: Option,
+}
+
+#[derive(Queryable, Selectable, Identifiable, Associations, Debug)]
+#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))]
+#[diesel(table_name = playback_progress)]
+pub struct PlaybackProgress {
+ pub id: i32,
+ pub user_id: Option,
+ pub media_item_id: i32,
+ pub position_ms: i64,
+ pub duration_ms: Option,
+ pub completed: bool,
+ pub updated_at: Option,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = playback_progress)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewPlaybackProgress {
+ pub user_id: i32,
+ pub media_item_id: i32,
+ pub position_ms: i64,
+ pub duration_ms: Option,
+ pub completed: bool,
+ pub updated_at: Option,
+}
+
+#[derive(Insertable, AsChangeset, Debug, Clone)]
+#[diesel(table_name = media_files)]
+#[diesel(treat_none_as_null = true)]
+pub struct NewMediaFile {
+ pub library_id: i32,
+ pub source_root_path: String,
+ pub relative_path: String,
+ pub file_size: i64,
+ pub modified_at: Option,
+ pub media_kind: String,
+ pub fingerprint_seed: String,
+ pub display_title: Option,
+ pub container: Option,
+ pub duration_ms: Option,
+ pub bit_rate: Option,
+ pub width: Option,
+ pub height: Option,
+ pub video_codec: Option,
+ pub audio_codec: Option,
+ pub metadata_json: Option,
+ pub metadata_updated_at: Option,
+ pub metadata_match_attempted_at: Option,
+ pub media_item_id: Option,
+}
diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs
index a9831b63..c215ef52 100644
--- a/crates/server/src/db/schema.rs
+++ b/crates/server/src/db/schema.rs
@@ -1,7 +1,174 @@
//! Database schema for the application.
// lib imports
-use diesel::table;
+use diesel::{allow_tables_to_appear_in_same_query, joinable, table};
+
+table! {
+ item_metadata_links (id) {
+ id -> Integer,
+ media_item_id -> Integer,
+ provider_id -> Text,
+ external_id -> Text,
+ title -> Nullable,
+ overview -> Nullable,
+ tagline -> Nullable,
+ artwork_url -> Nullable,
+ backdrop_url -> Nullable,
+ release_year -> Nullable,
+ media_type -> Nullable,
+ relation_kind -> Text,
+ match_state -> Text,
+ provider_payload_json -> Nullable,
+ cached_artwork_path -> Nullable,
+ cached_backdrop_path -> Nullable,
+ refresh_state -> Text,
+ refresh_interval_seconds -> BigInt,
+ last_refreshed_at -> Nullable,
+ next_refresh_at -> Nullable,
+ refresh_error -> Nullable,
+ updated_at -> Nullable,
+ }
+}
+
+table! {
+ item_metadata_collections (id) {
+ id -> Integer,
+ metadata_link_id -> Integer,
+ provider_id -> Text,
+ external_id -> Text,
+ name -> Text,
+ overview -> Nullable,
+ artwork_url -> Nullable,
+ backdrop_url -> Nullable,
+ provider_payload_json -> Nullable,
+ updated_at -> Nullable,
+ }
+}
+
+table! {
+ item_metadata_people (id) {
+ id -> Integer,
+ metadata_link_id -> Integer,
+ external_id -> Nullable,
+ name -> Text,
+ role -> Nullable,
+ department -> Nullable