From 77db5a51d2e2ba5ecd37439588dd83dcf2437b1c Mon Sep 17 00:00:00 2001 From: Lei Nelissen Date: Fri, 25 Oct 2024 00:25:01 +0200 Subject: [PATCH] fix: retrieve codec metadata and lyrics asynchronously --- .../Lyrics/components/LyricsRenderer.tsx | 20 +++---- .../Player/components/MediaInformation.tsx | 8 +-- src/store/music/actions.ts | 58 +++++++++++++++++-- src/store/music/index.ts | 14 ++++- src/store/music/types.ts | 33 ++++++++++- src/utility/JellyfinApi/album.ts | 3 +- src/utility/JellyfinApi/lyrics.ts | 48 +-------------- src/utility/JellyfinApi/playlist.ts | 3 +- src/utility/JellyfinApi/track.ts | 26 +++++---- 9 files changed, 125 insertions(+), 88 deletions(-) diff --git a/src/screens/modals/Lyrics/components/LyricsRenderer.tsx b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx index adead36..5eaef93 100644 --- a/src/screens/modals/Lyrics/components/LyricsRenderer.tsx +++ b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx @@ -1,17 +1,13 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import { LayoutChangeEvent, LayoutRectangle, StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; -import { Lyrics } from '@/utility/JellyfinApi/lyrics'; import { useProgress } from 'react-native-track-player'; import useCurrentTrack from '@/utility/useCurrentTrack'; import LyricsLine from './LyricsLine'; import { useNavigation } from '@react-navigation/native'; -import { useTypedSelector } from '@/store'; import { NOW_PLAYING_POPOVER_HEIGHT } from '@/screens/Music/overlays/NowPlaying'; import LyricsProgress, { LyricsProgressProps } from './LyricsProgress'; -type LyricsLine = Lyrics['Lyrics'][number]; - const styles = StyleSheet.create({ lyricsContainerFull: { padding: 40, @@ -42,9 +38,7 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) { const scrollViewRef = useRef(null); const lineLayoutsRef = useRef(new Map()); const { position } = useProgress(100); - const { track: trackPlayerTrack } = useCurrentTrack(); - const tracks = useTypedSelector((state) => state.music.tracks.entities); - const track = useMemo(() => tracks[trackPlayerTrack?.backendId], [trackPlayerTrack?.backendId, tracks]); + const { track, albumTrack } = useCurrentTrack(); const navigation = useNavigation(); // We will be using isUserScrolling to prevent lyrics controller scroll lyrics view @@ -83,12 +77,12 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) { const handleScrollBeginDrag = useCallback(() => isUserScrolling.current = true, []); const handleScrollEndDrag = useCallback(() => isUserScrolling.current = false, []); - if (!track) { + if (!track || !albumTrack) { return null; } // GUARD: If the track has no lyrics, close the modal - if (!track.HasLyrics || !track.Lyrics) { + if (!albumTrack.HasLyrics || !albumTrack.Lyrics) { navigation.goBack(); return null; } @@ -107,18 +101,18 @@ export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) { > - {track.Lyrics.Lyrics.map((lyrics, i) => { + {albumTrack.Lyrics.Lyrics.map((lyrics, i) => { const props: LyricsProgressProps = { start: lyrics.Start - TIME_OFFSET, - end: track.Lyrics!.Lyrics.length === i + 1 + end: albumTrack.Lyrics!.Lyrics.length === i + 1 ? track.RunTimeTicks - : track.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET + : albumTrack.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET , position: currentTime, onLayout: handleLayoutChange, diff --git a/src/screens/modals/Player/components/MediaInformation.tsx b/src/screens/modals/Player/components/MediaInformation.tsx index e355a94..95b0d0c 100644 --- a/src/screens/modals/Player/components/MediaInformation.tsx +++ b/src/screens/modals/Player/components/MediaInformation.tsx @@ -52,18 +52,18 @@ export default function MediaInformation() { {mediaStream && ( <> diff --git a/src/store/music/actions.ts b/src/store/music/actions.ts index 7f3083f..1d73450 100644 --- a/src/store/music/actions.ts +++ b/src/store/music/actions.ts @@ -1,15 +1,61 @@ -import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; -import { Album, AlbumTrack, Playlist } from './types'; -import { AsyncThunkAPI } from '..'; +import { AsyncThunkPayloadCreator, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; +import { Album, AlbumTrack, CodecMetadata, Lyrics, Playlist } from './types'; +import type { AsyncThunkAPI } from '..'; import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album'; import { retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi/playlist'; import { searchItem } from '@/utility/JellyfinApi/search'; +import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics'; +import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track'; export const albumAdapter = createEntityAdapter({ selectId: album => album.Id, sortComparer: (a, b) => a.Name.localeCompare(b.Name), }); +/** + * Fetch lyrics for a given track + */ +export const fetchLyricsByTrack = createAsyncThunk( + '/track/lyrics', + retrieveTrackLyrics, +); + +/** + * Fetch codec metadata for a given track + */ +export const fetchCodecMetadataByTrack = createAsyncThunk( + '/track/codecMetadata', + retrieveTrackCodecMetadata, +); + +/** A generic type for any action that retrieves tracks */ +type AlbumTrackPayloadCreator = AsyncThunkPayloadCreator; + +/** + * This is a wrapper that postprocesses any tracks, so that we can also support + * lyrics, codec metadata and potential other applications. + */ +export const postProcessTracks = function(creator: AlbumTrackPayloadCreator): AlbumTrackPayloadCreator { + // Return a new payload creator + return async (args, thunkAPI) => { + // Retrieve the tracks using the original creator + const tracks = await creator(args, thunkAPI); + + // GUARD: Check if we've retrieved any tracks + if (Array.isArray(tracks)) { + // If so, attempt to retrieve lyrics for the tracks that have them + tracks.filter((t) => t.HasLyrics) + .forEach((t) => thunkAPI.dispatch(fetchLyricsByTrack(t.Id))); + + // Also, retrieve codec metadata + tracks.forEach((t) => thunkAPI.dispatch(fetchCodecMetadataByTrack(t.Id))); + } + + return tracks; + }; +}; + + /** * Fetch all albums available on the jellyfin server */ @@ -36,7 +82,7 @@ export const trackAdapter = createEntityAdapter({ */ export const fetchTracksByAlbum = createAsyncThunk( '/tracks/byAlbum', - retrieveAlbumTracks, + postProcessTracks(retrieveAlbumTracks), ); export const fetchAlbum = createAsyncThunk( @@ -100,5 +146,5 @@ export const fetchAllPlaylists = createAsyncThunk( '/tracks/byPlaylist', - retrievePlaylistTracks, -); \ No newline at end of file + postProcessTracks(retrievePlaylistTracks), +); diff --git a/src/store/music/index.ts b/src/store/music/index.ts index 3b452d0..dab6d67 100644 --- a/src/store/music/index.ts +++ b/src/store/music/index.ts @@ -9,7 +9,9 @@ import { fetchAllPlaylists, fetchTracksByPlaylist, fetchAlbum, - fetchSimilarAlbums + fetchSimilarAlbums, + fetchCodecMetadataByTrack, + fetchLyricsByTrack } from './actions'; import { createSlice } from '@reduxjs/toolkit'; import { Album, AlbumTrack, Playlist } from './types'; @@ -162,6 +164,16 @@ const music = createSlice({ // Reset any caches we have when a new server is set builder.addCase(setJellyfinCredentials, () => initialState); + + /** + * Fetch track metadata + */ + builder.addCase(fetchCodecMetadataByTrack.fulfilled, (state, { payload, meta }) => { + state.tracks.entities[meta.arg].Codec = payload; + }); + builder.addCase(fetchLyricsByTrack.fulfilled, (state, { payload, meta }) => { + state.tracks.entities[meta.arg].Lyrics = payload; + }); } }); diff --git a/src/store/music/types.ts b/src/store/music/types.ts index c908774..4b4dfd7 100644 --- a/src/store/music/types.ts +++ b/src/store/music/types.ts @@ -1,5 +1,3 @@ -import {Lyrics} from '@/utility/JellyfinApi/lyrics.ts'; - export interface UserData { PlaybackPositionTicks: number; PlayCount: number; @@ -72,6 +70,34 @@ export interface Album { PrimaryImageItemId?: string; } +export interface CodecMetadata { + contentType?: string; + isDirectPlay: boolean; +} + +export interface LyricMetadata { + Artist: string + Album: string + Title: string + Author: string + Length: number + By: string + Offset: number + Creator: string + Version: string + IsSynced: boolean +} + +export interface LyricData { + Text: string + Start: number +} + +export interface Lyrics { + Metadata: LyricMetadata; + Lyrics: LyricData[] +} + export interface AlbumTrack { Name: string; ServerId: string; @@ -95,7 +121,8 @@ export interface AlbumTrack { LocationType: string; MediaType: string; HasLyrics: boolean; - Lyrics: Lyrics | null; + Lyrics?: Lyrics; + Codec?: CodecMetadata; MediaStreams: MediaStream[]; } diff --git a/src/utility/JellyfinApi/album.ts b/src/utility/JellyfinApi/album.ts index 9c0dc89..bf3d169 100644 --- a/src/utility/JellyfinApi/album.ts +++ b/src/utility/JellyfinApi/album.ts @@ -1,6 +1,5 @@ import { Album, AlbumTrack } from '@/store/music/types'; import { fetchApi } from './lib'; -import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts'; const albumOptions = { SortBy: 'AlbumArtist,SortName', @@ -73,5 +72,5 @@ export async function retrieveAlbumTracks(ItemId: string) { const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString(); return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`) - .then((data) => retrieveAndInjectLyricsToTracks(data.Items)); + .then((d) => d.Items); } diff --git a/src/utility/JellyfinApi/lyrics.ts b/src/utility/JellyfinApi/lyrics.ts index 839078f..88f0583 100644 --- a/src/utility/JellyfinApi/lyrics.ts +++ b/src/utility/JellyfinApi/lyrics.ts @@ -1,48 +1,6 @@ +import { Lyrics } from '@/store/music/types'; import { fetchApi } from './lib'; -import {AlbumTrack} from '@/store/music/types.ts'; -interface Metadata { - Artist: string - Album: string - Title: string - Author: string - Length: number - By: string - Offset: number - Creator: string - Version: string - IsSynced: boolean -} - -interface LyricData { - Text: string - Start: number -} - -export interface Lyrics { - Metadata: Metadata - Lyrics: LyricData[] -} - -async function retrieveTrackLyrics(trackId: string): Promise { - return fetchApi(`/Audio/${trackId}/Lyrics`) - .catch((e) => { - console.error('Error on fetching track lyrics: ', e); - return null; - }); -} - - -export async function retrieveAndInjectLyricsToTracks(tracks: AlbumTrack[]): Promise { - return Promise.all(tracks.map(async (track) => { - if (!track.HasLyrics) { - track.Lyrics = null; - return track; - } - - track.Lyrics = await retrieveTrackLyrics(track.Id); - - return track; - - })); +export async function retrieveTrackLyrics(trackId: string): Promise { + return fetchApi(`/Audio/${trackId}/Lyrics`); } diff --git a/src/utility/JellyfinApi/playlist.ts b/src/utility/JellyfinApi/playlist.ts index 109cf2d..419dbcb 100644 --- a/src/utility/JellyfinApi/playlist.ts +++ b/src/utility/JellyfinApi/playlist.ts @@ -1,6 +1,5 @@ import { AlbumTrack, Playlist } from '@/store/music/types'; import { asyncFetchStore, fetchApi } from './lib'; -import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts'; const playlistOptions = { SortBy: 'SortName', @@ -35,5 +34,5 @@ export async function retrievePlaylistTracks(ItemId: string) { const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString(); return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`) - .then((d) => retrieveAndInjectLyricsToTracks(d.Items)); + .then((d) => d.Items); } diff --git a/src/utility/JellyfinApi/track.ts b/src/utility/JellyfinApi/track.ts index ac08c44..ad048e0 100644 --- a/src/utility/JellyfinApi/track.ts +++ b/src/utility/JellyfinApi/track.ts @@ -1,9 +1,7 @@ -import { AlbumTrack } from '@/store/music/types'; +import { AlbumTrack, CodecMetadata } from '@/store/music/types'; import { Platform } from 'react-native'; import { Track } from 'react-native-track-player'; -import { fetchApi, getImage } from './lib'; -import store from '@/store'; -import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics'; +import { asyncFetchStore, fetchApi, getImage } from './lib'; const trackOptionsOsOverrides: Record> = { ios: { @@ -30,7 +28,7 @@ const baseTrackOptions: Record = { * Generate the track streaming url from the trackId */ export function generateTrackUrl(trackId: string) { - const credentials = store.getState().settings.credentials; + const credentials = asyncFetchStore().getState().settings.credentials; const trackOptions = { ...baseTrackOptions, UserId: credentials?.user_id || '', @@ -52,8 +50,6 @@ export async function generateTrack(track: AlbumTrack): Promise { // Also construct the URL for the stream const url = generateTrackUrl(track.Id); - const response = await fetch(url, { method: 'HEAD' }); - return { url, backendId: track.Id, @@ -62,10 +58,6 @@ export async function generateTrack(track: AlbumTrack): Promise { album: track.Album, duration: track.RunTimeTicks, artwork: getImage(track.Id), - hasLyrics: track.HasLyrics, - lyrics: track.Lyrics, - contentType: response.headers.get('Content-Type') || undefined, - isDirectPlay: response.headers.has('Content-Length'), bitRate: baseTrackOptions.audioBitRate, }; } @@ -84,5 +76,15 @@ const trackParams = { */ export async function retrieveAllTracks() { return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`) - .then((d) => retrieveAndInjectLyricsToTracks(d.Items)); + .then((d) => d.Items); } + +export async function retrieveTrackCodecMetadata(trackId: string): Promise { + const url = generateTrackUrl(trackId); + const response = await fetch(url, { method: 'HEAD' }); + + return { + contentType: response.headers.get('Content-Type') || undefined, + isDirectPlay: response.headers.has('Content-Length'), + }; +} \ No newline at end of file