fix: rearrange entities

This commit is contained in:
Lei Nelissen
2026-02-09 10:12:58 +01:00
parent 1a5a98bf0d
commit becdea9468
89 changed files with 849 additions and 2412 deletions
+3 -3
View File
@@ -4,9 +4,9 @@ import { useContext } from 'react';
import { ColorSchemeName, Platform, StyleSheet, View, useColorScheme } from 'react-native';
import { ColorScheme } from '@/store/settings/types';
import { useAccessibilitySetting } from 'react-native-accessibility-settings';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import appSettings from '@/store/settings/entity';
import { eq } from 'drizzle-orm';
const majorPlatformVersion = typeof Platform.Version === 'string' ? parseInt(Platform.Version, 10) : Platform.Version;
+6 -3
View File
@@ -7,11 +7,11 @@ 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';
import { useDownload } from '@/store/downloads/hooks';
interface DownloadIconProps {
track: Track;
trackId?: string;
download?: Download | null;
size?: number;
fill?: string;
@@ -29,10 +29,13 @@ const IconOverlay = styled.View`
transform: scale(0.5);
`;
function DownloadIcon({ track, download, size = 16, fill, style }: DownloadIconProps) {
function DownloadIcon({ trackId, download: downloadProp, size = 16, fill, style }: DownloadIconProps) {
const defaultStyles = useDefaultStyles();
const iconFill = fill || defaultStyles.textQuarterOpacity.color;
const { data: downloadData } = useDownload(trackId || '');
const download = downloadProp || downloadData || null;
const isQueued = download && !download.isComplete && !download.isFailed;
const radius = useMemo(() => size / 2, [size]);
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
@@ -20,6 +20,7 @@ import Trash from '@/assets/icons/trash.svg';
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue';
import { Header, SubHeader } from '@/components/Typography';
import { Text } from '@/components/Typography';
import { SafeScrollView, useNavigationOffsets } from '@/components/SafeNavigatorView';
const styles = StyleSheet.create({
index: {
+4 -4
View File
@@ -2,15 +2,15 @@ import React, { useCallback, useEffect } from 'react';
import styled from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@/screens';
import { setOnboardingStatus } from '@/store/settings/db';
import { setOnboardingStatus } from '@/store/settings/actions';
import { t } from '@/localisation';
import Button from '@/components/Button';
import { Header, Text as BaseText } from '@/components/Typography';
import { ShadowWrapper } from '@/components/Shadow';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { sources } from '@/store/db/schema/sources';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import sources from '@/store/sources/entity';
const Container = styled(SafeAreaView)`
flex: 1;
+3 -3
View File
@@ -11,9 +11,9 @@ import { useDownloads } from '@/store/downloads/hooks';
import { useSourceId } from '@/store/db/useSourceId';
import { searchAndStore } from '@/store/music/fetchers';
import { addSearchQuery, clearSearchHistory, parseSearchQueries } from '@/store/search/db';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { searchQueries } from '@/store/search-queries/search-queries';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import searchQueries from '@/store/search-queries/entity';
import { eq, desc } from 'drizzle-orm';
import { FlatList } from 'react-native-gesture-handler';
+5 -5
View File
@@ -5,11 +5,11 @@ import Button from '@/components/Button';
import styled from 'styled-components/native';
import { Paragraph } from '@/components/Typography';
import Container from '../components/Container';
import { db } from '@/store/db';
import { albums } from '@/store/albums/albums';
import { artists } from '@/store/artists/artists';
import { tracks } from '@/store/tracks/tracks';
import { playlists } from '@/store/playlists/playlists';
import { db } from '@/store';
import albums from '@/store/albums/entity';
import artists from '@/store/artists/entity';
import tracks from '@/store/tracks/entity';
import playlists from '@/store/playlists/entity';
const ClearCache = styled(Button)`
margin-top: 16px;
+5 -10
View File
@@ -4,20 +4,15 @@ import Container from '../components/Container';
import { t } from '@/localisation';
import { RadioItem, RadioList } from '../components/Radio';
import { ColorScheme } from '@/store/settings/types';
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 { setColorScheme as setColorSchemeDb } from '@/store/settings/db';
import { updateAppSettings } from '@/store/settings/actions';
import { useAppSettings } from '@/store/settings/hooks';
export default function ColorSchemeSetting() {
const { data: settings } = useLiveQuery(
db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1)
);
const scheme = settings?.[0]?.colorScheme || ColorScheme.System;
const { data: settings } = useAppSettings();
const scheme = settings?.colorScheme || ColorScheme.System;
const handlePress = useCallback((value: ColorScheme) => {
setColorSchemeDb(value);
updateAppSettings({ colorScheme: value });
}, []);
return (
+3 -3
View File
@@ -7,9 +7,9 @@ import Button from '@/components/Button';
import { Paragraph } from '@/components/Typography';
import Container from '../components/Container';
import { InputContainer, Input } from '../components/Input';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { sources } from '@/store/db/schema/sources';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import sources from '@/store/sources/entity';
export default function LibrarySettings() {
const defaultStyles = useDefaultStyles();
@@ -2,11 +2,11 @@ import { Paragraph } from '@/components/Typography';
import React, { useCallback } from 'react';
import { Switch } from 'react-native-gesture-handler';
import { t } from '@/localisation';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import appSettings from '@/store/settings/entity';
import { eq } from 'drizzle-orm';
import { setEnablePlaybackReporting } from '@/store/settings/db';
import { setEnablePlaybackReporting } from '@/store/settings/actions';
import Container from '../components/Container';
import { SwitchContainer, SwitchLabel } from '../components/Switch';
+3 -3
View File
@@ -20,9 +20,9 @@ import useDefaultStyles from '@/components/Colors';
import Player from './modals/Player';
import { StackParams } from './types';
import Lyrics from './modals/Lyrics';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import appSettings from '@/store/settings/entity';
import { eq } from 'drizzle-orm';
const Stack = createNativeStackNavigator<StackParams>();
@@ -3,8 +3,9 @@ import DateTimePickerModal from 'react-native-modal-datetime-picker';
import styled from 'styled-components/native';
import TimerIcon from '@/assets/icons/timer.svg';
import { setSleepTimerDate } from '@/store/sleep-timer/db';
import { useLiveQueryOne } from '@/store/db/live-queries';
import { sleepTimer } from '@/store/sleep-timer/sleep-timer';
import { useLiveQuery } from '@/store/live-queries';
import sleepTimer from '@/store/sleep-timer/entity';
import { db } from '@/store';
import { eq } from 'drizzle-orm';
import ticksToDuration from '@/utility/ticksToDuration';
import useDefaultStyles from '@/components/Colors';
@@ -40,11 +41,10 @@ export default function Timer() {
const [showPicker, setShowPicker] = useState<boolean>(false);
// Retrieve sleep timer from database using live query
const timerData = useLiveQueryOne(
(db) => db.select().from(sleepTimer).where(eq(sleepTimer.id, 1)).limit(1),
['sleep_timer']
const { data: timerRows } = useLiveQuery(
db.select().from(sleepTimer).where(eq(sleepTimer.id, 1)).limit(1)
);
const date = timerData?.date ?? null;
const date = timerRows?.[0]?.date ?? null;
// Retrieve styles
const defaultStyles = useDefaultStyles();
@@ -1,11 +1,16 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { debounce } from 'lodash';
import { AppState } from '@/store';
interface Props {
serverUrl: string;
onCredentialsRetrieved: (credentials: AppState['settings']['credentials']) => void;
onCredentialsRetrieved: (credentials: {
uri: string;
user_id: string;
access_token: string;
device_id: string;
type: 'emby' | 'jellyfin';
}) => void;
}
type CredentialEventData = {
@@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react';
import { Button, View } from 'react-native';
import Modal from '@/components/Modal';
import Input from '@/components/Input';
import { setCredentials } from '@/store/settings/db';
import { setCredentials } from '@/store/settings/actions';
import { useNavigation, StackActions } from '@react-navigation/native';
import CredentialGenerator from './components/CredentialGenerator';
import { t } from '@/localisation';
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Album-Artists relation table (many-to-many)
*/
export const albumArtists = sqliteTable('album_artists', {
const albumArtists = sqliteTable('album_artists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
albumId: text('album_id').notNull(),
artistId: text('artist_id').notNull(),
@@ -13,3 +13,5 @@ export const albumArtists = sqliteTable('album_artists', {
pk: primaryKey({ columns: [table.sourceId, table.albumId, table.artistId] }),
sourceArtistIdx: index('album_artists_source_artist_idx').on(table.sourceId, table.artistId),
}));
export default albumArtists;
@@ -1,13 +1,15 @@
import { sqliteTable, text, primaryKey } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Album-Similar relation table (for similar albums)
*/
export const albumSimilar = sqliteTable('album_similar', {
const albumSimilar = sqliteTable('album_similar', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
albumId: text('album_id').notNull(),
similarAlbumId: text('similar_album_id').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.sourceId, table.albumId, table.similarAlbumId] }),
}));
export default albumSimilar;
+2 -2
View File
@@ -2,8 +2,8 @@
* Database actions for albums
*/
import { db, sqliteDb } from '@/store/db';
import { albums } from './albums';
import { db, sqliteDb } from '@/store';
import albums from './entity';
import { eq } from 'drizzle-orm';
import type { InsertAlbum } from './types';
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/sources';
import sources from '../sources/entity';
/**
* Albums table
*/
export const albums = sqliteTable('albums', {
const albums = sqliteTable('albums', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -20,3 +20,5 @@ export const albums = sqliteTable('albums', {
sourceNameIdx: index('albums_source_name_idx').on(table.sourceId, table.name),
sourceYearIdx: index('albums_source_year_idx').on(table.sourceId, table.productionYear),
}));
export default albums;
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { albums } from './albums';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import albums from './entity';
import { eq, desc } from 'drizzle-orm';
import type { Album } from './types';
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { albums } from './albums';
import albums from './entity';
export type Album = InferSelectModel<typeof albums>;
export type InsertAlbum = typeof albums.$inferInsert;
+2 -2
View File
@@ -2,8 +2,8 @@
* Database actions for artists
*/
import { db, sqliteDb } from '@/store/db';
import { artists } from './artists';
import { db, sqliteDb } from '@/store';
import artists from './entity';
import { eq } from 'drizzle-orm';
import type { InsertArtist } from './types';
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/sources';
import sources from '../sources/entity';
/**
* Artists table
*/
export const artists = sqliteTable('artists', {
const artists = sqliteTable('artists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -15,3 +15,5 @@ export const artists = sqliteTable('artists', {
}, (table) => ({
sourceNameIdx: index('artists_source_name_idx').on(table.sourceId, table.name),
}));
export default artists;
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { artists } from './artists';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import artists from './entity';
import { eq } from 'drizzle-orm';
import type { Artist } from './types';
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { artists } from './artists';
import artists from './entity';
export type Artist = InferSelectModel<typeof artists>;
export type InsertArtist = typeof artists.$inferInsert;
-69
View File
@@ -1,69 +0,0 @@
import { drizzle } from 'drizzle-orm/op-sqlite';
import { open } from '@op-engineering/op-sqlite';
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
import migrations from './migrations/migrations.js';
// Import all schema tables
import { sources } from './schema/sources';
import { appSettings } from './schema/app-settings';
import { sleepTimer } from './schema/sleep-timer';
import { artists } from './schema/artists';
import { albums } from './schema/albums';
import { tracks } from './schema/tracks';
import { playlists } from './schema/playlists';
import { downloads } from './schema/downloads';
import { searchQueries } from './schema/search-queries';
import { albumArtists } from './schema/album-artists';
import { trackArtists } from './schema/track-artists';
import { playlistTracks } from './schema/playlist-tracks';
import { albumSimilar } from './schema/album-similar';
import { syncCursors } from './schema/sync-cursors';
// Combined schema for drizzle
const schema = {
sources,
appSettings,
sleepTimer,
artists,
albums,
tracks,
playlists,
downloads,
searchQueries,
albumArtists,
trackArtists,
playlistTracks,
albumSimilar,
syncCursors,
};
// Open the SQLite database
export const sqliteDb = open({
name: 'fintunes.db',
location: '../databases',
});
// Create drizzle instance with schema - exported as singleton
export const db = drizzle(sqliteDb, { schema });
/**
* Run database migrations
* Migrations should be generated using drizzle-kit
*/
export async function runMigrations() {
try {
await migrate(db, migrations);
console.log('Database migrations completed');
} catch (error) {
console.error('Migration error:', error);
throw error;
}
}
/**
* Initialize the database
*/
export async function initializeDatabase() {
await runMigrations();
return db;
}
-22
View File
@@ -1,22 +0,0 @@
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),
}));
-17
View File
@@ -1,17 +0,0 @@
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),
}));
-18
View File
@@ -1,18 +0,0 @@
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),
}));
+8 -37
View File
@@ -1,39 +1,10 @@
/**
* Database Schema Types
*
* Re-exports types from entity modules for convenience
*/
export type { Album, InsertAlbum } from '../albums/types';
export type { Artist, InsertArtist } from '../artists/types';
export type { Track, InsertTrack } from '../tracks/types';
export type { Playlist, InsertPlaylist } from '../playlists/types';
export type { Download, InsertDownload } from '../downloads/types';
export type { SearchQuery, InsertSearchQuery } from '../search-queries/types';
export type { SleepTimer, InsertSleepTimer } from '../sleep-timer/types';
// Re-export relationship and other schema types
import type { InferSelectModel } from 'drizzle-orm';
import { sources } from './schema/sources';
import { albumArtists } from './schema/album-artists';
import { trackArtists } from './schema/track-artists';
import { playlistTracks } from './schema/playlist-tracks';
import { albumSimilar } from './schema/album-similar';
import { syncCursors } from './schema/sync-cursors';
import { appSettings } from './schema/app-settings';
import artists from '../artists/entity';
import albums from '../albums/entity';
import tracks from '../tracks/entity';
import playlists from '../playlists/entity';
export type Source = InferSelectModel<typeof sources>;
export type AlbumArtist = InferSelectModel<typeof albumArtists>;
export type TrackArtist = InferSelectModel<typeof trackArtists>;
export type PlaylistTrack = InferSelectModel<typeof playlistTracks>;
export type AlbumSimilar = InferSelectModel<typeof albumSimilar>;
export type SyncCursor = InferSelectModel<typeof syncCursors>;
export type AppSettings = InferSelectModel<typeof appSettings>;
export type InsertSource = typeof sources.$inferInsert;
export type InsertAlbumArtist = typeof albumArtists.$inferInsert;
export type InsertTrackArtist = typeof trackArtists.$inferInsert;
export type InsertPlaylistTrack = typeof playlistTracks.$inferInsert;
export type InsertAlbumSimilar = typeof albumSimilar.$inferInsert;
export type InsertSyncCursor = typeof syncCursors.$inferInsert;
export type InsertAppSettings = typeof appSettings.$inferInsert;
export type Artist = InferSelectModel<typeof artists>;
export type Album = InferSelectModel<typeof albums>;
export type Track = InferSelectModel<typeof tracks>;
export type Playlist = InferSelectModel<typeof playlists>;
-16
View File
@@ -1,16 +0,0 @@
/**
* Hook to get the current source ID from the database
* Returns the first source's ID or an empty string if no source exists
*/
import { useLiveQuery } from './live-queries';
import { db } from './index';
import { sources } from './schema/sources';
import { useMemo } from 'react';
export function useSourceId(): string {
const { data: sourceData } = useLiveQuery(db.select().from(sources).limit(1));
return useMemo(() => {
return (sourceData?.[0] as typeof sources.$inferSelect | undefined)?.id || '';
}, [sourceData]);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { db, sqliteDb } from '@/store/db';
import { downloads } from './downloads';
import { db, sqliteDb } from '@/store';
import downloads from './entity';
import type { Download } from './types';
import { eq } from 'drizzle-orm';
-19
View File
@@ -1,19 +0,0 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/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(),
});
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Downloads table
*/
export const downloads = sqliteTable('downloads', {
const downloads = sqliteTable('downloads', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
hash: text('hash'),
@@ -17,3 +17,5 @@ export const downloads = sqliteTable('downloads', {
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
export default downloads;
+10 -7
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { downloads } from './downloads';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import downloads from './entity';
import { eq } from 'drizzle-orm';
import type { Download } from './types';
@@ -23,11 +23,14 @@ export function useDownloads(sourceId?: string) {
(data || []).forEach(download => {
const d = download as Download;
entities[d.id] = d;
ids.push(d.id);
const metadata = d.metadataJson ? JSON.parse(d.metadataJson) : {};
const normalized = { ...d, ...metadata } as Download;
entities[normalized.id] = normalized;
ids.push(normalized.id);
if (!d.isComplete && !d.isFailed) {
queued.push(d.id);
if (!normalized.isComplete && !normalized.isFailed) {
queued.push(normalized.id);
}
});
+95 -92
View File
@@ -1,110 +1,113 @@
/**
* Download queue manager
* Manages queueing and executing track downloads
*/
import { DocumentDirectoryPath, downloadFile, unlink, exists } from 'react-native-fs';
import { getActiveSource } from '@/store/settings/db';
import { db } from '@/store/db';
import { tracks } from '@/store/tracks/tracks';
import { downloads } from './downloads';
import { DocumentDirectoryPath, downloadFile, exists, unlink } from 'react-native-fs';
import { db } from '@/store';
import sources from '@/store/sources/entity';
import downloads from '@/store/downloads/entity';
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
import { initializeDownload, updateDownloadProgress, completeDownload, failDownload, removeDownload } from './actions';
import { eq } from 'drizzle-orm';
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
import { getImage } from '@/utility/JellyfinApi/lib';
import { getExtensionForUrl } from '@/utility/mimeType';
import {
initializeDownload,
updateDownloadProgress,
completeDownload,
failDownload,
removeDownload as dbRemoveDownload
} from './actions';
async function getDriver(): Promise<{ driver: SourceDriver; source: Source } | null> {
const result = await db.select().from(sources).limit(1);
const row = result[0];
if (!row) {
return null;
}
const source: Source = {
id: row.id,
uri: row.uri,
userId: row.userId || undefined,
accessToken: row.accessToken || undefined,
deviceId: row.deviceId || undefined,
type: row.type as SourceType,
};
if (source.type.startsWith('jellyfin')) {
return { driver: new JellyfinDriver(source), source };
}
if (source.type.startsWith('emby')) {
return { driver: new EmbyDriver(source), source };
}
return null;
}
async function updateDownloadMetadata(id: string, updates: Record<string, unknown>): Promise<void> {
const existing = await db.select().from(downloads).where(eq(downloads.id, id)).limit(1);
const current = existing[0];
const currentMetadata = current?.metadataJson ? JSON.parse(current.metadataJson) : {};
await db.update(downloads)
.set({
metadataJson: JSON.stringify({ ...currentMetadata, ...updates }),
updatedAt: Date.now(),
})
.where(eq(downloads.id, id));
}
export async function queueTrackForDownload(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
await initializeDownload(source.id, trackId);
const driverResult = await getDriver();
if (!driverResult) {
return;
}
await initializeDownload(driverResult.source.id, trackId);
}
export async function downloadTrack(trackId: string): Promise<void> {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver } = driverResult;
try {
const trackData = await db
.select()
.from(tracks)
.where(eq(tracks.id, trackId))
.limit(1);
const track = trackData[0];
if (!track) {
await failDownload(trackId);
return;
}
// Parse metadata if needed for image URL
let metadata: any = {};
try {
metadata = track.metadataJson ? JSON.parse(track.metadataJson as string) : {};
} catch (error) {
console.warn('Failed to parse track metadata:', error);
}
const trackWithMetadata = { ...track, ...metadata };
const audioUrl = generateTrackUrl(trackId);
const imageUrl = getImage(trackWithMetadata);
const [audioExt, imageExt] = await Promise.all([
getExtensionForUrl(audioUrl),
imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null
]);
const audioLocation = `${DocumentDirectoryPath}/${trackId}.${audioExt}`;
const imageLocation = imageExt ? `${DocumentDirectoryPath}/${trackId}.${imageExt}` : undefined;
const { promise: audioPromise } = downloadFile({
fromUrl: audioUrl,
progressInterval: 1000,
const info = await driver.getDownloadInfo(trackId);
const destination = `${DocumentDirectoryPath}/${info.filename}`;
await updateDownloadMetadata(trackId, { size: null, error: null });
const job = downloadFile({
fromUrl: info.url,
toFile: destination,
background: true,
begin: () => {
updateDownloadProgress(trackId, 0);
progress: (event) => {
if (!event.contentLength) {
return;
}
const progress = event.bytesWritten / event.contentLength;
updateDownloadProgress(trackId, progress);
updateDownloadMetadata(trackId, { size: event.contentLength });
},
progress: (result) => {
updateDownloadProgress(trackId, result.bytesWritten / result.contentLength);
},
toFile: audioLocation,
});
const { promise: imagePromise } = imageExt && imageLocation
? downloadFile({
fromUrl: imageUrl!,
toFile: imageLocation,
background: true,
})
: { promise: Promise.resolve(null) };
await Promise.all([audioPromise, imagePromise]);
await completeDownload(trackId, audioLocation);
await job.promise;
await completeDownload(trackId, destination);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await updateDownloadMetadata(trackId, { error: message });
await failDownload(trackId);
}
}
export async function removeDownloadedTrack(trackId: string): Promise<void> {
const downloadData = await db
.select()
.from(downloads)
.where(eq(downloads.id, trackId))
.limit(1);
const download = downloadData[0];
if (download?.filename && await exists(download.filename)) {
await unlink(download.filename);
const downloadRow = await db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1);
const entry = downloadRow[0];
if (entry?.filename) {
const filePath = entry.filename.startsWith('/')
? entry.filename
: `${DocumentDirectoryPath}/${entry.filename}`;
if (await exists(filePath)) {
await unlink(filePath);
}
}
await dbRemoveDownload(trackId);
await removeDownload(trackId);
}
+7 -2
View File
@@ -3,7 +3,12 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { downloads } from './downloads';
import downloads from './entity';
export type Download = InferSelectModel<typeof downloads>;
export type Download = InferSelectModel<typeof downloads> & {
image?: string | null;
location?: string | null;
size?: number | null;
error?: string | null;
};
export type InsertDownload = typeof downloads.$inferInsert;
+61 -48
View File
@@ -1,56 +1,69 @@
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
import { persistStore, persistReducer, PersistConfig, createMigrate, PersistState } from 'redux-persist';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
import { drizzle } from 'drizzle-orm/op-sqlite';
import { open } from '@op-engineering/op-sqlite';
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
import migrations from './db/migrations/migrations.js';
import settings from './settings';
import sleepTimer from './sleep-timer';
import search from './search';
import { ColorScheme } from './settings/types';
import MigratedStorage from '@/utility/MigratedStorage';
// Import all schema tables
import sources from './sources/entity';
import appSettings from './settings/entity.js';
import sleepTimer from './sleep-timer/entity';
import artists from './artists/entity';
import albums from './albums/entity';
import tracks from './tracks/entity';
import playlists from './playlists/entity';
import downloads from './downloads/entity';
import searchQueries from './search-queries/entity';
import albumArtists from './album-artists/entity';
import trackArtists from './track-artists/entity';
import playlistTracks from './playlist-tracks/entity';
import albumSimilar from './album-similar/entity';
import syncCursors from './sync-cursors/entity';
const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
key: 'root',
storage: MigratedStorage,
version: 6,
stateReconciler: autoMergeLevel2,
migrate: createMigrate({
// @ts-expect-error migrations are poorly typed
6: (state: AppState & PersistState) => {
// Migration v6: Remove music and downloads from Redux
// These are now database-backed only. Intentionally discarding
// old Redux state as data is persisted in SQLite database.
return {
settings: state.settings,
sleepTimer: state.sleepTimer,
search: state.search,
};
},
})
// Combined schema for drizzle
const schema = {
sources,
appSettings,
sleepTimer,
artists,
albums,
tracks,
playlists,
downloads,
searchQueries,
albumArtists,
trackArtists,
playlistTracks,
albumSimilar,
syncCursors,
};
const reducers = combineReducers({
settings,
sleepTimer: sleepTimer.reducer,
search: search.reducer,
// Open the SQLite database
export const sqliteDb = open({
name: 'fintunes.db',
location: '../databases',
});
const persistedReducer = persistReducer(persistConfig, reducers);
// Create drizzle instance with schema - exported as singleton
export const db = drizzle(sqliteDb, { schema });
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) => (
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false })
),
});
/**
* Run database migrations
* Migrations should be generated using drizzle-kit
*/
export async function runMigrations() {
try {
await migrate(db, migrations);
console.log('Database migrations completed');
} catch (error) {
console.error('Migration error:', error);
throw error;
}
}
export type AppState = ReturnType<typeof reducers> & { _persist: PersistState };
export type AppDispatch = typeof store.dispatch;
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
export type Store = typeof store;
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const persistedStore = persistStore(store);
export default store;
/**
* Initialize the database
*/
export async function initializeDatabase() {
await runMigrations();
return db;
}
@@ -11,15 +11,15 @@
*/
import { useState, useEffect, useRef, useMemo } from "react";
import { sqliteDb } from '@/store/db'
import { sqliteDb } from '@/store'
import { QueryPromise } from 'drizzle-orm';
type DrizzleQuery = {
type DrizzleQuery<T> = QueryPromise<T> &{
toSQL: () => { sql: string; params: unknown[] };
then: (onfulfilled?: (value: any) => any) => Promise<any>;
};
type UseLiveQueryResult<T> = {
data: T[];
data: T | null;
error: Error | undefined;
};
@@ -54,9 +54,9 @@ function extractTableNames(sql: string): string[] {
* https://github.com/drizzle-team/drizzle-orm/issues/2926
*/
export function useLiveQuery<T>(
query: DrizzleQuery | undefined | null
query: DrizzleQuery<T> | undefined | null
): UseLiveQueryResult<T> {
const [data, setData] = useState<T[]>([]);
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
const unsubscribeRef = useRef<(() => void) | null>(null);
@@ -88,7 +88,7 @@ export function useLiveQuery<T>(
// Initial fetch via drizzle (preserves ORM transformations)
query
.then((result: T[]) => {
.then((result: T) => {
setData(result);
setError(undefined);
})
@@ -104,7 +104,7 @@ export function useLiveQuery<T>(
fireOn,
callback: (response) => {
// response.rows contains raw row data from reactive callback
setData(response.rows as T[]);
setData(response.rows as T);
},
});
} catch (e) {
-142
View File
@@ -1,142 +0,0 @@
import { AsyncThunkPayloadCreator, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist, MusicArtist } from './types';
import type { AsyncThunkAPI } from '..';
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
import { searchItem, SearchResult } from '@/utility/JellyfinApi/search';
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
export const albumAdapter = createEntityAdapter<Album, string>({
selectId: album => album.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
});
/**
* Fetch lyrics for a given track
*/
export const fetchLyricsByTrack = createAsyncThunk<Lyrics, string, AsyncThunkAPI>(
'/track/lyrics',
retrieveTrackLyrics,
);
/**
* Fetch codec metadata for a given track
*/
export const fetchCodecMetadataByTrack = createAsyncThunk<CodecMetadata, string, AsyncThunkAPI>(
'/track/codecMetadata',
retrieveTrackCodecMetadata,
);
/** A generic type for any action that retrieves tracks */
type AlbumTrackPayloadCreator = AsyncThunkPayloadCreator<AlbumTrack[], string, AsyncThunkAPI>;
/**
* This is a wrapper that postprocesses any tracks, so that we can also support
* lyrics, codec metadata and potential other applications.
*/
export const postProcessTracks = function(creator: AlbumTrackPayloadCreator): AlbumTrackPayloadCreator {
// Return a new payload creator
return async (args, thunkAPI) => {
// Retrieve the tracks using the original creator
const tracks = await creator(args, thunkAPI);
// GUARD: Check if we've retrieved any tracks
if (Array.isArray(tracks)) {
// If so, attempt to retrieve lyrics for the tracks that have them
tracks.filter((t) => t.HasLyrics)
.forEach((t) => thunkAPI.dispatch(fetchLyricsByTrack(t.Id)));
// Also, retrieve codec metadata
tracks.forEach((t) => thunkAPI.dispatch(fetchCodecMetadataByTrack(t.Id)));
}
return tracks;
};
};
/**
* Fetch all albums available on the jellyfin server
*/
export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI>(
'/albums/all',
retrieveAllAlbums,
);
/**
* Retrieve the most recent albums
*/
export const fetchRecentAlbums = createAsyncThunk<Album[], number | undefined, AsyncThunkAPI>(
'/albums/recent',
retrieveRecentAlbums,
);
export const trackAdapter = createEntityAdapter<AlbumTrack, string>({
selectId: track => track.Id,
sortComparer: (a, b) => a.IndexNumber - b.IndexNumber,
});
/**
* Retrieve all tracks from a particular album
*/
export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byAlbum',
postProcessTracks(retrieveAlbumTracks),
);
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
'/albums/single',
retrieveAlbum,
);
export const fetchSimilarAlbums = createAsyncThunk<Album[], string, AsyncThunkAPI>(
'/albums/similar',
retrieveSimilarAlbums,
);
export const searchAndFetch = createAsyncThunk<SearchResult[], { term: string, limit?: number }, AsyncThunkAPI>(
'/search',
async ({ term, limit = 24 }) => searchItem(term, limit)
);
export const playlistAdapter = createEntityAdapter<Playlist, string>({
selectId: (playlist) => playlist.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
});
/**
* Fetch all playlists available
*/
export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncThunkAPI>(
'/playlists/all',
retrieveAllPlaylists,
);
/**
* Retrieve all tracks from a particular playlist
*/
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byPlaylist',
postProcessTracks(retrievePlaylistTracks)
);
export const artistAdapter = createEntityAdapter<MusicArtist, string>({
selectId: artist => artist.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name)
});
/**
* Fetch all albums available on the jellyfin server
*/
export const fetchAllArtists = createAsyncThunk<MusicArtist[], undefined, AsyncThunkAPI>(
'/artists/all',
retrieveAllArtists
);
export const fetchInstantMixByTrackId = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/instantMix/byTrackId',
(trackId: string) => retrieveInstantMixByTrackId(trackId)
);
-498
View File
@@ -1,498 +0,0 @@
import { db, sqliteDb } from '@/store/db';
import { albums } from '@/store/albums/albums';
import { artists } from '@/store/artists/artists';
import { tracks } from '@/store/tracks/tracks';
import { playlists } from '@/store/playlists/playlists';
import { albumArtists } from '@/store/db/schema/album-artists';
import { trackArtists } from '@/store/db/schema/track-artists';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { albumSimilar } from '@/store/db/schema/album-similar';
import { eq, and, desc, inArray } from 'drizzle-orm';
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
/**
* ALBUMS
*/
export async function getAllAlbums() {
const result = await db.select().from(albums);
return result.map(album => enrichAlbum(album));
}
export async function getAlbum(id: string) {
const result = await db.select().from(albums).where(eq(albums.id, id)).limit(1);
if (!result[0]) return undefined;
return enrichAlbum(result[0]);
}
export async function getRecentAlbums(limit: number = 50) {
const result = await db
.select()
.from(albums)
.orderBy(desc(albums.dateCreated))
.limit(limit);
return result.map(album => enrichAlbum(album));
}
export async function upsertAlbum(sourceId: string, album: Album) {
const now = Date.now();
const metadata = extractAlbumMetadata(album);
await db.insert(albums).values({
sourceId,
id: album.Id,
name: album.Name,
productionYear: album.ProductionYear || null,
isFolder: album.IsFolder,
albumArtist: album.AlbumArtist || null,
dateCreated: album.DateCreated ? new Date(album.DateCreated).getTime() : now,
lastRefreshed: now,
metadataJson: JSON.stringify(metadata),
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: albums.id,
set: {
name: album.Name,
productionYear: album.ProductionYear || null,
albumArtist: album.AlbumArtist || null,
lastRefreshed: now,
metadataJson: JSON.stringify(metadata),
updatedAt: now,
},
});
// Update album-artist relations
await updateAlbumArtists(sourceId, album.Id, album.AlbumArtists);
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertAlbums(sourceId: string, albumList: Album[]) {
for (const album of albumList) {
await upsertAlbum(sourceId, album);
}
}
export async function getSimilarAlbums(albumId: string) {
const relations = await db
.select()
.from(albumSimilar)
.where(eq(albumSimilar.albumId, albumId));
if (relations.length === 0) return [];
const similarIds = relations.map(r => r.similarAlbumId);
const result = await db
.select()
.from(albums)
.where(inArray(albums.id, similarIds));
return result.map(album => enrichAlbum(album));
}
export async function setSimilarAlbums(sourceId: string, albumId: string, similarAlbumIds: string[]) {
// Delete existing relations
await db.delete(albumSimilar).where(eq(albumSimilar.albumId, albumId));
// Insert new relations
if (similarAlbumIds.length > 0) {
await db.insert(albumSimilar).values(
similarAlbumIds.map(similarId => ({
sourceId,
albumId,
similarAlbumId: similarId,
}))
);
}
sqliteDb.flushPendingReactiveQueries();
}
/**
* ARTISTS
*/
export async function getAllArtists() {
const result = await db.select().from(artists);
return result.map(artist => enrichArtist(artist));
}
export async function upsertArtist(sourceId: string, artist: MusicArtist) {
const now = Date.now();
const metadata = extractArtistMetadata(artist);
await db.insert(artists).values({
sourceId,
id: artist.Id,
name: artist.Name,
isFolder: artist.IsFolder,
metadataJson: JSON.stringify(metadata),
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: artists.id,
set: {
name: artist.Name,
metadataJson: JSON.stringify(metadata),
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertArtists(sourceId: string, artistList: MusicArtist[]) {
for (const artist of artistList) {
await upsertArtist(sourceId, artist);
}
}
/**
* TRACKS
*/
export async function getTracksByAlbum(albumId: string) {
const result = await db
.select()
.from(tracks)
.where(eq(tracks.albumId, albumId));
return result.map(track => enrichTrack(track));
}
export async function getTracksByPlaylist(playlistId: string) {
const relations = await db
.select()
.from(playlistTracks)
.where(eq(playlistTracks.playlistId, playlistId));
if (relations.length === 0) return [];
const trackIds = relations.map(r => r.trackId);
const result = await db
.select()
.from(tracks)
.where(inArray(tracks.id, trackIds));
// Sort by position in playlist
const trackMap = new Map(result.map(t => [t.id, t]));
return relations
.sort((a, b) => (a.position || 0) - (b.position || 0))
.map(r => trackMap.get(r.trackId))
.filter(Boolean)
.map(track => enrichTrack(track!));
}
export async function getTrack(id: string) {
const result = await db.select().from(tracks).where(eq(tracks.id, id)).limit(1);
if (!result[0]) return undefined;
return enrichTrack(result[0]);
}
export async function upsertTrack(sourceId: string, track: AlbumTrack) {
const now = Date.now();
const metadata = extractTrackMetadata(track);
await db.insert(tracks).values({
sourceId,
id: track.Id,
name: track.Name,
albumId: track.AlbumId || null,
album: track.Album || null,
albumArtist: track.AlbumArtist || null,
productionYear: track.ProductionYear || null,
indexNumber: track.IndexNumber || null,
parentIndexNumber: track.ParentIndexNumber || null,
hasLyrics: track.HasLyrics || false,
runTimeTicks: track.RunTimeTicks || null,
lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null,
metadataJson: JSON.stringify(metadata),
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: tracks.id,
set: {
name: track.Name,
album: track.Album || null,
albumArtist: track.AlbumArtist || null,
hasLyrics: track.HasLyrics || false,
lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null,
metadataJson: JSON.stringify(metadata),
updatedAt: now,
},
});
// Update track-artist relations
await updateTrackArtists(sourceId, track.Id, track.ArtistItems);
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertTracks(sourceId: string, trackList: AlbumTrack[]) {
for (const track of trackList) {
await upsertTrack(sourceId, track);
}
}
export async function updateTrackCodec(trackId: string, codec: any) {
const track = await getTrack(trackId);
if (!track) return;
const metadata = track.metadataJson ? JSON.parse(track.metadataJson) : {};
metadata.Codec = codec;
await db.update(tracks)
.set({
metadataJson: JSON.stringify(metadata),
updatedAt: Date.now(),
})
.where(eq(tracks.id, trackId));
sqliteDb.flushPendingReactiveQueries();
}
/**
* PLAYLISTS
*/
export async function getAllPlaylists() {
const result = await db.select().from(playlists);
return result.map(playlist => enrichPlaylist(playlist));
}
export async function getPlaylist(id: string) {
const result = await db.select().from(playlists).where(eq(playlists.id, id)).limit(1);
if (!result[0]) return undefined;
return enrichPlaylist(result[0]);
}
export async function upsertPlaylist(sourceId: string, playlist: Playlist) {
const now = Date.now();
const metadata = extractPlaylistMetadata(playlist);
await db.insert(playlists).values({
sourceId,
id: playlist.Id,
name: playlist.Name,
canDelete: playlist.CanDelete,
childCount: playlist.ChildCount || null,
lastRefreshed: now,
metadataJson: JSON.stringify(metadata),
createdAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: playlists.id,
set: {
name: playlist.Name,
canDelete: playlist.CanDelete,
childCount: playlist.ChildCount || null,
lastRefreshed: now,
metadataJson: JSON.stringify(metadata),
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
export async function upsertPlaylists(sourceId: string, playlistList: Playlist[]) {
for (const playlist of playlistList) {
await upsertPlaylist(sourceId, playlist);
}
}
export async function setPlaylistTracks(sourceId: string, playlistId: string, trackIds: string[]) {
// Delete existing relations
await db.delete(playlistTracks).where(
and(
eq(playlistTracks.sourceId, sourceId),
eq(playlistTracks.playlistId, playlistId)
)
);
// Insert new relations with positions
if (trackIds.length > 0) {
await db.insert(playlistTracks).values(
trackIds.map((trackId, index) => ({
sourceId,
playlistId,
trackId,
position: index,
}))
);
}
sqliteDb.flushPendingReactiveQueries();
}
/**
* HELPER FUNCTIONS
*/
async function updateAlbumArtists(sourceId: string, albumId: string, albumArtistsList: any[]) {
// Delete existing relations
await db.delete(albumArtists).where(
and(
eq(albumArtists.sourceId, sourceId),
eq(albumArtists.albumId, albumId)
)
);
// Insert new relations
if (albumArtistsList && albumArtistsList.length > 0) {
await db.insert(albumArtists).values(
albumArtistsList.map((artist, index) => ({
sourceId,
albumId,
artistId: artist.Id,
orderIndex: index,
}))
);
}
}
async function updateTrackArtists(sourceId: string, trackId: string, artistItems: any[]) {
// Delete existing relations
await db.delete(trackArtists).where(
and(
eq(trackArtists.sourceId, sourceId),
eq(trackArtists.trackId, trackId)
)
);
// Insert new relations
if (artistItems && artistItems.length > 0) {
await db.insert(trackArtists).values(
artistItems.map((artist, index) => ({
sourceId,
trackId,
artistId: artist.Id,
orderIndex: index,
}))
);
}
}
function extractAlbumMetadata(album: Album) {
return {
ServerId: album.ServerId,
SortName: album.SortName,
RunTimeTicks: album.RunTimeTicks,
Type: album.Type,
UserData: album.UserData,
PrimaryImageAspectRatio: album.PrimaryImageAspectRatio,
Artists: album.Artists,
ArtistItems: album.ArtistItems,
AlbumArtists: album.AlbumArtists,
ImageTags: album.ImageTags,
BackdropImageTags: album.BackdropImageTags,
LocationType: album.LocationType,
Overview: album.Overview,
PrimaryImageItemId: album.PrimaryImageItemId,
};
}
function enrichAlbum(dbAlbum: any): Album {
const metadata = dbAlbum.metadataJson ? JSON.parse(dbAlbum.metadataJson) : {};
return {
Id: dbAlbum.id,
Name: dbAlbum.name,
ProductionYear: dbAlbum.productionYear,
IsFolder: dbAlbum.isFolder,
AlbumArtist: dbAlbum.albumArtist,
DateCreated: dbAlbum.dateCreated ? new Date(dbAlbum.dateCreated).toISOString() : new Date().toISOString(),
lastRefreshed: dbAlbum.lastRefreshed,
...metadata,
};
}
function extractArtistMetadata(artist: MusicArtist) {
return {
ServerId: artist.ServerId,
ChannelId: artist.ChannelId,
RunTimeTicks: artist.RunTimeTicks,
Type: artist.Type,
UserData: artist.UserData,
ImageTags: artist.ImageTags,
BackdropImageTags: artist.BackdropImageTags,
ImageBlurHashes: artist.ImageBlurHashes,
LocationType: artist.LocationType,
Overview: artist.Overview,
};
}
function enrichArtist(dbArtist: any): MusicArtist {
const metadata = dbArtist.metadataJson ? JSON.parse(dbArtist.metadataJson) : {};
return {
Id: dbArtist.id,
Name: dbArtist.name,
IsFolder: dbArtist.isFolder,
...metadata,
};
}
function extractTrackMetadata(track: AlbumTrack) {
return {
ServerId: track.ServerId,
Type: track.Type,
UserData: track.UserData,
Artists: track.Artists,
ArtistItems: track.ArtistItems,
AlbumPrimaryImageTag: track.AlbumPrimaryImageTag,
AlbumArtists: track.AlbumArtists,
ImageTags: track.ImageTags,
BackdropImageTags: track.BackdropImageTags,
LocationType: track.LocationType,
MediaType: track.MediaType,
MediaStreams: track.MediaStreams,
Codec: track.Codec,
};
}
function enrichTrack(dbTrack: any): AlbumTrack {
const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {};
const lyrics = dbTrack.lyrics ? JSON.parse(dbTrack.lyrics) : undefined;
return {
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,
Lyrics: lyrics,
...metadata,
};
}
function extractPlaylistMetadata(playlist: Playlist) {
return {
ServerId: playlist.ServerId,
SortName: playlist.SortName,
ChannelId: playlist.ChannelId,
RunTimeTicks: playlist.RunTimeTicks,
Type: playlist.Type,
UserData: playlist.UserData,
PrimaryImageAspectRatio: playlist.PrimaryImageAspectRatio,
ImageTags: playlist.ImageTags,
BackdropImageTags: playlist.BackdropImageTags,
LocationType: playlist.LocationType,
MediaType: playlist.MediaType,
};
}
function enrichPlaylist(dbPlaylist: any): Playlist {
const metadata = dbPlaylist.metadataJson ? JSON.parse(dbPlaylist.metadataJson) : {};
return {
Id: dbPlaylist.id,
Name: dbPlaylist.name,
CanDelete: dbPlaylist.canDelete,
ChildCount: dbPlaylist.childCount,
lastRefreshed: dbPlaylist.lastRefreshed,
...metadata,
};
}
+248 -178
View File
@@ -1,192 +1,262 @@
/**
* Bridge functions to fetch from Jellyfin API and store in database
* These replace the Redux thunks with direct database operations
*/
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
import { searchItem } from '@/utility/JellyfinApi/search';
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
import { getActiveSource } from '@/store/settings/db';
import * as musicDb from './db';
import { db } from '@/store';
import sources from '@/store/sources/entity';
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
import {
upsertAlbum,
upsertAlbums,
upsertArtist,
upsertArtists,
upsertPlaylist,
upsertPlaylists,
upsertTrack,
upsertTracks,
setPlaylistTracks,
setSimilarAlbums,
} from './db';
/**
* Fetch all albums from Jellyfin and store in database
*/
export async function fetchAndStoreAllAlbums() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const DEFAULT_LIMIT = 500;
const albums = await retrieveAllAlbums();
await musicDb.upsertAlbums(source.id, albums);
return albums;
type DriverWithSource = {
driver: SourceDriver;
source: Source;
};
async function getDriver(): Promise<DriverWithSource | null> {
const result = await db.select().from(sources).limit(1);
const row = result[0];
if (!row) {
return null;
}
const source: Source = {
id: row.id,
uri: row.uri,
userId: row.userId || undefined,
accessToken: row.accessToken || undefined,
deviceId: row.deviceId || undefined,
type: row.type as SourceType,
};
if (source.type.startsWith('jellyfin')) {
return { driver: new JellyfinDriver(source), source };
}
if (source.type.startsWith('emby')) {
return { driver: new EmbyDriver(source), source };
}
return null;
}
/**
* Fetch recent albums from Jellyfin and store in database
*/
export async function fetchAndStoreRecentAlbums() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
async function fetchAllPages<T>(
fetchPage: (offset: number, limit: number) => Promise<T[]>,
limit: number = DEFAULT_LIMIT,
): Promise<T[]> {
const results: T[] = [];
let offset = 0;
const albums = await retrieveRecentAlbums();
await musicDb.upsertAlbums(source.id, albums);
return albums;
}
/**
* Fetch a single album from Jellyfin and store in database
*/
export async function fetchAndStoreAlbum(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const album = await retrieveAlbum(albumId);
await musicDb.upsertAlbum(source.id, album);
return album;
}
/**
* Fetch similar albums from Jellyfin and store in database
*/
export async function fetchAndStoreSimilarAlbums(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const similarAlbums = await retrieveSimilarAlbums(albumId);
await musicDb.upsertAlbums(source.id, similarAlbums);
await musicDb.setSimilarAlbums(source.id, albumId, similarAlbums.map(a => a.Id));
return similarAlbums;
}
/**
* Fetch tracks by album from Jellyfin and store in database
*/
export async function fetchAndStoreTracksByAlbum(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const tracks = await retrieveAlbumTracks(albumId);
await musicDb.upsertTracks(source.id, tracks);
// Fetch codec metadata and lyrics for tracks
await Promise.all(tracks.map(async (track) => {
if (track.HasLyrics) {
try {
const lyrics = await retrieveTrackLyrics(track.Id);
track.Lyrics = lyrics;
await musicDb.upsertTrack(source.id, track);
} catch (e) {
console.error('Error fetching lyrics for track', track.Id, e);
}
while (true) {
const page = await fetchPage(offset, limit);
if (!page.length) {
break;
}
try {
const codec = await retrieveTrackCodecMetadata(track.Id);
await musicDb.updateTrackCodec(track.Id, codec);
} catch (e) {
console.error('Error fetching codec for track', track.Id, e);
results.push(...page);
offset += page.length;
if (page.length < limit) {
break;
}
}));
return tracks;
}
/**
* Fetch all artists from Jellyfin and store in database
*/
export async function fetchAndStoreAllArtists() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const artists = await retrieveAllArtists();
await musicDb.upsertArtists(source.id, artists);
return artists;
}
/**
* Fetch all playlists from Jellyfin and store in database
*/
export async function fetchAndStoreAllPlaylists() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const playlists = await retrieveAllPlaylists();
await musicDb.upsertPlaylists(source.id, playlists);
return playlists;
}
/**
* Fetch tracks by playlist from Jellyfin and store in database
*/
export async function fetchAndStoreTracksByPlaylist(playlistId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const tracks = await retrievePlaylistTracks(playlistId);
await musicDb.upsertTracks(source.id, tracks);
await musicDb.setPlaylistTracks(source.id, playlistId, tracks.map(t => t.Id));
// Fetch codec metadata and lyrics for tracks
await Promise.all(tracks.map(async (track) => {
if (track.HasLyrics) {
try {
const lyrics = await retrieveTrackLyrics(track.Id);
track.Lyrics = lyrics;
await musicDb.upsertTrack(source.id, track);
} catch (e) {
console.error('Error fetching lyrics for track', track.Id, e);
}
}
try {
const codec = await retrieveTrackCodecMetadata(track.Id);
await musicDb.updateTrackCodec(track.Id, codec);
} catch (e) {
console.error('Error fetching codec for track', track.Id, e);
}
}));
return tracks;
}
/**
* Search Jellyfin and store results in database
*/
export async function searchAndStore(term: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const results = await searchItem(term);
// Separate results by type
const albums = results.filter(item => item.Type === 'MusicAlbum') as Album[];
const tracks = results.filter(item => item.Type === 'Audio') as AlbumTrack[];
const artists = results.filter(item => item.Type === 'MusicArtist') as MusicArtist[];
const playlists = results.filter(item => item.Type === 'Playlist') as Playlist[];
// Store in database
await Promise.all([
musicDb.upsertAlbums(source.id, albums),
musicDb.upsertTracks(source.id, tracks),
musicDb.upsertArtists(source.id, artists),
musicDb.upsertPlaylists(source.id, playlists),
]);
}
return results;
}
/**
* Fetch instant mix by track and store in database
*/
export async function fetchAndStoreInstantMixByTrack(trackId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
function mapArtist(artist: { id: string; name: string; isFolder?: boolean; metadataJson?: string | null }): MusicArtist {
const metadata = artist.metadataJson ? JSON.parse(artist.metadataJson) : {};
const tracks = await retrieveInstantMixByTrackId(trackId);
await musicDb.upsertTracks(source.id, tracks);
return tracks;
return {
Id: artist.id,
Name: artist.name,
IsFolder: artist.isFolder,
...metadata,
};
}
function mapArtistItems(items?: Array<{ id: string; name: string; metadataJson?: string | null }>): Array<{ Id: string; Name?: string; [key: string]: unknown }> {
if (!items) {
return [];
}
return items.map((item) => ({
Id: item.id,
Name: item.name,
...(item.metadataJson ? JSON.parse(item.metadataJson) : {}),
}));
}
function mapAlbum(album: {
id: string;
name: string;
productionYear?: number | null;
isFolder?: boolean;
albumArtist?: string | null;
dateCreated?: string | null;
artistItems?: Array<{ id: string; name: string; metadataJson?: string | null }>;
metadataJson?: string | null;
}): Album {
const metadata = album.metadataJson ? JSON.parse(album.metadataJson) : {};
return {
Id: album.id,
Name: album.name,
ProductionYear: album.productionYear || undefined,
IsFolder: album.isFolder,
AlbumArtist: album.albumArtist || undefined,
DateCreated: album.dateCreated || undefined,
ArtistItems: mapArtistItems(album.artistItems),
...metadata,
};
}
function mapTrack(track: {
id: string;
name: string;
albumId?: string | null;
album?: string | null;
albumArtist?: string | null;
productionYear?: number | null;
indexNumber?: number | null;
parentIndexNumber?: number | null;
runTimeTicks?: number | null;
artistItems?: Array<{ id: string; name: string; metadataJson?: string | null }>;
metadataJson?: string | null;
}): AlbumTrack {
const metadata = track.metadataJson ? JSON.parse(track.metadataJson) : {};
return {
Id: track.id,
Name: track.name,
AlbumId: track.albumId || undefined,
Album: track.album || undefined,
AlbumArtist: track.albumArtist || undefined,
ProductionYear: track.productionYear || undefined,
IndexNumber: track.indexNumber || undefined,
ParentIndexNumber: track.parentIndexNumber || undefined,
RunTimeTicks: track.runTimeTicks || undefined,
ArtistItems: mapArtistItems(track.artistItems),
...metadata,
};
}
function mapPlaylist(playlist: {
id: string;
name: string;
canDelete?: boolean;
childCount?: number | null;
metadataJson?: string | null;
}): Playlist {
const metadata = playlist.metadataJson ? JSON.parse(playlist.metadataJson) : {};
return {
Id: playlist.id,
Name: playlist.name,
CanDelete: playlist.canDelete,
ChildCount: playlist.childCount || undefined,
...metadata,
};
}
export async function fetchAndStoreAllArtists(): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const artists = await fetchAllPages((offset, limit) => driver.getArtists({ offset, limit }));
await upsertArtists(source.id, artists.map(mapArtist));
}
export async function fetchAndStoreAllAlbums(): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const albums = await fetchAllPages((offset, limit) => driver.getAlbums({ offset, limit }));
await upsertAlbums(source.id, albums.map(mapAlbum));
}
export async function fetchAndStoreRecentAlbums(): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const albums = await driver.getRecentAlbums({ limit: 24 });
await upsertAlbums(source.id, albums.map(mapAlbum));
}
export async function fetchAndStoreAllPlaylists(): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const playlists = await fetchAllPages((offset, limit) => driver.getPlaylists({ offset, limit }));
await upsertPlaylists(source.id, playlists.map(mapPlaylist));
}
export async function fetchAndStoreAlbum(albumId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const album = await driver.getAlbum(albumId);
await upsertAlbum(source.id, mapAlbum(album));
}
export async function fetchAndStoreTracksByAlbum(albumId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const tracks = await fetchAllPages((offset, limit) => driver.getTracksByAlbum(albumId, { offset, limit }));
await upsertTracks(source.id, tracks.map(mapTrack));
}
export async function fetchAndStoreTracksByPlaylist(playlistId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const tracks = await fetchAllPages((offset, limit) => driver.getTracksByPlaylist(playlistId, { offset, limit }));
const mappedTracks = tracks.map(mapTrack);
await upsertTracks(source.id, mappedTracks);
await setPlaylistTracks(source.id, playlistId, mappedTracks.map((track) => track.Id));
}
export async function fetchAndStoreSimilarAlbums(albumId: string): Promise<void> {
const driverResult = await getDriver();
if (!driverResult) {
return;
}
const { driver, source } = driverResult;
const albums = await driver.getSimilarAlbums(albumId, { limit: 20 });
const mapped = albums.map(mapAlbum);
await upsertAlbums(source.id, mapped);
await setSimilarAlbums(source.id, albumId, mapped.map((album) => album.Id));
}
+27 -17
View File
@@ -4,13 +4,13 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { albums } from '@/store/albums/albums';
import { artists } from '@/store/artists/artists';
import { tracks } from '@/store/tracks/tracks';
import { playlists } from '@/store/playlists/playlists';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import albums from '@/store/albums/entity';
import artists from '@/store/artists/entity';
import tracks from '@/store/tracks/entity';
import playlists from '@/store/playlists/entity';
import playlistTracks from '@/store/playlist-tracks/entity';
import { eq, desc, inArray } from 'drizzle-orm';
import { ALPHABET_LETTERS } from '@/CONSTANTS';
import type { SectionListData } from 'react-native';
@@ -19,9 +19,11 @@ import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
/**
* Get all albums (from all sources)
*/
export function useAlbums() {
export function useAlbums(sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(albums)
sourceId
? db.select().from(albums).where(eq(albums.sourceId, sourceId))
: db.select().from(albums)
);
return useMemo(() => {
@@ -50,9 +52,11 @@ export function useAlbums() {
/**
* Get recent albums (sorted by date created, from all sources)
*/
export function useRecentAlbums(amount: number = 24) {
export function useRecentAlbums(amount: number = 24, sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(albums).orderBy(desc(albums.dateCreated)).limit(amount)
sourceId
? db.select().from(albums).where(eq(albums.sourceId, sourceId)).orderBy(desc(albums.dateCreated)).limit(amount)
: db.select().from(albums).orderBy(desc(albums.dateCreated)).limit(amount)
);
return useMemo(() => {
@@ -115,9 +119,11 @@ export function useAlbumsByAlphabet() {
/**
* Get all artists (from all sources)
*/
export function useArtists() {
export function useArtists(sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(artists)
sourceId
? db.select().from(artists).where(eq(artists.sourceId, sourceId))
: db.select().from(artists)
);
return useMemo(() => {
@@ -167,9 +173,11 @@ export function useArtistsByAlphabet() {
/**
* Get all playlists (from all sources)
*/
export function usePlaylists() {
export function usePlaylists(sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(playlists)
sourceId
? db.select().from(playlists).where(eq(playlists.sourceId, sourceId))
: db.select().from(playlists)
);
return useMemo(() => {
@@ -258,9 +266,11 @@ export function useTracksByPlaylist(playlistId: string) {
/**
* Get all tracks (from all sources)
*/
export function useTracks() {
export function useTracks(sourceId?: string) {
const { data, error } = useLiveQuery(
db.select().from(tracks)
sourceId
? db.select().from(tracks).where(eq(tracks.sourceId, sourceId))
: db.select().from(tracks)
);
return useMemo(() => {
+68 -146
View File
@@ -1,175 +1,97 @@
export interface UserData {
PlaybackPositionTicks: number;
PlayCount: number;
IsFavorite: boolean;
Played: boolean;
Key: string;
export interface ArtistItem {
Id: string;
Name?: string;
PrimaryImageItemId?: string;
ImageTags?: {
Primary?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface MediaStream {
Codec: string
TimeBase: string
VideoRange: string
VideoRangeType: string
AudioSpatialFormat: string
DisplayTitle: string
IsInterlaced: boolean
ChannelLayout: string
BitRate: number
Channels: number
SampleRate: number
IsDefault: boolean
IsForced: boolean
IsHearingImpaired: boolean
Type: string
Index: number
IsExternal: boolean
IsTextSubtitleStream: boolean
SupportsExternalStream: boolean
Level: number
}
export interface ArtistItem {
Name: string;
Id: string;
Type?: string;
Codec?: string;
BitRate?: number;
SampleRate?: number;
[key: string]: unknown;
}
export interface AlbumArtist {
Name: string;
Id: string;
export interface TrackLyricsLine {
Start: number;
Text?: string;
[key: string]: unknown;
}
export interface MusicArtist {
Name: string;
ServerId: string;
Id: string;
ChannelId: string;
RunTimeTicks: number;
IsFolder: boolean;
UserData: UserData;
Type: 'MusicArtist';
ImageTags: ImageTags;
BackdropImageTags: any[];
ImageBlurHashes: any;
LocationType: string;
Overview: string;
export interface TrackLyrics {
Lyrics: TrackLyricsLine[];
[key: string]: unknown;
}
export interface ImageTags {
Primary: string;
export interface CodecInfo {
isDirectPlay?: boolean;
contentType?: string;
[key: string]: unknown;
}
export interface Album {
Name: string;
ServerId: string;
Id: string;
SortName: string;
RunTimeTicks: number;
ProductionYear: number;
IsFolder: boolean;
Type: 'MusicAlbum';
UserData: UserData;
PrimaryImageAspectRatio: number;
Artists: string[];
ArtistItems: ArtistItem[];
AlbumArtist?: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
Tracks?: string[];
lastRefreshed?: number;
DateCreated: string;
Name: string;
AlbumArtist?: string | null;
Artists?: string[];
ArtistItems?: ArtistItem[];
Overview?: string;
Similar?: string[];
/** Emby potentially carries different ids for primary images */
PrimaryImageItemId?: string;
DateCreated?: string;
ProductionYear?: number | null;
IsFolder?: boolean;
lastRefreshed?: number;
Similar?: string[];
[key: string]: unknown;
}
export interface CodecMetadata {
contentType?: string;
isDirectPlay: boolean;
}
export interface LyricMetadata {
Artist: string
Album: string
Title: string
Author: string
Length: number
By: string
Offset: number
Creator: string
Version: string
IsSynced: boolean
}
export interface LyricData {
Text: string
Start: number
}
export interface Lyrics {
Metadata: LyricMetadata;
Lyrics: LyricData[]
export interface MusicArtist {
Id: string;
Name: string;
IsFolder?: boolean;
Overview?: string;
PrimaryImageItemId?: string;
ImageTags?: {
Primary?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface AlbumTrack {
Name: string;
ServerId: string;
Id: string;
RunTimeTicks: number;
ProductionYear: number;
IndexNumber: number;
ParentIndexNumber: number;
IsFolder: boolean;
Type: 'Audio';
UserData: UserData;
Artists: string[];
ArtistItems: ArtistItem[];
Album: string;
AlbumId: string;
AlbumPrimaryImageTag: string;
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
HasLyrics: boolean;
Lyrics?: Lyrics;
Codec?: CodecMetadata;
MediaStreams: MediaStream[];
}
export interface State {
albums: {
ids: string[];
entities: Record<string, Album>;
isLoading: boolean;
}
Name: string;
AlbumId?: string | null;
Album?: string | null;
AlbumArtist?: string | null;
Artists?: string[];
ArtistItems?: ArtistItem[];
ProductionYear?: number | null;
IndexNumber?: number | null;
ParentIndexNumber?: number | null;
RunTimeTicks?: number | null;
HasLyrics?: boolean;
Lyrics?: TrackLyrics;
MediaStreams?: MediaStream[];
Codec?: CodecInfo;
PrimaryImageItemId?: string;
[key: string]: unknown;
}
export interface Playlist {
Name: string;
ServerId: string;
Id: string;
CanDelete: boolean;
SortName: string;
ChannelId?: any;
RunTimeTicks: number;
IsFolder: boolean;
Type: 'Playlist';
UserData: UserData;
PrimaryImageAspectRatio: number;
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
ChildCount?: number;
Tracks?: string[];
Name: string;
CanDelete?: boolean;
ChildCount?: number | null;
lastRefreshed?: number;
Overview?: string;
PrimaryImageItemId?: string;
[key: string]: unknown;
}
// Type alias for section list artist items
export type SectionArtistItem = MusicArtist;
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Playlist-Tracks relation table (many-to-many with position)
*/
export const playlistTracks = sqliteTable('playlist_tracks', {
const playlistTracks = sqliteTable('playlist_tracks', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
playlistId: text('playlist_id').notNull(),
trackId: text('track_id').notNull(),
@@ -13,3 +13,5 @@ export const playlistTracks = sqliteTable('playlist_tracks', {
pk: primaryKey({ columns: [table.sourceId, table.playlistId, table.trackId] }),
sourcePlaylistPositionIdx: index('playlist_tracks_source_playlist_position_idx').on(table.sourceId, table.playlistId, table.position),
}));
export default playlistTracks;
+3 -3
View File
@@ -2,9 +2,9 @@
* Database actions for playlists
*/
import { db, sqliteDb } from '@/store/db';
import { playlists } from './playlists';
import { playlistTracks } from '@/store/db/schema/playlist-tracks';
import { db, sqliteDb } from '@/store';
import playlists from './entity';
import playlistTracks from '@/store/playlist-tracks/entity';
import { eq, and } from 'drizzle-orm';
import type { InsertPlaylist } from './types';
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Playlists table
*/
export const playlists = sqliteTable('playlists', {
const playlists = sqliteTable('playlists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -17,3 +17,5 @@ export const playlists = sqliteTable('playlists', {
}, (table) => ({
sourceNameIdx: index('playlists_source_name_idx').on(table.sourceId, table.name),
}));
export default playlists;
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { playlists } from './playlists';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import playlists from './entity';
import { eq } from 'drizzle-orm';
import type { Playlist } from './types';
-19
View File
@@ -1,19 +0,0 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/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),
}));
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { playlists } from './playlists';
import playlists from './entity';
export type Playlist = InferSelectModel<typeof playlists>;
export type InsertPlaylist = typeof playlists.$inferInsert;
+10 -20
View File
@@ -8,17 +8,17 @@
*/
import PQueue from 'p-queue';
import { db } from '../db/client';
import type { SourceDriver } from '../sources/types';
import { syncCursors } from '../db/schema/sync-cursors';
import { artists } from '../artists/artists';
import { albums } from '../albums/albums';
import { playlists } from '../playlists/playlists';
import { tracks } from '../tracks/tracks';
import { playlistTracks } from '../db/schema/playlist-tracks';
import { albumSimilar } from '../db/schema/album-similar';
import { eq, and } from 'drizzle-orm';
import { invalidateTable } from '../db/live-queries';
import { db } from '@/store/db';
import type { SourceDriver } from '@/store/sources/types';
import syncCursors from '@/store/sync-cursors/entity';
import artists from '@/store/artists/entity';
import albums from '@/store/albums/entity';
import playlists from '@/store/playlists/entity';
import tracks from '@/store/tracks/entity';
import playlistTracks from '@/store/playlist-tracks/entity';
import albumSimilar from '@/store/album-similar/entity';
/**
* Entity types that can be prefilled
@@ -197,7 +197,6 @@ export class PrefillManager {
if (artistsData.length === 0) {
this.updateProgress(entityType, { isComplete: true });
await this.updateSyncCursor(entityType, offset, true);
invalidateTable('artists');
return;
}
@@ -231,7 +230,6 @@ export class PrefillManager {
});
await this.updateSyncCursor(entityType, newOffset, false);
invalidateTable('artists');
// If we got a full page, recursively spawn the next page fetch
if (artistsData.length === this.config.pageSize) {
@@ -264,7 +262,6 @@ export class PrefillManager {
if (albumsData.length === 0) {
this.updateProgress(entityType, { isComplete: true });
await this.updateSyncCursor(entityType, offset, true);
invalidateTable('albums');
return;
}
@@ -305,7 +302,6 @@ export class PrefillManager {
});
await this.updateSyncCursor(entityType, newOffset, false);
invalidateTable('albums');
// If we got a full page, recursively spawn the next page fetch
if (albumsData.length === this.config.pageSize) {
@@ -338,7 +334,6 @@ export class PrefillManager {
if (playlistsData.length === 0) {
this.updateProgress(entityType, { isComplete: true });
await this.updateSyncCursor(entityType, offset, true);
invalidateTable('playlists');
return;
}
@@ -375,7 +370,6 @@ export class PrefillManager {
});
await this.updateSyncCursor(entityType, newOffset, false);
invalidateTable('playlists');
// If we got a full page, recursively spawn the next page fetch
if (playlistsData.length === this.config.pageSize) {
@@ -447,7 +441,6 @@ export class PrefillManager {
totalInserted: this.progress[entityType].totalInserted + tracksData.length,
});
invalidateTable('tracks');
// Recursively fetch next page if this was a full page
if (tracksData.length === this.config.pageSize) {
@@ -528,8 +521,6 @@ export class PrefillManager {
totalInserted: this.progress[entityType].totalInserted + tracksData.length,
});
invalidateTable('tracks');
invalidateTable('playlist_tracks');
// Recursively fetch next page if this was a full page
if (tracksData.length === this.config.pageSize) {
@@ -573,7 +564,6 @@ export class PrefillManager {
totalInserted: this.progress[EntityType.SIMILAR_ALBUMS].totalInserted + similarAlbums.length,
});
invalidateTable('album_similar');
} catch (error) {
// Similar albums are optional, silently fail
console.debug(`Could not fetch similar albums for ${albumId}:`, error);
+2 -2
View File
@@ -2,8 +2,8 @@
* Database actions for search queries
*/
import { db, sqliteDb } from '@/store/db';
import { searchQueries } from './search-queries';
import { db, sqliteDb } from '@/store';
import searchQueries from './entity';
import { eq } from 'drizzle-orm';
import type { InsertSearchQuery } from './types';
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/sources';
import sources from '../sources/entity';
/**
* Search queries table
*/
export const searchQueries = sqliteTable('search_queries', {
const searchQueries = sqliteTable('search_queries', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
query: text('query').notNull(),
@@ -16,3 +16,5 @@ export const searchQueries = sqliteTable('search_queries', {
}, (table) => ({
sourceTimestampIdx: index('search_queries_source_timestamp_idx').on(table.sourceId, table.timestamp),
}));
export default searchQueries;
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { searchQueries } from './search-queries';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import searchQueries from './entity';
import { eq, desc } from 'drizzle-orm';
import type { SearchQuery } from './types';
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { searchQueries } from './search-queries';
import searchQueries from './entity';
export type SearchQuery = InferSelectModel<typeof searchQueries>;
export type InsertSearchQuery = typeof searchQueries.$inferInsert;
-90
View File
@@ -1,90 +0,0 @@
import { db, sqliteDb } from '@/store/db';
import { searchQueries } from '@/store/search-queries/search-queries';
import type { SearchQuery } from '@/store/db/types';
import { desc, eq } from 'drizzle-orm';
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
export interface SearchQueryDisplay {
query: string;
filters: SearchType[];
localPlaybackOnly: boolean;
timestamp: number;
}
/**
* Get search history for a source (ordered by timestamp desc, limit 10)
*/
export async function getSearchHistory(sourceId: string): Promise<SearchQuery[]> {
const result = await db
.select()
.from(searchQueries)
.where(eq(searchQueries.sourceId, sourceId))
.orderBy(desc(searchQueries.timestamp))
.limit(10);
return result as SearchQuery[];
}
/**
* Add a search query to history
*/
export async function addSearchQuery(
sourceId: string,
query: string,
filters: SearchType[],
localPlaybackOnly: boolean
): Promise<void> {
const now = Date.now();
const id = `${sourceId}-${query}-${filters.sort().join(',')}-${localPlaybackOnly}`;
const metadata = { filters };
// Delete existing query with same parameters (to move it to top)
await db.delete(searchQueries).where(eq(searchQueries.id, id));
// Insert new query
await db.insert(searchQueries).values({
sourceId,
id,
query,
timestamp: now,
localPlaybackOnly,
metadataJson: JSON.stringify(metadata),
createdAt: now,
updatedAt: now,
});
// Keep only last 10 queries for this source
const allQueries = await getSearchHistory(sourceId);
if (allQueries.length > 10) {
const idsToDelete = allQueries.slice(10).map(q => q.id);
for (const id of idsToDelete) {
await db.delete(searchQueries).where(eq(searchQueries.id, id));
}
}
sqliteDb.flushPendingReactiveQueries();
}
/**
* Clear all search history for a source
*/
export async function clearSearchHistory(sourceId: string): Promise<void> {
await db.delete(searchQueries).where(eq(searchQueries.sourceId, sourceId));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Parse search queries to display format
*/
export function parseSearchQueries(queries: SearchQuery[]): SearchQueryDisplay[] {
return queries.map(q => {
const metadata = q.metadataJson ? JSON.parse(q.metadataJson) : { filters: [] };
return {
query: q.query,
filters: metadata.filters || [],
localPlaybackOnly: q.localPlaybackOnly,
timestamp: q.timestamp,
};
});
}
-53
View File
@@ -1,53 +0,0 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist';
export interface SearchQuery {
query: string;
filters: SearchType[];
localPlaybackOnly: boolean;
timestamp: number;
}
export interface State {
queryHistory: SearchQuery[];
}
export const initialState: State = {
queryHistory: [],
};
const search = createSlice({
name: 'search',
initialState,
reducers: {
addSearchQuery(state, action: PayloadAction<Omit<SearchQuery, 'timestamp'>>) {
const newQuery: SearchQuery = {
...action.payload,
timestamp: Date.now(),
};
// Remove duplicate queries (same query and filters)
state.queryHistory = state.queryHistory.filter(
item => !(item.query === newQuery.query &&
JSON.stringify(item.filters.sort()) === JSON.stringify(newQuery.filters.sort()) &&
item.localPlaybackOnly === newQuery.localPlaybackOnly)
);
// Add new query to the beginning
state.queryHistory.unshift(newQuery);
// Keep only the last 10 queries
if (state.queryHistory.length > 10) {
state.queryHistory = state.queryHistory.slice(0, 10);
}
},
clearSearchHistory(state) {
state.queryHistory = [];
},
},
});
export const { addSearchQuery, clearSearchHistory } = search.actions;
export default search;
+92 -8
View File
@@ -1,9 +1,93 @@
import { createAction } from '@reduxjs/toolkit';
import { ColorScheme } from './types';
import { db, sqliteDb } from '@/store';
import settings from '@/store/settings/entity';
import sources from '@/store/sources/entity';
import { eq } from 'drizzle-orm';
import { AppSettings, ColorScheme } from './types';
export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; type: 'jellyfin' | 'emby' }>('SET_JELLYFIN_CREDENTIALS');
export const setBitrate = createAction<number>('SET_BITRATE');
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT');
export const setEnablePlaybackReporting = createAction<boolean>('SET_ENABLE_PLAYBACK_REPORTING');
export const setColorScheme = createAction<ColorScheme>('SET_COLOR_SCHEME');
/**
* Get app settings (single row, id=1)
*/
export async function getAppSettings(): Promise<AppSettings | undefined> {
const result = await db.select().from(settings)
.where(eq(settings.id, 1))
.limit(1);
return result[0];
}
/**
* Initialize app settings with defaults if not exists
*/
export async function initializeAppSettings(): Promise<AppSettings> {
const existing = await getAppSettings();
if (existing) {
return existing;
}
const now = Date.now();
const defaults: typeof settings.$inferInsert = {
id: 1,
bitrate: 140000000,
isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false,
enablePlaybackReporting: true,
colorScheme: ColorScheme.System,
createdAt: now,
updatedAt: now,
};
await db.insert(settings).values(defaults);
sqliteDb.flushPendingReactiveQueries();
return defaults as AppSettings;
}
/**
* Update app settings
*/
export async function updateAppSettings(updates: Partial<Omit<AppSettings, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void> {
await db.update(settings)
.set({ ...updates, updatedAt: Date.now() })
.where(eq(settings.id, 1));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Set Jellyfin/Emby credentials
*/
export async function setCredentials(credentials: {
uri: string;
userId: string;
accessToken: string;
deviceId: string;
type: 'jellyfin' | 'emby';
}): Promise<void> {
const now = Date.now();
const sourceType = credentials.type === 'jellyfin' ? 'jellyfin.v1' : 'emby.v1';
// Use deviceId as the source id for consistency
const sourceId = credentials.deviceId;
await db.insert(sources)
.values({
id: sourceId,
uri: credentials.uri,
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
type: sourceType,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sources.id,
set: {
uri: credentials.uri,
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
type: sourceType,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
-139
View File
@@ -1,139 +0,0 @@
import { db, sqliteDb } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { sources } from '@/store/db/schema/sources';
import type { AppSettings as AppSettingsType, Source } from '@/store/db/types';
import { eq } from 'drizzle-orm';
import { ColorScheme } from './types';
// Re-export for convenience
export type AppSettings = AppSettingsType;
export type SourceCredentials = Source;
/**
* Get app settings (single row, id=1)
*/
export async function getAppSettings(): Promise<AppSettings | undefined> {
const result = await db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1);
return result[0] as AppSettings | undefined;
}
/**
* Initialize app settings with defaults if not exists
*/
export async function initializeAppSettings(): Promise<AppSettings> {
const existing = await getAppSettings();
if (existing) {
return existing;
}
const now = Date.now();
const defaults: typeof appSettings.$inferInsert = {
id: 1,
bitrate: 140000000,
isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false,
enablePlaybackReporting: true,
colorScheme: ColorScheme.System,
createdAt: now,
updatedAt: now,
};
await db.insert(appSettings).values(defaults);
sqliteDb.flushPendingReactiveQueries();
return defaults as AppSettings;
}
/**
* Update app settings
*/
export async function updateAppSettings(updates: Partial<Omit<AppSettings, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void> {
await db.update(appSettings)
.set({ ...updates, updatedAt: Date.now() })
.where(eq(appSettings.id, 1));
sqliteDb.flushPendingReactiveQueries();
}
/**
* Set bitrate
*/
export async function setBitrate(bitrate: number): Promise<void> {
await updateAppSettings({ bitrate });
}
/**
* Set onboarding status
*/
export async function setOnboardingStatus(isOnboardingComplete: boolean): Promise<void> {
await updateAppSettings({ isOnboardingComplete });
}
/**
* Set error reporting alert received
*/
export async function setReceivedErrorReportingAlert(): Promise<void> {
await updateAppSettings({ hasReceivedErrorReportingAlert: true });
}
/**
* Set enable playback reporting
*/
export async function setEnablePlaybackReporting(enablePlaybackReporting: boolean): Promise<void> {
await updateAppSettings({ enablePlaybackReporting });
}
/**
* Set color scheme
*/
export async function setColorScheme(colorScheme: ColorScheme): Promise<void> {
await updateAppSettings({ colorScheme });
}
/**
* Get active source (credentials)
*/
export async function getActiveSource(): Promise<SourceCredentials | undefined> {
const result = await db.select().from(sources).limit(1);
return result[0] as SourceCredentials | undefined;
}
/**
* Set Jellyfin/Emby credentials
*/
export async function setCredentials(credentials: {
uri: string;
userId: string;
accessToken: string;
deviceId: string;
type: 'jellyfin' | 'emby';
}): Promise<void> {
const now = Date.now();
const sourceType = credentials.type === 'jellyfin' ? 'jellyfin.v1' : 'emby.v1';
// Use deviceId as the source id for consistency
const sourceId = credentials.deviceId;
await db.insert(sources)
.values({
id: sourceId,
uri: credentials.uri,
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
type: sourceType,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sources.id,
set: {
uri: credentials.uri,
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
type: sourceType,
updatedAt: now,
},
});
sqliteDb.flushPendingReactiveQueries();
}
@@ -3,7 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
/**
* App settings - global application settings (single row, id=1)
*/
export const appSettings = sqliteTable('app_settings', {
const settings = sqliteTable('app_settings', {
id: integer('id').primaryKey().$default(() => 1),
bitrate: integer('bitrate').notNull(),
isOnboardingComplete: integer('is_onboarding_complete', { mode: 'boolean' }).notNull(),
@@ -13,3 +13,5 @@ export const appSettings = sqliteTable('app_settings', {
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
export default settings;
+8 -11
View File
@@ -2,20 +2,17 @@
* 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 { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import { eq } from 'drizzle-orm';
import type { AppSettings } from '@/store/db/types';
import settings from './entity';
export function useAppSettings() {
const { data, error } = useLiveQuery(
db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1)
db.select().from(settings)
.where(eq(settings.id, 1))
.limit(1)
);
return useMemo(() => ({
data: data?.[0] as AppSettings | undefined,
error,
}), [data, error]);
return { data: data?.[0], error };
}
-55
View File
@@ -1,55 +0,0 @@
import { createReducer } from '@reduxjs/toolkit';
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting, setColorScheme } from './actions';
import { ColorScheme } from './types';
interface State {
credentials?: {
uri: string;
user_id: string;
access_token: string;
device_id: string;
type: 'jellyfin' | 'emby';
}
bitrate: number;
isOnboardingComplete: boolean;
hasReceivedErrorReportingAlert: boolean;
enablePlaybackReporting: boolean;
colorScheme: ColorScheme;
}
const initialState: State = {
bitrate: 140000000,
isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false,
enablePlaybackReporting: true,
colorScheme: ColorScheme.System,
};
const settings = createReducer(initialState, builder => {
builder.addCase(setJellyfinCredentials, (state, action) => ({
...state,
credentials: action.payload,
}));
builder.addCase(setBitrate, (state, action) => ({
...state,
bitrate: action.payload,
}));
builder.addCase(setOnboardingStatus, (state, action) => ({
...state,
isOnboardingComplete: action.payload,
}));
builder.addCase(setReceivedErrorReportingAlert, (state) => ({
...state,
hasReceivedErrorReportingAlert: true,
}));
builder.addCase(setEnablePlaybackReporting, (state, action) => ({
...state,
enablePlaybackReporting: action.payload,
}));
builder.addCase(setColorScheme, (state, action) => ({
...state,
colorScheme: action.payload,
}));
});
export default settings;
+8 -1
View File
@@ -1,5 +1,12 @@
import type { InferSelectModel } from 'drizzle-orm';
import settings from './entity';
export enum ColorScheme {
System = 'system',
Light = 'light',
Dark = 'dark',
}
}
export type AppSettings = InferSelectModel<typeof settings>;
export type InsertAppSettings = typeof settings.$inferInsert;
+2 -3
View File
@@ -2,9 +2,8 @@
* Database actions for sleep timer
*/
import { db, sqliteDb } from '@/store/db';
import { sleepTimer } from './sleep-timer';
import { eq } from 'drizzle-orm';
import { db, sqliteDb } from '@/store';
import sleepTimer from './entity';
export async function setSleepTimer(date: number | null): Promise<void> {
const now = Date.now();
+2 -2
View File
@@ -5,9 +5,9 @@
*/
import { db } from '../db/client';
import { sleepTimer } from '../db/schema/sleep-timer';
import sleepTimer from './entity';
import { eq } from 'drizzle-orm';
import { invalidateTable } from '../db/live-queries';
import { invalidateTable } from '../live-queries';
const SLEEP_TIMER_ID = 1;
@@ -3,9 +3,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', {
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(),
});
export default sleepTimer;
+3 -3
View File
@@ -3,9 +3,9 @@
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { sleepTimer } from './sleep-timer';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import sleepTimer from './entity';
import { eq } from 'drizzle-orm';
import type { SleepTimer } from './types';
-23
View File
@@ -1,23 +0,0 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
export interface State {
date: number | null;
}
export const initialState: State = {
date: null,
};
const sleepTimer = createSlice({
name: 'sleep-timer',
initialState,
reducers: {
setTimerDate(state, action: PayloadAction<Date | null>) {
state.date = action.payload?.getTime() || null;
}
},
});
export const { setTimerDate } = sleepTimer.actions;
export default sleepTimer;
-11
View File
@@ -1,11 +0,0 @@
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(),
date: integer('date'), // nullable - epoch ms
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { sleepTimer } from './sleep-timer';
import sleepTimer from './entity';
export type SleepTimer = InferSelectModel<typeof sleepTimer>;
export type InsertSleepTimer = typeof sleepTimer.$inferInsert;
@@ -3,7 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
/**
* Sources table - stores Jellyfin/Emby server connections
*/
export const sources = sqliteTable('sources', {
const sources = sqliteTable('sources', {
id: text('id').primaryKey(),
uri: text('uri').notNull(),
userId: text('user_id'),
@@ -13,3 +13,5 @@ export const sources = sqliteTable('sources', {
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
export default sources;
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Sync cursors table - tracks prefill progress
*/
export const syncCursors = sqliteTable('sync_cursors', {
const syncCursors = sqliteTable('sync_cursors', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
entityType: text('entity_type').notNull(), // 'artists', 'albums', 'tracks', 'playlists', etc.
startIndex: integer('start_index').notNull(),
@@ -14,3 +14,5 @@ export const syncCursors = sqliteTable('sync_cursors', {
}, (table) => ({
pk: primaryKey({ columns: [table.sourceId, table.entityType] }),
}));
export default syncCursors;
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Track-Artists relation table (many-to-many)
*/
export const trackArtists = sqliteTable('track_artists', {
const trackArtists = sqliteTable('track_artists', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
trackId: text('track_id').notNull(),
artistId: text('artist_id').notNull(),
@@ -13,3 +13,5 @@ export const trackArtists = sqliteTable('track_artists', {
pk: primaryKey({ columns: [table.sourceId, table.trackId, table.artistId] }),
sourceArtistIdx: index('track_artists_source_artist_idx').on(table.sourceId, table.artistId),
}));
export default trackArtists;
+3 -19
View File
@@ -2,9 +2,8 @@
* Database actions for tracks
*/
import { db, sqliteDb } from '@/store/db';
import { tracks } from './tracks';
import { eq } from 'drizzle-orm';
import { db, sqliteDb } from '@/store';
import tracks from './entity';
import type { InsertTrack } from './types';
export async function upsertTrack(track: InsertTrack): Promise<void> {
@@ -29,19 +28,4 @@ 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();
}
}
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from './sources';
import sources from '../sources/entity';
/**
* Tracks table
*/
export const tracks = sqliteTable('tracks', {
const tracks = sqliteTable('tracks', {
sourceId: text('source_id').notNull().references(() => sources.id, { onDelete: 'cascade' }),
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -24,3 +24,5 @@ export const tracks = sqliteTable('tracks', {
sourceAlbumIdx: index('tracks_source_album_idx').on(table.sourceId, table.albumId),
sourceNameIdx: index('tracks_source_name_idx').on(table.sourceId, table.name),
}));
export default tracks;
+7 -156
View File
@@ -2,170 +2,21 @@
* 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;
}
import { eq } from 'drizzle-orm';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import tracks from './entity';
export function useTracks(sourceId?: string) {
const { data, error } = useLiveQuery(
return 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(
return 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
@@ -1,26 +0,0 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sources } from '../db/schema/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),
}));
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { InferSelectModel } from 'drizzle-orm';
import { tracks } from './tracks';
import tracks from './entity';
export type Track = InferSelectModel<typeof tracks>;
export type InsertTrack = typeof tracks.$inferInsert;
+4 -4
View File
@@ -1,13 +1,13 @@
import { useEffect } from 'react';
import { Alert } from 'react-native';
import { t } from '@/localisation';
import { setReceivedErrorReportingAlert } from '@/store/settings/db';
import { setReceivedErrorReportingAlert } from '@/store/settings/actions';
import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@/screens/types';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { appSettings } from '@/store/db/schema/app-settings';
import { useLiveQuery } from '@/store/live-queries';
import { db } from '@/store';
import appSettings from '@/store/settings/entity';
import { eq } from 'drizzle-orm';
/**
-76
View File
@@ -1,76 +0,0 @@
import { Album, AlbumTrack } from '@/store/music/types';
import { fetchApi } from './lib';
const albumOptions = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
};
const albumParams = new URLSearchParams(albumOptions).toString();
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllAlbums() {
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Users/${user_id}/Items?${albumParams}`)
.then((data) => data!.Items);
}
/**
* Retrieve a single album
*/
export async function retrieveAlbum(id: string): Promise<Album> {
return fetchApi<Album>(({ user_id }) => `/Users/${user_id}/Items/${id}`);
}
/**
* Retrieve albums that are similar to the provided album
*/
export async function retrieveSimilarAlbums(id: string): Promise<Album[]> {
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Items/${id}/Similar?userId=${user_id}&limit=12`)
.then((albums) => albums!.Items);
}
const latestAlbumsOptions = {
IncludeItemTypes: 'MusicAlbum',
Fields: 'DateCreated',
SortOrder: 'Descending',
SortBy: 'DateCreated',
Recursive: 'true',
};
/**
* Retrieve the most recently added albums on the Jellyfin server
*/
export async function retrieveRecentAlbums(numberOfAlbums = 24) {
// Generate custom config based on function input
const options = {
...latestAlbumsOptions,
Limit: numberOfAlbums.toString(),
};
const params = new URLSearchParams(options).toString();
// Retrieve albums
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Users/${user_id}/Items?${params}`)
.then((d) => d.Items);
}
/**
* Retrieve a single album from the Emby server
*/
export async function retrieveAlbumTracks(ItemId: string) {
const singleAlbumOptions = {
ParentId: ItemId,
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
Fields: 'MediaStreams',
};
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
.then((d) => d.Items);
}
-21
View File
@@ -1,21 +0,0 @@
import { MusicArtist } from '@/store/music/types';
import { fetchApi } from './lib';
const artistOptions = {
SortBy: 'SortName',
SortOrder: 'Ascending',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
};
const artistParams = new URLSearchParams(artistOptions).toString();
/**
* Retrieve all artists that are available on the Jellyfin server
*/
export function retrieveAllArtists() {
return fetchApi<{ Items: MusicArtist[] }>(() => `/Artists/AlbumArtists?${artistParams}`)
.then(response => response.Items);
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { Platform } from 'react-native';
import { version } from '../../../package.json';
import { Album, AlbumTrack, ArtistItem, Playlist } from '@/store/music/types';
import { db } from '@/store/db';
import { sources } from '@/store/db/schema/sources';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store';
import sources from '@/store/sources/entity';
import { useLiveQuery } from '@/store/live-queries';
import { useCallback } from 'react';
type Credentials = {
@@ -165,7 +165,7 @@ export function useGetImage() {
const { data: sourceData } = useLiveQuery(db.select().from(sources).limit(1));
const credentials = sourceData?.[0];
return useCallback(async (item: Parameters<typeof getImage>[0]) => {
return useCallback((item: Parameters<typeof getImage>[0]) => {
return getImage(item, credentials);
}, [credentials]);
}
-6
View File
@@ -1,6 +0,0 @@
import { Lyrics } from '@/store/music/types';
import { fetchApi } from './lib';
export async function retrieveTrackLyrics(trackId: string): Promise<Lyrics> {
return fetchApi<Lyrics>(`/Audio/${trackId}/Lyrics`);
}
-65
View File
@@ -1,65 +0,0 @@
import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
import { fetchApi } from './lib';
/**
* This maps the react-native-track-player RepeatMode to a RepeatMode that is
* expected by Jellyfin when reporting playback events.
*/
const RepeatModeMap: Record<RepeatMode, string> = {
[RepeatMode.Off]: 'RepeatNone',
[RepeatMode.Track]: 'RepeatOne',
[RepeatMode.Queue]: 'RepeatAll',
};
/**
* This will generate the payload that is required for playback events and send
* it to the supplied path.
*/
export async function sendPlaybackEvent(
path: string,
track?: Track,
lastPosition?: number,
) {
// Extract all data from react-native-track-player
const [
activeTrack, { position: currentPosition }, repeatMode, volume, { state },
] = await Promise.all([
track || TrackPlayer.getActiveTrack(),
TrackPlayer.getProgress(),
TrackPlayer.getRepeatMode(),
TrackPlayer.getVolume(),
TrackPlayer.getPlaybackState(),
]);
// GUARD: Ensure that no empty events are sent out
if (!activeTrack?.backendId) return;
// Generate a payload from the gathered data
const payload = {
VolumeLevel: volume * 100,
IsMuted: false,
IsPaused: state === State.Paused,
RepeatMode: RepeatModeMap[repeatMode],
ShuffleMode: 'Sorted',
PositionTicks: Math.round((lastPosition || currentPosition) * 10_000_000),
PlaybackRate: 1,
PlayMethod: 'transcode',
MediaSourceId: activeTrack.backendId,
ItemId: activeTrack.backendId,
CanSeek: true,
PlaybackStartTimeTicks: null,
PlaySessionId: activeTrack?.backendId || 'fintunes',
};
// Generate a config from the credentials and dispatch the request
await fetchApi(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
// Swallow and errors from the request
}, false).catch((err) => {
console.error(err);
});
}
-47
View File
@@ -1,47 +0,0 @@
import { AlbumTrack, Playlist } from '@/store/music/types';
import { asyncFetchStore, fetchApi } from './lib';
const playlistOptions = {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,ChildCount',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
};
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllPlaylists() {
const playlistParams = new URLSearchParams(playlistOptions).toString();
return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`)
.then((d) => d!.Items);
}
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrievePlaylistTracks(ItemId: string) {
const credentials = asyncFetchStore().getState().settings.credentials;
const singlePlaylistOptions = {
SortBy: 'IndexNumber,SortName',
UserId: credentials?.user_id || '',
};
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
.then((d) => d.Items);
}
export async function retrieveInstantMixByTrackId(trackId: string, limit = 100) {
const credentials = asyncFetchStore().getState().settings.credentials;
const instantMixOptions = {
UserId: credentials?.user_id ?? '',
Limit: limit.toString()
};
const instantMixParams = new URLSearchParams(instantMixOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(`/Items/${trackId}/InstantMix?${instantMixParams}`).then(response => response.Items);
}
-30
View File
@@ -1,30 +0,0 @@
import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types';
import { fetchApi } from './lib';
const searchParams = {
IncludeItemTypes: 'Audio,MusicAlbum,Playlist',
SortBy: 'SearchScore,Album,SortName',
SortOrder: 'Ascending',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated,Overview',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
};
export type SearchResult = Album | AlbumTrack | MusicArtist | Playlist;
/**
* Remotely search the Jellyfin library for a particular search term
*/
export function searchItem(
term: string, limit = 24
) {
const params = new URLSearchParams({
...searchParams,
SearchTerm: term,
Limit: limit.toString(),
}).toString();
return fetchApi<{ Items: SearchResult[]}>(({ user_id }) => `/Users/${user_id}/Items?${params}`)
.then(result => result.Items);
}
+41 -83
View File
@@ -1,96 +1,54 @@
import { AlbumTrack, CodecMetadata } from '@/store/music/types';
import { Platform } from 'react-native';
import { Track } from 'react-native-track-player';
import { asyncFetchStore, fetchApi, getImage, generateConfig } from './lib';
import type { Track as PlayerTrack } from 'react-native-track-player';
import { db } from '@/store';
import sources from '@/store/sources/entity';
import { JellyfinDriver } from '@/store/sources/drivers/jellyfin/driver';
import { EmbyDriver } from '@/store/sources/drivers/emby/driver';
import type { Source, SourceDriver, SourceType } from '@/store/sources/types';
import type { AlbumTrack } from '@/store/music/types';
import { getCredentials, getImage } from './lib';
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
ios: {
Container: 'mp3,aac,m4a|aac,m4b|aac,flac,alac,m4a|alac,m4b|alac,wav,m4a,aiff,aif',
},
android: {
Container: 'mp3,aac,flac,wav,ogg,ogg|vorbis,ogg|opus,mka|mp3,mka|opus,mka|mp3',
},
macos: {},
web: {},
windows: {},
};
async function getDriver(): Promise<{ driver: SourceDriver; source: Source } | null> {
const result = await db.select().from(sources).limit(1);
const row = result[0];
const baseTrackOptions: Record<string, string> = {
TranscodingProtocol: 'http',
TranscodingContainer: 'aac',
AudioCodec: 'aac',
Container: 'mp3,aac',
audioBitRate: '320000',
...trackOptionsOsOverrides[Platform.OS],
} as const;
if (!row) {
return null;
}
/**
* Generate the track streaming url from the trackId
*/
export function generateTrackUrl(trackId: string) {
const credentials = asyncFetchStore().getState().settings.credentials;
const trackOptions = {
...baseTrackOptions,
UserId: credentials?.user_id || '',
api_key: credentials?.access_token || '',
DeviceId: credentials?.device_id || '',
const source: Source = {
id: row.id,
uri: row.uri,
userId: row.userId || undefined,
accessToken: row.accessToken || undefined,
deviceId: row.deviceId || undefined,
type: row.type as SourceType,
};
const trackParams = new URLSearchParams(trackOptions).toString();
const url = encodeURI(`${credentials?.uri}/Audio/${trackId}/universal?`) + trackParams;
if (source.type.startsWith('jellyfin')) {
return { driver: new JellyfinDriver(source), source };
}
return url;
if (source.type.startsWith('emby')) {
return { driver: new EmbyDriver(source), source };
}
return null;
}
/**
* Generate a track object from a Jellyfin ItemId so that
* react-native-track-player can easily consume it.
*/
export async function generateTrack(track: AlbumTrack): Promise<Track> {
// Also construct the URL for the stream
const url = generateTrackUrl(track.Id);
// Get credentials and generate authentication headers
const credentials = asyncFetchStore().getState().settings.credentials;
const config = generateConfig(credentials);
const headers = config.headers;
export async function generateTrack(track: AlbumTrack): Promise<PlayerTrack> {
const driverResult = await getDriver();
const credentials = await getCredentials();
const artwork = credentials ? getImage(track, credentials) : undefined;
const url = driverResult ? await driverResult.driver.getStreamUrl(track.Id) : '';
return {
id: track.Id,
url,
backendId: track.Id,
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
duration: track.RunTimeTicks,
artwork: getImage(track),
headers,
bitRate: baseTrackOptions.audioBitRate,
};
artist: track.AlbumArtist || undefined,
album: track.Album || undefined,
artwork,
duration: track.RunTimeTicks ? track.RunTimeTicks / 10_000_000 : undefined,
backendId: track.Id,
} as PlayerTrack;
}
const trackParams = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
};
/**
* Retrieve all possible tracks that can be found in Jellyfin
*/
export async function retrieveAllTracks() {
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
.then((d) => d.Items);
}
export async function retrieveTrackCodecMetadata(trackId: string): Promise<CodecMetadata> {
const url = generateTrackUrl(trackId);
const response = await fetch(url, { method: 'HEAD' });
return {
contentType: response.headers.get('Content-Type') || undefined,
isDirectPlay: response.headers.has('Content-Length'),
};
}