From b4488bd5c1caef97463d38a26ee24f4b2df8826c Mon Sep 17 00:00:00 2001 From: Joseph Madigan Date: Sat, 27 Jun 2026 10:49:29 -0700 Subject: [PATCH] feat(sessions): un-stub Manual TrackSource via shared write sink The 'manual' arm of useTrackSource was a no-op (useTrackSource.ts:322), so a session could only be sourced from a Spotify Premium coordinator. Add a source-agnostic normalize->write sink (addIncomingTrack) shared with the Spotify path, expose a functional addTrack from the hook for manual sessions, and add an AddTrackPanel affordance (manual entry + local-library search) to SessionView. Turn advancement stays derived (computeEffectiveTurn) to avoid the advanceTurn double-fire race. P2P arm (#38) remains a no-op (out of scope). --- .../components/Session/AddTrackPanel.tsx | 258 ++++++++++++++++++ .../components/Session/SessionView.tsx | 20 +- .../db/services/__tests__/track-sink.test.ts | 135 +++++++++ app/src/renderer/db/services/track-sink.ts | 63 +++++ app/src/renderer/hooks/useTrackSource.ts | 69 ++++- app/src/shared/session-interfaces.ts | 3 +- 6 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 app/src/renderer/components/Session/AddTrackPanel.tsx create mode 100644 app/src/renderer/db/services/__tests__/track-sink.test.ts create mode 100644 app/src/renderer/db/services/track-sink.ts diff --git a/app/src/renderer/components/Session/AddTrackPanel.tsx b/app/src/renderer/components/Session/AddTrackPanel.tsx new file mode 100644 index 0000000..31fbbfc --- /dev/null +++ b/app/src/renderer/components/Session/AddTrackPanel.tsx @@ -0,0 +1,258 @@ +/** + * AddTrackPanel + * Session-level "Add track" affordance for the Manual TrackSource (#37). + * + * Two inputs, one sink: a hand-typed entry (title/artists/album/duration) or a + * pick from the local library (`searchTracks`). Both are normalized to an + * `IncomingTrack` and handed to `onAdd`, which routes through the shared + * `useTrackSource` add path → `addIncomingTrack`. The panel is rendered only + * for manual sessions (SessionView gates on a non-null `addTrack`), so the + * session/feed/turn layers stay source-agnostic. + */ + +import { useState } from 'react'; +import { searchTracks } from '../../db/services/track-service'; +import type { TrackDocument } from '../../db/schemas'; +import type { IncomingTrack } from '../../../shared/session-interfaces'; + +interface AddTrackPanelProps { + /** Routes an IncomingTrack through the shared sink. Returns once persisted. */ + onAdd: (incoming: IncomingTrack) => Promise; + /** Last add error surfaced by the source hook, if any. */ + error?: string | null; +} + +type Mode = 'manual' | 'library'; + +/** + * Parse a "m:ss", "h:mm:ss", or plain-seconds duration string to milliseconds. + * Returns 0 for empty/unparseable input (duration is non-critical metadata). + */ +function parseDurationToMs(raw: string): number { + const trimmed = raw.trim(); + if (!trimmed) return 0; + if (!trimmed.includes(':')) { + const secs = Number(trimmed); + return Number.isFinite(secs) && secs >= 0 ? Math.round(secs * 1000) : 0; + } + const parts = trimmed.split(':').map((p) => Number(p)); + if (parts.some((n) => !Number.isFinite(n) || n < 0)) return 0; + const seconds = parts.reduce((acc, n) => acc * 60 + n, 0); + return Math.round(seconds * 1000); +} + +export function AddTrackPanel({ onAdd, error }: AddTrackPanelProps) { + const [open, setOpen] = useState(false); + const [mode, setMode] = useState('manual'); + const [submitting, setSubmitting] = useState(false); + + // Manual-entry form state + const [title, setTitle] = useState(''); + const [artists, setArtists] = useState(''); + const [album, setAlbum] = useState(''); + const [duration, setDuration] = useState(''); + + // Library-search state + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + const resetForm = () => { + setTitle(''); + setArtists(''); + setAlbum(''); + setDuration(''); + }; + + const handleManualSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || submitting) return; + setSubmitting(true); + try { + await onAdd({ + title: title.trim(), + artists: artists + .split(',') + .map((a) => a.trim()) + .filter(Boolean), + album: album.trim(), + durationMs: parseDurationToMs(duration), + externalSource: 'manual', + addedAt: new Date().toISOString(), + }); + resetForm(); + } catch { + // Error surfaced via the `error` prop from the source hook. + } finally { + setSubmitting(false); + } + }; + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + const q = query.trim(); + if (!q) { + setResults([]); + return; + } + setSearching(true); + try { + const docs = await (await searchTracks(q)).exec(); + setResults(docs); + } finally { + setSearching(false); + } + }; + + const handleAddFromLibrary = async (track: TrackDocument) => { + if (submitting) return; + setSubmitting(true); + try { + await onAdd({ + title: track.title, + artists: track.artists, + album: track.album, + durationMs: track.durationMs, + externalId: track.spotifyId, + externalSource: track.source ?? 'local', + albumArtUrl: track.albumArtUrl, + localFilePath: track.localFilePath, + addedAt: new Date().toISOString(), + }); + } catch { + // Error surfaced via the `error` prop from the source hook. + } finally { + setSubmitting(false); + } + }; + + if (!open) { + return ( + + ); + } + + return ( +
+
+
+ + +
+ +
+ + {mode === 'manual' ? ( +
+ setTitle(e.target.value)} + aria-label="Track title" + /> + setArtists(e.target.value)} + aria-label="Artists" + /> + setAlbum(e.target.value)} + aria-label="Album" + /> + setDuration(e.target.value)} + aria-label="Duration" + /> + +
+ ) : ( +
+
+ setQuery(e.target.value)} + aria-label="Search library" + /> + +
+
    + {results.map((track) => ( +
  • + +
  • + ))} + {!searching && query.trim() && results.length === 0 && ( +
  • + No matching tracks in your library. +
  • + )} +
