mirror of
https://github.com/leinelissen/jellyfin-audio-player.git
synced 2026-09-02 21:03:11 +03:00
Migrate remaining files from Redux to database
- Created downloads hooks (useDownloads, useDownload, useIsDownloaded) - Created download queue manager with database operations - Updated Downloads screen to use database hooks - Updated DownloadIcon component to use database - Updated DownloadManager to use database - Updated usePlayTracks to use database hooks - Updated useCurrentTrack to use database hooks - Updated TrackPopupMenu to use database hooks - Updated Search screen to use database hooks and fetchers - Made playTracks backward compatible with CarPlay templates Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
co-authored by
leinelissen
parent
17fd5aa9f1
commit
1ff5b1b341
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<Set<SearchType>>(new Set());
|
||||
const [localPlaybackOnly, setLocalPlaybackOnly] = useState(false);
|
||||
|
||||
const albumEntities: Record<string, Album> = useTypedSelector(albumSelector);
|
||||
const trackEntities: Record<string, AlbumTrack> = useTypedSelector(tracksSelector);
|
||||
const artistEntities: Record<string, MusicArtist> = useTypedSelector(artistsSelector);
|
||||
const playlistEntities: Record<string, Playlist> = 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<NavigationProp>();
|
||||
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(() => (
|
||||
<Animated.View style={{ paddingBottom: SEARCH_INPUT_OFFSET }}>
|
||||
|
||||
@@ -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 (
|
||||
<ColoredBlurView style={{flex: 1}}>
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, DownloadWithMetadata> = {};
|
||||
const ids: string[] = [];
|
||||
const queued: string[] = [];
|
||||
|
||||
(data || []).forEach(download => {
|
||||
const enriched = enrichDownload(download as Download);
|
||||
entities[enriched.id] = enriched;
|
||||
ids.push(enriched.id);
|
||||
|
||||
// If download is not complete and not failed, it's queued
|
||||
if (!enriched.isComplete && !enriched.isFailed) {
|
||||
queued.push(enriched.id);
|
||||
}
|
||||
});
|
||||
|
||||
return { entities, ids, queued, error };
|
||||
}, [data, error]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
// Initialize the download in the database
|
||||
await initializeDownload(source.id, trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a track download
|
||||
* This is called by the DownloadManager component
|
||||
*/
|
||||
export async function downloadTrack(trackId: string): Promise<void> {
|
||||
const source = await getActiveSource();
|
||||
if (!source) throw new Error('No active source');
|
||||
|
||||
try {
|
||||
// Get track from database
|
||||
const trackData = await db
|
||||
.select()
|
||||
.from(tracks)
|
||||
.where(eq(tracks.id, trackId))
|
||||
.limit(1);
|
||||
|
||||
const dbTrack = trackData[0];
|
||||
if (!dbTrack) {
|
||||
await failDownload(trackId, 'Track not found in database');
|
||||
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<void> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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__) {
|
||||
|
||||
@@ -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<Track | undefined>();
|
||||
const [index, setIndex] = useState<number | undefined>();
|
||||
|
||||
// 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(() => (
|
||||
|
||||
@@ -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<string, AlbumTrack>,
|
||||
downloads: Record<string, DownloadEntity>,
|
||||
downloads: Record<string, DownloadWithMetadata | any>,
|
||||
options: Partial<PlayOptions> = {},
|
||||
): Promise<Track[] | undefined> {
|
||||
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<PlayOptions> = {}) =>
|
||||
|
||||
Reference in New Issue
Block a user