feat: also store cover images for downloaded tracks

This commit is contained in:
Lei Nelissen
2025-01-26 22:55:09 +01:00
parent 8bef5c66e3
commit 6316814eba
6 changed files with 85 additions and 25 deletions

View File

@@ -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;

View File

@@ -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,
})); }));
}); });
} }

View File

@@ -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 }));
}, },
); );

View File

@@ -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
View 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);
});
}

View File

@@ -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');