diff --git a/src/components/Colors.tsx b/src/components/Colors.tsx index ca506b1..96e8868 100644 --- a/src/components/Colors.tsx +++ b/src/components/Colors.tsx @@ -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; diff --git a/src/components/DownloadIcon.tsx b/src/components/DownloadIcon.tsx index d21e230..62724e1 100644 --- a/src/components/DownloadIcon.tsx +++ b/src/components/DownloadIcon.tsx @@ -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]); diff --git a/src/screens/Music/stacks/components/TrackListView.tsx b/src/screens/Music/stacks/components/TrackListView.tsx index 9ced1d5..218580e 100644 --- a/src/screens/Music/stacks/components/TrackListView.tsx +++ b/src/screens/Music/stacks/components/TrackListView.tsx @@ -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: { diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index 3aa773f..2b662da 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -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; diff --git a/src/screens/Search/stacks/Search/index.tsx b/src/screens/Search/stacks/Search/index.tsx index a46ce1e..3310010 100644 --- a/src/screens/Search/stacks/Search/index.tsx +++ b/src/screens/Search/stacks/Search/index.tsx @@ -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'; diff --git a/src/screens/Settings/stacks/Cache.tsx b/src/screens/Settings/stacks/Cache.tsx index d6cc9dd..68e88a5 100644 --- a/src/screens/Settings/stacks/Cache.tsx +++ b/src/screens/Settings/stacks/Cache.tsx @@ -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; diff --git a/src/screens/Settings/stacks/ColorScheme.tsx b/src/screens/Settings/stacks/ColorScheme.tsx index 5d71686..92b78dc 100644 --- a/src/screens/Settings/stacks/ColorScheme.tsx +++ b/src/screens/Settings/stacks/ColorScheme.tsx @@ -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 ( diff --git a/src/screens/Settings/stacks/Library.tsx b/src/screens/Settings/stacks/Library.tsx index a09ef2a..4c62713 100644 --- a/src/screens/Settings/stacks/Library.tsx +++ b/src/screens/Settings/stacks/Library.tsx @@ -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(); diff --git a/src/screens/Settings/stacks/PlaybackReporting.tsx b/src/screens/Settings/stacks/PlaybackReporting.tsx index ce3583d..294db68 100644 --- a/src/screens/Settings/stacks/PlaybackReporting.tsx +++ b/src/screens/Settings/stacks/PlaybackReporting.tsx @@ -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'; diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 3210b70..4262dad 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -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(); diff --git a/src/screens/modals/Player/components/Timer.tsx b/src/screens/modals/Player/components/Timer.tsx index a5b8d53..84dbf60 100644 --- a/src/screens/modals/Player/components/Timer.tsx +++ b/src/screens/modals/Player/components/Timer.tsx @@ -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(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(); diff --git a/src/screens/modals/SetJellyfinServer/components/CredentialGenerator.tsx b/src/screens/modals/SetJellyfinServer/components/CredentialGenerator.tsx index fde6bfe..c077bdf 100644 --- a/src/screens/modals/SetJellyfinServer/components/CredentialGenerator.tsx +++ b/src/screens/modals/SetJellyfinServer/components/CredentialGenerator.tsx @@ -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 = { diff --git a/src/screens/modals/SetJellyfinServer/index.tsx b/src/screens/modals/SetJellyfinServer/index.tsx index a351b21..2b78322 100644 --- a/src/screens/modals/SetJellyfinServer/index.tsx +++ b/src/screens/modals/SetJellyfinServer/index.tsx @@ -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'; diff --git a/src/store/db/schema/album-artists.ts b/src/store/album-artists/entity.ts similarity index 82% rename from src/store/db/schema/album-artists.ts rename to src/store/album-artists/entity.ts index f30324d..074066c 100644 --- a/src/store/db/schema/album-artists.ts +++ b/src/store/album-artists/entity.ts @@ -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; diff --git a/src/store/db/schema/album-similar.ts b/src/store/album-similar/entity.ts similarity index 78% rename from src/store/db/schema/album-similar.ts rename to src/store/album-similar/entity.ts index 62ee277..fea598e 100644 --- a/src/store/db/schema/album-similar.ts +++ b/src/store/album-similar/entity.ts @@ -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; diff --git a/src/store/albums/actions.ts b/src/store/albums/actions.ts index a9befab..007225e 100644 --- a/src/store/albums/actions.ts +++ b/src/store/albums/actions.ts @@ -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'; diff --git a/src/store/albums/albums.ts b/src/store/albums/entity.ts similarity index 89% rename from src/store/albums/albums.ts rename to src/store/albums/entity.ts index 5ddb077..18bca70 100644 --- a/src/store/albums/albums.ts +++ b/src/store/albums/entity.ts @@ -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; diff --git a/src/store/albums/hooks.ts b/src/store/albums/hooks.ts index 2ed7366..2a3b712 100644 --- a/src/store/albums/hooks.ts +++ b/src/store/albums/hooks.ts @@ -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'; diff --git a/src/store/albums/types.ts b/src/store/albums/types.ts index a16210c..f7d7c33 100644 --- a/src/store/albums/types.ts +++ b/src/store/albums/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { albums } from './albums'; +import albums from './entity'; export type Album = InferSelectModel; export type InsertAlbum = typeof albums.$inferInsert; diff --git a/src/store/artists/actions.ts b/src/store/artists/actions.ts index 8b85fee..c3bd0a8 100644 --- a/src/store/artists/actions.ts +++ b/src/store/artists/actions.ts @@ -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'; diff --git a/src/store/artists/artists.ts b/src/store/artists/entity.ts similarity index 85% rename from src/store/artists/artists.ts rename to src/store/artists/entity.ts index 1bba5cb..579a06c 100644 --- a/src/store/artists/artists.ts +++ b/src/store/artists/entity.ts @@ -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; diff --git a/src/store/artists/hooks.ts b/src/store/artists/hooks.ts index 1992603..71e9b76 100644 --- a/src/store/artists/hooks.ts +++ b/src/store/artists/hooks.ts @@ -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'; diff --git a/src/store/artists/types.ts b/src/store/artists/types.ts index 526f1d2..de9e6f4 100644 --- a/src/store/artists/types.ts +++ b/src/store/artists/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { artists } from './artists'; +import artists from './entity'; export type Artist = InferSelectModel; export type InsertArtist = typeof artists.$inferInsert; diff --git a/src/store/db/index.ts b/src/store/db/index.ts deleted file mode 100644 index 4a37009..0000000 --- a/src/store/db/index.ts +++ /dev/null @@ -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; -} diff --git a/src/store/db/schema/albums.ts b/src/store/db/schema/albums.ts deleted file mode 100644 index bbcde65..0000000 --- a/src/store/db/schema/albums.ts +++ /dev/null @@ -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), -})); diff --git a/src/store/db/schema/artists.ts b/src/store/db/schema/artists.ts deleted file mode 100644 index b4bc0e1..0000000 --- a/src/store/db/schema/artists.ts +++ /dev/null @@ -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), -})); diff --git a/src/store/db/schema/search-queries.ts b/src/store/db/schema/search-queries.ts deleted file mode 100644 index 7fe62f8..0000000 --- a/src/store/db/schema/search-queries.ts +++ /dev/null @@ -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), -})); diff --git a/src/store/db/types.ts b/src/store/db/types.ts index 425e23e..6324290 100644 --- a/src/store/db/types.ts +++ b/src/store/db/types.ts @@ -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; -export type AlbumArtist = InferSelectModel; -export type TrackArtist = InferSelectModel; -export type PlaylistTrack = InferSelectModel; -export type AlbumSimilar = InferSelectModel; -export type SyncCursor = InferSelectModel; -export type AppSettings = InferSelectModel; - -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; +export type Album = InferSelectModel; +export type Track = InferSelectModel; +export type Playlist = InferSelectModel; diff --git a/src/store/db/useSourceId.ts b/src/store/db/useSourceId.ts deleted file mode 100644 index 3c815ae..0000000 --- a/src/store/db/useSourceId.ts +++ /dev/null @@ -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]); -} diff --git a/src/store/downloads/actions.ts b/src/store/downloads/actions.ts index 176795c..4109113 100644 --- a/src/store/downloads/actions.ts +++ b/src/store/downloads/actions.ts @@ -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'; diff --git a/src/store/downloads/downloads.ts b/src/store/downloads/downloads.ts deleted file mode 100644 index 1bb5497..0000000 --- a/src/store/downloads/downloads.ts +++ /dev/null @@ -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(), -}); diff --git a/src/store/db/schema/downloads.ts b/src/store/downloads/entity.ts similarity index 85% rename from src/store/db/schema/downloads.ts rename to src/store/downloads/entity.ts index acd77af..363c350 100644 --- a/src/store/db/schema/downloads.ts +++ b/src/store/downloads/entity.ts @@ -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; diff --git a/src/store/downloads/hooks.ts b/src/store/downloads/hooks.ts index 2f6ebb7..75de2f7 100644 --- a/src/store/downloads/hooks.ts +++ b/src/store/downloads/hooks.ts @@ -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); } }); diff --git a/src/store/downloads/queue.ts b/src/store/downloads/queue.ts index 2ca3aca..e6b50d4 100644 --- a/src/store/downloads/queue.ts +++ b/src/store/downloads/queue.ts @@ -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): Promise { + 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 { - 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 { - 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 { - 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); } diff --git a/src/store/downloads/types.ts b/src/store/downloads/types.ts index 5943d24..b68054d 100644 --- a/src/store/downloads/types.ts +++ b/src/store/downloads/types.ts @@ -3,7 +3,12 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { downloads } from './downloads'; +import downloads from './entity'; -export type Download = InferSelectModel; +export type Download = InferSelectModel & { + image?: string | null; + location?: string | null; + size?: number | null; + error?: string | null; +}; export type InsertDownload = typeof downloads.$inferInsert; diff --git a/src/store/index.ts b/src/store/index.ts index cbe498f..ef16320 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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> = { - 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 & { _persist: PersistState }; -export type AppDispatch = typeof store.dispatch; -export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch }; -export type Store = typeof store; -export const useTypedSelector: TypedUseSelectorHook = useSelector; -export const useAppDispatch: () => AppDispatch = useDispatch; - -export const persistedStore = persistStore(store); - -export default store; \ No newline at end of file +/** + * Initialize the database + */ +export async function initializeDatabase() { + await runMigrations(); + return db; +} diff --git a/src/store/db/live-queries.ts b/src/store/live-queries.ts similarity index 91% rename from src/store/db/live-queries.ts rename to src/store/live-queries.ts index 126a73e..c8b1a39 100644 --- a/src/store/db/live-queries.ts +++ b/src/store/live-queries.ts @@ -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 = QueryPromise &{ toSQL: () => { sql: string; params: unknown[] }; - then: (onfulfilled?: (value: any) => any) => Promise; }; type UseLiveQueryResult = { - 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( - query: DrizzleQuery | undefined | null + query: DrizzleQuery | undefined | null ): UseLiveQueryResult { - const [data, setData] = useState([]); + const [data, setData] = useState(null); const [error, setError] = useState(undefined); const unsubscribeRef = useRef<(() => void) | null>(null); @@ -88,7 +88,7 @@ export function useLiveQuery( // 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( fireOn, callback: (response) => { // response.rows contains raw row data from reactive callback - setData(response.rows as T[]); + setData(response.rows as T); }, }); } catch (e) { diff --git a/src/store/music/actions.ts b/src/store/music/actions.ts deleted file mode 100644 index 8aa0a5c..0000000 --- a/src/store/music/actions.ts +++ /dev/null @@ -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({ - selectId: album => album.Id, - sortComparer: (a, b) => a.Name.localeCompare(b.Name), -}); - -/** - * Fetch lyrics for a given track - */ -export const fetchLyricsByTrack = createAsyncThunk( - '/track/lyrics', - retrieveTrackLyrics, -); - -/** - * Fetch codec metadata for a given track - */ -export const fetchCodecMetadataByTrack = createAsyncThunk( - '/track/codecMetadata', - retrieveTrackCodecMetadata, -); - -/** A generic type for any action that retrieves tracks */ -type AlbumTrackPayloadCreator = AsyncThunkPayloadCreator; - -/** - * 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( - '/albums/all', - retrieveAllAlbums, -); - -/** - * Retrieve the most recent albums - */ -export const fetchRecentAlbums = createAsyncThunk( - '/albums/recent', - retrieveRecentAlbums, -); - -export const trackAdapter = createEntityAdapter({ - selectId: track => track.Id, - sortComparer: (a, b) => a.IndexNumber - b.IndexNumber, -}); - -/** - * Retrieve all tracks from a particular album - */ -export const fetchTracksByAlbum = createAsyncThunk( - '/tracks/byAlbum', - postProcessTracks(retrieveAlbumTracks), -); - -export const fetchAlbum = createAsyncThunk( - '/albums/single', - retrieveAlbum, -); - -export const fetchSimilarAlbums = createAsyncThunk( - '/albums/similar', - retrieveSimilarAlbums, -); - -export const searchAndFetch = createAsyncThunk( - '/search', - async ({ term, limit = 24 }) => searchItem(term, limit) -); - -export const playlistAdapter = createEntityAdapter({ - selectId: (playlist) => playlist.Id, - sortComparer: (a, b) => a.Name.localeCompare(b.Name), -}); - -/** - * Fetch all playlists available - */ -export const fetchAllPlaylists = createAsyncThunk( - '/playlists/all', - retrieveAllPlaylists, -); - -/** - * Retrieve all tracks from a particular playlist - */ -export const fetchTracksByPlaylist = createAsyncThunk( - '/tracks/byPlaylist', - postProcessTracks(retrievePlaylistTracks) -); - -export const artistAdapter = createEntityAdapter({ - selectId: artist => artist.Id, - sortComparer: (a, b) => a.Name.localeCompare(b.Name) -}); - -/** - * Fetch all albums available on the jellyfin server - */ -export const fetchAllArtists = createAsyncThunk( - '/artists/all', - retrieveAllArtists -); - -export const fetchInstantMixByTrackId = createAsyncThunk( - '/instantMix/byTrackId', - (trackId: string) => retrieveInstantMixByTrackId(trackId) -); diff --git a/src/store/music/db.ts b/src/store/music/db.ts deleted file mode 100644 index a82546f..0000000 --- a/src/store/music/db.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/store/music/fetchers.ts b/src/store/music/fetchers.ts index d2e58b7..fd9fbd4 100644 --- a/src/store/music/fetchers.ts +++ b/src/store/music/fetchers.ts @@ -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 { + 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( + fetchPage: (offset: number, limit: number) => Promise, + limit: number = DEFAULT_LIMIT, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); } diff --git a/src/store/music/hooks.ts b/src/store/music/hooks.ts index fcaa5d0..6f7c98a 100644 --- a/src/store/music/hooks.ts +++ b/src/store/music/hooks.ts @@ -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(() => { diff --git a/src/store/music/types.ts b/src/store/music/types.ts index ccd24d6..a7d6e34 100644 --- a/src/store/music/types.ts +++ b/src/store/music/types.ts @@ -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; - 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; diff --git a/src/store/db/schema/playlist-tracks.ts b/src/store/playlist-tracks/entity.ts similarity index 82% rename from src/store/db/schema/playlist-tracks.ts rename to src/store/playlist-tracks/entity.ts index 5026527..0c0d096 100644 --- a/src/store/db/schema/playlist-tracks.ts +++ b/src/store/playlist-tracks/entity.ts @@ -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; diff --git a/src/store/playlists/actions.ts b/src/store/playlists/actions.ts index 4307e0d..1b68a20 100644 --- a/src/store/playlists/actions.ts +++ b/src/store/playlists/actions.ts @@ -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'; diff --git a/src/store/db/schema/playlists.ts b/src/store/playlists/entity.ts similarity index 86% rename from src/store/db/schema/playlists.ts rename to src/store/playlists/entity.ts index a3b07e9..6bbe563 100644 --- a/src/store/db/schema/playlists.ts +++ b/src/store/playlists/entity.ts @@ -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; diff --git a/src/store/playlists/hooks.ts b/src/store/playlists/hooks.ts index fadf148..efd49dc 100644 --- a/src/store/playlists/hooks.ts +++ b/src/store/playlists/hooks.ts @@ -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'; diff --git a/src/store/playlists/playlists.ts b/src/store/playlists/playlists.ts deleted file mode 100644 index c5c7521..0000000 --- a/src/store/playlists/playlists.ts +++ /dev/null @@ -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), -})); diff --git a/src/store/playlists/types.ts b/src/store/playlists/types.ts index f1209eb..04ff142 100644 --- a/src/store/playlists/types.ts +++ b/src/store/playlists/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { playlists } from './playlists'; +import playlists from './entity'; export type Playlist = InferSelectModel; export type InsertPlaylist = typeof playlists.$inferInsert; diff --git a/src/store/prefill/prefill.ts b/src/store/prefill/prefill.ts index 3ebbe26..6d6de2f 100644 --- a/src/store/prefill/prefill.ts +++ b/src/store/prefill/prefill.ts @@ -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); diff --git a/src/store/search-queries/actions.ts b/src/store/search-queries/actions.ts index 1e57684..39d71eb 100644 --- a/src/store/search-queries/actions.ts +++ b/src/store/search-queries/actions.ts @@ -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'; diff --git a/src/store/search-queries/search-queries.ts b/src/store/search-queries/entity.ts similarity index 84% rename from src/store/search-queries/search-queries.ts rename to src/store/search-queries/entity.ts index 511da56..40571db 100644 --- a/src/store/search-queries/search-queries.ts +++ b/src/store/search-queries/entity.ts @@ -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; diff --git a/src/store/search-queries/hooks.ts b/src/store/search-queries/hooks.ts index d4f6aa2..e92bbcd 100644 --- a/src/store/search-queries/hooks.ts +++ b/src/store/search-queries/hooks.ts @@ -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'; diff --git a/src/store/search-queries/types.ts b/src/store/search-queries/types.ts index 12443ab..7c02bee 100644 --- a/src/store/search-queries/types.ts +++ b/src/store/search-queries/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { searchQueries } from './search-queries'; +import searchQueries from './entity'; export type SearchQuery = InferSelectModel; export type InsertSearchQuery = typeof searchQueries.$inferInsert; diff --git a/src/store/search/db.ts b/src/store/search/db.ts deleted file mode 100644 index 0271b48..0000000 --- a/src/store/search/db.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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, - }; - }); -} diff --git a/src/store/search/index.ts b/src/store/search/index.ts deleted file mode 100644 index 5dc3fa0..0000000 --- a/src/store/search/index.ts +++ /dev/null @@ -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>) { - 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; diff --git a/src/store/settings/actions.ts b/src/store/settings/actions.ts index 84acb5c..15596c3 100644 --- a/src/store/settings/actions.ts +++ b/src/store/settings/actions.ts @@ -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('SET_BITRATE'); -export const setOnboardingStatus = createAction('SET_ONBOARDING_STATUS'); -export const setReceivedErrorReportingAlert = createAction('SET_RECEIVED_ERROR_REPORTING_ALERT'); -export const setEnablePlaybackReporting = createAction('SET_ENABLE_PLAYBACK_REPORTING'); -export const setColorScheme = createAction('SET_COLOR_SCHEME'); +/** + * Get app settings (single row, id=1) + */ +export async function getAppSettings(): Promise { + 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 { + 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>): Promise { + 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 { + 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(); +} diff --git a/src/store/settings/db.ts b/src/store/settings/db.ts deleted file mode 100644 index d03d4e7..0000000 --- a/src/store/settings/db.ts +++ /dev/null @@ -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 { - 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 { - 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>): Promise { - 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 { - await updateAppSettings({ bitrate }); -} - -/** - * Set onboarding status - */ -export async function setOnboardingStatus(isOnboardingComplete: boolean): Promise { - await updateAppSettings({ isOnboardingComplete }); -} - -/** - * Set error reporting alert received - */ -export async function setReceivedErrorReportingAlert(): Promise { - await updateAppSettings({ hasReceivedErrorReportingAlert: true }); -} - -/** - * Set enable playback reporting - */ -export async function setEnablePlaybackReporting(enablePlaybackReporting: boolean): Promise { - await updateAppSettings({ enablePlaybackReporting }); -} - -/** - * Set color scheme - */ -export async function setColorScheme(colorScheme: ColorScheme): Promise { - await updateAppSettings({ colorScheme }); -} - -/** - * Get active source (credentials) - */ -export async function getActiveSource(): Promise { - 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 { - 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(); -} diff --git a/src/store/db/schema/app-settings.ts b/src/store/settings/entity.ts similarity index 90% rename from src/store/db/schema/app-settings.ts rename to src/store/settings/entity.ts index d662654..8a212f8 100644 --- a/src/store/db/schema/app-settings.ts +++ b/src/store/settings/entity.ts @@ -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; \ No newline at end of file diff --git a/src/store/settings/hooks.ts b/src/store/settings/hooks.ts index c6ba9b0..6a38143 100644 --- a/src/store/settings/hooks.ts +++ b/src/store/settings/hooks.ts @@ -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 }; } diff --git a/src/store/settings/index.ts b/src/store/settings/index.ts deleted file mode 100644 index 4fbfa9f..0000000 --- a/src/store/settings/index.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/src/store/settings/types.ts b/src/store/settings/types.ts index fc27cb7..80ac6f3 100644 --- a/src/store/settings/types.ts +++ b/src/store/settings/types.ts @@ -1,5 +1,12 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import settings from './entity'; + export enum ColorScheme { System = 'system', Light = 'light', Dark = 'dark', -} \ No newline at end of file +} + +export type AppSettings = InferSelectModel; +export type InsertAppSettings = typeof settings.$inferInsert; + \ No newline at end of file diff --git a/src/store/sleep-timer/actions.ts b/src/store/sleep-timer/actions.ts index e980e7c..63cb5fe 100644 --- a/src/store/sleep-timer/actions.ts +++ b/src/store/sleep-timer/actions.ts @@ -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 { const now = Date.now(); diff --git a/src/store/sleep-timer/db.ts b/src/store/sleep-timer/db.ts index 2f94577..45272d0 100644 --- a/src/store/sleep-timer/db.ts +++ b/src/store/sleep-timer/db.ts @@ -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; diff --git a/src/store/db/schema/sleep-timer.ts b/src/store/sleep-timer/entity.ts similarity index 81% rename from src/store/db/schema/sleep-timer.ts rename to src/store/sleep-timer/entity.ts index c518d25..d42b097 100644 --- a/src/store/db/schema/sleep-timer.ts +++ b/src/store/sleep-timer/entity.ts @@ -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; diff --git a/src/store/sleep-timer/hooks.ts b/src/store/sleep-timer/hooks.ts index 3752378..7afa84a 100644 --- a/src/store/sleep-timer/hooks.ts +++ b/src/store/sleep-timer/hooks.ts @@ -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'; diff --git a/src/store/sleep-timer/index.ts b/src/store/sleep-timer/index.ts deleted file mode 100644 index f968838..0000000 --- a/src/store/sleep-timer/index.ts +++ /dev/null @@ -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) { - state.date = action.payload?.getTime() || null; - } - }, -}); - -export const { setTimerDate } = sleepTimer.actions; - -export default sleepTimer; \ No newline at end of file diff --git a/src/store/sleep-timer/sleep-timer.ts b/src/store/sleep-timer/sleep-timer.ts deleted file mode 100644 index 5952978..0000000 --- a/src/store/sleep-timer/sleep-timer.ts +++ /dev/null @@ -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(), -}); diff --git a/src/store/sleep-timer/types.ts b/src/store/sleep-timer/types.ts index 72b9dc8..cd1e8d3 100644 --- a/src/store/sleep-timer/types.ts +++ b/src/store/sleep-timer/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { sleepTimer } from './sleep-timer'; +import sleepTimer from './entity'; export type SleepTimer = InferSelectModel; export type InsertSleepTimer = typeof sleepTimer.$inferInsert; diff --git a/src/store/sources/emby/driver.ts b/src/store/sources/drivers/emby/driver.ts similarity index 100% rename from src/store/sources/emby/driver.ts rename to src/store/sources/drivers/emby/driver.ts diff --git a/src/store/sources/emby/types.ts b/src/store/sources/drivers/emby/types.ts similarity index 100% rename from src/store/sources/emby/types.ts rename to src/store/sources/drivers/emby/types.ts diff --git a/src/store/sources/jellyfin/driver.ts b/src/store/sources/drivers/jellyfin/driver.ts similarity index 100% rename from src/store/sources/jellyfin/driver.ts rename to src/store/sources/drivers/jellyfin/driver.ts diff --git a/src/store/sources/jellyfin/types.ts b/src/store/sources/drivers/jellyfin/types.ts similarity index 100% rename from src/store/sources/jellyfin/types.ts rename to src/store/sources/drivers/jellyfin/types.ts diff --git a/src/store/db/schema/sources.ts b/src/store/sources/entity.ts similarity index 87% rename from src/store/db/schema/sources.ts rename to src/store/sources/entity.ts index 6a03652..1d73ad4 100644 --- a/src/store/db/schema/sources.ts +++ b/src/store/sources/entity.ts @@ -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; diff --git a/src/store/db/schema/sync-cursors.ts b/src/store/sync-cursors/entity.ts similarity index 84% rename from src/store/db/schema/sync-cursors.ts rename to src/store/sync-cursors/entity.ts index 45d26ba..e26e24e 100644 --- a/src/store/db/schema/sync-cursors.ts +++ b/src/store/sync-cursors/entity.ts @@ -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; diff --git a/src/store/db/schema/track-artists.ts b/src/store/track-artists/entity.ts similarity index 82% rename from src/store/db/schema/track-artists.ts rename to src/store/track-artists/entity.ts index d3ebb6a..a8f05d3 100644 --- a/src/store/db/schema/track-artists.ts +++ b/src/store/track-artists/entity.ts @@ -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; diff --git a/src/store/tracks/actions.ts b/src/store/tracks/actions.ts index 80ab0ec..3bc811e 100644 --- a/src/store/tracks/actions.ts +++ b/src/store/tracks/actions.ts @@ -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 { @@ -29,19 +28,4 @@ export async function upsertTracks(trackList: InsertTrack[]): Promise { for (const track of trackList) { await upsertTrack(track); } -} - -export async function deleteTrack(id: string): Promise { - await db.delete(tracks).where(eq(tracks.id, id)); - sqliteDb.flushPendingReactiveQueries(); -} - -export async function deleteTracksBySource(sourceId: string): Promise { - await db.delete(tracks).where(eq(tracks.sourceId, sourceId)); - sqliteDb.flushPendingReactiveQueries(); -} - -export async function deleteTracksByAlbum(albumId: string): Promise { - await db.delete(tracks).where(eq(tracks.albumId, albumId)); - sqliteDb.flushPendingReactiveQueries(); -} +} \ No newline at end of file diff --git a/src/store/db/schema/tracks.ts b/src/store/tracks/entity.ts similarity index 90% rename from src/store/db/schema/tracks.ts rename to src/store/tracks/entity.ts index 8957a2d..ea778f6 100644 --- a/src/store/db/schema/tracks.ts +++ b/src/store/tracks/entity.ts @@ -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; diff --git a/src/store/tracks/hooks.ts b/src/store/tracks/hooks.ts index 87f8b43..f17e41a 100644 --- a/src/store/tracks/hooks.ts +++ b/src/store/tracks/hooks.ts @@ -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]); -} +} \ No newline at end of file diff --git a/src/store/tracks/tracks.ts b/src/store/tracks/tracks.ts deleted file mode 100644 index 7f7d8b7..0000000 --- a/src/store/tracks/tracks.ts +++ /dev/null @@ -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), -})); diff --git a/src/store/tracks/types.ts b/src/store/tracks/types.ts index ab3efb8..1ca065c 100644 --- a/src/store/tracks/types.ts +++ b/src/store/tracks/types.ts @@ -3,7 +3,7 @@ */ import type { InferSelectModel } from 'drizzle-orm'; -import { tracks } from './tracks'; +import tracks from './entity'; export type Track = InferSelectModel; export type InsertTrack = typeof tracks.$inferInsert; diff --git a/src/utility/ErrorReportingAlert.ts b/src/utility/ErrorReportingAlert.ts index a50a89e..bd09547 100644 --- a/src/utility/ErrorReportingAlert.ts +++ b/src/utility/ErrorReportingAlert.ts @@ -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'; /** diff --git a/src/utility/JellyfinApi/album.ts b/src/utility/JellyfinApi/album.ts deleted file mode 100644 index bf3d169..0000000 --- a/src/utility/JellyfinApi/album.ts +++ /dev/null @@ -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 { - return fetchApi(({ user_id }) => `/Users/${user_id}/Items/${id}`); -} - -/** - * Retrieve albums that are similar to the provided album - */ -export async function retrieveSimilarAlbums(id: string): Promise { - 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); -} diff --git a/src/utility/JellyfinApi/artist.ts b/src/utility/JellyfinApi/artist.ts deleted file mode 100644 index 0cf468c..0000000 --- a/src/utility/JellyfinApi/artist.ts +++ /dev/null @@ -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); -} diff --git a/src/utility/JellyfinApi/lib.ts b/src/utility/JellyfinApi/lib.ts index f829a60..a830ebb 100644 --- a/src/utility/JellyfinApi/lib.ts +++ b/src/utility/JellyfinApi/lib.ts @@ -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[0]) => { + return useCallback((item: Parameters[0]) => { return getImage(item, credentials); }, [credentials]); } \ No newline at end of file diff --git a/src/utility/JellyfinApi/lyrics.ts b/src/utility/JellyfinApi/lyrics.ts deleted file mode 100644 index 88f0583..0000000 --- a/src/utility/JellyfinApi/lyrics.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Lyrics } from '@/store/music/types'; -import { fetchApi } from './lib'; - -export async function retrieveTrackLyrics(trackId: string): Promise { - return fetchApi(`/Audio/${trackId}/Lyrics`); -} diff --git a/src/utility/JellyfinApi/playback.ts b/src/utility/JellyfinApi/playback.ts deleted file mode 100644 index 727dcba..0000000 --- a/src/utility/JellyfinApi/playback.ts +++ /dev/null @@ -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.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); - }); -} \ No newline at end of file diff --git a/src/utility/JellyfinApi/playlist.ts b/src/utility/JellyfinApi/playlist.ts deleted file mode 100644 index 55853ff..0000000 --- a/src/utility/JellyfinApi/playlist.ts +++ /dev/null @@ -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); -} diff --git a/src/utility/JellyfinApi/search.ts b/src/utility/JellyfinApi/search.ts deleted file mode 100644 index 836b944..0000000 --- a/src/utility/JellyfinApi/search.ts +++ /dev/null @@ -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); -} diff --git a/src/utility/JellyfinApi/track.ts b/src/utility/JellyfinApi/track.ts index f885630..cc930d8 100644 --- a/src/utility/JellyfinApi/track.ts +++ b/src/utility/JellyfinApi/track.ts @@ -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> = { - 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 = { - 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 { - // 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 { + 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 { - 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'), - }; -} \ No newline at end of file