mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
Create new entity folder structure with hooks and actions
Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
co-authored by
leinelissen
parent
fcb0dfbea1
commit
6a8443f4fd
@@ -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' }]}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user