diff --git a/src/components/DownloadIcon.tsx b/src/components/DownloadIcon.tsx index 373a20c..2a6fb87 100644 --- a/src/components/DownloadIcon.tsx +++ b/src/components/DownloadIcon.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { useTypedSelector } from '@/store'; +import { useDownload } from '@/store/downloads/hooks'; import CloudIcon from '@/assets/icons/cloud.svg'; import CloudDownArrow from '@/assets/icons/cloud-down-arrow.svg'; import CloudExclamationMarkIcon from '@/assets/icons/cloud-exclamation-mark.svg'; @@ -32,9 +32,9 @@ function DownloadIcon({ trackId, size = 16, fill, style }: DownloadIconProps) { const defaultStyles = useDefaultStyles(); const iconFill = fill || defaultStyles.textQuarterOpacity.color; - // Get download icon from state - const entity = useTypedSelector((state) => state.downloads.entities[trackId]); - const isQueued = useTypedSelector((state) => state.downloads.queued.includes(trackId)); + // Get download icon from database + const { entity } = useDownload(trackId); + const isQueued = entity && !entity.isComplete && !entity.isFailed; // Memoize calculations for radius and circumference of the circle const radius = useMemo(() => size / 2, [size]); diff --git a/src/components/DownloadManager.ts b/src/components/DownloadManager.ts index 9b64368..245cc29 100644 --- a/src/components/DownloadManager.ts +++ b/src/components/DownloadManager.ts @@ -1,8 +1,10 @@ import { xor } from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { DocumentDirectoryPath, readDir } from 'react-native-fs'; -import { useAppDispatch, useTypedSelector } from '@/store'; -import { completeDownload, downloadTrack } from '@/store/downloads/actions'; +import { useDownloads } from '@/store/downloads/hooks'; +import { useSourceId } from '@/store/db/useSourceId'; +import { downloadTrack } from '@/store/downloads/queue'; +import { completeDownload } from '@/store/downloads/db'; import { getMimeTypeForExtension } from '@/utility/mimeType'; /** @@ -17,9 +19,8 @@ const MAX_CONCURRENT_DOWNLOADS = 5; */ function DownloadManager () { // Retrieve store helpers - const { queued, ids, entities } = useTypedSelector((state) => state.downloads); - const rehydrated = useTypedSelector((state) => state._persist.rehydrated); - const dispatch = useAppDispatch(); + const sourceId = useSourceId(); + const { queued, ids, entities } = useDownloads(sourceId); // Keep state for the currently active downloads (i.e. the downloads that // have actually been pushed out to react-native-fs). @@ -42,7 +43,7 @@ function DownloadManager () { queue.filter((id) => !activeDownloads.current.has(id)) .forEach((id) => { // We dispatch the actual call to start downloading - dispatch(downloadTrack(id)); + downloadTrack(id); // And add it to the active downloads activeDownloads.current.add(id); }); @@ -52,7 +53,7 @@ function DownloadManager () { xor(Array.from(activeDownloads.current), queue) .forEach((id) => activeDownloads.current.delete(id)); - }, [queued, dispatch, activeDownloads]); + }, [queued, activeDownloads]); useEffect(() => { // GUARD: We only run this function once @@ -60,16 +61,15 @@ function DownloadManager () { return; } - // GUARD: If the state has not been rehydrated, we cannot check against - // the store ids. - if (!rehydrated) { + // GUARD: Need a source ID to hydrate orphans + if (!sourceId) { return; } /** * Whenever the store is cleared, existing downloads get "lost" because * the only reference we have is the store. This function checks for - * those lost downloads and adds them to the store + * those lost downloads and adds them to the database */ async function hydrateOrphanedDownloads() { // Retrieve all files for this app @@ -82,7 +82,7 @@ function DownloadManager () { const mimeType = getMimeTypeForExtension(extension); // GUARD: Only process audio mime types - if (!mimeType || mimeType.startsWith('audio')) { + if (!mimeType || !mimeType.startsWith('audio')) { return; } @@ -92,19 +92,14 @@ function DownloadManager () { return; } - // Add the download to the store - dispatch(completeDownload({ - id, - location: file.path, - size: file.size, - - })); + // Add the download to the database + completeDownload(id, file.path); }); } hydrateOrphanedDownloads(); setHasRehydratedOrphans(true); - }, [rehydrated, ids, hasRehydratedOrphans, dispatch, entities]); + }, [sourceId, ids, hasRehydratedOrphans, entities]); return null; } diff --git a/src/screens/Downloads/index.tsx b/src/screens/Downloads/index.tsx index d0b7a7c..58e20b2 100644 --- a/src/screens/Downloads/index.tsx +++ b/src/screens/Downloads/index.tsx @@ -2,11 +2,13 @@ import useDefaultStyles from '@/components/Colors'; import React, { useCallback, useMemo } from 'react'; import { Alert, FlatListProps, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useAppDispatch, useTypedSelector } from '@/store'; import formatBytes from '@/utility/formatBytes'; import TrashIcon from '@/assets/icons/trash.svg'; import ArrowClockwise from '@/assets/icons/arrow-clockwise.svg'; -import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/actions'; +import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue'; +import { useDownloads } from '@/store/downloads/hooks'; +import { useTracks } from '@/store/music/hooks'; +import { useSourceId } from '@/store/db/useSourceId'; import Button from '@/components/Button'; import DownloadIcon from '@/components/DownloadIcon'; import styled from 'styled-components/native'; @@ -37,11 +39,11 @@ const ErrorWrapper = styled.View` function Downloads() { const defaultStyles = useDefaultStyles(); - const dispatch = useAppDispatch(); const getImage = useGetImage(); + const sourceId = useSourceId(); - const { entities, ids } = useTypedSelector((state) => state.downloads); - const tracks = useTypedSelector((state) => state.music.tracks.entities); + const { entities, ids } = useDownloads(sourceId); + const { tracks } = useTracks(sourceId); // Calculate the total download size const totalDownloadSize = useMemo(() => ( @@ -53,9 +55,9 @@ function Downloads() { */ // Delete a single downloaded track - const handleDelete = useCallback((id: string) => { - dispatch(removeDownloadedTrack(id)); - }, [dispatch]); + const handleDelete = useCallback(async (id: string) => { + await removeDownloadedTrack(id); + }, []); // Delete all downloaded tracks const handleDeleteAllTracks = useCallback(() => { @@ -76,9 +78,9 @@ function Downloads() { }, [handleDelete, ids]); // Retry a single failed track - const retryTrack = useCallback((id: string) => { - dispatch(queueTrackForDownload(id)); - }, [dispatch]); + const retryTrack = useCallback(async (id: string) => { + await queueTrackForDownload(id); + }, []); // Retry all failed tracks const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]); diff --git a/src/screens/Search/stacks/Search/index.tsx b/src/screens/Search/stacks/Search/index.tsx index 24d293b..0e56949 100644 --- a/src/screens/Search/stacks/Search/index.tsx +++ b/src/screens/Search/stacks/Search/index.tsx @@ -4,10 +4,17 @@ import Input from '@/components/Input'; import { ActivityIndicator, Animated, KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native'; import styled from 'styled-components/native'; -import { AppState, useAppDispatch, useTypedSelector } from '@/store'; import Fuse, { IFuseOptions } from 'fuse.js'; import { Album, AlbumTrack, MusicArtist, Playlist } from '@/store/music/types'; -import { addSearchQuery, clearSearchHistory } from '@/store/search'; +import { useAlbums, useTracks, useArtists, usePlaylists } from '@/store/music/hooks'; +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/db/schema/search-queries'; +import { eq, desc } from 'drizzle-orm'; import { FlatList } from 'react-native-gesture-handler'; import TouchableHandler from '@/components/TouchableHandler'; @@ -15,7 +22,6 @@ import { useNavigation } from '@react-navigation/native'; import { useGetImage } from '@/utility/JellyfinApi/lib'; import { t } from '@/localisation'; import useDefaultStyles from '@/components/Colors'; -import { searchAndFetch } from '@/store/music/actions'; import { SubHeader, Text } from '@/components/Typography'; import DownloadIcon from '@/components/DownloadIcon'; import ChevronRight from '@/assets/icons/chevron-right.svg'; @@ -40,7 +46,7 @@ import { retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist'; const KEYBOARD_OFFSET = Platform.select({ ios: 0, // Android 15+ has edge-to-edge support, changing the keyboard offset to 0 - android: Number.parseInt(Platform.Version as string) >= 35 ? 0 : 72, + android: parseInt(Platform.Version as string, 10) >= 35 ? 0 : 72, }); const SEARCH_INPUT_HEIGHT = 104; @@ -149,17 +155,11 @@ interface SearchResult { type SearchItem = Album | AlbumTrack | MusicArtist | Playlist; -const albumSelector = (state: AppState) => state.music.albums.entities; -const tracksSelector = (state: AppState) => state.music.tracks.entities; -const artistsSelector = (state: AppState) => state.music.artists.entities; -const playlistsSelector = (state: AppState) => state.music.playlists.entities; -const downloadsSelector = (state: AppState) => state.downloads.entities; -const searchHistorySelector = (state: AppState) => state.search.queryHistory; - export default function Search() { const defaultStyles = useDefaultStyles(); const offsets = useNavigationOffsets({ includeOverlay: false }); const playTracks = usePlayTracks(); + const sourceId = useSourceId(); // Prepare state for fuse and albums const [searchTerm, setSearchTerm] = useState(''); @@ -168,17 +168,26 @@ export default function Search() { const [activeFilters, setActiveFilters] = useState>(new Set()); const [localPlaybackOnly, setLocalPlaybackOnly] = useState(false); - const albumEntities: Record = useTypedSelector(albumSelector); - const trackEntities: Record = useTypedSelector(tracksSelector); - const artistEntities: Record = useTypedSelector(artistsSelector); - const playlistEntities: Record = useTypedSelector(playlistsSelector); - const downloadEntities = useTypedSelector(downloadsSelector); - const searchHistory = useTypedSelector(searchHistorySelector); + const { albums: albumEntities } = useAlbums(sourceId); + const { tracks: trackEntities } = useTracks(sourceId); + const { artists: artistEntities } = useArtists(sourceId); + const { playlists: playlistEntities } = usePlaylists(sourceId); + const { entities: downloadEntities } = useDownloads(sourceId); + + // Use live query for search history + const { data: searchHistoryData } = useLiveQuery( + sourceId + ? db.select().from(searchQueries).where(eq(searchQueries.sourceId, sourceId)).orderBy(desc(searchQueries.timestamp)).limit(10) + : null + ); + + const searchHistory = useMemo(() => { + return searchHistoryData ? parseSearchQueries(searchHistoryData as any) : []; + }, [searchHistoryData]); // Prepare helpers const navigation = useNavigation(); const getImage = useGetImage(); - const dispatch = useAppDispatch(); /** * This function retrieves search results from Jellyfin. It is a seperate @@ -187,11 +196,11 @@ export default function Search() { */ // eslint-disable-next-line react-hooks/exhaustive-deps const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string) => { - await dispatch(searchAndFetch({ term: searchTerm })); + await searchAndStore(searchTerm); // Loading is now complete setLoading(false); - }, 150), [dispatch]); + }, 150), []); /** * Debounced function to save search query to history after 10 seconds @@ -199,12 +208,10 @@ export default function Search() { */ // eslint-disable-next-line react-hooks/exhaustive-deps const saveSearchToHistory = useCallback(debounce((query: string, filters: SearchType[], localOnly: boolean) => { - dispatch(addSearchQuery({ - query, - filters, - localPlaybackOnly: localOnly, - })); - }, 10_000), [dispatch]); + if (sourceId) { + addSearchQuery(sourceId, query, filters, localOnly); + } + }, 10_000), [sourceId]); const searchItems = useMemo(() => ({ @@ -320,11 +327,9 @@ export default function Search() { const selectItem = useCallback(async ({ id, type }: { id: string; type: SearchType; }) => { // Save search query immediately when user selects a result - dispatch(addSearchQuery({ - query: searchTerm.trim(), - filters: Array.from(activeFilters), - localPlaybackOnly, - })); + if (sourceId) { + await addSearchQuery(sourceId, searchTerm.trim(), Array.from(activeFilters), localPlaybackOnly); + } switch (type) { case 'Audio': { @@ -349,7 +354,7 @@ export default function Search() { navigation.navigate('Playlist', { id }); break; } - }, [navigation, searchItems, dispatch, playTracks, searchTerm, activeFilters, localPlaybackOnly]); + }, [navigation, searchItems, playTracks, searchTerm, activeFilters, localPlaybackOnly, sourceId]); const applyHistoryItem = useCallback((query: string, filters: SearchType[], localOnly: boolean) => { setSearchTerm(query); @@ -362,9 +367,11 @@ export default function Search() { setSearchTerm(''); }, [searchTerm, activeFilters, localPlaybackOnly, saveSearchToHistory]); - const handleClearHistory = useCallback(() => { - dispatch(clearSearchHistory()); - }, [dispatch]); + const handleClearHistory = useCallback(async () => { + if (sourceId) { + await clearSearchHistory(sourceId); + } + }, [sourceId]); const SearchInput = React.useMemo(() => ( diff --git a/src/screens/modals/TrackPopupMenu.tsx b/src/screens/modals/TrackPopupMenu.tsx index 6badfd0..c28a5c1 100644 --- a/src/screens/modals/TrackPopupMenu.tsx +++ b/src/screens/modals/TrackPopupMenu.tsx @@ -1,7 +1,10 @@ import React, { useCallback } from 'react'; import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native'; import { StackParams } from '@/screens/types'; -import { useAppDispatch, useTypedSelector } from '@/store'; +import { useTracks } from '@/store/music/hooks'; +import { useIsDownloaded } from '@/store/downloads/hooks'; +import { useSourceId } from '@/store/db/useSourceId'; +import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/queue'; import { Header, SubHeader } from '@/components/Typography'; import styled from 'styled-components/native'; import { t } from '@/localisation'; @@ -12,9 +15,7 @@ import TrashIcon from '@/assets/icons/trash.svg'; import { WrappableButton, WrappableButtonRow } from '@/components/WrappableButtonRow'; import CoverImage from '@/components/CoverImage'; -import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/actions'; import usePlayTracks from '@/utility/usePlayTracks'; -import { selectIsDownloaded } from '@/store/downloads/selectors'; import { useGetImage } from '@/utility/JellyfinApi/lib'; import { ColoredBlurView } from '@/components/Colors'; @@ -37,13 +38,14 @@ function TrackPopupMenu() { // Retrieve helpers const navigation = useNavigation(); - const dispatch = useAppDispatch(); const playTracks = usePlayTracks(); const getImage = useGetImage(); + const sourceId = useSourceId(); - // Retrieve data from store - const track = useTypedSelector((state) => state.music.tracks.entities[trackId]); - const isDownloaded = useTypedSelector(selectIsDownloaded(trackId)); + // Retrieve data from database + const { tracks } = useTracks(sourceId); + const track = tracks[trackId]; + const isDownloaded = useIsDownloaded(trackId); // Set callback to close the modal const closeModal = useCallback(() => { @@ -63,16 +65,16 @@ function TrackPopupMenu() { }, [playTracks, closeModal, trackId]); // Callback for downloading the track - const handleDownload = useCallback(() => { - dispatch(queueTrackForDownload(trackId)); + const handleDownload = useCallback(async () => { + await queueTrackForDownload(trackId); closeModal(); - }, [trackId, dispatch, closeModal]); + }, [trackId, closeModal]); // Callback for removing the downloaded track - const handleDelete = useCallback(() => { - dispatch(removeDownloadedTrack(trackId)); + const handleDelete = useCallback(async () => { + await removeDownloadedTrack(trackId); closeModal(); - }, [trackId, dispatch, closeModal]); + }, [trackId, closeModal]); return ( diff --git a/src/store/downloads/db.ts b/src/store/downloads/db.ts index 81d4da0..3e91df7 100644 --- a/src/store/downloads/db.ts +++ b/src/store/downloads/db.ts @@ -19,6 +19,7 @@ export interface Download { export interface DownloadMetadata { size?: number; error?: string; + image?: string; } /** @@ -100,7 +101,10 @@ export async function updateDownloadProgress( }; if (metadata) { - updates.metadataJson = JSON.stringify(metadata); + // Merge with existing metadata + const existing = await getDownload(id); + const existingMetadata = existing ? parseDownloadMetadata(existing) : {}; + updates.metadataJson = JSON.stringify({ ...existingMetadata, ...metadata }); } await db.update(downloads) @@ -115,18 +119,26 @@ export async function updateDownloadProgress( */ export async function completeDownload( id: string, - hash?: string, - filename?: string + filename?: string, + imageFilename?: string ): Promise { const updates: any = { isComplete: true, isFailed: false, - progress: 100, + progress: 1, updatedAt: Date.now(), }; - if (hash) updates.hash = hash; - if (filename) updates.filename = filename; + if (filename) { + updates.filename = filename; + } + + // Merge image into existing metadata + if (imageFilename) { + const existing = await getDownload(id); + const existingMetadata = existing ? parseDownloadMetadata(existing) : {}; + updates.metadataJson = JSON.stringify({ ...existingMetadata, image: imageFilename }); + } await db.update(downloads) .set(updates) @@ -182,6 +194,7 @@ export function parseDownloadMetadata(download: Download): DownloadMetadata { export interface DownloadWithMetadata extends Download { size?: number; error?: string; + image?: string; } export function enrichDownload(download: Download): DownloadWithMetadata { diff --git a/src/store/downloads/hooks.ts b/src/store/downloads/hooks.ts new file mode 100644 index 0000000..3ebb161 --- /dev/null +++ b/src/store/downloads/hooks.ts @@ -0,0 +1,63 @@ +/** + * Database-backed hooks for downloads data + */ + +import { useMemo } from 'react'; +import { useLiveQuery } from '@/store/db/live-queries'; +import { db } from '@/store/db'; +import { downloads } from '@/store/db/schema/downloads'; +import { eq } from 'drizzle-orm'; +import { enrichDownload, type Download, type DownloadWithMetadata } from './db'; + +/** + * Get all downloads for a source + */ +export function useDownloads(sourceId: string) { + const { data, error } = useLiveQuery( + sourceId ? db.select().from(downloads).where(eq(downloads.sourceId, sourceId)) : null + ); + + return useMemo(() => { + const entities: Record = {}; + const ids: string[] = []; + const queued: string[] = []; + + (data || []).forEach(download => { + const enriched = enrichDownload(download as Download); + entities[enriched.id] = enriched; + ids.push(enriched.id); + + // If download is not complete and not failed, it's queued + if (!enriched.isComplete && !enriched.isFailed) { + queued.push(enriched.id); + } + }); + + return { entities, ids, queued, error }; + }, [data, error]); +} + +/** + * Get a single download by id + */ +export function useDownload(trackId: string) { + const { data, error } = useLiveQuery( + trackId ? db.select().from(downloads).where(eq(downloads.id, trackId)).limit(1) : null + ); + + return useMemo(() => { + const download = data?.[0] as Download | undefined; + return { + entity: download ? enrichDownload(download) : undefined, + error + }; + }, [data, error]); +} + +/** + * Check if a track is downloaded + */ +export function useIsDownloaded(trackId: string): boolean { + const { entity } = useDownload(trackId); + return entity?.isComplete === true; +} diff --git a/src/store/downloads/queue.ts b/src/store/downloads/queue.ts new file mode 100644 index 0000000..2637dff --- /dev/null +++ b/src/store/downloads/queue.ts @@ -0,0 +1,149 @@ +/** + * 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/db/schema/tracks'; +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 './db'; + +/** + * Queue a track for download + */ +export async function queueTrackForDownload(trackId: string): Promise { + const source = await getActiveSource(); + if (!source) throw new Error('No active source'); + + // Initialize the download in the database + await initializeDownload(source.id, trackId); +} + +/** + * Execute a track download + * This is called by the DownloadManager component + */ +export async function downloadTrack(trackId: string): Promise { + const source = await getActiveSource(); + if (!source) throw new Error('No active source'); + + try { + // Get track from database + const trackData = await db + .select() + .from(tracks) + .where(eq(tracks.id, trackId)) + .limit(1); + + const dbTrack = trackData[0]; + if (!dbTrack) { + await failDownload(trackId, 'Track not found in database'); + return; + } + + // Enrich track to get full AlbumTrack object + const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {}; + const track = { + Id: dbTrack.id, + Name: dbTrack.name, + AlbumId: dbTrack.albumId, + Album: dbTrack.album, + AlbumArtist: dbTrack.albumArtist, + ProductionYear: dbTrack.productionYear, + IndexNumber: dbTrack.indexNumber, + ParentIndexNumber: dbTrack.parentIndexNumber, + HasLyrics: dbTrack.hasLyrics, + RunTimeTicks: dbTrack.runTimeTicks, + ...metadata, + }; + + // Generate URLs + const audioUrl = generateTrackUrl(trackId); + const imageUrl = getImage(track); + + // Get extensions + const [audioExt, imageExt] = await Promise.all([ + getExtensionForUrl(audioUrl), + imageUrl ? getExtensionForUrl(imageUrl).catch(() => null) : null + ]); + + // Generate file locations + const audioLocation = `${DocumentDirectoryPath}/${trackId}.${audioExt}`; + const imageLocation = imageExt ? `${DocumentDirectoryPath}/${trackId}.${imageExt}` : undefined; + + // Download audio file + const { promise: audioPromise } = downloadFile({ + fromUrl: audioUrl, + progressInterval: 1000, + background: true, + begin: ({ contentLength }) => { + updateDownloadProgress(trackId, 0, { size: contentLength }); + }, + progress: (result) => { + const progressValue = result.bytesWritten / result.contentLength; + updateDownloadProgress(trackId, progressValue); + }, + toFile: audioLocation, + }); + + // Download image file if available + const { promise: imagePromise } = imageExt && imageLocation + ? downloadFile({ + fromUrl: imageUrl!, + toFile: imageLocation, + background: true, + }) + : { promise: Promise.resolve(null) }; + + // Wait for completion + const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]); + const totalSize = audioResult.bytesWritten + (imageResult?.bytesWritten || 0); + + // Mark as complete + await completeDownload(trackId, audioLocation, imageLocation); + await updateDownloadProgress(trackId, 1, { size: totalSize }); + } catch (error) { + await failDownload(trackId, error instanceof Error ? error.message : 'Unknown error'); + } +} + +/** + * Remove a downloaded track + */ +export async function removeDownloadedTrack(trackId: string): Promise { + // Get the download from database + const downloadData = await db + .select() + .from(require('@/store/db/schema/downloads').downloads) + .where(eq(require('@/store/db/schema/downloads').downloads.id, trackId)) + .limit(1); + + const download = downloadData[0]; + + if (download) { + // Delete files if they exist + if (download.filename && await exists(download.filename)) { + await unlink(download.filename); + } + + // Extract image path from metadata if present + const metadata = download.metadataJson ? JSON.parse(download.metadataJson) : {}; + if (metadata.image && await exists(metadata.image)) { + await unlink(metadata.image); + } + } + + // Remove from database + await dbRemoveDownload(trackId); +} diff --git a/src/store/music/hooks.ts b/src/store/music/hooks.ts index 4dc9302..3ac6320 100644 --- a/src/store/music/hooks.ts +++ b/src/store/music/hooks.ts @@ -12,7 +12,6 @@ import { tracks } from '@/store/db/schema/tracks'; import { playlists } from '@/store/db/schema/playlists'; import { playlistTracks } from '@/store/db/schema/playlist-tracks'; import { eq, desc, and, inArray } from 'drizzle-orm'; -import { parseISO } from 'date-fns'; import { ALPHABET_LETTERS } from '@/CONSTANTS'; import type { SectionListData } from 'react-native'; import type { Album, AlbumTrack, MusicArtist, Playlist } from './types'; diff --git a/src/utility/JellyfinApi/lib.ts b/src/utility/JellyfinApi/lib.ts index 945438c..17fc556 100644 --- a/src/utility/JellyfinApi/lib.ts +++ b/src/utility/JellyfinApi/lib.ts @@ -3,8 +3,6 @@ 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 { downloads } from '@/store/db/schema/downloads'; -import { eq } from 'drizzle-orm'; import { useLiveQuery } from '@/store/db/live-queries'; import { useCallback } from 'react'; @@ -141,15 +139,6 @@ export function getImage(item: string | number | Album | AlbumTrack | Playlist | return undefined; } - // Get the item ID - const itemId = typeof item === 'string' || typeof item === 'number' - ? item - : 'PrimaryImageItemId' in item - ? item.PrimaryImageItemId || item.Id - : 'AlbumId' in item - ? item.AlbumId || item.Id - : item.Id; - // Return server URL for the image if (typeof item === 'string' || typeof item === 'number') { if (__DEV__) { diff --git a/src/utility/useCurrentTrack.ts b/src/utility/useCurrentTrack.ts index 942e202..ce590f5 100644 --- a/src/utility/useCurrentTrack.ts +++ b/src/utility/useCurrentTrack.ts @@ -1,4 +1,5 @@ -import { useTypedSelector } from '@/store'; +import { useTracks } from '@/store/music/hooks'; +import { useSourceId } from '@/store/db/useSourceId'; import { AlbumTrack } from '@/store/music/types'; import { useEffect, useMemo, useState } from 'react'; import TrackPlayer, { Event, useTrackPlayerEvents, Track } from 'react-native-track-player'; @@ -16,8 +17,9 @@ export default function useCurrentTrack(): CurrentTrackResponse { const [track, setTrack] = useState(); const [index, setIndex] = useState(); - // Retrieve entities from the store - const entities = useTypedSelector((state) => state.music.tracks.entities); + // Retrieve entities from the database + const sourceId = useSourceId(); + const { tracks: entities } = useTracks(sourceId); // Attempt to extract the track from the store const albumTrack = useMemo(() => ( diff --git a/src/utility/usePlayTracks.ts b/src/utility/usePlayTracks.ts index 54bb7a4..5dcab70 100644 --- a/src/utility/usePlayTracks.ts +++ b/src/utility/usePlayTracks.ts @@ -1,10 +1,12 @@ -import { useTypedSelector } from '@/store'; import { useCallback } from 'react'; import TrackPlayer, { Track } from 'react-native-track-player'; import { shuffle as shuffleArray } from 'lodash'; import { generateTrack } from './JellyfinApi/track'; +import { useTracks } from '@/store/music/hooks'; +import { useDownloads } from '@/store/downloads/hooks'; +import { useSourceId } from '@/store/db/useSourceId'; import type { AlbumTrack } from '@/store/music/types'; -import type { DownloadEntity } from '@/store/downloads/types'; +import type { DownloadWithMetadata } from '@/store/downloads/db'; interface PlayOptions { play: boolean; @@ -33,7 +35,7 @@ const defaults: PlayOptions = { export async function playTracks( trackIds: string[] | undefined, tracks: Record, - downloads: Record, + downloads: Record, options: Partial = {}, ): Promise { if (!trackIds) { @@ -61,9 +63,14 @@ export async function playTracks( // Check if a downloaded version exists, and if so rewrite the URL const download = downloads[trackId]; - if (download?.location) { - generatedTrack.url = 'file://' + download.location; + if (download?.isComplete) { + // Handle both old Redux format (location) and new DB format (filename) + const audioPath = download.filename || download.location; + if (audioPath) { + generatedTrack.url = 'file://' + audioPath; + } } + // Check for downloaded image (both old and new format) if (download?.image) { generatedTrack.artwork = 'file://' + download.image; } @@ -165,8 +172,9 @@ export async function playTracks( * supplied id. */ export default function usePlayTracks() { - const tracksEntities = useTypedSelector(state => state.music.tracks.entities); - const downloadsEntities = useTypedSelector(state => state.downloads.entities); + const sourceId = useSourceId(); + const { tracks: tracksEntities } = useTracks(sourceId); + const { entities: downloadsEntities } = useDownloads(sourceId); return useCallback( (trackIds: string[] | undefined, options: Partial = {}) =>