Files
Psychotoxical-psysonic/src/lib/api/subsonicStatistics.ts
T
Psychotoxical 8cc022581f refactor(api): pull feature-resident subsonic clients into lib/api
Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.

This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.

Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
2026-06-30 08:02:19 +02:00

146 lines
5.1 KiB
TypeScript

import { useAuthStore } from '@/store/authStore';
import { genreTagsFor } from '@/utils/library/genreTags';
import { getArtists } from '@/lib/api/subsonicArtists';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import type {
StatisticsFormatSample,
StatisticsLibraryAggregates,
StatisticsOverviewData,
SubsonicAlbum,
SubsonicArtist,
SubsonicGenre,
SubsonicSong,
} from '@/lib/api/subsonicTypes';
/** Cache TTL for statistics page aggregates — same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
const STATS_CACHE_TTL = 7 * 60 * 1000;
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes.
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const tags = genreTagsFor(a);
const labels = tags.length > 0 ? tags : [''];
for (const label of labels) {
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
}
if (albums.length < pageSize) break;
offset += pageSize;
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}