fix vibe engine audit findings: pg.Pool, plan replan, dead exclusions, legacy engine removal
Backend: - app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205) - v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206) - session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor) - db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232) - app.ts: drop test enqueue-job endpoint (#234) Frontend: - AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// Track ids whose next natural feedback transition should be skipped because
|
||||
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
|
||||
// them explicitly. Consumed once, then cleared.
|
||||
const suppressedFeedbackIds = new Set<string>();
|
||||
export function suppressAutoFeedback(trackId: string): void {
|
||||
suppressedFeedbackIds.add(trackId);
|
||||
}
|
||||
|
||||
// Threshold (seconds) above which a store position change is treated as a user
|
||||
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
|
||||
// effect loop from fighting itself.
|
||||
@@ -39,9 +48,6 @@ export const AudioEngine = () => {
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
const crossedThresholdRef = useRef(false);
|
||||
// Track whether we've already recorded a completed play for the current track
|
||||
// (to avoid double-recording when both threshold crossed AND ended fires).
|
||||
const recordedCompletedRef = useRef(false);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
@@ -73,16 +79,10 @@ export const AudioEngine = () => {
|
||||
if (store().isPlaying) store().pause();
|
||||
};
|
||||
const onEnded = () => {
|
||||
const trackId = loadedIdRef.current;
|
||||
if (trackId && !recordedCompletedRef.current) {
|
||||
endedNaturallyRef.current = true;
|
||||
recordedCompletedRef.current = true;
|
||||
try {
|
||||
void vibeService.feedback(trackId, 'completed').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
// Just flag it — applyTrack (below) is the single place that sends
|
||||
// feedback, on the resulting track-change, so completion is recorded
|
||||
// exactly once per track.
|
||||
endedNaturallyRef.current = true;
|
||||
store().next();
|
||||
};
|
||||
|
||||
@@ -114,21 +114,23 @@ export const AudioEngine = () => {
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
if (prevId) {
|
||||
try {
|
||||
if (completed) {
|
||||
recordedCompletedRef.current = true;
|
||||
void vibeService.feedback(prevId, 'completed').catch(() => {});
|
||||
} else {
|
||||
void vibeService.feedback(prevId, 'skipped').catch(() => {});
|
||||
// Only vibe sessions want this feedback — plain library browsing
|
||||
// shouldn't write skip/completed evidence for tracks merely sampled.
|
||||
const inVibeSession = !!useVibeStore.getState().activeSessionId;
|
||||
if (prevId && inVibeSession) {
|
||||
if (suppressedFeedbackIds.delete(prevId)) {
|
||||
// Caller already recorded explicit feedback (e.g. dislike) for
|
||||
// this track — don't also record the implicit transition.
|
||||
} else {
|
||||
try {
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
recordedCompletedRef.current = false;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import type { Track } from '../types';
|
||||
import { VibeTimeline } from '../components/VibeTimeline';
|
||||
import { suppressAutoFeedback } from '../components/AudioEngine';
|
||||
import { toast } from '../store/useToastStore';
|
||||
|
||||
const INITIAL_BATCH_SIZE = 5;
|
||||
const PREFETCH_THRESHOLD = 3;
|
||||
@@ -19,7 +21,7 @@ function bestEffort(p: Promise<unknown>): void {
|
||||
}
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack, queue, setQueue, playTrack, next: playNext } = usePlaybackStore();
|
||||
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
@@ -117,23 +119,34 @@ export default function Vibe() {
|
||||
if (idx > 0) {
|
||||
setBuffer(buffer.slice(idx));
|
||||
}
|
||||
}, [activeSessionId, currentTrack]);
|
||||
}, [activeSessionId, currentTrack, buffer, setBuffer]);
|
||||
|
||||
const handleKeep = useCallback(() => {
|
||||
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
|
||||
// AudioEngine would otherwise also record a 'skipped' on the track
|
||||
// change caused by playNext() below — suppress that duplicate.
|
||||
suppressAutoFeedback(currentTrack.id);
|
||||
}
|
||||
playNext();
|
||||
}, [currentTrack, playNext]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
|
||||
pause();
|
||||
setQueue([]);
|
||||
setCurrentTrack(null);
|
||||
reset();
|
||||
setEmpty(false);
|
||||
setError(null);
|
||||
}, [reset]);
|
||||
}, [reset, pause, setQueue, setCurrentTrack]);
|
||||
|
||||
const upcoming = currentTrack
|
||||
? (() => {
|
||||
@@ -208,12 +221,12 @@ export default function Vibe() {
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
|
||||
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{libraryTracks.map((track) => (
|
||||
{libraryTracks.map((track, index) => (
|
||||
<li key={track.id}>
|
||||
<TrackRow
|
||||
track={track}
|
||||
queue={libraryTracks}
|
||||
index={libraryTracks.findIndex((t) => t.id === track.id)}
|
||||
index={index}
|
||||
showActions={false}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -12,6 +12,8 @@ interface PlaybackState {
|
||||
volume: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
|
||||
shufflePlayed: Set<string>;
|
||||
|
||||
setQueue: (queue: Track[]) => void;
|
||||
playTrack: (track: Track) => void;
|
||||
@@ -36,8 +38,9 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
volume: 1,
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
shufflePlayed: new Set<string>(),
|
||||
|
||||
setQueue: (queue) => set({ queue }),
|
||||
setQueue: (queue) => set({ queue, shufflePlayed: new Set() }),
|
||||
|
||||
playTrack: (track) =>
|
||||
set({
|
||||
@@ -45,13 +48,14 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
isPlaying: true,
|
||||
position: 0,
|
||||
duration: track.duration ?? 0,
|
||||
shufflePlayed: new Set(),
|
||||
}),
|
||||
|
||||
play: () => set({ isPlaying: true }),
|
||||
pause: () => set({ isPlaying: false }),
|
||||
|
||||
next: () => {
|
||||
const { queue, currentTrack, shuffle, repeat } = get();
|
||||
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
@@ -63,17 +67,22 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
}
|
||||
|
||||
if (shuffle) {
|
||||
// Shuffle: pick a random track from the remaining queue (excluding current)
|
||||
const remaining = queue.filter((t) => t.id !== currentTrack?.id);
|
||||
// Shuffle: pick a random track not yet played this lap (excludes current),
|
||||
// so repeat-all doesn't bounce between the same few tracks.
|
||||
const played = new Set(shufflePlayed);
|
||||
if (currentTrack) played.add(currentTrack.id);
|
||||
let remaining = queue.filter((t) => !played.has(t.id));
|
||||
if (remaining.length === 0) {
|
||||
if (repeat === 'all') {
|
||||
const pick = queue[Math.floor(Math.random() * queue.length)];
|
||||
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
}
|
||||
return;
|
||||
if (repeat !== 'all') return;
|
||||
// Lap complete — start a fresh one.
|
||||
played.clear();
|
||||
if (currentTrack) played.add(currentTrack.id);
|
||||
remaining = queue.filter((t) => t.id !== currentTrack?.id);
|
||||
if (remaining.length === 0) return;
|
||||
}
|
||||
const pick = remaining[Math.floor(Math.random() * remaining.length)];
|
||||
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
played.add(pick.id);
|
||||
set({ currentTrack: pick, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user