Add music fetchers and database hooks to bridge Jellyfin API with database

Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-08 15:05:09 +00:00
co-authored by leinelissen
parent e34dfc3780
commit 27a4c79fe8
2 changed files with 467 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* Bridge functions to fetch from Jellyfin API and store in database
* These replace the Redux thunks with direct database operations
*/
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum, retrieveSimilarAlbums } from '@/utility/JellyfinApi/album';
import { retrieveAllPlaylists, retrievePlaylistTracks, retrieveInstantMixByTrackId } from '@/utility/JellyfinApi/playlist';
import { retrieveAllArtists } from '@/utility/JellyfinApi/artist';
import { searchItem } from '@/utility/JellyfinApi/search';
import { retrieveTrackLyrics } from '@/utility/JellyfinApi/lyrics';
import { retrieveTrackCodecMetadata } from '@/utility/JellyfinApi/track';
import { getActiveSource } from '@/store/settings/db';
import * as musicDb from './db';
import type { Album, AlbumTrack, MusicArtist, Playlist } from './types';
/**
* Fetch all albums from Jellyfin and store in database
*/
export async function fetchAndStoreAllAlbums() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const albums = await retrieveAllAlbums();
await musicDb.upsertAlbums(source.id, albums);
return albums;
}
/**
* Fetch recent albums from Jellyfin and store in database
*/
export async function fetchAndStoreRecentAlbums() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const albums = await retrieveRecentAlbums();
await musicDb.upsertAlbums(source.id, albums);
return albums;
}
/**
* Fetch a single album from Jellyfin and store in database
*/
export async function fetchAndStoreAlbum(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const album = await retrieveAlbum(albumId);
await musicDb.upsertAlbum(source.id, album);
return album;
}
/**
* Fetch similar albums from Jellyfin and store in database
*/
export async function fetchAndStoreSimilarAlbums(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const similarAlbums = await retrieveSimilarAlbums(albumId);
await musicDb.upsertAlbums(source.id, similarAlbums);
await musicDb.setSimilarAlbums(source.id, albumId, similarAlbums.map(a => a.Id));
return similarAlbums;
}
/**
* Fetch tracks by album from Jellyfin and store in database
*/
export async function fetchAndStoreTracksByAlbum(albumId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const tracks = await retrieveAlbumTracks(albumId);
await musicDb.upsertTracks(source.id, tracks);
// Fetch codec metadata and lyrics for tracks
await Promise.all(tracks.map(async (track) => {
if (track.HasLyrics) {
try {
const lyrics = await retrieveTrackLyrics(track.Id);
track.Lyrics = lyrics;
await musicDb.upsertTrack(source.id, track);
} catch (e) {
console.error('Error fetching lyrics for track', track.Id, e);
}
}
try {
const codec = await retrieveTrackCodecMetadata(track.Id);
await musicDb.updateTrackCodec(track.Id, codec);
} catch (e) {
console.error('Error fetching codec for track', track.Id, e);
}
}));
return tracks;
}
/**
* Fetch all artists from Jellyfin and store in database
*/
export async function fetchAndStoreAllArtists() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const artists = await retrieveAllArtists();
await musicDb.upsertArtists(source.id, artists);
return artists;
}
/**
* Fetch all playlists from Jellyfin and store in database
*/
export async function fetchAndStoreAllPlaylists() {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const playlists = await retrieveAllPlaylists();
await musicDb.upsertPlaylists(source.id, playlists);
return playlists;
}
/**
* Fetch tracks by playlist from Jellyfin and store in database
*/
export async function fetchAndStoreTracksByPlaylist(playlistId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const tracks = await retrievePlaylistTracks(playlistId);
await musicDb.upsertTracks(source.id, tracks);
await musicDb.setPlaylistTracks(source.id, playlistId, tracks.map(t => t.Id));
// Fetch codec metadata and lyrics for tracks
await Promise.all(tracks.map(async (track) => {
if (track.HasLyrics) {
try {
const lyrics = await retrieveTrackLyrics(track.Id);
track.Lyrics = lyrics;
await musicDb.upsertTrack(source.id, track);
} catch (e) {
console.error('Error fetching lyrics for track', track.Id, e);
}
}
try {
const codec = await retrieveTrackCodecMetadata(track.Id);
await musicDb.updateTrackCodec(track.Id, codec);
} catch (e) {
console.error('Error fetching codec for track', track.Id, e);
}
}));
return tracks;
}
/**
* Search Jellyfin and store results in database
*/
export async function searchAndStore(term: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const results = await searchItem(term);
// Separate results by type
const albums = results.filter(item => item.Type === 'MusicAlbum') as Album[];
const tracks = results.filter(item => item.Type === 'Audio') as AlbumTrack[];
const artists = results.filter(item => item.Type === 'MusicArtist') as MusicArtist[];
const playlists = results.filter(item => item.Type === 'Playlist') as Playlist[];
// Store in database
await Promise.all([
musicDb.upsertAlbums(source.id, albums),
musicDb.upsertTracks(source.id, tracks),
musicDb.upsertArtists(source.id, artists),
musicDb.upsertPlaylists(source.id, playlists),
]);
return results;
}
/**
* Fetch instant mix by track and store in database
*/
export async function fetchAndStoreInstantMixByTrack(trackId: string) {
const source = await getActiveSource();
if (!source) throw new Error('No active source');
const tracks = await retrieveInstantMixByTrackId(trackId);
await musicDb.upsertTracks(source.id, tracks);
return tracks;
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Database-backed hooks for music data
* These replace Redux selectors with live database queries
*/
import { useMemo } from 'react';
import { useLiveQuery } from '@/store/db/live-queries';
import { db } from '@/store/db';
import { albums } from '@/store/db/schema/albums';
import { artists } from '@/store/db/schema/artists';
import { tracks } from '@/store/db/schema/tracks';
import { playlists } from '@/store/db/schema/playlists';
import { eq, desc } 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';
/**
* Get all albums for a source
*/
export function useAlbums(sourceId: string) {
const { data, error } = useLiveQuery(
sourceId ? db.select().from(albums).where(eq(albums.sourceId, sourceId)) : null
);
return useMemo(() => {
const albumsMap: Record<string, Album> = {};
const ids: string[] = [];
(data || []).forEach(album => {
const enriched = enrichAlbum(album);
albumsMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { albums: albumsMap, ids, error, isLoading: false };
}, [data, error]);
}
/**
* Get recent albums (sorted by date created)
*/
export function useRecentAlbums(sourceId: string, amount: number = 24) {
const { data, error } = useLiveQuery(
sourceId
? db.select().from(albums).where(eq(albums.sourceId, sourceId)).orderBy(desc(albums.dateCreated)).limit(amount)
: null
);
return useMemo(() => {
const albumsMap: Record<string, Album> = {};
const ids: string[] = [];
(data || []).forEach(album => {
const enriched = enrichAlbum(album);
albumsMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { albums: albumsMap, ids, error };
}, [data, error]);
}
/**
* Get albums sorted by alphabet with sections
*/
export function useAlbumsByAlphabet(sourceId: string) {
const { albums: albumsMap, ids } = useAlbums(sourceId);
return useMemo(() => {
// Sort by album artist
const sorted = [...ids].sort((a, b) => {
const albumA = albumsMap[a];
const albumB = albumsMap[b];
if ((!albumA && !albumB) || (!albumA?.AlbumArtist && !albumB?.AlbumArtist)) {
return 0;
} else if (!albumA || !albumA.AlbumArtist) {
return 1;
} else if (!albumB || !albumB.AlbumArtist) {
return -1;
}
return albumA.AlbumArtist.localeCompare(albumB.AlbumArtist);
});
// Split into alphabet sections
const sections: SectionListData<string[]>[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [[]] }));
sorted.forEach((id) => {
const album = albumsMap[id];
const letter = album?.AlbumArtist?.toUpperCase().charAt(0);
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
const section = sections[index >= 0 ? index : 26];
const row = section.data.length - 1;
section.data[row].push(id);
if (section.data[row].length >= 2) {
(section.data as string[][]).push([]);
}
});
return sections;
}, [albumsMap, ids]);
}
/**
* Get all artists for a source
*/
export function useArtists(sourceId: string) {
const { data, error } = useLiveQuery(
sourceId ? db.select().from(artists).where(eq(artists.sourceId, sourceId)) : null
);
return useMemo(() => {
const artistsMap: Record<string, MusicArtist> = {};
const ids: string[] = [];
(data || []).forEach(artist => {
const enriched = enrichArtist(artist);
artistsMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { artists: artistsMap, ids, error };
}, [data, error]);
}
/**
* Get artists sorted by alphabet with sections
*/
export function useArtistsByAlphabet(sourceId: string) {
const { artists: artistsMap } = useArtists(sourceId);
return useMemo(() => {
const artistsList = Object.values(artistsMap);
const sections: SectionListData<MusicArtist>[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
artistsList.forEach((artist) => {
const letter = artist.Name.toUpperCase().charAt(0);
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
const section = sections[index >= 0 ? index : 26];
(section.data as MusicArtist[]).push(artist);
});
return sections;
}, [artistsMap]);
}
/**
* Get all playlists for a source
*/
export function usePlaylists(sourceId: string) {
const { data, error } = useLiveQuery(
sourceId ? db.select().from(playlists).where(eq(playlists.sourceId, sourceId)) : null
);
return useMemo(() => {
const playlistsMap: Record<string, Playlist> = {};
const ids: string[] = [];
(data || []).forEach(playlist => {
const enriched = enrichPlaylist(playlist);
playlistsMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { playlists: playlistsMap, ids, error };
}, [data, error]);
}
/**
* Get tracks by album
*/
export function useTracksByAlbum(albumId: string) {
const { data, error } = useLiveQuery(
albumId ? db.select().from(tracks).where(eq(tracks.albumId, albumId)) : null
);
return useMemo(() => {
const tracksMap: Record<string, AlbumTrack> = {};
const ids: string[] = [];
(data || []).forEach(track => {
const enriched = enrichTrack(track);
tracksMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { tracks: tracksMap, ids, error };
}, [data, error]);
}
/**
* Get all tracks for a source
*/
export function useTracks(sourceId: string) {
const { data, error } = useLiveQuery(
sourceId ? db.select().from(tracks).where(eq(tracks.sourceId, sourceId)) : null
);
return useMemo(() => {
const tracksMap: Record<string, AlbumTrack> = {};
const ids: string[] = [];
(data || []).forEach(track => {
const enriched = enrichTrack(track);
tracksMap[enriched.Id] = enriched;
ids.push(enriched.Id);
});
return { tracks: tracksMap, ids, error };
}, [data, error]);
}
/**
* Helper functions to enrich database rows with full type information
*/
function enrichAlbum(dbAlbum: any): Album {
const metadata = dbAlbum.metadataJson ? JSON.parse(dbAlbum.metadataJson) : {};
return {
Id: dbAlbum.id,
Name: dbAlbum.name,
ProductionYear: dbAlbum.productionYear,
IsFolder: dbAlbum.isFolder,
AlbumArtist: dbAlbum.albumArtist,
DateCreated: dbAlbum.dateCreated ? new Date(dbAlbum.dateCreated).toISOString() : new Date().toISOString(),
lastRefreshed: dbAlbum.lastRefreshed,
...metadata,
};
}
function enrichArtist(dbArtist: any): MusicArtist {
const metadata = dbArtist.metadataJson ? JSON.parse(dbArtist.metadataJson) : {};
return {
Id: dbArtist.id,
Name: dbArtist.name,
IsFolder: dbArtist.isFolder,
...metadata,
};
}
function enrichTrack(dbTrack: any): AlbumTrack {
const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {};
const lyrics = dbTrack.lyrics ? JSON.parse(dbTrack.lyrics) : undefined;
return {
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,
Lyrics: lyrics,
...metadata,
};
}
function enrichPlaylist(dbPlaylist: any): Playlist {
const metadata = dbPlaylist.metadataJson ? JSON.parse(dbPlaylist.metadataJson) : {};
return {
Id: dbPlaylist.id,
Name: dbPlaylist.name,
CanDelete: dbPlaylist.canDelete,
ChildCount: dbPlaylist.childCount,
lastRefreshed: dbPlaylist.lastRefreshed,
...metadata,
};
}