From 2f0675914c257f05748f4f7fdd6650182e287779 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:08:31 +0000 Subject: [PATCH] Restore orchestrator, live queries, and reorganize types with schema integration Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com> --- src/store/db/live-queries.ts | 146 ++++++++++ src/store/db/types.ts | 58 ++++ src/store/prefill/orchestrator.ts | 336 ++++++++++++++++++++++++ src/store/prefill/task-graph.ts | 297 +++++++++++++++++++++ src/store/sources/emby/api-types.ts | 72 +++++ src/store/sources/emby/types.ts | 3 +- src/store/sources/jellyfin/api-types.ts | 72 +++++ src/store/sources/jellyfin/types.ts | 3 +- src/store/sources/types.ts | 128 ++++----- 9 files changed, 1041 insertions(+), 74 deletions(-) create mode 100644 src/store/db/live-queries.ts create mode 100644 src/store/db/types.ts create mode 100644 src/store/prefill/orchestrator.ts create mode 100644 src/store/prefill/task-graph.ts create mode 100644 src/store/sources/emby/api-types.ts create mode 100644 src/store/sources/jellyfin/api-types.ts diff --git a/src/store/db/live-queries.ts b/src/store/db/live-queries.ts new file mode 100644 index 0000000..201f908 --- /dev/null +++ b/src/store/db/live-queries.ts @@ -0,0 +1,146 @@ +/** + * Live Queries for Reactive UI + * + * Provides React hooks for live database queries that automatically + * re-render when data changes. + * + * Note: This is a simple implementation. For production, consider using + * a more sophisticated solution like @powersync/react or similar. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { db } from './index'; +import type { SQLiteRunResult } from 'drizzle-orm/sqlite-core'; + +/** + * Table change listeners + */ +type TableListener = () => void; +const tableListeners = new Map>(); + +/** + * Register a listener for table changes + */ +function subscribeToTable(tableName: string, listener: TableListener) { + if (!tableListeners.has(tableName)) { + tableListeners.set(tableName, new Set()); + } + tableListeners.get(tableName)!.add(listener); + + return () => { + const listeners = tableListeners.get(tableName); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + tableListeners.delete(tableName); + } + } + }; +} + +/** + * Notify listeners that a table has changed + */ +export function invalidateTable(tableName: string) { + const listeners = tableListeners.get(tableName); + if (listeners) { + listeners.forEach(listener => listener()); + } +} + +/** + * Invalidate multiple tables at once + */ +export function invalidateTables(tableNames: string[]) { + tableNames.forEach(invalidateTable); +} + +/** + * Hook for live query results + * + * @param query - SQL query string + * @param params - Query parameters + * @param tables - Array of table names to watch for changes + * @returns Query results that update when tables change + * + * @example + * ```typescript + * const albums = useLiveQuery( + * 'SELECT * FROM albums WHERE source_id = ? ORDER BY name', + * [sourceId], + * ['albums'] + * ); + * ``` + */ +export function useLiveQuery( + query: string, + params: unknown[] = [], + tables: string[] = [] +): T[] | null { + const [data, setData] = useState(null); + const [version, setVersion] = useState(0); + + // Increment version when any watched table changes + useEffect(() => { + if (tables.length === 0) return; + + const unsubscribers = tables.map(table => + subscribeToTable(table, () => setVersion(v => v + 1)) + ); + + return () => { + unsubscribers.forEach(unsub => unsub()); + }; + }, [tables]); + + // Execute query whenever version changes + useEffect(() => { + let cancelled = false; + + async function executeQuery() { + try { + // Execute raw SQL query + const result = await db.execute(query, params); + + if (!cancelled) { + setData(result.rows as T[]); + } + } catch (error) { + console.error('Live query error:', error); + if (!cancelled) { + setData([]); + } + } + } + + executeQuery(); + + return () => { + cancelled = true; + }; + }, [query, JSON.stringify(params), version]); + + return data; +} + +/** + * Hook for a single live query result + */ +export function useLiveQueryOne( + query: string, + params: unknown[] = [], + tables: string[] = [] +): T | null { + const results = useLiveQuery(query, params, tables); + return results && results.length > 0 ? results[0] : null; +} + +/** + * Hook to invalidate tables manually + */ +export function useInvalidateTables() { + return useCallback((tableNames: string | string[]) => { + const tables = Array.isArray(tableNames) ? tableNames : [tableNames]; + invalidateTables(tables); + }, []); +} diff --git a/src/store/db/types.ts b/src/store/db/types.ts new file mode 100644 index 0000000..c4e2f29 --- /dev/null +++ b/src/store/db/types.ts @@ -0,0 +1,58 @@ +/** + * Database Schema Types + * + * These types are derived from the Drizzle schema and represent + * the structure of data in the database. + */ + +import type { InferSelectModel } from 'drizzle-orm'; +import { sources } from './schema/sources'; +import { artists } from './schema/artists'; +import { albums } from './schema/albums'; +import { tracks } from './schema/tracks'; +import { playlists } from './schema/playlists'; +import { downloads } from './schema/downloads'; +import { searchQueries } from './schema/search-queries'; +import { albumArtists } from './schema/album-artists'; +import { trackArtists } from './schema/track-artists'; +import { playlistTracks } from './schema/playlist-tracks'; +import { albumSimilar } from './schema/album-similar'; +import { syncCursors } from './schema/sync-cursors'; +import { appSettings } from './schema/app-settings'; +import { sleepTimer } from './schema/sleep-timer'; + +/** + * Inferred types from schema tables + */ +export type Source = InferSelectModel; +export type Artist = InferSelectModel; +export type Album = InferSelectModel; +export type Track = InferSelectModel; +export type Playlist = InferSelectModel; +export type Download = InferSelectModel; +export type SearchQuery = InferSelectModel; +export type AlbumArtist = InferSelectModel; +export type TrackArtist = InferSelectModel; +export type PlaylistTrack = InferSelectModel; +export type AlbumSimilar = InferSelectModel; +export type SyncCursor = InferSelectModel; +export type AppSettings = InferSelectModel; +export type SleepTimer = InferSelectModel; + +/** + * Insert types (for creating new records) + */ +export type InsertSource = typeof sources.$inferInsert; +export type InsertArtist = typeof artists.$inferInsert; +export type InsertAlbum = typeof albums.$inferInsert; +export type InsertTrack = typeof tracks.$inferInsert; +export type InsertPlaylist = typeof playlists.$inferInsert; +export type InsertDownload = typeof downloads.$inferInsert; +export type InsertSearchQuery = typeof searchQueries.$inferInsert; +export type InsertAlbumArtist = typeof albumArtists.$inferInsert; +export type InsertTrackArtist = typeof trackArtists.$inferInsert; +export type InsertPlaylistTrack = typeof playlistTracks.$inferInsert; +export type InsertAlbumSimilar = typeof albumSimilar.$inferInsert; +export type InsertSyncCursor = typeof syncCursors.$inferInsert; +export type InsertAppSettings = typeof appSettings.$inferInsert; +export type InsertSleepTimer = typeof sleepTimer.$inferInsert; diff --git a/src/store/prefill/orchestrator.ts b/src/store/prefill/orchestrator.ts new file mode 100644 index 0000000..711fce1 --- /dev/null +++ b/src/store/prefill/orchestrator.ts @@ -0,0 +1,336 @@ +/** + * Prefill Orchestrator + * + * Manages the automated prefill of data from sources into the local database. + * Uses p-queue for bounded concurrency and supports cursor-based resume. + */ + +import PQueue from 'p-queue'; +import { db } from '../db/index'; +import type { SourceDriver } from '../sources/types'; +import type { InsertSyncCursor } from '../db/types'; +import { syncCursors } from '../db/schema/sync-cursors'; +import { eq, and } from 'drizzle-orm'; + +/** + * Entity types that can be prefilled + */ +export enum EntityType { + ARTISTS = 'artists', + ALBUMS = 'albums', + PLAYLISTS = 'playlists', + ALBUM_TRACKS = 'album_tracks', + PLAYLIST_TRACKS = 'playlist_tracks', + SIMILAR_ALBUMS = 'similar_albums', + LYRICS = 'lyrics', +} + +/** + * Progress callback for prefill operations + */ +export interface PrefillProgress { + entityType: EntityType; + totalFetched: number; + hasMore: boolean; + error?: Error; +} + +export type PrefillProgressCallback = (progress: PrefillProgress) => void; + +/** + * Prefill configuration + */ +export interface PrefillConfig { + /** Maximum number of concurrent requests */ + concurrency?: number; + /** Page size for list operations */ + pageSize?: number; + /** Progress callback */ + onProgress?: PrefillProgressCallback; +} + +/** + * Default configuration + */ +const DEFAULT_CONFIG: Required> = { + concurrency: 5, + pageSize: 500, +}; + +/** + * Get sync cursor for resuming prefill + */ +async function getSyncCursor( + sourceId: string, + entityType: EntityType +): Promise { + const cursor = await db + .select() + .from(syncCursors) + .where( + and( + eq(syncCursors.sourceId, sourceId), + eq(syncCursors.entityType, entityType) + ) + ) + .limit(1); + + return cursor[0]?.offset || 0; +} + +/** + * Update sync cursor + */ +async function updateSyncCursor( + sourceId: string, + entityType: EntityType, + offset: number +) { + const now = Date.now(); + + await db + .insert(syncCursors) + .values({ + sourceId, + entityType, + offset, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [syncCursors.sourceId, syncCursors.entityType], + set: { + offset, + updatedAt: now, + }, + }); +} + +/** + * Prefill orchestrator for basic entities (artists, albums, playlists) + */ +export class PrefillOrchestrator { + private queue: PQueue; + private config: Required> & Pick; + private sourceId: string; + private driver: SourceDriver; + + constructor(sourceId: string, driver: SourceDriver, config: PrefillConfig = {}) { + this.sourceId = sourceId; + this.driver = driver; + this.config = { + ...DEFAULT_CONFIG, + ...config, + }; + this.queue = new PQueue({ concurrency: this.config.concurrency }); + } + + /** + * Report progress + */ + private reportProgress(progress: PrefillProgress) { + if (this.config.onProgress) { + this.config.onProgress(progress); + } + } + + /** + * Prefill artists + */ + async prefillArtists(): Promise { + const entityType = EntityType.ARTISTS; + let offset = await getSyncCursor(this.sourceId, entityType); + let hasMore = true; + let totalFetched = 0; + + while (hasMore) { + try { + const artists = await this.driver.getArtists({ + offset, + limit: this.config.pageSize, + }); + + if (artists.length === 0) { + hasMore = false; + break; + } + + // Insert artists into database + const now = Date.now(); + await db.insert(syncCursors).values( + artists.map(artist => ({ + sourceId: this.sourceId, + id: artist.id, + name: artist.name, + isFolder: artist.isFolder, + metadataJson: JSON.stringify(artist), + createdAt: now, + updatedAt: now, + })) + ).onConflictDoUpdate({ + target: [syncCursors.sourceId, syncCursors.id], + set: { + name: artists[0].name, + isFolder: artists[0].isFolder, + metadataJson: JSON.stringify(artists[0]), + updatedAt: now, + }, + }); + + totalFetched += artists.length; + offset += artists.length; + + await updateSyncCursor(this.sourceId, entityType, offset); + + this.reportProgress({ + entityType, + totalFetched, + hasMore: artists.length === this.config.pageSize, + }); + + hasMore = artists.length === this.config.pageSize; + } catch (error) { + this.reportProgress({ + entityType, + totalFetched, + hasMore: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } + } + + /** + * Prefill albums + */ + async prefillAlbums(): Promise { + const entityType = EntityType.ALBUMS; + let offset = await getSyncCursor(this.sourceId, entityType); + let hasMore = true; + let totalFetched = 0; + + while (hasMore) { + try { + const albums = await this.driver.getAlbums({ + offset, + limit: this.config.pageSize, + }); + + if (albums.length === 0) { + hasMore = false; + break; + } + + // Albums will be inserted by upsert logic + // This is a placeholder - actual implementation would use proper upsert + + totalFetched += albums.length; + offset += albums.length; + + await updateSyncCursor(this.sourceId, entityType, offset); + + this.reportProgress({ + entityType, + totalFetched, + hasMore: albums.length === this.config.pageSize, + }); + + hasMore = albums.length === this.config.pageSize; + } catch (error) { + this.reportProgress({ + entityType, + totalFetched, + hasMore: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } + } + + /** + * Prefill playlists + */ + async prefillPlaylists(): Promise { + const entityType = EntityType.PLAYLISTS; + let offset = await getSyncCursor(this.sourceId, entityType); + let hasMore = true; + let totalFetched = 0; + + while (hasMore) { + try { + const playlists = await this.driver.getPlaylists({ + offset, + limit: this.config.pageSize, + }); + + if (playlists.length === 0) { + hasMore = false; + break; + } + + // Playlists will be inserted by upsert logic + // This is a placeholder - actual implementation would use proper upsert + + totalFetched += playlists.length; + offset += playlists.length; + + await updateSyncCursor(this.sourceId, entityType, offset); + + this.reportProgress({ + entityType, + totalFetched, + hasMore: playlists.length === this.config.pageSize, + }); + + hasMore = playlists.length === this.config.pageSize; + } catch (error) { + this.reportProgress({ + entityType, + totalFetched, + hasMore: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } + } + + /** + * Run complete prefill + */ + async runPrefill(): Promise { + await this.queue.add(() => this.prefillArtists()); + await this.queue.add(() => this.prefillAlbums()); + await this.queue.add(() => this.prefillPlaylists()); + + await this.queue.onIdle(); + } + + /** + * Get queue size + */ + getQueueSize(): number { + return this.queue.size; + } + + /** + * Get pending tasks + */ + getPending(): number { + return this.queue.pending; + } +} + +/** + * Helper function to run prefill + */ +export async function runPrefill( + sourceId: string, + driver: SourceDriver, + config?: PrefillConfig +): Promise { + const orchestrator = new PrefillOrchestrator(sourceId, driver, config); + await orchestrator.runPrefill(); +} diff --git a/src/store/prefill/task-graph.ts b/src/store/prefill/task-graph.ts new file mode 100644 index 0000000..3f4ed3f --- /dev/null +++ b/src/store/prefill/task-graph.ts @@ -0,0 +1,297 @@ +/** + * Prefill Task Graph + * + * Manages dependent prefill tasks that require parent entities to exist first. + * For example, album tracks require albums to be fetched first. + */ + +import PQueue from 'p-queue'; +import { db } from '../db/index'; +import type { SourceDriver } from '../sources/types'; +import { EntityType, type PrefillProgressCallback } from './orchestrator'; + +/** + * Task graph configuration + */ +export interface TaskGraphConfig { + /** Maximum number of concurrent tasks */ + concurrency?: number; + /** Progress callback */ + onProgress?: PrefillProgressCallback; +} + +/** + * Default configuration + */ +const DEFAULT_CONFIG: Required> = { + concurrency: 5, +}; + +/** + * Task graph for dependent prefill operations + */ +export class PrefillTaskGraph { + private queue: PQueue; + private config: Required> & Pick; + private sourceId: string; + private driver: SourceDriver; + + constructor(sourceId: string, driver: SourceDriver, config: TaskGraphConfig = {}) { + this.sourceId = sourceId; + this.driver = driver; + this.config = { + ...DEFAULT_CONFIG, + ...config, + }; + this.queue = new PQueue({ concurrency: this.config.concurrency }); + } + + /** + * Report progress + */ + private reportProgress(entityType: EntityType, totalFetched: number, hasMore: boolean, error?: Error) { + if (this.config.onProgress) { + this.config.onProgress({ + entityType, + totalFetched, + hasMore, + error, + }); + } + } + + /** + * Prefill album tracks for all albums + * This should run after albums are prefilled + */ + async prefillAlbumTracks(): Promise { + const entityType = EntityType.ALBUM_TRACKS; + let totalFetched = 0; + + try { + // Get all albums that need tracks fetched + const albums = await db.query.albums.findMany({ + where: (albums, { eq }) => eq(albums.sourceId, this.sourceId), + }); + + // Queue up tasks to fetch tracks for each album + const tasks = albums.map(album => + () => this.fetchAlbumTracks(album.id) + ); + + await Promise.all(tasks.map(task => this.queue.add(task))); + + this.reportProgress(entityType, totalFetched, false); + } catch (error) { + this.reportProgress( + entityType, + totalFetched, + false, + error instanceof Error ? error : new Error(String(error)) + ); + throw error; + } + } + + /** + * Fetch tracks for a single album + */ + private async fetchAlbumTracks(albumId: string): Promise { + let offset = 0; + const limit = 500; + let hasMore = true; + + while (hasMore) { + const tracks = await this.driver.getTracksByAlbum(albumId, { offset, limit }); + + if (tracks.length === 0) { + break; + } + + // Insert tracks into database + // This is a placeholder - actual implementation would use proper upsert + + offset += tracks.length; + hasMore = tracks.length === limit; + } + } + + /** + * Prefill playlist tracks for all playlists + * This should run after playlists are prefilled + */ + async prefillPlaylistTracks(): Promise { + const entityType = EntityType.PLAYLIST_TRACKS; + let totalFetched = 0; + + try { + // Get all playlists that need tracks fetched + const playlists = await db.query.playlists.findMany({ + where: (playlists, { eq }) => eq(playlists.sourceId, this.sourceId), + }); + + // Queue up tasks to fetch tracks for each playlist + const tasks = playlists.map(playlist => + () => this.fetchPlaylistTracks(playlist.id) + ); + + await Promise.all(tasks.map(task => this.queue.add(task))); + + this.reportProgress(entityType, totalFetched, false); + } catch (error) { + this.reportProgress( + entityType, + totalFetched, + false, + error instanceof Error ? error : new Error(String(error)) + ); + throw error; + } + } + + /** + * Fetch tracks for a single playlist + */ + private async fetchPlaylistTracks(playlistId: string): Promise { + let offset = 0; + const limit = 500; + let hasMore = true; + + while (hasMore) { + const tracks = await this.driver.getTracksByPlaylist(playlistId, { offset, limit }); + + if (tracks.length === 0) { + break; + } + + // Insert tracks into database + // This is a placeholder - actual implementation would use proper upsert + + offset += tracks.length; + hasMore = tracks.length === limit; + } + } + + /** + * Prefill similar albums for all albums + * This is optional and can fail gracefully + */ + async prefillSimilarAlbums(): Promise { + const entityType = EntityType.SIMILAR_ALBUMS; + let totalFetched = 0; + + try { + // Get all albums + const albums = await db.query.albums.findMany({ + where: (albums, { eq }) => eq(albums.sourceId, this.sourceId), + limit: 100, // Limit to avoid too many requests + }); + + // Queue up tasks to fetch similar albums + const tasks = albums.map(album => + () => this.fetchSimilarAlbums(album.id).catch(() => { + // Silently fail for similar albums + }) + ); + + await Promise.all(tasks.map(task => this.queue.add(task))); + + this.reportProgress(entityType, totalFetched, false); + } catch (error) { + // Similar albums are optional, so just report but don't throw + this.reportProgress( + entityType, + totalFetched, + false, + error instanceof Error ? error : new Error(String(error)) + ); + } + } + + /** + * Fetch similar albums for a single album + */ + private async fetchSimilarAlbums(albumId: string): Promise { + const similarAlbums = await this.driver.getSimilarAlbums(albumId, { limit: 20 }); + + // Insert similar albums into database + // This is a placeholder - actual implementation would use proper upsert + } + + /** + * Prefill lyrics for tracks + * This is optional and can fail gracefully + */ + async prefillLyrics(): Promise { + const entityType = EntityType.LYRICS; + let totalFetched = 0; + + try { + // Get tracks that might have lyrics + const tracks = await db.query.tracks.findMany({ + where: (tracks, { eq }) => eq(tracks.sourceId, this.sourceId), + limit: 100, // Limit to avoid too many requests + }); + + // Queue up tasks to fetch lyrics + const tasks = tracks.map(track => + () => this.fetchLyrics(track.id).catch(() => { + // Silently fail for lyrics + }) + ); + + await Promise.all(tasks.map(task => this.queue.add(task))); + + this.reportProgress(entityType, totalFetched, false); + } catch (error) { + // Lyrics are optional, so just report but don't throw + this.reportProgress( + entityType, + totalFetched, + false, + error instanceof Error ? error : new Error(String(error)) + ); + } + } + + /** + * Fetch lyrics for a single track + */ + private async fetchLyrics(trackId: string): Promise { + const lyrics = await this.driver.getTrackLyrics(trackId); + + if (lyrics) { + // Update track with lyrics + // This is a placeholder - actual implementation would update the track + } + } + + /** + * Run all dependent tasks + */ + async runAllTasks(): Promise { + // Run critical tasks first + await this.prefillAlbumTracks(); + await this.prefillPlaylistTracks(); + + // Run optional tasks (can fail) + await Promise.allSettled([ + this.prefillSimilarAlbums(), + this.prefillLyrics(), + ]); + + await this.queue.onIdle(); + } +} + +/** + * Helper function to run task graph + */ +export async function runTaskGraph( + sourceId: string, + driver: SourceDriver, + config?: TaskGraphConfig +): Promise { + const taskGraph = new PrefillTaskGraph(sourceId, driver, config); + await taskGraph.runAllTasks(); +} diff --git a/src/store/sources/emby/api-types.ts b/src/store/sources/emby/api-types.ts new file mode 100644 index 0000000..07a085e --- /dev/null +++ b/src/store/sources/emby/api-types.ts @@ -0,0 +1,72 @@ +/** + * Emby API Response Types + * + * Types for data coming from the Emby API. + * These use PascalCase to match the API responses. + */ + +/** + * Base item from Emby API + */ +export interface EmbyBaseItem { + Id: string; + Name: string; + ServerId?: string; + [key: string]: unknown; +} + +/** + * Artist from Emby API + */ +export interface EmbyArtist extends EmbyBaseItem { + IsFolder: boolean; +} + +/** + * Album from Emby API + */ +export interface EmbyAlbum extends EmbyBaseItem { + ProductionYear?: number; + IsFolder: boolean; + AlbumArtist?: string; + DateCreated?: string; + ArtistItems?: EmbyArtist[]; +} + +/** + * Track from Emby API + */ +export interface EmbyTrack extends EmbyBaseItem { + AlbumId?: string; + Album?: string; + AlbumArtist?: string; + ProductionYear?: number; + IndexNumber?: number; + ParentIndexNumber?: number; + RunTimeTicks?: number; + ArtistItems?: EmbyArtist[]; +} + +/** + * Playlist from Emby API + */ +export interface EmbyPlaylist extends EmbyBaseItem { + CanDelete: boolean; + ChildCount?: number; +} + +/** + * Items response wrapper + */ +export interface EmbyItemsResponse { + Items: T[]; + TotalRecordCount: number; + StartIndex: number; +} + +/** + * Search result from Emby API + */ +export interface EmbySearchResult extends EmbyBaseItem { + Type: string; +} diff --git a/src/store/sources/emby/types.ts b/src/store/sources/emby/types.ts index 5e43fa9..24ef9b1 100644 --- a/src/store/sources/emby/types.ts +++ b/src/store/sources/emby/types.ts @@ -29,4 +29,5 @@ export { SourceDriver, } from '../types'; -// Emby-specific types can be added here as needed +// Export Emby API types +export * from './api-types'; diff --git a/src/store/sources/jellyfin/api-types.ts b/src/store/sources/jellyfin/api-types.ts new file mode 100644 index 0000000..91b03cf --- /dev/null +++ b/src/store/sources/jellyfin/api-types.ts @@ -0,0 +1,72 @@ +/** + * Jellyfin API Response Types + * + * Types for data coming from the Jellyfin API. + * These use PascalCase to match the API responses. + */ + +/** + * Base item from Jellyfin API + */ +export interface JellyfinBaseItem { + Id: string; + Name: string; + ServerId?: string; + [key: string]: unknown; +} + +/** + * Artist from Jellyfin API + */ +export interface JellyfinArtist extends JellyfinBaseItem { + IsFolder: boolean; +} + +/** + * Album from Jellyfin API + */ +export interface JellyfinAlbum extends JellyfinBaseItem { + ProductionYear?: number; + IsFolder: boolean; + AlbumArtist?: string; + DateCreated?: string; + ArtistItems?: JellyfinArtist[]; +} + +/** + * Track from Jellyfin API + */ +export interface JellyfinTrack extends JellyfinBaseItem { + AlbumId?: string; + Album?: string; + AlbumArtist?: string; + ProductionYear?: number; + IndexNumber?: number; + ParentIndexNumber?: number; + RunTimeTicks?: number; + ArtistItems?: JellyfinArtist[]; +} + +/** + * Playlist from Jellyfin API + */ +export interface JellyfinPlaylist extends JellyfinBaseItem { + CanDelete: boolean; + ChildCount?: number; +} + +/** + * Items response wrapper + */ +export interface JellyfinItemsResponse { + Items: T[]; + TotalRecordCount: number; + StartIndex: number; +} + +/** + * Search result from Jellyfin API + */ +export interface JellyfinSearchResult extends JellyfinBaseItem { + Type: string; +} diff --git a/src/store/sources/jellyfin/types.ts b/src/store/sources/jellyfin/types.ts index 4598614..64a49ce 100644 --- a/src/store/sources/jellyfin/types.ts +++ b/src/store/sources/jellyfin/types.ts @@ -29,4 +29,5 @@ export { SourceDriver, } from '../types'; -// Jellyfin-specific types can be added here as needed +// Export Jellyfin API types +export * from './api-types'; diff --git a/src/store/sources/types.ts b/src/store/sources/types.ts index 905b074..b6ffa96 100644 --- a/src/store/sources/types.ts +++ b/src/store/sources/types.ts @@ -1,150 +1,134 @@ /** * Shared Source Driver Types * - * Defines common types and the base abstract class for source drivers + * Defines common types and the base abstract class for source drivers. + * Driver methods return types compatible with the database schema. */ +import type { Artist as SchemaArtist, Album as SchemaAlbum, Track as SchemaTrack, Playlist as SchemaPlaylist } from '../db/types'; + /** * Source types enum */ export enum SourceType { - JELLYFIN_V1 = 'jellyfin.v1', - EMBY_V1 = 'emby.v1', + JELLYFIN_V1 = 'jellyfin.v1', + EMBY_V1 = 'emby.v1', } /** * Source information */ export interface Source { - id: string; - uri: string; - userId?: string; - accessToken?: string; - deviceId?: string; - type: SourceType; + id: string; + uri: string; + userId?: string; + accessToken?: string; + deviceId?: string; + type: SourceType; } /** * Source info returned during connection */ export interface SourceInfo { - id: string; - name: string; - version: string; - operatingSystem?: string; + id: string; + name: string; + version: string; + operatingSystem?: string; } /** * Credentials */ export interface Credentials { - accessToken: string; - userId: string; + accessToken: string; + userId: string; } /** * List parameters for paging */ export interface ListParams { - offset?: number; // Start index - limit?: number; // Page size (default: 500) + offset?: number; // Start index + limit?: number; // Page size (default: 500) } /** - * Artist entity + * Artist entity returned from drivers + * Compatible with schema but without sourceId, timestamps */ -export interface Artist { - id: string; - name: string; - isFolder: boolean; - [key: string]: unknown; // Additional metadata -} +export type Artist = Omit; /** - * Album entity + * Album entity returned from drivers + * Compatible with schema but without sourceId, timestamps + * Includes temporary artistItems field for relationship data */ -export interface Album { - id: string; - name: string; - productionYear?: number; - isFolder: boolean; - albumArtist?: string; - dateCreated?: number; - artistItems?: Artist[]; - [key: string]: unknown; // Additional metadata -} +export type Album = Omit & { + artistItems?: Artist[]; +}; /** - * Track entity + * Track entity returned from drivers + * Compatible with schema but without sourceId, timestamps + * Includes temporary artistItems field for relationship data */ -export interface Track { - id: string; - name: string; - albumId?: string; - album?: string; - albumArtist?: string; - productionYear?: number; - indexNumber?: number; - parentIndexNumber?: number; - runTimeTicks?: number; - artistItems?: Artist[]; - [key: string]: unknown; // Additional metadata -} +export type Track = Omit & { + artistItems?: Artist[]; +}; /** - * Playlist entity + * Playlist entity returned from drivers + * Compatible with schema but without sourceId, timestamps */ -export interface Playlist { - id: string; - name: string; - canDelete: boolean; - childCount?: number; - [key: string]: unknown; // Additional metadata -} +export type Playlist = Omit & { + canDelete: boolean; + childCount?: number; +}; /** * Search filter types */ export enum SearchFilterType { - ALBUMS = 'albums', - ARTISTS = 'artists', - TRACKS = 'tracks', - PLAYLISTS = 'playlists', + ALBUMS = 'albums', + ARTISTS = 'artists', + TRACKS = 'tracks', + PLAYLISTS = 'playlists', } /** * Search filter */ export interface SearchFilter { - type: SearchFilterType; + type: SearchFilterType; } /** * Search result item */ export interface SearchResultItem { - id: string; - name: string; - type: SearchFilterType; - [key: string]: unknown; + id: string; + name: string; + type: SearchFilterType; + [key: string]: unknown; } /** * Codec metadata */ export interface CodecMetadata { - codec?: string; - bitrate?: number; - sampleRate?: number; - channels?: number; - bitDepth?: number; + codec?: string; + bitrate?: number; + sampleRate?: number; + channels?: number; + bitDepth?: number; } /** * Lyrics */ export interface Lyrics { - lyrics: string; + lyrics: string; } /**