+
+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/app/src/renderer/components/Session/SessionView.tsx b/app/src/renderer/components/Session/SessionView.tsx index c3da086..bd40e99 100644 --- a/app/src/renderer/components/Session/SessionView.tsx +++ b/app/src/renderer/components/Session/SessionView.tsx @@ -27,6 +27,7 @@ import { ParticipantRoster } from './ParticipantRoster'; import { SessionTrackList } from './SessionTrackList'; import { TrackEndingWarning } from './TrackEndingWarning'; import { SessionFeed } from './SessionFeed'; +import { AddTrackPanel } from './AddTrackPanel'; import { SharingToggle } from '../FileTransfer/SharingToggle'; import { TransferProgressAggregate } from '../FileTransfer/TransferProgressBar'; import { FileManifestPanel } from '../FileTransfer/FileManifestPanel'; @@ -206,7 +207,12 @@ export function SessionView({ playlistId }: SessionViewProps) { const isHost = sessionState?.hostId === userId; - const { error: trackSourceError, syncNow, syncing: trackSourceSyncing } = useTrackSource({ + const { + error: trackSourceError, + syncNow, + syncing: trackSourceSyncing, + addTrack, + } = useTrackSource({ config: sessionState?.trackSource ?? { type: 'manual' }, playlistId: activeId ?? '', enabled: isActiveSession, @@ -390,6 +396,18 @@ export function SessionView({ playlistId }: SessionViewProps) { turnOrder={playlist?.turnOrder} /> + {/* Manual TrackSource (#37): local add affordance. Rendered + only when the source hook exposes a functional add path + (manual sessions) and we know who is adding. */} + {addTrack && userId && ( + { + await addTrack(incoming, userId); + }} + error={trackSourceError} + /> + )} + ({ + createTrack: (input: CreateTrackInput) => createTrack(input), +})); +vi.mock('../playlist-service', () => ({ + bulkAddTracksToPlaylist: (playlistId: string, ids: string[]) => + bulkAddTracksToPlaylist(playlistId, ids), +})); + +// Imported after the mocks are registered. +import { addIncomingTrack } from '../track-sink'; + +const PLAYLIST_ID = 'playlist-1'; +const ADDER = 'user-abc'; + +function baseIncoming(overrides: Partial = {}): IncomingTrack { + return { + title: 'Song', + artists: ['Artist A', 'Artist B'], + album: 'Album', + durationMs: 180000, + addedAt: '2026-06-27T12:00:00.000Z', + ...overrides, + }; +} + +describe('addIncomingTrack', () => { + beforeEach(() => { + createTrack.mockReset(); + bulkAddTracksToPlaylist.mockReset(); + createTrack.mockResolvedValue({ id: 'track-123' }); + bulkAddTracksToPlaylist.mockResolvedValue(null); + }); + + it('persists a manual entry attributed to the adder and appends it', async () => { + const result = await addIncomingTrack( + baseIncoming({ externalSource: 'manual' }), + PLAYLIST_ID, + ADDER + ); + + expect(createTrack).toHaveBeenCalledTimes(1); + const input = createTrack.mock.calls[0][0] as CreateTrackInput; + expect(input).toMatchObject({ + title: 'Song', + artists: ['Artist A', 'Artist B'], + album: 'Album', + durationMs: 180000, + addedBy: ADDER, + addedAt: '2026-06-27T12:00:00.000Z', + source: 'manual', + }); + expect(input.spotifyId).toBeUndefined(); + expect(input.localFilePath).toBeUndefined(); + + expect(bulkAddTracksToPlaylist).toHaveBeenCalledWith(PLAYLIST_ID, [ + 'track-123', + ]); + expect(result).toEqual({ trackId: 'track-123' }); + }); + + it('maps externalId to spotifyId only for the spotify source', async () => { + await addIncomingTrack( + baseIncoming({ externalSource: 'spotify', externalId: 'spfy-99' }), + PLAYLIST_ID, + ADDER + ); + const input = createTrack.mock.calls[0][0] as CreateTrackInput; + expect(input.spotifyId).toBe('spfy-99'); + expect(input.source).toBe('spotify'); + }); + + it('does NOT map externalId to spotifyId for non-spotify sources', async () => { + await addIncomingTrack( + baseIncoming({ externalSource: 'musicbrainz', externalId: 'mb-1' }), + PLAYLIST_ID, + ADDER + ); + const input = createTrack.mock.calls[0][0] as CreateTrackInput; + expect(input.spotifyId).toBeUndefined(); + expect(input.source).toBe('musicbrainz'); + }); + + it('preserves localFilePath and source for a library-backed local add', async () => { + await addIncomingTrack( + baseIncoming({ + externalSource: 'local', + localFilePath: '/music/song.flac', + albumArtUrl: 'file:///art.jpg', + }), + PLAYLIST_ID, + ADDER + ); + const input = createTrack.mock.calls[0][0] as CreateTrackInput; + expect(input.source).toBe('local'); + expect(input.localFilePath).toBe('/music/song.flac'); + expect(input.albumArtUrl).toBe('file:///art.jpg'); + expect(input.spotifyId).toBeUndefined(); + }); + + it('appends only after the track is created (create → append order)', async () => { + const order: string[] = []; + createTrack.mockImplementation(async () => { + order.push('create'); + return { id: 'track-xyz' }; + }); + bulkAddTracksToPlaylist.mockImplementation(async () => { + order.push('append'); + return null; + }); + + await addIncomingTrack( + baseIncoming({ externalSource: 'manual' }), + PLAYLIST_ID, + ADDER + ); + expect(order).toEqual(['create', 'append']); + }); +}); diff --git a/app/src/renderer/db/services/track-sink.ts b/app/src/renderer/db/services/track-sink.ts new file mode 100644 index 0000000..1f9f0fe --- /dev/null +++ b/app/src/renderer/db/services/track-sink.ts @@ -0,0 +1,63 @@ +/** + * Track Sink — source-agnostic normalize→write path. + * + * The single tail that every `TrackSource` arm funnels through: it takes an + * `IncomingTrack` (the lean, pre-normalization shape any adapter emits), + * persists it as a `TrackDocType` attributed to `addedBy`, and adds it to the + * target session playlist. A manual entry, a library pick, and (later) a + * replicated P2P insert are therefore indistinguishable downstream — same + * `TrackDocType`, same feed render, same turn accounting. + * + * Turn advancement is intentionally NOT triggered here. The track is appended + * via `bulkAddTracksToPlaylist` (which does not mutate turn counters), mirroring + * the Spotify arm (`useTrackSource` → `processIncomingTracks`). The UI derives + * the current turn from each track's `addedBy` via `computeEffectiveTurn` + * (turn-helpers), so a single add advances the turn exactly once and we avoid + * the `advanceTurn()` double-fire race flagged in the epic. See + * `.claude/cycle/impl-notes.md`. + */ + +import type { IncomingTrack } from '../../../shared/session-interfaces'; +import type { CreateTrackInput } from '../types'; +import { createTrack } from './track-service'; +import { bulkAddTracksToPlaylist } from './playlist-service'; + +export interface AddIncomingTrackResult { + /** ID of the freshly-created TrackDocType. */ + trackId: string; +} + +/** + * Normalize an `IncomingTrack` to a `TrackDocType`, persist it attributed to + * `addedBy`, and append it to `playlistId`. + * + * A fresh track doc is minted on every call (even for library-backed picks) so + * the track is attributed to the session participant who added it — turn + * accounting derives the current turn from each track's `addedBy`. + */ +export async function addIncomingTrack( + incoming: IncomingTrack, + playlistId: string, + addedBy: string +): Promise { + // Spotify is the only source whose externalId maps onto spotifyId. + const isSpotify = incoming.externalSource === 'spotify'; + + const input: CreateTrackInput = { + title: incoming.title, + artists: incoming.artists, + album: incoming.album, + durationMs: incoming.durationMs, + albumArtUrl: incoming.albumArtUrl, + addedBy, + addedAt: incoming.addedAt, + source: incoming.externalSource, + spotifyId: isSpotify ? incoming.externalId : undefined, + localFilePath: incoming.localFilePath, + }; + + const track = await createTrack(input); + await bulkAddTracksToPlaylist(playlistId, [track.id]); + + return { trackId: track.id }; +} diff --git a/app/src/renderer/hooks/useTrackSource.ts b/app/src/renderer/hooks/useTrackSource.ts index 3084708..571a403 100644 --- a/app/src/renderer/hooks/useTrackSource.ts +++ b/app/src/renderer/hooks/useTrackSource.ts @@ -18,6 +18,10 @@ import { bulkImportTracks } from '../db/services/track-service'; import { createSessionParticipant, } from '../db/services/user-service'; +import { + addIncomingTrack, + type AddIncomingTrackResult, +} from '../db/services/track-sink'; import type { TrackSourceConfig, IncomingTrack, @@ -33,11 +37,22 @@ export interface UseTrackSourceOptions { onNewTracks?: (tracks: IncomingTrack[]) => void; } +/** + * Imperative add used by local-initiated sources (the Manual arm). Normalizes + * `incoming`, writes it attributed to `addedBy`, and surfaces it in the feed. + */ +export type AddTrackFn = ( + incoming: IncomingTrack, + addedBy: string +) => Promise; + export interface UseTrackSourceResult { syncing: boolean; lastSyncAt: string | null; error: string | null; syncNow: (() => void) | null; + /** Functional for `type: 'manual'`; null for poll/replication-driven arms. */ + addTrack: AddTrackFn | null; } /** @@ -164,6 +179,10 @@ export function useTrackSource(options: UseTrackSourceOptions): UseTrackSourceRe const [lastSyncAt, setLastSyncAt] = useState(null); const [error, setError] = useState(null); + // Manual-arm state: there is no poll loop, only local-initiated adds. + const [manualLastAddAt, setManualLastAddAt] = useState(null); + const [manualError, setManualError] = useState(null); + const lastSnapshotIdRef = useRef(null); const lastTotalRef = useRef(0); const pollRef = useRef<(() => Promise) | null>(null); @@ -319,9 +338,53 @@ export function useTrackSource(options: UseTrackSourceOptions): UseTrackSourceRe pollRef.current?.(); }, []); - if (config.type === 'manual' || config.type === 'p2p') { - return { syncing: false, lastSyncAt: null, error: null, syncNow: null }; + // Manual arm: local-initiated writes through the shared sink. There is no + // poll loop — the feed updates from the reactive playlist query once the + // track lands, and `onNewTracks` mirrors the Spotify arm's emission so feed + // and turn listeners react identically regardless of source. + const addTrack = useCallback( + async (incoming, addedBy) => { + try { + const result = await addIncomingTrack( + incoming, + playlistId, + addedBy + ); + setManualError(null); + setManualLastAddAt(new Date().toISOString()); + onNewTracks?.([incoming]); + return result; + } catch (err) { + setManualError( + err instanceof Error ? err.message : String(err) + ); + throw err; + } + }, + [playlistId, onNewTracks] + ); + + if (config.type === 'manual') { + return { + syncing: false, + lastSyncAt: manualLastAddAt, + error: manualError, + syncNow: null, + addTrack, + }; + } + + // P2P arm (#38) is a separate, P2P-gated lane (depends on replication + // fan-in) — still a no-op here; out of this lane's scope. + if (config.type === 'p2p') { + return { + syncing: false, + lastSyncAt: null, + error: null, + syncNow: null, + addTrack: null, + }; } - return { syncing, lastSyncAt, error, syncNow }; + return { syncing, lastSyncAt, error, syncNow, addTrack: null }; } diff --git a/app/src/shared/session-interfaces.ts b/app/src/shared/session-interfaces.ts index 50b3349..10a57e0 100644 --- a/app/src/shared/session-interfaces.ts +++ b/app/src/shared/session-interfaces.ts @@ -11,8 +11,9 @@ export interface IncomingTrack { album: string; durationMs: number; externalId?: string; // e.g. Spotify track ID, MusicBrainz ID - externalSource?: string; // 'spotify' | 'musicbrainz' | 'manual' + externalSource?: string; // 'spotify' | 'musicbrainz' | 'manual' | 'local' albumArtUrl?: string; + localFilePath?: string; // Absolute path when backed by a scanned local file addedAt: string; // ISO timestamp addedByExternalId?: string; // external user ID for attribution mapping }