Create new entity folder structure with hooks and actions

Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-08 20:35:54 +00:00
co-authored by leinelissen
parent fcb0dfbea1
commit 6a8443f4fd
33 changed files with 1041 additions and 403 deletions
+13 -20
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useDownload } from '@/store/downloads/hooks';
import CloudIcon from '@/assets/icons/cloud.svg';
import CloudDownArrow from '@/assets/icons/cloud-down-arrow.svg';
import CloudExclamationMarkIcon from '@/assets/icons/cloud-exclamation-mark.svg';
@@ -8,9 +7,12 @@ import useDefaultStyles from './Colors';
import Svg, { Circle, CircleProps } from 'react-native-svg';
import { Animated, Easing, ViewProps } from 'react-native';
import styled from 'styled-components/native';
import type { Track } from '@/store/tracks/types';
import type { Download } from '@/store/downloads/types';
interface DownloadIconProps {
trackId: string;
track: Track;
download?: Download | null;
size?: number;
fill?: string;
style?: ViewProps['style'];
@@ -27,35 +29,26 @@ const IconOverlay = styled.View`
transform: scale(0.5);
`;
function DownloadIcon({ trackId, size = 16, fill, style }: DownloadIconProps) {
// determine styles
function DownloadIcon({ track, download, size = 16, fill, style }: DownloadIconProps) {
const defaultStyles = useDefaultStyles();
const iconFill = fill || defaultStyles.textQuarterOpacity.color;
// Get download icon from database
const { entity } = useDownload(trackId);
const isQueued = entity && !entity.isComplete && !entity.isFailed;
// Memoize calculations for radius and circumference of the circle
const isQueued = download && !download.isComplete && !download.isFailed;
const radius = useMemo(() => size / 2, [size]);
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
// Initialize refs for the circle and the animated value
const circleRef = useRef<Circle>(null);
const offsetAnimation = useRef(new Animated.Value(entity?.progress || 0)).current;
const offsetAnimation = useRef(new Animated.Value(download?.progress || 0)).current;
// Whenever the progress changes, trigger the animation
useEffect(() => {
Animated.timing(offsetAnimation, {
toValue: (circumference * (1 - (entity?.progress || 0))),
toValue: (circumference * (1 - (download?.progress || 0))),
duration: 250,
useNativeDriver: false,
easing: Easing.ease,
}).start();
}, [entity?.progress, offsetAnimation, circumference]);
}, [download?.progress, offsetAnimation, circumference]);
// On mount, subscribe to changes in the animation value and then
// apply them to the circle using native props
useEffect(() => {
const subscription = offsetAnimation.addListener((offset) => {
const setNativeProps = circleRef.current?.setNativeProps as (props: CircleProps) => void | undefined;
@@ -65,25 +58,25 @@ function DownloadIcon({ trackId, size = 16, fill, style }: DownloadIconProps) {
return () => offsetAnimation.removeListener(subscription);
}, [offsetAnimation]);
if (!entity && !isQueued) {
if (!download && !isQueued) {
return (
<CloudIcon width={size} height={size} fill={iconFill} style={style} />
);
}
if (entity?.isComplete) {
if (download?.isComplete) {
return (
<InternalDriveIcon width={size} height={size} fill={iconFill} style={style} />
);
}
if (entity?.isFailed) {
if (download?.isFailed) {
return (
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} style={style} />
);
}
if (isQueued || (!entity?.isFailed && !entity?.isComplete)) {
if (isQueued || (!download?.isFailed && !download?.isComplete)) {
return (
<DownloadContainer>
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
+42
View File
@@ -0,0 +1,42 @@
/**
* Database actions for albums
*/
import { db, sqliteDb } from '@/store/db';
import { albums } from './albums';
import { eq } from 'drizzle-orm';
import type { InsertAlbum } from './types';
export async function upsertAlbum(album: InsertAlbum): Promise<void> {
const now = Date.now();
await db.insert(albums).values({
...album,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: albums.id,
set: {
...album,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertAlbums(albumList: InsertAlbum[]): Promise<void> {
for (const album of albumList) {
await upsertAlbum(album);
}
}
export async function deleteAlbum(id: string): Promise<void> {
await db.delete(albums).where(eq(albums.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteAlbumsBySource(sourceId: string): Promise<void> {
await db.delete(albums).where(eq(albums.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
+22
View File
@@ -0,0 +1,22 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Albums table
*/
export const albums = sqliteTable('albums', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
productionYear: integer('production_year'),
isFolder: integer('is_folder', { mode: 'boolean' }).notNull(),
albumArtist: text('album_artist'),
dateCreated: integer('date_created'),
lastRefreshed: integer('last_refreshed'),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => ({
sourceNameIdx: index('albums_source_name_idx').on(table.sourceId, table.name),
sourceYearIdx: index('albums_source_year_idx').on(table.sourceId, table.productionYear),
}));
+47
View File
@@ -0,0 +1,47 @@
/**
* Database-backed hooks for albums
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { albums } from './albums';
import { eq, desc } from 'drizzle-orm';
import type { Album } from './types';
export function useAlbums(sourceId?: string) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(albums).where(eq(albums.sourceId, sourceId))
: db.select().from(albums)
);
return useMemo(() => ({
data: (data || []) as Album[],
error,
}), [data, error]);
}
export function useAlbum(id: string) {
const { data, error } = useLiveQuery(
id ? db.select().from(albums).where(eq(albums.id, id)).limit(1) : null
);
return useMemo(() => ({
data: data?.[0] as Album | undefined,
error,
}), [data, error]);
}
export function useRecentAlbums(limit: number = 24, sourceId?: string) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(albums).where(eq(albums.sourceId, sourceId)).orderBy(desc(albums.dateCreated)).limit(limit)
: db.select().from(albums).orderBy(desc(albums.dateCreated)).limit(limit)
);
return useMemo(() => ({
data: (data || []) as Album[],
error,
}), [data, error]);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Album types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { albums } from './albums';
export type Album = InferSelectModel<typeof albums>;
export type InsertAlbum = typeof albums.$inferInsert;
+42
View File
@@ -0,0 +1,42 @@
/**
* Database actions for artists
*/
import { db, sqliteDb } from '@/store/db';
import { artists } from './artists';
import { eq } from 'drizzle-orm';
import type { InsertArtist } from './types';
export async function upsertArtist(artist: InsertArtist): Promise<void> {
const now = Date.now();
await db.insert(artists).values({
...artist,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: artists.id,
set: {
...artist,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertArtists(artistList: InsertArtist[]): Promise<void> {
for (const artist of artistList) {
await upsertArtist(artist);
}
}
export async function deleteArtist(id: string): Promise<void> {
await db.delete(artists).where(eq(artists.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteArtistsBySource(sourceId: string): Promise<void> {
await db.delete(artists).where(eq(artists.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
+17
View File
@@ -0,0 +1,17 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Artists table
*/
export const artists = sqliteTable('artists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
isFolder: integer('is_folder', { mode: 'boolean' }).notNull(),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => ({
sourceNameIdx: index('artists_source_name_idx').on(table.sourceId, table.name),
}));
+34
View File
@@ -0,0 +1,34 @@
/**
* Database-backed hooks for artists
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { artists } from './artists';
import { eq } from 'drizzle-orm';
import type { Artist } from './types';
export function useArtists(sourceId?: string) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(artists).where(eq(artists.sourceId, sourceId))
: db.select().from(artists)
);
return useMemo(() => ({
data: (data || []) as Artist[],
error,
}), [data, error]);
}
export function useArtist(id: string) {
const { data, error } = useLiveQuery(
id ? db.select().from(artists).where(eq(artists.id, id)).limit(1) : null
);
return useMemo(() => ({
data: data?.[0] as Artist | undefined,
error,
}), [data, error]);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Artist types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { artists } from './artists';
export type Artist = InferSelectModel<typeof artists>;
export type InsertArtist = typeof artists.$inferInsert;
+86
View File
@@ -0,0 +1,86 @@
import { createAction, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { AppState } from '@/store';
import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs';
import { DownloadEntity } from './types';
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
import { getImage } from '@/utility/JellyfinApi/lib';
import { getExtensionForUrl } from '@/utility/mimeType';
export const downloadAdapter = createEntityAdapter<DownloadEntity>();
export const queueTrackForDownload = createAction<string>('download/queue');
export const initializeDownload = createAction<{ id: string, size?: number, jobId?: number, location: string, image?: string }>('download/initialize');
export const progressDownload = createAction<{ id: string, progress: number, jobId?: number }>('download/progress');
export const completeDownload = createAction<{ id: string, location: string, size?: number, image?: string }>('download/complete');
export const failDownload = createAction<{ id: string }>('download/fail');
export const downloadTrack = createAsyncThunk(
'/downloads/track',
async (id: string, { dispatch, getState }) => {
// Generate the URL we can use to download the file
const entity = (getState() as AppState).music.tracks.entities[id];
const audioUrl = generateTrackUrl(id);
const imageUrl = getImage(entity);
// Get the content-type from the URL by doing a HEAD-only request
const [audioExt, imageExt] = await Promise.all([
getExtensionForUrl(audioUrl),
// Image files may be absent
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
]);
// Then generate the proper location
const audioLocation = `${DocumentDirectoryPath}/${id}.${audioExt}`;
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${id}.${imageExt}` : undefined;
// Actually kick off the download
const { promise: audioPromise } = downloadFile({
fromUrl: audioUrl,
progressInterval: 1000,
background: true,
begin: ({ jobId, contentLength }) => {
// Dispatch the initialization
dispatch(initializeDownload({ id, jobId, size: contentLength, location: audioLocation, image: imageLocation }));
},
progress: (result) => {
// Dispatch a progress update
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
},
toFile: audioLocation,
});
const { promise: imagePromise } = imageExt && imageLocation
? downloadFile({
fromUrl: imageUrl!,
toFile: imageLocation,
background: true,
})
: { promise: Promise.resolve(null) };
// Await job completion
const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]);
const totalSize = audioResult.bytesWritten + (imageResult?.bytesWritten || 0);
dispatch(completeDownload({ id, location: audioLocation, size: totalSize, image: imageLocation }));
},
);
export const removeDownloadedTrack = createAsyncThunk(
'/downloads/remove/track',
async (id: string, { getState }) => {
// Retrieve the state
const { downloads: { entities } } = getState() as AppState;
// Attempt to retrieve the entity from the state
const download = entities[id];
if (!download) {
throw new Error('Attempted to remove unknown downloaded track.');
}
// Then unlink the file, if it exists
if (download.location && await exists(download.location)) {
return unlink(download.location);
}
}
);
+100 -76
View File
@@ -1,86 +1,110 @@
import { createAction, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { AppState } from '@/store';
import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs';
import { DownloadEntity } from './types';
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
import { db, sqliteDb } from '@/store/db';
import { downloads } from './downloads';
import type { Download } from './types';
import { eq } from 'drizzle-orm';
import { getImage } from '@/utility/JellyfinApi/lib';
import { getExtensionForUrl } from '@/utility/mimeType';
export async function getAllDownloads(): Promise<Download[]> {
const result = await db.select().from(downloads);
return result as Download[];
}
export const downloadAdapter = createEntityAdapter<DownloadEntity>();
export async function getDownload(id: string): Promise<Download | undefined> {
const result = await db
.select()
.from(downloads)
.where(eq(downloads.id, id))
.limit(1);
return result[0] as Download | undefined;
}
export const queueTrackForDownload = createAction<string>('download/queue');
export const initializeDownload = createAction<{ id: string, size?: number, jobId?: number, location: string, image?: string }>('download/initialize');
export const progressDownload = createAction<{ id: string, progress: number, jobId?: number }>('download/progress');
export const completeDownload = createAction<{ id: string, location: string, size?: number, image?: string }>('download/complete');
export const failDownload = createAction<{ id: string }>('download/fail');
export async function initializeDownload(
sourceId: string,
id: string,
hash?: string,
filename?: string,
mimetype?: string
): Promise<void> {
const now = Date.now();
await db.insert(downloads).values({
sourceId,
id,
hash: hash || null,
filename: filename || null,
mimetype: mimetype || null,
progress: 0,
isFailed: false,
isComplete: false,
metadataJson: null,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: downloads.id,
set: {
hash: hash || null,
filename: filename || null,
mimetype: mimetype || null,
progress: 0,
isFailed: false,
isComplete: false,
updatedAt: now,
},
});
export const downloadTrack = createAsyncThunk(
'/downloads/track',
async (id: string, { dispatch, getState }) => {
// Generate the URL we can use to download the file
const entity = (getState() as AppState).music.tracks.entities[id];
const audioUrl = generateTrackUrl(id);
const imageUrl = getImage(entity);
sqliteDb.flushPendingReactiveQueries();
}
// Get the content-type from the URL by doing a HEAD-only request
const [audioExt, imageExt] = await Promise.all([
getExtensionForUrl(audioUrl),
// Image files may be absent
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
]);
export async function updateDownloadProgress(
id: string,
progress: number
): Promise<void> {
await db.update(downloads)
.set({
progress,
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
// Then generate the proper location
const audioLocation = `${DocumentDirectoryPath}/${id}.${audioExt}`;
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${id}.${imageExt}` : undefined;
sqliteDb.flushPendingReactiveQueries();
}
// Actually kick off the download
const { promise: audioPromise } = downloadFile({
fromUrl: audioUrl,
progressInterval: 1000,
background: true,
begin: ({ jobId, contentLength }) => {
// Dispatch the initialization
dispatch(initializeDownload({ id, jobId, size: contentLength, location: audioLocation, image: imageLocation }));
},
progress: (result) => {
// Dispatch a progress update
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
},
toFile: audioLocation,
});
export async function completeDownload(
id: string,
filename?: string
): Promise<void> {
const updates: any = {
isComplete: true,
isFailed: false,
progress: 1,
updatedAt: Date.now(),
};
const { promise: imagePromise } = imageExt && imageLocation
? downloadFile({
fromUrl: imageUrl!,
toFile: imageLocation,
background: true,
})
: { promise: Promise.resolve(null) };
// Await job completion
const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]);
const totalSize = audioResult.bytesWritten + (imageResult?.bytesWritten || 0);
dispatch(completeDownload({ id, location: audioLocation, size: totalSize, image: imageLocation }));
},
);
export const removeDownloadedTrack = createAsyncThunk(
'/downloads/remove/track',
async (id: string, { getState }) => {
// Retrieve the state
const { downloads: { entities } } = getState() as AppState;
// Attempt to retrieve the entity from the state
const download = entities[id];
if (!download) {
throw new Error('Attempted to remove unknown downloaded track.');
}
// Then unlink the file, if it exists
if (download.location && await exists(download.location)) {
return unlink(download.location);
}
if (filename) {
updates.filename = filename;
}
);
await db.update(downloads)
.set(updates)
.where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function failDownload(id: string): Promise<void> {
await db.update(downloads)
.set({
isFailed: true,
isComplete: false,
progress: 0,
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function removeDownload(id: string): Promise<void> {
await db.delete(downloads).where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
-192
View File
@@ -1,192 +0,0 @@
import { db, sqliteDb } from '@/store/db';
import { downloads } from '@/store/db/schema/downloads';
import type { Download } from '@/store/db/types';
import { eq } from 'drizzle-orm';
export interface DownloadMetadata {
size?: number;
error?: string;
image?: string;
}
/**
* Get all downloads (from all sources)
*/
export async function getAllDownloads(): Promise<Download[]> {
const result = await db
.select()
.from(downloads);
return result as Download[];
}
/**
* Get a single download by id
*/
export async function getDownload(id: string): Promise<Download | undefined> {
const result = await db
.select()
.from(downloads)
.where(eq(downloads.id, id))
.limit(1);
return result[0] as Download | undefined;
}
/**
* Initialize a download
*/
export async function initializeDownload(
sourceId: string,
id: string,
hash?: string,
filename?: string,
mimetype?: string
): Promise<void> {
const now = Date.now();
await db.insert(downloads).values({
sourceId,
id,
hash: hash || null,
filename: filename || null,
mimetype: mimetype || null,
progress: 0,
isFailed: false,
isComplete: false,
metadataJson: null,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: downloads.id,
set: {
hash: hash || null,
filename: filename || null,
mimetype: mimetype || null,
progress: 0,
isFailed: false,
isComplete: false,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
/**
* Update download progress
*/
export async function updateDownloadProgress(
id: string,
progress: number,
metadata?: DownloadMetadata
): Promise<void> {
const updates: any = {
progress,
updatedAt: Date.now(),
};
if (metadata) {
// Merge with existing metadata
const existing = await getDownload(id);
const existingMetadata = existing ? parseDownloadMetadata(existing) : {};
updates.metadataJson = JSON.stringify({ ...existingMetadata, ...metadata });
}
await db.update(downloads)
.set(updates)
.where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Mark download as complete
*/
export async function completeDownload(
id: string,
filename?: string,
imageFilename?: string
): Promise<void> {
const updates: any = {
isComplete: true,
isFailed: false,
progress: 1,
updatedAt: Date.now(),
};
if (filename) {
updates.filename = filename;
}
// Merge image into existing metadata
if (imageFilename) {
const existing = await getDownload(id);
const existingMetadata = existing ? parseDownloadMetadata(existing) : {};
updates.metadataJson = JSON.stringify({ ...existingMetadata, image: imageFilename });
}
await db.update(downloads)
.set(updates)
.where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Mark download as failed
*/
export async function failDownload(id: string, error?: string): Promise<void> {
const metadata = error ? JSON.stringify({ error }) : null;
await db.update(downloads)
.set({
isFailed: true,
isComplete: false,
progress: 0,
metadataJson: metadata,
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Remove a download
*/
export async function removeDownload(id: string): Promise<void> {
await db.delete(downloads).where(eq(downloads.id, id));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Parse download metadata
*/
export function parseDownloadMetadata(download: Download): DownloadMetadata {
if (!download.metadataJson) {
return {};
}
try {
return JSON.parse(download.metadataJson);
} catch {
return {};
}
}
/**
* Get download with parsed metadata
*/
export interface DownloadWithMetadata extends Download {
size?: number;
error?: string;
image?: string;
}
export function enrichDownload(download: Download): DownloadWithMetadata {
const metadata = parseDownloadMetadata(download);
return {
...download,
...metadata,
};
}
+19
View File
@@ -0,0 +1,19 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Downloads table
*/
export const downloads = sqliteTable('downloads', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
hash: text('hash'),
filename: text('filename'),
mimetype: text('mimetype'),
progress: integer('progress'),
isFailed: integer('is_failed', { mode: 'boolean' }).notNull(),
isComplete: integer('is_complete', { mode: 'boolean' }).notNull(),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
+16 -40
View File
@@ -5,59 +5,35 @@
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { downloads } from '@/store/db/schema/downloads';
import { downloads } from './downloads';
import { eq } from 'drizzle-orm';
import { enrichDownload, type Download, type DownloadWithMetadata } from './db';
import type { Download } from './types';
/**
* Get all downloads (from all sources)
*/
export function useDownloads() {
export function useDownloads(sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(downloads)
sourceId
? db.select().from(downloads).where(eq(downloads.sourceId, sourceId))
: db.select().from(downloads)
);
return useMemo(() => {
const entities: Record<string, DownloadWithMetadata> = {};
const ids: string[] = [];
const queued: string[] = [];
(data || []).forEach(download => {
const enriched = enrichDownload(download as Download);
entities[enriched.id] = enriched;
ids.push(enriched.id);
// If download is not complete and not failed, it's queued
if (!enriched.isComplete && !enriched.isFailed) {
queued.push(enriched.id);
}
});
return { entities, ids, queued, error };
}, [data, error]);
return useMemo(() => ({
data: (data || []) as Download[],
error,
}), [data, error]);
}
/**
* Get a single download by id
*/
export function useDownload(trackId: string) {
const { data, error } = useLiveQuery(
trackId ? db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1) : null
);
return useMemo(() => {
const download = data?.[0] as Download | undefined;
return {
entity: download ? enrichDownload(download) : undefined,
error
};
}, [data, error]);
return useMemo(() => ({
data: data?.[0] as Download | undefined,
error,
}), [data, error]);
}
/**
* Check if a track is downloaded
*/
export function useIsDownloaded(trackId: string): boolean {
const { entity } = useDownload(trackId);
return entity?.isComplete === true;
const { data } = useDownload(trackId);
return data?.isComplete === true;
}
+18 -64
View File
@@ -6,8 +6,8 @@
import { DocumentDirectoryPath, downloadFile, unlink, exists } from 'react-native-fs';
import { getActiveSource } from '@/store/settings/db';
import { db } from '@/store/db';
import { tracks } from '@/store/db/schema/tracks';
import { downloads } from '@/store/db/schema/downloads';
import { tracks } from '@/store/tracks/tracks';
import { downloads } from './downloads';
import { eq } from 'drizzle-orm';
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
import { getImage } from '@/utility/JellyfinApi/lib';
@@ -18,87 +18,60 @@ import {
completeDownload,
failDownload,
removeDownload as dbRemoveDownload
} from './db';
} from './actions';
/**
* Queue a track for download
*/
export async function queueTrackForDownload(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
// Initialize the download in the database
await initializeDownload(source.id, trackId);
}
/**
* Execute a track download
* This is called by the DownloadManager component
*/
export async function downloadTrack(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
try {
// Get track from database
const trackData = await db
.select()
.from(tracks)
.where(eq(tracks.id, trackId))
.limit(1);
const dbTrack = trackData[0];
if (!dbTrack) {
await failDownload(trackId, 'Track not found in database');
const track = trackData[0];
if (!track) {
await failDownload(trackId);
return;
}
// Enrich track to get full AlbumTrack object
const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {};
const track = {
Id: dbTrack.id,
Name: dbTrack.name,
AlbumId: dbTrack.albumId,
Album: dbTrack.album,
AlbumArtist: dbTrack.albumArtist,
ProductionYear: dbTrack.productionYear,
IndexNumber: dbTrack.indexNumber,
ParentIndexNumber: dbTrack.parentIndexNumber,
HasLyrics: dbTrack.hasLyrics,
RunTimeTicks: dbTrack.runTimeTicks,
...metadata,
};
// Parse metadata if needed for image URL
const metadata = track.metadataJson ? JSON.parse(track.metadataJson as string) : {};
const trackWithMetadata = { ...track, ...metadata };
// Generate URLs
const audioUrl = generateTrackUrl(trackId);
const imageUrl = getImage(track);
const imageUrl = getImage(trackWithMetadata);
// Get extensions
const [audioExt, imageExt] = await Promise.all([
getExtensionForUrl(audioUrl),
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
]);
// Generate file locations
const audioLocation = `${DocumentDirectoryPath}/${trackId}.${audioExt}`;
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${trackId}.${imageExt}` : undefined;
// Download audio file
const { promise: audioPromise } = downloadFile({
fromUrl: audioUrl,
progressInterval: 1000,
background: true,
begin: ({ contentLength }) => {
updateDownloadProgress(trackId, 0, { size: contentLength });
begin: () => {
updateDownloadProgress(trackId, 0);
},
progress: (result) => {
const progressValue = result.bytesWritten / result.contentLength;
updateDownloadProgress(trackId, progressValue);
updateDownloadProgress(trackId, result.bytesWritten / result.contentLength);
},
toFile: audioLocation,
});
// Download image file if available
const { promise: imagePromise } = imageExt && imageLocation
? downloadFile({
fromUrl: imageUrl!,
@@ -107,23 +80,14 @@ export async function downloadTrack(trackId: string): Promise<void> {
})
: { promise: Promise.resolve(null) };
// Wait for completion
const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]);
const totalSize = audioResult.bytesWritten + (imageResult?.bytesWritten || 0);
// Mark as complete
await completeDownload(trackId, audioLocation, imageLocation);
await updateDownloadProgress(trackId, 1, { size: totalSize });
await Promise.all([audioPromise, imagePromise]);
await completeDownload(trackId, audioLocation);
} catch (error) {
await failDownload(trackId, error instanceof Error ? error.message : 'Unknown error');
await failDownload(trackId);
}
}
/**
* Remove a downloaded track
*/
export async function removeDownloadedTrack(trackId: string): Promise<void> {
// Get the download from database
const downloadData = await db
.select()
.from(downloads)
@@ -132,19 +96,9 @@ export async function removeDownloadedTrack(trackId: string): Promise<void> {
const download = downloadData[0];
if (download) {
// Delete files if they exist
if (download.filename && await exists(download.filename)) {
await unlink(download.filename);
}
// Extract image path from metadata if present
const metadata = download.metadataJson ? JSON.parse(download.metadataJson) : {};
if (metadata.image && await exists(metadata.image)) {
await unlink(metadata.image);
}
if (download?.filename && await exists(download.filename)) {
await unlink(download.filename);
}
// Remove from database
await dbRemoveDownload(trackId);
}
+9 -11
View File
@@ -1,11 +1,9 @@
export interface DownloadEntity {
id: string;
progress: number;
isFailed: boolean;
isComplete: boolean;
size?: number;
location?: string;
jobId?: number;
error?: string;
image?: string;
}
/**
* Download types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { downloads } from './downloads';
export type Download = InferSelectModel<typeof downloads>;
export type InsertDownload = typeof downloads.$inferInsert;
+65
View File
@@ -0,0 +1,65 @@
/**
* Database actions for playlists
*/
import { db, sqliteDb } from '@/store/db';
import { playlists } from './playlists';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { eq, and } from 'drizzle-orm';
import type { InsertPlaylist } from './types';
export async function upsertPlaylist(playlist: InsertPlaylist): Promise<void> {
const now = Date.now();
await db.insert(playlists).values({
...playlist,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: playlists.id,
set: {
...playlist,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertPlaylists(playlistList: InsertPlaylist[]): Promise<void> {
for (const playlist of playlistList) {
await upsertPlaylist(playlist);
}
}
export async function deletePlaylist(id: string): Promise<void> {
await db.delete(playlists).where(eq(playlists.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function deletePlaylistsBySource(sourceId: string): Promise<void> {
await db.delete(playlists).where(eq(playlists.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
export async function setPlaylistTracks(sourceId: string, playlistId: string, trackIds: string[]): Promise<void> {
await db.delete(playlistTracks).where(
and(
eq(playlistTracks.sourceId, sourceId),
eq(playlistTracks.playlistId, playlistId)
)
);
if (trackIds.length > 0) {
await db.insert(playlistTracks).values(
trackIds.map((trackId, index) => ({
sourceId,
playlistId,
trackId,
position: index,
}))
);
}
sqliteDb.flushPendingReactiveQueries();
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Database-backed hooks for playlists
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { playlists } from './playlists';
import { eq } from 'drizzle-orm';
import type { Playlist } from './types';
export function usePlaylists(sourceId?: string) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(playlists).where(eq(playlists.sourceId, sourceId))
: db.select().from(playlists)
);
return useMemo(() => ({
data: (data || []) as Playlist[],
error,
}), [data, error]);
}
export function usePlaylist(id: string) {
const { data, error } = useLiveQuery(
id ? db.select().from(playlists).where(eq(playlists.id, id)).limit(1) : null
);
return useMemo(() => ({
data: data?.[0] as Playlist | undefined,
error,
}), [data, error]);
}
+19
View File
@@ -0,0 +1,19 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Playlists table
*/
export const playlists = sqliteTable('playlists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
canDelete: integer('can_delete', { mode: 'boolean' }).notNull(),
childCount: integer('child_count'),
lastRefreshed: integer('last_refreshed'),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => ({
sourceNameIdx: index('playlists_source_name_idx').on(table.sourceId, table.name),
}));
+9
View File
@@ -0,0 +1,9 @@
/**
* Playlist types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { playlists } from './playlists';
export type Playlist = InferSelectModel<typeof playlists>;
export type InsertPlaylist = typeof playlists.$inferInsert;
+36
View File
@@ -0,0 +1,36 @@
/**
* Database actions for search queries
*/
import { db, sqliteDb } from '@/store/db';
import { searchQueries } from './search-queries';
import { eq } from 'drizzle-orm';
import type { InsertSearchQuery } from './types';
export async function upsertSearchQuery(query: InsertSearchQuery): Promise<void> {
const now = Date.now();
await db.insert(searchQueries).values({
...query,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: searchQueries.id,
set: {
...query,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteSearchQuery(id: string): Promise<void> {
await db.delete(searchQueries).where(eq(searchQueries.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteSearchQueriesBySource(sourceId: string): Promise<void> {
await db.delete(searchQueries).where(eq(searchQueries.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Database-backed hooks for search queries
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { searchQueries } from './search-queries';
import { eq, desc } from 'drizzle-orm';
import type { SearchQuery } from './types';
export function useSearchQueries(sourceId?: string, limit?: number) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(searchQueries).where(eq(searchQueries.sourceId, sourceId)).orderBy(desc(searchQueries.timestamp)).limit(limit || 100)
: db.select().from(searchQueries).orderBy(desc(searchQueries.timestamp)).limit(limit || 100)
);
return useMemo(() => ({
data: (data || []) as SearchQuery[],
error,
}), [data, error]);
}
@@ -0,0 +1,18 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Search queries table
*/
export const searchQueries = sqliteTable('search_queries', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
query: text('query').notNull(),
timestamp: integer('timestamp').notNull(),
localPlaybackOnly: integer('local_playback_only', { mode: 'boolean' }).notNull(),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => ({
sourceTimestampIdx: index('search_queries_source_timestamp_idx').on(table.sourceId, table.timestamp),
}));
+9
View File
@@ -0,0 +1,9 @@
/**
* SearchQuery types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { searchQueries } from './search-queries';
export type SearchQuery = InferSelectModel<typeof searchQueries>;
export type InsertSearchQuery = typeof searchQueries.$inferInsert;
+21
View File
@@ -0,0 +1,21 @@
/**
* Database-backed hooks for app settings
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { eq } from 'drizzle-orm';
import type { AppSettings } from '@/store/db/types';
export function useAppSettings() {
const { data, error } = useLiveQuery(
db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1)
);
return useMemo(() => ({
data: data?.[0] as AppSettings | undefined,
error,
}), [data, error]);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Database actions for sleep timer
*/
import { db, sqliteDb } from '@/store/db';
import { sleepTimer } from './sleep-timer';
import { eq } from 'drizzle-orm';
export async function setSleepTimer(date: number | null): Promise<void> {
const now = Date.now();
await db.insert(sleepTimer).values({
id: 1,
date,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: sleepTimer.id,
set: {
date,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function clearSleepTimer(): Promise<void> {
await setSleepTimer(null);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Database-backed hooks for sleep timer
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { sleepTimer } from './sleep-timer';
import { eq } from 'drizzle-orm';
import type { SleepTimer } from './types';
export function useSleepTimer() {
const { data, error } = useLiveQuery(
db.select().from(sleepTimer).where(eq(sleepTimer.id, 1)).limit(1)
);
return useMemo(() => ({
data: data?.[0] as SleepTimer | undefined,
error,
}), [data, error]);
}
+11
View File
@@ -0,0 +1,11 @@
import { sqliteTable, integer } from 'drizzle-orm/sqlite-core';
/**
* Sleep timer - global sleep timer settings (single row, id=1)
*/
export const sleepTimer = sqliteTable('sleep_timer', {
id: integer('id').primaryKey().$default(() => 1),
date: integer('date'), // nullable - epoch ms
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
+9
View File
@@ -0,0 +1,9 @@
/**
* SleepTimer types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { sleepTimer } from './sleep-timer';
export type SleepTimer = InferSelectModel<typeof sleepTimer>;
export type InsertSleepTimer = typeof sleepTimer.$inferInsert;
+47
View File
@@ -0,0 +1,47 @@
/**
* Database actions for tracks
*/
import { db, sqliteDb } from '@/store/db';
import { tracks } from './tracks';
import { eq } from 'drizzle-orm';
import type { InsertTrack } from './types';
export async function upsertTrack(track: InsertTrack): Promise<void> {
const now = Date.now();
await db.insert(tracks).values({
...track,
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: tracks.id,
set: {
...track,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertTracks(trackList: InsertTrack[]): Promise<void> {
for (const track of trackList) {
await upsertTrack(track);
}
}
export async function deleteTrack(id: string): Promise<void> {
await db.delete(tracks).where(eq(tracks.id, id));
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteTracksBySource(sourceId: string): Promise<void> {
await db.delete(tracks).where(eq(tracks.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
export async function deleteTracksByAlbum(albumId: string): Promise<void> {
await db.delete(tracks).where(eq(tracks.albumId, albumId));
sqliteDb.flushPendingReactiveQueries();
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Database-backed hooks for tracks with download joins
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { tracks } from './tracks';
import { downloads } from '@/store/downloads/downloads';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { eq, inArray } from 'drizzle-orm';
import type { Track } from './types';
import type { Download } from '@/store/downloads/types';
export interface TrackWithDownload {
track: Track;
download: Download | null;
}
export function useTracks(sourceId?: string) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(tracks).where(eq(tracks.sourceId, sourceId))
: db.select().from(tracks)
);
return useMemo(() => ({
data: (data || []) as Track[],
error,
}), [data, error]);
}
export function useTrack(id: string) {
const { data, error } = useLiveQuery(
id ? db.select().from(tracks).where(eq(tracks.id, id)).limit(1) : null
);
return useMemo(() => ({
data: data?.[0] as Track | undefined,
error,
}), [data, error]);
}
export function useTrackWithDownload(id: string) {
const { data, error } = useLiveQuery(
id
? db.select({
track: tracks,
download: downloads,
})
.from(tracks)
.leftJoin(downloads, eq(tracks.id, downloads.id))
.where(eq(tracks.id, id))
.limit(1)
: null
);
return useMemo(() => {
const result = data?.[0];
return {
data: result ? {
track: result.track as Track,
download: result.download as Download | null,
} : undefined,
error,
};
}, [data, error]);
}
export function useTracksByAlbum(albumId: string) {
const { data, error } = useLiveQuery(
albumId
? db.select().from(tracks).where(eq(tracks.albumId, albumId))
: null
);
return useMemo(() => ({
data: (data || []) as Track[],
error,
}), [data, error]);
}
export function useTracksWithDownloadsByAlbum(albumId: string) {
const { data, error } = useLiveQuery(
albumId
? db.select({
track: tracks,
download: downloads,
})
.from(tracks)
.leftJoin(downloads, eq(tracks.id, downloads.id))
.where(eq(tracks.albumId, albumId))
: null
);
return useMemo(() => ({
data: (data || []).map(row => ({
track: row.track as Track,
download: row.download as Download | null,
})),
error,
}), [data, error]);
}
export function useTracksByPlaylist(playlistId: string) {
const { data: relations, error: relError } = useLiveQuery(
playlistId
? db.select().from(playlistTracks).where(eq(playlistTracks.playlistId, playlistId))
: null
);
const trackIds = useMemo(() => (relations || []).map(r => r.trackId), [relations]);
const { data: tracksData, error: tracksError } = useLiveQuery(
trackIds.length > 0
? db.select().from(tracks).where(inArray(tracks.id, trackIds))
: null
);
return useMemo(() => {
const tracksMap = new Map((tracksData || []).map(t => [t.id, t as Track]));
const sortedTracks = (relations || [])
.sort((a, b) => (a.position || 0) - (b.position || 0))
.map(r => tracksMap.get(r.trackId))
.filter(Boolean) as Track[];
return {
data: sortedTracks,
error: relError || tracksError,
};
}, [relations, tracksData, relError, tracksError]);
}
export function useTracksWithDownloadsByPlaylist(playlistId: string) {
const { data: relations, error: relError } = useLiveQuery(
playlistId
? db.select().from(playlistTracks).where(eq(playlistTracks.playlistId, playlistId))
: null
);
const trackIds = useMemo(() => (relations || []).map(r => r.trackId), [relations]);
const { data: tracksData, error: tracksError } = useLiveQuery(
trackIds.length > 0
? db.select({
track: tracks,
download: downloads,
})
.from(tracks)
.leftJoin(downloads, eq(tracks.id, downloads.id))
.where(inArray(tracks.id, trackIds))
: null
);
return useMemo(() => {
const tracksMap = new Map((tracksData || []).map(row => [row.track.id, {
track: row.track as Track,
download: row.download as Download | null,
}]));
const sortedTracks = (relations || [])
.sort((a, b) => (a.position || 0) - (b.position || 0))
.map(r => tracksMap.get(r.trackId))
.filter(Boolean) as TrackWithDownload[];
return {
data: sortedTracks,
error: relError || tracksError,
};
}, [relations, tracksData, relError, tracksError]);
}
+26
View File
@@ -0,0 +1,26 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
/**
* Tracks table
*/
export const tracks = sqliteTable('tracks', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
albumId: text('album_id'),
album: text('album'),
albumArtist: text('album_artist'),
productionYear: integer('production_year'),
indexNumber: integer('index_number'),
parentIndexNumber: integer('parent_index_number'),
hasLyrics: integer('has_lyrics', { mode: 'boolean' }).notNull().default(false),
runTimeTicks: integer('run_time_ticks'),
lyrics: text('lyrics'),
metadataJson: text('metadata_json'), // JSON-encoded additional fields
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
}, (table) => ({
sourceAlbumIdx: index('tracks_source_album_idx').on(table.sourceId, table.albumId),
sourceNameIdx: index('tracks_source_name_idx').on(table.sourceId, table.name),
}));
+9
View File
@@ -0,0 +1,9 @@
/**
* Track types
*/
import type { InferSelectModel } from 'drizzle-orm';
import { tracks } from './tracks';
export type Track = InferSelectModel<typeof tracks>;
export type InsertTrack = typeof tracks.$inferInsert;