feat: also store cover images for downloaded tracks
This commit is contained in:
@@ -50,7 +50,7 @@ function CoverImage({
|
|||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
const colorScheme = useUserOrSystemScheme();
|
const colorScheme = useUserOrSystemScheme();
|
||||||
|
|
||||||
const image = useImage(src || null, console.log);
|
const image = useImage(src || null);
|
||||||
const fallback = useImage(colorScheme === 'light' ? emptyAlbumLight: emptyAlbumDark);
|
const fallback = useImage(colorScheme === 'light' ? emptyAlbumLight: emptyAlbumDark);
|
||||||
const { canvasSize, imageSize } = useMemo(() => {
|
const { canvasSize, imageSize } = useMemo(() => {
|
||||||
const imageSize = Screen.width - margin;
|
const imageSize = Screen.width - margin;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { DocumentDirectoryPath, readDir } from 'react-native-fs';
|
import { DocumentDirectoryPath, readDir } from 'react-native-fs';
|
||||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||||
import { completeDownload, downloadTrack } from '@/store/downloads/actions';
|
import { completeDownload, downloadTrack } from '@/store/downloads/actions';
|
||||||
|
import { getMimeTypeForExtension } from '@/utility/mimeType';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The maximum number of concurrent downloads we allow to take place at once.
|
* The maximum number of concurrent downloads we allow to take place at once.
|
||||||
@@ -77,11 +78,17 @@ function DownloadManager () {
|
|||||||
// Loop through the mp3 files
|
// Loop through the mp3 files
|
||||||
files.filter((file) => file.isFile())
|
files.filter((file) => file.isFile())
|
||||||
.forEach((file) => {
|
.forEach((file) => {
|
||||||
const [id] = file.name.split('.');
|
const [id, extension] = file.name.split('.');
|
||||||
|
const mimeType = getMimeTypeForExtension(extension);
|
||||||
|
|
||||||
|
// GUARD: Only process audio mime types
|
||||||
|
if (!mimeType || mimeType.startsWith('audio')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// GUARD: If the id is already in the store, there's nothing
|
// GUARD: If the id is already in the store, there's nothing
|
||||||
// left for us to do.
|
// left for us to do.
|
||||||
if (ids.includes(id) && file.path === entities[id]?.location) {
|
if (ids.includes(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +97,7 @@ function DownloadManager () {
|
|||||||
id,
|
id,
|
||||||
location: file.path,
|
location: file.path,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,61 @@ import { AppState } from '@/store';
|
|||||||
import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs';
|
import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs';
|
||||||
import { DownloadEntity } from './types';
|
import { DownloadEntity } from './types';
|
||||||
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
|
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
|
||||||
import db from 'mime-db';
|
|
||||||
|
import { getImage } from '@/utility/JellyfinApi/lib';
|
||||||
|
import { getExtensionForUrl } from '@/utility/mimeType';
|
||||||
|
|
||||||
export const downloadAdapter = createEntityAdapter<DownloadEntity>();
|
export const downloadAdapter = createEntityAdapter<DownloadEntity>();
|
||||||
|
|
||||||
export const queueTrackForDownload = createAction<string>('download/queue');
|
export const queueTrackForDownload = createAction<string>('download/queue');
|
||||||
export const initializeDownload = createAction<{ id: string, size?: number, jobId?: number, location: string }>('download/initialize');
|
export const initializeDownload = createAction<{ id: string, size?: number, jobId?: number, location: string, image: string }>('download/initialize');
|
||||||
export const progressDownload = createAction<{ id: string, progress: number, jobId?: number }>('download/progress');
|
export const progressDownload = createAction<{ id: string, progress: number, jobId?: number }>('download/progress');
|
||||||
export const completeDownload = createAction<{ id: string, location: string, size?: number }>('download/complete');
|
export const completeDownload = createAction<{ id: string, location: string, size?: number, image?: string }>('download/complete');
|
||||||
export const failDownload = createAction<{ id: string }>('download/fail');
|
export const failDownload = createAction<{ id: string }>('download/fail');
|
||||||
|
|
||||||
export const downloadTrack = createAsyncThunk(
|
export const downloadTrack = createAsyncThunk(
|
||||||
'/downloads/track',
|
'/downloads/track',
|
||||||
async (id: string, { dispatch }) => {
|
async (id: string, { dispatch }) => {
|
||||||
// Generate the URL we can use to download the file
|
// Generate the URL we can use to download the file
|
||||||
const url = generateTrackUrl(id);
|
const audioUrl = generateTrackUrl(id);
|
||||||
|
const imageUrl = getImage(id);
|
||||||
|
|
||||||
// Get the content-type from the URL by doing a HEAD-only request
|
// Get the content-type from the URL by doing a HEAD-only request
|
||||||
const contentType = (await fetch(url, { method: 'HEAD' })).headers.get('Content-Type');
|
const [audioExt, imageExt] = await Promise.all([
|
||||||
if (!contentType) {
|
getExtensionForUrl(audioUrl),
|
||||||
throw new Error('Jellyfin did not return a Content-Type for a streaming URL.');
|
getExtensionForUrl(imageUrl)
|
||||||
}
|
]);
|
||||||
|
|
||||||
// Then convert the MIME-type to an extension
|
|
||||||
const extensions = db[contentType]?.extensions;
|
|
||||||
if (!extensions?.length) {
|
|
||||||
throw new Error(`Unsupported MIME-type ${contentType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then generate the proper location
|
// Then generate the proper location
|
||||||
const location = `${DocumentDirectoryPath}/${id}.${extensions[0]}`;
|
const audioLocation = `${DocumentDirectoryPath}/${id}.${audioExt}`;
|
||||||
|
const imageLocation = `${DocumentDirectoryPath}/${id}.${imageExt}`;
|
||||||
|
|
||||||
// Actually kick off the download
|
// Actually kick off the download
|
||||||
const { promise } = await downloadFile({
|
const { promise: audioPromise } = downloadFile({
|
||||||
fromUrl: url,
|
fromUrl: audioUrl,
|
||||||
progressInterval: 250,
|
progressInterval: 250,
|
||||||
background: true,
|
background: true,
|
||||||
begin: ({ jobId, contentLength }) => {
|
begin: ({ jobId, contentLength }) => {
|
||||||
// Dispatch the initialization
|
// Dispatch the initialization
|
||||||
dispatch(initializeDownload({ id, jobId, size: contentLength, location }));
|
dispatch(initializeDownload({ id, jobId, size: contentLength, location: audioLocation, image: imageLocation }));
|
||||||
},
|
},
|
||||||
progress: (result) => {
|
progress: (result) => {
|
||||||
// Dispatch a progress update
|
// Dispatch a progress update
|
||||||
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
|
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
|
||||||
},
|
},
|
||||||
toFile: location,
|
toFile: audioLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { promise: imagePromise } = downloadFile({
|
||||||
|
fromUrl: imageUrl,
|
||||||
|
toFile: imageLocation,
|
||||||
|
background: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Await job completion
|
// Await job completion
|
||||||
const result = await promise;
|
const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]);
|
||||||
dispatch(completeDownload({ id, location, size: result.bytesWritten }));
|
const totalSize = audioResult.bytesWritten + imageResult.bytesWritten;
|
||||||
|
dispatch(completeDownload({ id, location: audioLocation, size: totalSize, image: imageLocation }));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export interface DownloadEntity {
|
|||||||
location?: string;
|
location?: string;
|
||||||
jobId?: number;
|
jobId?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/utility/mimeType.ts
Normal file
43
src/utility/mimeType.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import db from 'mime-db';
|
||||||
|
|
||||||
|
const MIME_OVERRIDES: Record<string, string> = {
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/ogg': '.ogg'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an extension for a given URL by fetching its Content-Type
|
||||||
|
*/
|
||||||
|
export async function getExtensionForUrl(url: string) {
|
||||||
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
|
const contentType = response.headers.get('Content-Type');
|
||||||
|
|
||||||
|
// GUARD: Check that we received a content type
|
||||||
|
if (!contentType) {
|
||||||
|
throw new Error('Jellyfin did not return a Content-Type for a streaming URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUARD: Check whether there is a custom override for a particular content type
|
||||||
|
if (contentType in MIME_OVERRIDES) {
|
||||||
|
return MIME_OVERRIDES[contentType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternatively, retrieve it from mime-db
|
||||||
|
const extensions = db[contentType]?.extensions;
|
||||||
|
|
||||||
|
// GUARD: Check that we received an extension
|
||||||
|
if (!extensions?.length) {
|
||||||
|
throw new Error(`Unsupported MIME-type ${contentType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a mime type by its extension
|
||||||
|
*/
|
||||||
|
export function getMimeTypeForExtension(extension: string) {
|
||||||
|
return Object.keys(db).find((type) => {
|
||||||
|
return db[type].extensions?.includes(extension);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ export default function usePlayTracks() {
|
|||||||
if (download?.location) {
|
if (download?.location) {
|
||||||
generatedTrack.url = 'file://' + download.location;
|
generatedTrack.url = 'file://' + download.location;
|
||||||
}
|
}
|
||||||
|
if (download?.image) {
|
||||||
|
generatedTrack.artwork = 'file://' + download.image;
|
||||||
|
}
|
||||||
|
|
||||||
return generatedTrack;
|
return generatedTrack;
|
||||||
}))).filter((t): t is Track => typeof t !== 'undefined');
|
}))).filter((t): t is Track => typeof t !== 'undefined');
|
||||||
|
|||||||
Reference in New Issue
Block a user