Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
+52
-2
@@ -664,6 +664,21 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cleanupStaleJobs() {
|
||||
return this.request<{
|
||||
success: boolean;
|
||||
cleaned: {
|
||||
discoveryBatches: { cleaned: number; ids: string[] };
|
||||
downloadJobs: { cleaned: number; ids: string[] };
|
||||
spotifyImportJobs: { cleaned: number; ids: string[] };
|
||||
bullQueues: { cleaned: number; queues: string[] };
|
||||
};
|
||||
totalCleaned: number;
|
||||
}>("/settings/cleanup-stale-jobs", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// System Settings Tests
|
||||
async testLidarr(url: string, apiKey: string) {
|
||||
return this.request<any>("/system-settings/test-lidarr", {
|
||||
@@ -686,10 +701,10 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async testLastfm(apiKey: string, apiSecret: string) {
|
||||
async testLastfm(apiKey: string) {
|
||||
return this.request<any>("/system-settings/test-lastfm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apiKey, apiSecret }),
|
||||
body: JSON.stringify({ lastfmApiKey: apiKey }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1347,6 +1362,20 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async syncLibraryEnrichment() {
|
||||
return this.request<{
|
||||
message: string;
|
||||
description: string;
|
||||
result: {
|
||||
artists: number;
|
||||
tracks: number;
|
||||
audioQueued: number;
|
||||
};
|
||||
}>("/enrichment/sync", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async getEnrichmentProgress() {
|
||||
return this.request<{
|
||||
artists: {
|
||||
@@ -1423,6 +1452,27 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async resetArtistMetadata(artistId: string) {
|
||||
return this.request<{ message: string; artist: any }>(
|
||||
`/enrichment/artists/${artistId}/reset`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
async resetAlbumMetadata(albumId: string) {
|
||||
return this.request<{ message: string; album: any }>(
|
||||
`/enrichment/albums/${albumId}/reset`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
async resetTrackMetadata(trackId: string) {
|
||||
return this.request<{ message: string; track: any }>(
|
||||
`/enrichment/tracks/${trackId}/reset`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
// Homepage
|
||||
async getHomepageGenres(limit = 4) {
|
||||
return this.request<any[]>(`/homepage/genres?limit=${limit}`);
|
||||
|
||||
@@ -48,6 +48,7 @@ interface AudioControlsContextType {
|
||||
|
||||
// Podcast methods
|
||||
playPodcast: (podcast: Podcast) => void;
|
||||
nextPodcastEpisode: () => void;
|
||||
|
||||
// Playback controls
|
||||
pause: () => void;
|
||||
@@ -213,6 +214,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
state.setCurrentTrack(track);
|
||||
state.setCurrentAudiobook(null);
|
||||
state.setCurrentPodcast(null);
|
||||
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
|
||||
state.setQueue([track]);
|
||||
state.setCurrentIndex(0);
|
||||
playback.setIsPlaying(true);
|
||||
@@ -246,6 +248,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
state.setPlaybackType("track");
|
||||
state.setCurrentAudiobook(null);
|
||||
state.setCurrentPodcast(null);
|
||||
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
|
||||
state.setQueue(tracks);
|
||||
state.setCurrentIndex(startIndex);
|
||||
state.setCurrentTrack(tracks[startIndex]);
|
||||
@@ -275,6 +278,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
state.setCurrentAudiobook(audiobook);
|
||||
state.setCurrentTrack(null);
|
||||
state.setCurrentPodcast(null);
|
||||
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing audiobooks
|
||||
state.setQueue([]);
|
||||
state.setCurrentIndex(0);
|
||||
playback.setIsPlaying(true);
|
||||
@@ -313,6 +317,44 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
playback.setIsPlaying(false);
|
||||
}, [playback]);
|
||||
|
||||
const nextPodcastEpisode = useCallback(() => {
|
||||
if (!state.podcastEpisodeQueue || state.podcastEpisodeQueue.length === 0) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.currentPodcast) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract episodeId from currentPodcast.id (format: "podcastId:episodeId")
|
||||
const [podcastId, currentEpisodeId] = state.currentPodcast.id.split(":");
|
||||
|
||||
// Find current episode index
|
||||
const currentIndex = state.podcastEpisodeQueue.findIndex(
|
||||
(ep) => ep.id === currentEpisodeId
|
||||
);
|
||||
|
||||
// If there's a next episode, play it
|
||||
if (currentIndex >= 0 && currentIndex < state.podcastEpisodeQueue.length - 1) {
|
||||
const nextEpisode = state.podcastEpisodeQueue[currentIndex + 1];
|
||||
// Build the podcast object for playback
|
||||
playPodcast({
|
||||
id: `${podcastId}:${nextEpisode.id}`,
|
||||
title: nextEpisode.title,
|
||||
podcastTitle: state.currentPodcast.podcastTitle,
|
||||
coverUrl: state.currentPodcast.coverUrl,
|
||||
duration: nextEpisode.duration,
|
||||
progress: nextEpisode.progress || null,
|
||||
});
|
||||
} else {
|
||||
// Last episode, pause and clear queue
|
||||
pause();
|
||||
state.setPodcastEpisodeQueue(null);
|
||||
}
|
||||
}, [state.podcastEpisodeQueue, state.currentPodcast, playPodcast, pause, state.setPodcastEpisodeQueue]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
playback.setIsPlaying(true);
|
||||
}, [playback]);
|
||||
@@ -809,6 +851,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
playTracks,
|
||||
playAudiobook,
|
||||
playPodcast,
|
||||
nextPodcastEpisode,
|
||||
pause,
|
||||
resume,
|
||||
play,
|
||||
@@ -836,6 +879,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
|
||||
playTracks,
|
||||
playAudiobook,
|
||||
playPodcast,
|
||||
nextPodcastEpisode,
|
||||
pause,
|
||||
resume,
|
||||
play,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReactNode,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useAudioState } from "./audio-state-context";
|
||||
|
||||
interface AudioPlaybackContextType {
|
||||
isPlaying: boolean;
|
||||
@@ -129,6 +130,30 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Get state from AudioStateContext for position sync
|
||||
const state = useAudioState();
|
||||
|
||||
// Sync currentTime from audiobook/podcast progress when not playing
|
||||
// This ensures the UI shows the correct saved position on page load
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return;
|
||||
if (isPlaying) return; // Don't override during active playback
|
||||
|
||||
const { currentAudiobook, currentPodcast, playbackType } = state;
|
||||
|
||||
if (playbackType === "audiobook" && currentAudiobook?.progress?.currentTime) {
|
||||
setCurrentTime(currentAudiobook.progress.currentTime);
|
||||
} else if (playbackType === "podcast" && currentPodcast?.progress?.currentTime) {
|
||||
setCurrentTime(currentPodcast.progress.currentTime);
|
||||
}
|
||||
}, [
|
||||
isHydrated,
|
||||
isPlaying,
|
||||
state.currentAudiobook?.progress?.currentTime,
|
||||
state.currentPodcast?.progress?.currentTime,
|
||||
state.playbackType,
|
||||
]);
|
||||
|
||||
// Cleanup seek lock timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Episode } from "@/features/podcast/types";
|
||||
|
||||
function queueDebugEnabled(): boolean {
|
||||
try {
|
||||
@@ -56,6 +57,10 @@ export interface Track {
|
||||
album: { title: string; coverArt?: string; id?: string };
|
||||
duration: number;
|
||||
filePath?: string;
|
||||
// Metadata override fields
|
||||
displayTitle?: string | null;
|
||||
displayTrackNo?: number | null;
|
||||
hasUserOverrides?: boolean;
|
||||
// Audio features for vibe mode visualization
|
||||
audioFeatures?: {
|
||||
bpm?: number | null;
|
||||
@@ -122,6 +127,7 @@ interface AudioStateContextType {
|
||||
repeatMode: "off" | "one" | "all";
|
||||
isRepeat: boolean;
|
||||
shuffleIndices: number[];
|
||||
podcastEpisodeQueue: Episode[] | null;
|
||||
|
||||
// UI state
|
||||
playerMode: PlayerMode;
|
||||
@@ -151,6 +157,7 @@ interface AudioStateContextType {
|
||||
setIsShuffle: (shuffle: SetStateAction<boolean>) => void;
|
||||
setRepeatMode: (mode: SetStateAction<"off" | "one" | "all">) => void;
|
||||
setShuffleIndices: (indices: SetStateAction<number[]>) => void;
|
||||
setPodcastEpisodeQueue: (queue: SetStateAction<Episode[] | null>) => void;
|
||||
setPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
|
||||
setPreviousPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
|
||||
setVolume: (volume: SetStateAction<number>) => void;
|
||||
@@ -158,7 +165,9 @@ interface AudioStateContextType {
|
||||
setLastServerSync: (date: SetStateAction<Date | null>) => void;
|
||||
setRepeatOneCount: (count: SetStateAction<number>) => void;
|
||||
setVibeMode: (mode: SetStateAction<boolean>) => void;
|
||||
setVibeSourceFeatures: (features: SetStateAction<AudioFeatures | null>) => void;
|
||||
setVibeSourceFeatures: (
|
||||
features: SetStateAction<AudioFeatures | null>
|
||||
) => void;
|
||||
setVibeQueueIds: (ids: SetStateAction<string[]>) => void;
|
||||
}
|
||||
|
||||
@@ -179,6 +188,7 @@ const STORAGE_KEYS = {
|
||||
PLAYER_MODE: "lidify_player_mode",
|
||||
VOLUME: "lidify_volume",
|
||||
IS_MUTED: "lidify_muted",
|
||||
PODCAST_EPISODE_QUEUE: "lidify_podcast_episode_queue",
|
||||
};
|
||||
|
||||
export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
@@ -196,6 +206,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
const [shuffleIndices, setShuffleIndices] = useState<number[]>([]);
|
||||
const [repeatMode, setRepeatMode] = useState<"off" | "one" | "all">("off");
|
||||
const [repeatOneCount, setRepeatOneCount] = useState(0);
|
||||
const [podcastEpisodeQueue, setPodcastEpisodeQueue] = useState<Episode[] | null>(null);
|
||||
const [playerMode, setPlayerMode] = useState<PlayerMode>("full");
|
||||
const [previousPlayerMode, setPreviousPlayerMode] =
|
||||
useState<PlayerMode>("full");
|
||||
@@ -203,10 +214,11 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [lastServerSync, setLastServerSync] = useState<Date | null>(null);
|
||||
|
||||
|
||||
// Vibe mode state
|
||||
const [vibeMode, setVibeMode] = useState(false);
|
||||
const [vibeSourceFeatures, setVibeSourceFeatures] = useState<AudioFeatures | null>(null);
|
||||
const [vibeSourceFeatures, setVibeSourceFeatures] =
|
||||
useState<AudioFeatures | null>(null);
|
||||
const [vibeQueueIds, setVibeQueueIds] = useState<string[]>([]);
|
||||
|
||||
// Restore state from localStorage on mount
|
||||
@@ -230,6 +242,9 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
const savedRepeatMode = localStorage.getItem(
|
||||
STORAGE_KEYS.REPEAT_MODE
|
||||
);
|
||||
const savedPodcastQueue = localStorage.getItem(
|
||||
STORAGE_KEYS.PODCAST_EPISODE_QUEUE
|
||||
);
|
||||
const savedPlayerMode = localStorage.getItem(
|
||||
STORAGE_KEYS.PLAYER_MODE
|
||||
);
|
||||
@@ -297,6 +312,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
if (savedShuffle) setIsShuffle(savedShuffle === "true");
|
||||
if (savedRepeatMode)
|
||||
setRepeatMode(savedRepeatMode as "off" | "one" | "all");
|
||||
if (savedPodcastQueue) setPodcastEpisodeQueue(JSON.parse(savedPodcastQueue));
|
||||
if (savedVolume) setVolume(parseFloat(savedVolume));
|
||||
if (savedMuted) setIsMuted(savedMuted === "true");
|
||||
if (savedPlayerMode) setPlayerMode(savedPlayerMode as PlayerMode);
|
||||
@@ -420,6 +436,14 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
localStorage.setItem(STORAGE_KEYS.IS_SHUFFLE, isShuffle.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.REPEAT_MODE, repeatMode);
|
||||
if (podcastEpisodeQueue) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.PODCAST_EPISODE_QUEUE,
|
||||
JSON.stringify(podcastEpisodeQueue)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.PODCAST_EPISODE_QUEUE);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEYS.PLAYER_MODE, playerMode);
|
||||
localStorage.setItem(STORAGE_KEYS.VOLUME, volume.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.IS_MUTED, isMuted.toString());
|
||||
@@ -435,6 +459,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
currentIndex,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
podcastEpisodeQueue,
|
||||
playerMode,
|
||||
volume,
|
||||
isMuted,
|
||||
@@ -669,6 +694,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
repeatMode,
|
||||
isRepeat: repeatMode !== "off",
|
||||
shuffleIndices,
|
||||
podcastEpisodeQueue,
|
||||
playerMode,
|
||||
previousPlayerMode,
|
||||
volume,
|
||||
@@ -688,6 +714,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
setIsShuffle,
|
||||
setRepeatMode,
|
||||
setShuffleIndices,
|
||||
setPodcastEpisodeQueue,
|
||||
setPlayerMode,
|
||||
setPreviousPlayerMode,
|
||||
setVolume,
|
||||
@@ -708,6 +735,7 @@ export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
shuffleIndices,
|
||||
podcastEpisodeQueue,
|
||||
playerMode,
|
||||
previousPlayerMode,
|
||||
volume,
|
||||
|
||||
@@ -54,23 +54,28 @@ export function DownloadProvider({ children }: { children: ReactNode }) {
|
||||
// Remove pending downloads that have completed or failed
|
||||
setPendingDownloads((prev) => {
|
||||
return prev.filter((pending) => {
|
||||
// Check if this MBID has a job that's completed or failed
|
||||
// Check if this MBID has an active job being tracked by API
|
||||
const hasActiveJob = downloadStatus.activeDownloads.some(
|
||||
(job) => job.targetMbid === pending.mbid
|
||||
);
|
||||
|
||||
// If job is now being tracked by the API, remove from local pending
|
||||
if (hasActiveJob) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this MBID has completed or failed
|
||||
const matchingJob = [
|
||||
...downloadStatus.activeDownloads,
|
||||
...downloadStatus.recentDownloads,
|
||||
...downloadStatus.failedDownloads,
|
||||
].find((job) => job.targetMbid === pending.mbid);
|
||||
|
||||
// If job is completed or failed, remove from pending
|
||||
if (
|
||||
matchingJob &&
|
||||
(matchingJob.status === "completed" ||
|
||||
matchingJob.status === "failed")
|
||||
) {
|
||||
if (matchingJob) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep if still pending/processing or no job found yet
|
||||
// Keep if no job found yet
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -80,6 +85,28 @@ export function DownloadProvider({ children }: { children: ReactNode }) {
|
||||
downloadStatus.failedDownloads,
|
||||
]);
|
||||
|
||||
// Cleanup pending downloads older than 2 minutes
|
||||
// This handles cases where jobs fail immediately and don't appear in any API response
|
||||
useEffect(() => {
|
||||
const STALE_THRESHOLD = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
setPendingDownloads((prev) => {
|
||||
const now = Date.now();
|
||||
const filtered = prev.filter((pending) => {
|
||||
const age = now - pending.timestamp;
|
||||
if (age > STALE_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
}, 30000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(cleanup);
|
||||
}, []);
|
||||
|
||||
const addPendingDownload = (
|
||||
type: "artist" | "album",
|
||||
subject: string,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Enrichment API Client
|
||||
*
|
||||
* Client-side methods for enrichment control and failure management
|
||||
*/
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
export interface EnrichmentState {
|
||||
status: "idle" | "running" | "paused" | "stopping";
|
||||
startedAt?: string;
|
||||
pausedAt?: string;
|
||||
stoppedAt?: string;
|
||||
currentPhase: "artists" | "tracks" | "audio" | null;
|
||||
lastActivity: string;
|
||||
stoppingInfo?: {
|
||||
phase: string;
|
||||
currentItem: string;
|
||||
itemsRemaining: number;
|
||||
};
|
||||
artists: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
current?: string;
|
||||
};
|
||||
tracks: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
current?: string;
|
||||
};
|
||||
audio: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
processing: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnrichmentFailure {
|
||||
id: string;
|
||||
entityType: "artist" | "track" | "audio";
|
||||
entityId: string;
|
||||
entityName: string | null;
|
||||
errorMessage: string | null;
|
||||
errorCode: string | null;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
firstFailedAt: string;
|
||||
lastFailedAt: string;
|
||||
skipped: boolean;
|
||||
skippedAt: string | null;
|
||||
resolved: boolean;
|
||||
resolvedAt: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface FailureCounts {
|
||||
artist: number;
|
||||
track: number;
|
||||
audio: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ConcurrencyConfig {
|
||||
concurrency: number;
|
||||
estimatedSpeed: string;
|
||||
artistsPerMin: number;
|
||||
tracksPerMin: number;
|
||||
}
|
||||
|
||||
export interface AnalysisWorkersConfig {
|
||||
workers: number;
|
||||
cpuCores: number;
|
||||
recommended: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const enrichmentApi = {
|
||||
/**
|
||||
* Get detailed enrichment state
|
||||
*/
|
||||
getStatus: async (): Promise<EnrichmentState | null> => {
|
||||
return api.get("/enrichment/status");
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause enrichment
|
||||
*/
|
||||
pause: async (): Promise<{ message: string; state: EnrichmentState }> => {
|
||||
return api.post("/enrichment/pause", {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Resume enrichment
|
||||
*/
|
||||
resume: async (): Promise<{ message: string; state: EnrichmentState }> => {
|
||||
return api.post("/enrichment/resume", {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop enrichment
|
||||
*/
|
||||
stop: async (): Promise<{ message: string; state: EnrichmentState }> => {
|
||||
return api.post("/enrichment/stop", {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get enrichment failures with filtering
|
||||
*/
|
||||
getFailures: async (params?: {
|
||||
entityType?: "artist" | "track" | "audio";
|
||||
includeSkipped?: boolean;
|
||||
includeResolved?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ failures: EnrichmentFailure[]; total: number }> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.entityType) query.set("entityType", params.entityType);
|
||||
if (params?.includeSkipped) query.set("includeSkipped", "true");
|
||||
if (params?.includeResolved) query.set("includeResolved", "true");
|
||||
if (params?.limit) query.set("limit", params.limit.toString());
|
||||
if (params?.offset) query.set("offset", params.offset.toString());
|
||||
|
||||
const queryString = query.toString();
|
||||
return api.get(
|
||||
`/enrichment/failures${queryString ? `?${queryString}` : ""}`
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get failure counts by type
|
||||
*/
|
||||
getFailureCounts: async (): Promise<FailureCounts> => {
|
||||
return api.get("/enrichment/failures/counts");
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry specific failures
|
||||
*/
|
||||
retryFailures: async (
|
||||
ids: string[]
|
||||
): Promise<{ message: string; queued: number }> => {
|
||||
return api.post("/enrichment/retry", { ids });
|
||||
},
|
||||
|
||||
/**
|
||||
* Skip failures permanently
|
||||
*/
|
||||
skipFailures: async (
|
||||
ids: string[]
|
||||
): Promise<{ message: string; count: number }> => {
|
||||
return api.post("/enrichment/skip", { ids });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a failure record
|
||||
*/
|
||||
deleteFailure: async (
|
||||
id: string
|
||||
): Promise<{ message: string; count: number }> => {
|
||||
return api.delete(`/enrichment/failures/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get enrichment concurrency configuration
|
||||
*/
|
||||
getConcurrency: async (): Promise<ConcurrencyConfig> => {
|
||||
return api.get("/enrichment/concurrency");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set enrichment concurrency (1-5)
|
||||
*/
|
||||
setConcurrency: async (concurrency: number): Promise<ConcurrencyConfig> => {
|
||||
return api.request("/enrichment/concurrency", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ concurrency }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get audio analyzer worker configuration
|
||||
*/
|
||||
getAnalysisWorkers: async (): Promise<AnalysisWorkersConfig> => {
|
||||
return api.get("/analysis/workers");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set audio analyzer worker count (1-8)
|
||||
*/
|
||||
setAnalysisWorkers: async (workers: number): Promise<AnalysisWorkersConfig> => {
|
||||
return api.request("/analysis/workers", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ workers }),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Format large numbers into compact notation (e.g., 5,100,000 → "5.1M")
|
||||
*/
|
||||
export function formatListeners(count: number | undefined): string {
|
||||
if (!count || count === 0) return "Artist";
|
||||
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M listeners`;
|
||||
}
|
||||
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}K listeners`;
|
||||
}
|
||||
|
||||
return `${count.toLocaleString()} listeners`;
|
||||
}
|
||||
@@ -186,9 +186,6 @@ class HowlerEngine {
|
||||
this.state.currentSrc
|
||||
) {
|
||||
this.retryCount++;
|
||||
console.log(
|
||||
`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`
|
||||
);
|
||||
|
||||
// Save src before cleanup
|
||||
const srcToRetry = this.state.currentSrc;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Query Events - Typed event system for cache invalidation
|
||||
*
|
||||
* Provides a type-safe wrapper around CustomEvent for triggering React Query cache invalidation.
|
||||
* Events are dispatched when data changes and listeners refetch queries to update the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available query event types
|
||||
*/
|
||||
export type QueryEventType =
|
||||
| "audiobook-progress-updated"
|
||||
| "podcast-progress-updated"
|
||||
| "library-updated"
|
||||
| "mixes-updated"; // Include existing event for consistency
|
||||
|
||||
/**
|
||||
* Event payload interface - can be extended for event-specific data
|
||||
*/
|
||||
export interface QueryEventDetail {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a typed query event
|
||||
*
|
||||
* @param eventType - The type of event to dispatch
|
||||
* @param detail - Optional event payload data
|
||||
*
|
||||
* @example
|
||||
* dispatchQueryEvent("audiobook-progress-updated", { audiobookId: "123" });
|
||||
*/
|
||||
export function dispatchQueryEvent(
|
||||
eventType: QueryEventType,
|
||||
detail?: QueryEventDetail
|
||||
): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(eventType, { detail: detail || {} })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a typed query event
|
||||
*
|
||||
* @param eventType - The type of event to listen for
|
||||
* @param handler - Callback function to execute when event fires
|
||||
* @returns Cleanup function to remove the event listener
|
||||
*
|
||||
* @example
|
||||
* const unsubscribe = subscribeQueryEvent("audiobook-progress-updated", () => {
|
||||
* queryClient.refetchQueries({ queryKey: ["audiobook", id] });
|
||||
* });
|
||||
* // Later: unsubscribe();
|
||||
*/
|
||||
export function subscribeQueryEvent(
|
||||
eventType: QueryEventType,
|
||||
handler: (event: CustomEvent<QueryEventDetail>) => void
|
||||
): () => void {
|
||||
const listener = handler as EventListener;
|
||||
window.addEventListener(eventType, listener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(eventType, listener);
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0">
|
||||
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0" aria-live="polite" aria-atomic="false">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
@@ -130,6 +130,9 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
role={toast.type === "error" ? "alert" : "status"}
|
||||
aria-live={toast.type === "error" ? "assertive" : "polite"}
|
||||
aria-atomic="true"
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-sm border shadow-2xl animate-in slide-in-from-right duration-300",
|
||||
styles[toast.type]
|
||||
|
||||
Reference in New Issue
Block a user