mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
Restore orchestrator, live queries, and reorganize types with schema integration
Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
co-authored by
leinelissen
parent
ccdf8aad4f
commit
2f0675914c
@@ -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<string, Set<TableListener>>();
|
||||
|
||||
/**
|
||||
* 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<T = unknown>(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
tables: string[] = []
|
||||
): T[] | null {
|
||||
const [data, setData] = useState<T[] | null>(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<T = unknown>(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
tables: string[] = []
|
||||
): T | null {
|
||||
const results = useLiveQuery<T>(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);
|
||||
}, []);
|
||||
}
|
||||
@@ -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<typeof sources>;
|
||||
export type Artist = InferSelectModel<typeof artists>;
|
||||
export type Album = InferSelectModel<typeof albums>;
|
||||
export type Track = InferSelectModel<typeof tracks>;
|
||||
export type Playlist = InferSelectModel<typeof playlists>;
|
||||
export type Download = InferSelectModel<typeof downloads>;
|
||||
export type SearchQuery = InferSelectModel<typeof searchQueries>;
|
||||
export type AlbumArtist = InferSelectModel<typeof albumArtists>;
|
||||
export type TrackArtist = InferSelectModel<typeof trackArtists>;
|
||||
export type PlaylistTrack = InferSelectModel<typeof playlistTracks>;
|
||||
export type AlbumSimilar = InferSelectModel<typeof albumSimilar>;
|
||||
export type SyncCursor = InferSelectModel<typeof syncCursors>;
|
||||
export type AppSettings = InferSelectModel<typeof appSettings>;
|
||||
export type SleepTimer = InferSelectModel<typeof sleepTimer>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -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<Omit<PrefillConfig, 'onProgress'>> = {
|
||||
concurrency: 5,
|
||||
pageSize: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get sync cursor for resuming prefill
|
||||
*/
|
||||
async function getSyncCursor(
|
||||
sourceId: string,
|
||||
entityType: EntityType
|
||||
): Promise<number> {
|
||||
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<Omit<PrefillConfig, 'onProgress'>> & Pick<PrefillConfig, 'onProgress'>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const orchestrator = new PrefillOrchestrator(sourceId, driver, config);
|
||||
await orchestrator.runPrefill();
|
||||
}
|
||||
@@ -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<Omit<TaskGraphConfig, 'onProgress'>> = {
|
||||
concurrency: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Task graph for dependent prefill operations
|
||||
*/
|
||||
export class PrefillTaskGraph {
|
||||
private queue: PQueue;
|
||||
private config: Required<Omit<TaskGraphConfig, 'onProgress'>> & Pick<TaskGraphConfig, 'onProgress'>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
const taskGraph = new PrefillTaskGraph(sourceId, driver, config);
|
||||
await taskGraph.runAllTasks();
|
||||
}
|
||||
@@ -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<T> {
|
||||
Items: T[];
|
||||
TotalRecordCount: number;
|
||||
StartIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search result from Emby API
|
||||
*/
|
||||
export interface EmbySearchResult extends EmbyBaseItem {
|
||||
Type: string;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<T> {
|
||||
Items: T[];
|
||||
TotalRecordCount: number;
|
||||
StartIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search result from Jellyfin API
|
||||
*/
|
||||
export interface JellyfinSearchResult extends JellyfinBaseItem {
|
||||
Type: string;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+56
-72
@@ -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<SchemaArtist, 'sourceId' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* 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<SchemaAlbum, 'sourceId' | 'createdAt' | 'updatedAt' | 'lastRefreshed'> & {
|
||||
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<SchemaTrack, 'sourceId' | 'createdAt' | 'updatedAt' | 'hasLyrics' | 'lyrics'> & {
|
||||
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<SchemaPlaylist, 'sourceId' | 'createdAt' | 'updatedAt'> & {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user