mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-03 05:10:01 +03:00
Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
10 KiB
10 KiB
Type Architecture and Infrastructure
Overview
This document explains the reorganized type architecture and restored infrastructure for the Jellyfin audio player.
Type Flow
┌─────────────────────────────────────────────────────────────────┐
│ API Response (PascalCase) │
│ JellyfinAlbum { Id, Name, ArtistItems: [{ Id, Name }] } │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Driver Transformation │
│ Transform PascalCase → camelCase + add metadataJson │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Schema-Compatible Type (camelCase) │
│ Album { id, name, metadataJson, artistItems: Artist[] } │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Database Insert │
│ db.insert(albums).values({ ...album, sourceId, timestamps }) │
└─────────────────────────────────────────────────────────────────┘
Directory Structure
src/store/
├── db/
│ ├── index.ts # Database client (singleton export)
│ ├── types.ts # Schema types (derived from Drizzle)
│ ├── live-queries.ts # React hooks for reactive queries
│ ├── schema/
│ │ ├── artists.ts # Artist table definition
│ │ ├── albums.ts # Album table definition
│ │ ├── tracks.ts # Track table definition
│ │ └── ... # Other tables
│ └── migrations/
│ └── migrations.js # Drizzle-generated migrations
│
├── sources/
│ ├── types.ts # Base types using schema types
│ ├── jellyfin/
│ │ ├── driver.ts # Jellyfin implementation
│ │ ├── types.ts # Re-exports + Jellyfin types
│ │ └── api-types.ts # Jellyfin API response types
│ └── emby/
│ ├── driver.ts # Emby implementation
│ ├── types.ts # Re-exports + Emby types
│ └── api-types.ts # Emby API response types
│
└── prefill/
├── orchestrator.ts # Main prefill orchestration
└── task-graph.ts # Dependent task execution
Type Definitions
1. Schema Types (db/types.ts)
These are derived from the Drizzle schema using InferSelectModel:
import type { InferSelectModel } from 'drizzle-orm';
import { albums } from './schema/albums';
export type Album = InferSelectModel<typeof albums>;
// Result: { id, sourceId, name, productionYear, isFolder, ..., createdAt, updatedAt }
export type InsertAlbum = typeof albums.$inferInsert;
// Used for inserts
2. API Response Types (sources/*/api-types.ts)
API types match the external API responses (PascalCase):
// jellyfin/api-types.ts
export interface JellyfinAlbum {
Id: string;
Name: string;
ProductionYear?: number;
IsFolder: boolean;
AlbumArtist?: string;
DateCreated?: string;
ArtistItems?: JellyfinArtist[];
}
export interface JellyfinItemsResponse<T> {
Items: T[];
TotalRecordCount: number;
StartIndex: number;
}
3. Source Driver Types (sources/types.ts)
Driver return types are schema-compatible but exclude fields added at insert time:
// Based on schema but without sourceId, timestamps
export type Album = Omit<SchemaAlbum, 'sourceId' | 'createdAt' | 'updatedAt' | 'lastRefreshed'> & {
artistItems?: Artist[]; // Temporary field for relationships
};
// Driver methods return these types:
abstract class SourceDriver {
abstract getAlbums(params?: ListParams): Promise<Album[]>;
}
Driver Implementation Pattern
Transformation Example
async getAlbums(params?: ListParams): Promise<Album[]> {
// 1. Fetch from API using API types
const response = await this.fetch<JellyfinItemsResponse<JellyfinAlbum>>(url);
// 2. Transform to schema-compatible format
return response.Items.map(item => ({
// Map PascalCase → camelCase
id: item.Id,
name: item.Name,
productionYear: item.ProductionYear,
isFolder: item.IsFolder || false,
albumArtist: item.AlbumArtist,
dateCreated: item.DateCreated ? new Date(item.DateCreated).getTime() : undefined,
// Store full API response as JSON
metadataJson: JSON.stringify(item),
// Transform nested relationships
artistItems: item.ArtistItems?.map(artist => ({
id: artist.Id,
name: artist.Name,
isFolder: artist.IsFolder,
metadataJson: JSON.stringify(artist),
})) || [],
}));
}
Database Insert Example
const albums = await driver.getAlbums();
const now = Date.now();
await db.insert(albumsTable).values(
albums.map(album => ({
...album,
sourceId: 'source-123', // Add sourceId
createdAt: now, // Add timestamps
updatedAt: now,
}))
).onConflictDoUpdate({
target: [albumsTable.sourceId, albumsTable.id],
set: {
name: albums[0].name,
// ... other fields
updatedAt: now,
},
});
Prefill Infrastructure
Orchestrator (prefill/orchestrator.ts)
Manages basic entity prefill with:
- p-queue for bounded concurrency (max 5 concurrent requests)
- Cursor-based resume via
sync_cursorstable - Progress callbacks for UI updates
const orchestrator = new PrefillOrchestrator(sourceId, driver, {
concurrency: 5,
pageSize: 500,
onProgress: (progress) => {
console.log(`${progress.entityType}: ${progress.totalFetched} items`);
},
});
await orchestrator.runPrefill();
Task Graph (prefill/task-graph.ts)
Handles dependent tasks that require parent entities:
- Album tracks (requires albums)
- Playlist tracks (requires playlists)
- Similar albums (optional)
- Lyrics (optional)
const taskGraph = new PrefillTaskGraph(sourceId, driver, {
concurrency: 5,
onProgress: callback,
});
await taskGraph.runAllTasks();
Live Queries (db/live-queries.ts)
React hooks for reactive database queries:
// Hook that re-renders when albums table changes
const albums = useLiveQuery<Album>(
'SELECT * FROM albums WHERE source_id = ? ORDER BY name',
[sourceId],
['albums'] // Tables to watch
);
// Invalidate manually after inserts
await db.insert(albums).values(newAlbums);
invalidateTable('albums');
Benefits
Type Safety
- ✅ Compile-time errors if schema changes
- ✅ No confusion between API and internal types
- ✅ IntelliSense support throughout
Maintainability
- ✅ API changes isolated to api-types.ts
- ✅ Schema changes propagate automatically
- ✅ Clear separation of concerns
Flexibility
- ✅ Full API response preserved in
metadataJson - ✅ Can add computed fields at query time
- ✅ Easy to add new drivers
Migration Path
When adding a new entity type:
- Define schema in
db/schema/new-entity.ts - Add to db/types.ts exports
- Update sources/types.ts with driver return type
- Add API type to
api-types.ts(PascalCase) - Implement driver method with transformation
- Add to orchestrator if needed for prefill
Example: Adding a New Entity
// 1. Schema (db/schema/genres.ts)
export const genres = sqliteTable('genres', {
sourceId: text('source_id').notNull(),
id: text('id').primaryKey(),
name: text('name').notNull(),
metadataJson: text('metadata_json'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
// 2. Export type (db/types.ts)
export type Genre = InferSelectModel<typeof genres>;
// 3. Driver type (sources/types.ts)
export type Genre = Omit<SchemaGenre, 'sourceId' | 'createdAt' | 'updatedAt'>;
// 4. API type (jellyfin/api-types.ts)
export interface JellyfinGenre {
Id: string;
Name: string;
}
// 5. Driver method (jellyfin/driver.ts)
async getGenres(): Promise<Genre[]> {
const response = await this.fetch<JellyfinItemsResponse<JellyfinGenre>>('/Genres');
return response.Items.map(item => ({
id: item.Id,
name: item.Name,
metadataJson: JSON.stringify(item),
}));
}
Notes
metadataJsonfield stores the complete API response for future extensibilityartistItemsis a temporary field on Album/Track types for relationship data- Actual artist relationships are stored in separate junction tables
- Schema types are the source of truth for database structure
- API types document the external contracts