mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7e9cb60763
Move the Subsonic + Navidrome protocol clients and the library IPC facade (subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/, the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/). Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150 consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis infra stay in src/api/ pending their own M4 placement. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { getInternetRadioStations } from '@/features/radio';
|
|
import { getStarred } from '@/lib/api/subsonicStarRating';
|
|
import type {
|
|
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
|
|
} from '@/lib/api/subsonicTypes';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { usePlayerStore } from '@/store/playerStore';
|
|
import type { TopFavoriteArtist } from '@/features/favorites/components/TopFavoriteArtists';
|
|
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
|
|
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
|
|
import { useOfflineBrowseContext } from '@/features/offline';
|
|
import { useOfflineBrowseReloadToken } from '@/features/offline';
|
|
import {
|
|
loadStarredFromAllLibraryIndexes,
|
|
loadStarredFromAllServersOnline,
|
|
} from '@/features/offline';
|
|
|
|
export interface FavoritesDataResult {
|
|
albums: SubsonicAlbum[];
|
|
artists: SubsonicArtist[];
|
|
songs: SubsonicSong[];
|
|
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
|
radioStations: InternetRadioStation[];
|
|
setRadioStations: React.Dispatch<React.SetStateAction<InternetRadioStation[]>>;
|
|
loading: boolean;
|
|
topFavoriteArtists: TopFavoriteArtist[];
|
|
unfavoriteStation: (id: string) => void;
|
|
}
|
|
|
|
function topArtistKey(song: SubsonicSong): string {
|
|
const artistKey = song.artistId || song.artist;
|
|
if (!artistKey) return '';
|
|
return song.serverId ? `${song.serverId}:${artistKey}` : artistKey;
|
|
}
|
|
|
|
export function useFavoritesData(): FavoritesDataResult {
|
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
|
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
|
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
|
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
|
const servers = useAuthStore(s => s.servers);
|
|
const { status: connStatus } = useConnectionStatus();
|
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
|
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const applyStarred = (starred: {
|
|
albums: SubsonicAlbum[];
|
|
artists: SubsonicArtist[];
|
|
songs: SubsonicSong[];
|
|
}) => {
|
|
if (cancelled) return;
|
|
setAlbums(starred.albums);
|
|
setArtists(starred.artists);
|
|
setSongs(starred.songs);
|
|
};
|
|
|
|
const loadRadioFavorites = async () => {
|
|
if (!isActiveServerReachable()) return;
|
|
try {
|
|
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
|
if (favIds.size === 0) return;
|
|
const all = await getInternetRadioStations();
|
|
if (!cancelled) {
|
|
setRadioStations(all.filter(s => favIds.has(s.id)));
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const loadAll = async () => {
|
|
setLoading(true);
|
|
|
|
if (favoritesOfflineEnabled) {
|
|
try {
|
|
applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
|
|
} catch { /* ignore */ }
|
|
if (!cancelled) setLoading(false);
|
|
|
|
if (connStatus === 'connected' && isActiveServerReachable()) {
|
|
try {
|
|
applyStarred(await loadStarredFromAllServersOnline());
|
|
} catch { /* keep library snapshot */ }
|
|
}
|
|
} else {
|
|
if (connStatus === 'connected' && isActiveServerReachable()) {
|
|
const [starredResult] = await Promise.allSettled([getStarred()]);
|
|
if (starredResult.status === 'fulfilled') {
|
|
applyStarred(starredResult.value);
|
|
}
|
|
}
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
|
|
void loadRadioFavorites();
|
|
};
|
|
|
|
void loadAll();
|
|
return () => { cancelled = true; };
|
|
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
|
|
|
|
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
|
|
const counts = new Map<string, TopFavoriteArtist>();
|
|
for (const s of songs) {
|
|
if (starredOverrides[s.id] === false) continue;
|
|
const key = topArtistKey(s);
|
|
if (!key) continue;
|
|
const existing = counts.get(key);
|
|
if (existing) {
|
|
existing.count += 1;
|
|
} else {
|
|
counts.set(key, {
|
|
id: key,
|
|
name: s.artist || key,
|
|
count: 1,
|
|
coverArtId: s.artistId || '',
|
|
serverId: s.serverId,
|
|
artistId: s.artistId || s.artist,
|
|
});
|
|
}
|
|
}
|
|
return Array.from(counts.values())
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 12);
|
|
}, [songs, starredOverrides]);
|
|
|
|
function unfavoriteStation(id: string) {
|
|
setRadioStations(prev => prev.filter(s => s.id !== id));
|
|
try {
|
|
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
|
next.delete(id);
|
|
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
return {
|
|
albums, artists, songs, setSongs, radioStations, setRadioStations,
|
|
loading, topFavoriteArtists, unfavoriteStation,
|
|
};
|
|
}
|