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:
copilot-swe-agent[bot]
2026-02-08 14:08:31 +00:00
co-authored by leinelissen
parent ccdf8aad4f
commit 2f0675914c
9 changed files with 1041 additions and 74 deletions
+146
View File
@@ -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);
}, []);
}
+58
View File
@@ -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;
+336
View File
@@ -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();
}
+297
View File
@@ -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();
}
+72
View File
@@ -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;
}
+2 -1
View File
@@ -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';
+72
View File
@@ -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;
}
+2 -1
View File
@@ -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
View File
@@ -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;
}
/**