From 6316814eba59ed2bd2c0fe1a23ed64bfe2f1bed3 Mon Sep 17 00:00:00 2001 From: Lei Nelissen Date: Sun, 26 Jan 2025 22:55:09 +0100 Subject: [PATCH] feat: also store cover images for downloaded tracks --- src/components/CoverImage.tsx | 2 +- src/components/DownloadManager.ts | 12 ++++++-- src/store/downloads/actions.ts | 49 +++++++++++++++++-------------- src/store/downloads/types.ts | 1 + src/utility/mimeType.ts | 43 +++++++++++++++++++++++++++ src/utility/usePlayTracks.ts | 3 ++ 6 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 src/utility/mimeType.ts diff --git a/src/components/CoverImage.tsx b/src/components/CoverImage.tsx index cb94273..0694dae 100644 --- a/src/components/CoverImage.tsx +++ b/src/components/CoverImage.tsx @@ -50,7 +50,7 @@ function CoverImage({ const defaultStyles = useDefaultStyles(); const colorScheme = useUserOrSystemScheme(); - const image = useImage(src || null, console.log); + const image = useImage(src || null); const fallback = useImage(colorScheme === 'light' ? emptyAlbumLight: emptyAlbumDark); const { canvasSize, imageSize } = useMemo(() => { const imageSize = Screen.width - margin; diff --git a/src/components/DownloadManager.ts b/src/components/DownloadManager.ts index f6f484e..9b64368 100644 --- a/src/components/DownloadManager.ts +++ b/src/components/DownloadManager.ts @@ -3,6 +3,7 @@ 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 { getMimeTypeForExtension } from '@/utility/mimeType'; /** * The maximum number of concurrent downloads we allow to take place at once. @@ -77,11 +78,17 @@ function DownloadManager () { // Loop through the mp3 files files.filter((file) => file.isFile()) .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 // left for us to do. - if (ids.includes(id) && file.path === entities[id]?.location) { + if (ids.includes(id)) { return; } @@ -90,6 +97,7 @@ function DownloadManager () { id, location: file.path, size: file.size, + })); }); } diff --git a/src/store/downloads/actions.ts b/src/store/downloads/actions.ts index 5038b59..f69995f 100644 --- a/src/store/downloads/actions.ts +++ b/src/store/downloads/actions.ts @@ -3,56 +3,61 @@ import { AppState } from '@/store'; import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs'; import { DownloadEntity } from './types'; 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(); export const queueTrackForDownload = createAction('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 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 downloadTrack = createAsyncThunk( '/downloads/track', async (id: string, { dispatch }) => { // 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 - const contentType = (await fetch(url, { method: 'HEAD' })).headers.get('Content-Type'); - if (!contentType) { - throw new Error('Jellyfin did not return a Content-Type for a streaming URL.'); - } - - // Then convert the MIME-type to an extension - const extensions = db[contentType]?.extensions; - if (!extensions?.length) { - throw new Error(`Unsupported MIME-type ${contentType}`); - } + const [audioExt, imageExt] = await Promise.all([ + getExtensionForUrl(audioUrl), + getExtensionForUrl(imageUrl) + ]); // 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 - const { promise } = await downloadFile({ - fromUrl: url, + // Actually kick off the download + const { promise: audioPromise } = downloadFile({ + fromUrl: audioUrl, progressInterval: 250, background: true, begin: ({ jobId, contentLength }) => { // Dispatch the initialization - dispatch(initializeDownload({ id, jobId, size: contentLength, location })); + dispatch(initializeDownload({ id, jobId, size: contentLength, location: audioLocation, image: imageLocation })); }, progress: (result) => { // Dispatch a progress update 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 - const result = await promise; - dispatch(completeDownload({ id, location, size: result.bytesWritten })); + const [audioResult, imageResult] = await Promise.all([audioPromise, imagePromise]); + const totalSize = audioResult.bytesWritten + imageResult.bytesWritten; + dispatch(completeDownload({ id, location: audioLocation, size: totalSize, image: imageLocation })); }, ); diff --git a/src/store/downloads/types.ts b/src/store/downloads/types.ts index ed92aed..fc27a18 100644 --- a/src/store/downloads/types.ts +++ b/src/store/downloads/types.ts @@ -7,4 +7,5 @@ export interface DownloadEntity { location?: string; jobId?: number; error?: string; + image?: string; } diff --git a/src/utility/mimeType.ts b/src/utility/mimeType.ts new file mode 100644 index 0000000..7bc44e5 --- /dev/null +++ b/src/utility/mimeType.ts @@ -0,0 +1,43 @@ +import db from 'mime-db'; + +const MIME_OVERRIDES: Record = { + '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); + }); +} \ No newline at end of file diff --git a/src/utility/usePlayTracks.ts b/src/utility/usePlayTracks.ts index c270a8a..4f197af 100644 --- a/src/utility/usePlayTracks.ts +++ b/src/utility/usePlayTracks.ts @@ -65,6 +65,9 @@ export default function usePlayTracks() { if (download?.location) { generatedTrack.url = 'file://' + download.location; } + if (download?.image) { + generatedTrack.artwork = 'file://' + download.image; + } return generatedTrack; }))).filter((t): t is Track => typeof t !== 'undefined');