fix: introduce entityid

This commit is contained in:
Lei Nelissen
2026-03-01 10:46:49 +01:00
parent 343f8f321f
commit 8b7a680e0a
13 changed files with 67 additions and 162 deletions
+3 -3
View File
@@ -1,5 +1,6 @@
import { db, sqliteDb } from '@/store';
import albumArtists from './entity';
import type { EntityId } from '@/store/types';
import type { Artist } from '../sources/types';
/**
@@ -7,8 +8,7 @@ import type { Artist } from '../sources/types';
* Preserves order from the source by storing orderIndex.
*/
export async function upsertAlbumArtists(
sourceId: string,
albumId: string,
[sourceId, albumId]: EntityId,
artistItems: Pick<Artist, 'id'>[],
): Promise<void> {
if (artistItems.length === 0) return;
@@ -26,4 +26,4 @@ export async function upsertAlbumArtists(
}
sqliteDb.flushPendingReactiveQueries();
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { db, sqliteDb } from '@/store';
import albumSimilar from './entity';
import type { EntityId } from '@/store/types';
export async function upsertAlbumSimilar(
sourceId: string,
albumId: string,
[sourceId, albumId]: EntityId,
similarAlbumIds: string[],
): Promise<void> {
if (similarAlbumIds.length === 0) return;
@@ -17,4 +17,4 @@ export async function upsertAlbumSimilar(
}
sqliteDb.flushPendingReactiveQueries();
}
}
+6 -5
View File
@@ -4,7 +4,8 @@
import { db, sqliteDb } from '@/store';
import albums from './entity';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import type { EntityId } from '@/store/types';
import type { InsertAlbum } from './types';
import { getAllSourceDrivers } from '../sources/actions';
@@ -46,8 +47,8 @@ export async function upsertAlbums(albumList: UpsertAlbum[]): Promise<void> {
}
}
export async function deleteAlbum(id: string): Promise<void> {
await db.delete(albums).where(eq(albums.id, id));
export async function deleteAlbum([sourceId, id]: EntityId): Promise<void> {
await db.delete(albums).where(and(eq(albums.sourceId, sourceId), eq(albums.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
@@ -58,5 +59,5 @@ export async function deleteAlbumsBySource(sourceId: string): Promise<void> {
export async function refreshAlbums(): Promise<void> {
// TODO: implement per-driver refresh logic
await getAllSourceDrivers();
}
(await getAllSourceDrivers()).forEach((driver) => driver.getAlbums());
}
+4 -3
View File
@@ -4,8 +4,9 @@
import { db, sqliteDb } from '@/store';
import artists from './entity';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import type { InsertArtist } from './types';
import type { EntityId } from '@/store/types';
/**
* createdAt and updatedAt are optional — they reflect server-reported dates and
@@ -43,8 +44,8 @@ export async function upsertArtists(artistList: UpsertArtist[]): Promise<void> {
}
}
export async function deleteArtist(id: string): Promise<void> {
await db.delete(artists).where(eq(artists.id, id));
export async function deleteArtist([sourceId, id]: EntityId): Promise<void> {
await db.delete(artists).where(and(eq(artists.sourceId, sourceId), eq(artists.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
+18 -17
View File
@@ -1,32 +1,32 @@
import { db, sqliteDb } from '@/store';
import downloads from './entity';
import type { Download } from './types';
import { eq } from 'drizzle-orm';
import type { EntityId } from '@/store/types';
import { and, eq } from 'drizzle-orm';
export async function getAllDownloads(): Promise<Download[]> {
const result = await db.select().from(downloads);
return result as Download[];
}
export async function getDownload(id: string): Promise<Download | undefined> {
export async function getDownload([sourceId, id]: EntityId): Promise<Download | undefined> {
const result = await db
.select()
.from(downloads)
.where(eq(downloads.id, id))
.where(and(eq(downloads.sourceId, sourceId), eq(downloads.id, id)))
.limit(1);
return result[0] as Download | undefined;
}
export async function initializeDownload(
sourceId: string,
id: string,
[sourceId, id]: EntityId,
hash?: string,
filename?: string,
mimetype?: string
): Promise<void> {
const now = Date.now();
await db.insert(downloads).values({
sourceId,
id,
@@ -56,7 +56,7 @@ export async function initializeDownload(
}
export async function updateDownloadProgress(
id: string,
[sourceId, id]: EntityId,
progress: number
): Promise<void> {
await db.update(downloads)
@@ -64,16 +64,16 @@ export async function updateDownloadProgress(
progress,
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
.where(and(eq(downloads.sourceId, sourceId), eq(downloads.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
export async function completeDownload(
id: string,
[sourceId, id]: EntityId,
filename?: string
): Promise<void> {
const updates: any = {
const updates: Partial<typeof downloads.$inferInsert> = {
isComplete: true,
isFailed: false,
progress: 1,
@@ -86,12 +86,12 @@ export async function completeDownload(
await db.update(downloads)
.set(updates)
.where(eq(downloads.id, id));
.where(and(eq(downloads.sourceId, sourceId), eq(downloads.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
export async function failDownload(id: string): Promise<void> {
export async function failDownload([sourceId, id]: EntityId): Promise<void> {
await db.update(downloads)
.set({
isFailed: true,
@@ -99,12 +99,13 @@ export async function failDownload(id: string): Promise<void> {
progress: 0,
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
.where(and(eq(downloads.sourceId, sourceId), eq(downloads.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
export async function removeDownload(id: string): Promise<void> {
await db.delete(downloads).where(eq(downloads.id, id));
export async function removeDownload([sourceId, id]: EntityId): Promise<void> {
await db.delete(downloads)
.where(and(eq(downloads.sourceId, sourceId), eq(downloads.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ const downloads = sqliteTable('downloads', {
isFailed: integer('is_failed', { mode: 'boolean' }).notNull(),
isComplete: integer('is_complete', { mode: 'boolean' }).notNull(),
metadata: text('metadata'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now()).$onUpdateFn(() => Date.now()),
});
export default downloads;
-113
View File
@@ -1,113 +0,0 @@
import { DocumentDirectoryPath, downloadFile, exists, unlink } from 'react-native-fs';
import { db } from '@/store';
import sources from '@/store/sources/entity';
import downloads from '@/store/downloads/entity';
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
import { initializeDownload, updateDownloadProgress, completeDownload, failDownload, removeDownload } from './actions';
import { eq } from 'drizzle-orm';
async function getDriver(): Promise<{ driver: SourceDriver; source: Source } | null> {
const result = await db.select().from(sources).limit(1);
const row = result[0];
if (!row) {
return null;
}
const source: Source = {
id: row.id,
uri: row.uri,
userId: row.userId || undefined,
accessToken: row.accessToken || undefined,
deviceId: row.deviceId || undefined,
type: row.type as SourceType,
};
if (source.type.startsWith('jellyfin')) {
return { driver: new JellyfinDriver(source), source };
}
if (source.type.startsWith('emby')) {
return { driver: new EmbyDriver(source), source };
}
return null;
}
async function updateDownloadMetadata(id: string, updates: Record<string, unknown>): Promise<void> {
const existing = await db.select().from(downloads).where(eq(downloads.id, id)).limit(1);
const current = existing[0];
const currentMetadata = current?.metadata ? JSON.parse(current.metadata) : {};
await db.update(downloads)
.set({
metadata: JSON.stringify({ ...currentMetadata, ...updates }),
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
}
export async function queueTrackForDownload(trackId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
await initializeDownload(driverResult.source.id, trackId);
}
export async function downloadTrack(trackId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver } = driverResult;
try {
const info = await driver.getDownloadInfo(trackId);
const destination = `${DocumentDirectoryPath}/${info.filename}`;
await updateDownloadMetadata(trackId, { size: null, error: null });
const job = downloadFile({
fromUrl: info.url,
toFile: destination,
background: true,
progress: (event) => {
if (!event.contentLength) {
return;
}
const progress = event.bytesWritten / event.contentLength;
updateDownloadProgress(trackId, progress);
updateDownloadMetadata(trackId, { size: event.contentLength });
},
});
await job.promise;
await completeDownload(trackId, destination);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await updateDownloadMetadata(trackId, { error: message });
await failDownload(trackId);
}
}
export async function removeDownloadedTrack(trackId: string): Promise<void> {
const downloadRow = await db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1);
const entry = downloadRow[0];
if (entry?.filename) {
const filePath = entry.filename.startsWith('/')
? entry.filename
: `${DocumentDirectoryPath}/${entry.filename}`;
if (await exists(filePath)) {
await unlink(filePath);
}
}
await removeDownload(trackId);
}
+3 -3
View File
@@ -1,14 +1,14 @@
import { db, sqliteDb } from '@/store';
import { eq, and } from 'drizzle-orm';
import playlistTracks from './entity';
import type { EntityId } from '@/store/types';
/**
* Replaces all tracks for a playlist with the provided ordered list of track IDs.
* Deletes existing entries first to handle removals and reordering.
*/
export async function setPlaylistTracks(
sourceId: string,
playlistId: string,
[sourceId, playlistId]: EntityId,
trackIds: string[],
): Promise<void> {
await db.delete(playlistTracks).where(
@@ -30,4 +30,4 @@ export async function setPlaylistTracks(
}
sqliteDb.flushPendingReactiveQueries();
}
}
+4 -3
View File
@@ -4,8 +4,9 @@
import { db, sqliteDb } from '@/store';
import playlists from './entity';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import type { InsertPlaylist } from './types';
import type { EntityId } from '@/store/types';
/**
* createdAt and updatedAt are optional — they reflect server-provided timestamps
@@ -43,8 +44,8 @@ export async function upsertPlaylists(playlistList: UpsertPlaylist[]): Promise<v
}
}
export async function deletePlaylist(id: string): Promise<void> {
await db.delete(playlists).where(eq(playlists.id, id));
export async function deletePlaylist([sourceId, id]: EntityId): Promise<void> {
await db.delete(playlists).where(and(eq(playlists.sourceId, sourceId), eq(playlists.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
+4 -3
View File
@@ -4,8 +4,9 @@
import { db, sqliteDb } from '@/store';
import searchQueries from './entity';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import type { InsertSearchQuery } from './types';
import type { EntityId } from '@/store/types';
export async function upsertSearchQuery(query: InsertSearchQuery): Promise<void> {
const now = Date.now();
@@ -25,8 +26,8 @@ export async function upsertSearchQuery(query: InsertSearchQuery): Promise<void>
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteSearchQuery(id: string): Promise<void> {
await db.delete(searchQueries).where(eq(searchQueries.id, id));
export async function deleteSearchQuery([sourceId, id]: EntityId): Promise<void> {
await db.delete(searchQueries).where(and(eq(searchQueries.sourceId, sourceId), eq(searchQueries.id, id)));
sqliteDb.flushPendingReactiveQueries();
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { db, sqliteDb } from '@/store';
import trackArtists from './entity';
import type { EntityId } from '@/store/types';
export async function upsertTrackArtists(
sourceId: string,
trackId: string,
[sourceId, trackId]: EntityId,
artistItems: { id: string }[],
): Promise<void> {
if (artistItems.length === 0) return;
@@ -21,4 +21,4 @@ export async function upsertTrackArtists(
}
sqliteDb.flushPendingReactiveQueries();
}
}
+5 -4
View File
@@ -1,6 +1,7 @@
import { db, sqliteDb } from '@/store';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import tracks from './entity';
import type { EntityId } from '@/store/types';
/**
* Update lyrics content for a track.
@@ -9,12 +10,12 @@ import tracks from './entity';
* last-modified date, not local operations. lastSyncedAt is bumped automatically
* by the schema $onUpdateFn.
*/
export async function updateTrackLyrics(trackId: string, lyrics: string | null): Promise<void> {
export async function updateTrackLyrics([sourceId, trackId]: EntityId, lyrics: string | null): Promise<void> {
await db.update(tracks)
.set({
lyrics,
})
.where(eq(tracks.id, trackId));
.where(and(eq(tracks.sourceId, sourceId), eq(tracks.id, trackId)));
sqliteDb.flushPendingReactiveQueries();
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* A tuple that uniquely identifies any entity stored in the local database.
*
* Because every entity is scoped to a source (Jellyfin / Emby server), both
* the source's own identifier and the entity's identifier are required together
* to unambiguously address a single row.
*
* Usage:
* const id: EntityId = [sourceId, itemId];
* const [sourceId, itemId] = id;
*/
export type EntityId = [sourceId: string, itemId: string];