diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index f6e38f6..7945f52 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -30,9 +30,12 @@ import { SlideshowComposition } from '../composition' import { StoryboardGrid } from './StoryboardGrid' import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { SoundtrackPanel } from './SoundtrackPanel' +import { JamendoPanel } from './JamendoPanel' import { SlideSettingsDialog } from './SlideSettingsDialog' import { TitleSlideDialog } from './TitleSlideDialog' import { reconcileSlides, slidesToJson } from './slidePersistence' +import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' +import { downloadTrack, sanitizeFilename } from '../jamendo' import './App.css' const FPS = 30 @@ -45,6 +48,7 @@ export function App() { const [globalSettings, setGlobalSettings] = useState(DEFAULT_GLOBAL_SETTINGS) const [slides, setSlides] = useState([]) const [soundtrackFilename, setSoundtrackFilename] = useState(null) + const [soundtrackAttribution, setSoundtrackAttribution] = useState(null) const [themeName, setThemeName] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -90,9 +94,9 @@ export function App() { if (!handle) return if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current) autosaveTimerRef.current = setTimeout(() => { - saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName)).catch(console.error) + saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution)).catch(console.error) }, AUTOSAVE_DELAY) - }, [globalSettings, slides, soundtrackFilename, themeName]) + }, [globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName]) const loadFolder = useCallback( async (handle: FileSystemDirectoryHandle, savedData?: SlideshowJson) => { @@ -108,6 +112,7 @@ export function App() { savedSoundtrack && nextAudioTracks.some((track) => track.filename === savedSoundtrack) ? savedSoundtrack : null + const restoredAttribution = validSoundtrack ? (savedData?.soundtrackAttribution ?? null) : null let finalSlides: Slide[] = savedData ? reconcileSlides(enumerated, savedData) : enumerated // Apply global duration only to media slides not already in savedData (new files added to folder). const savedFilenames = new Set( @@ -126,6 +131,7 @@ export function App() { setThemeName(restoredThemeName) setSlides(finalSlides) setSoundtrackFilename(validSoundtrack) + setSoundtrackAttribution(restoredAttribution) setFolderOpen(true) setSelectedSlideId(null) } catch (e) { @@ -229,6 +235,22 @@ export function App() { setSlides(prev => prev.map(s => (s.id === id && isTitleSlide(s) ? { ...s, ...updates } : s))) }, []) + const handleJamendoAdd = useCallback(async (track: JamendoTrack, attribution: JamendoAttribution) => { + const handle = dirHandleRef.current + if (!handle) return + const filename = sanitizeFilename(`${track.artistName} - ${track.name}`) + await downloadTrack(track.audioUrl, filename, handle) + try { + const nextAudioTracks = await enumerateAudioTracks(handle) + pendingAudioRevokeRef.current = latestAudioTracksRef.current + setAudioTracks(nextAudioTracks) + setSoundtrackFilename(filename) + setSoundtrackAttribution(attribution) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to refresh audio tracks') + } + }, []) + const handleStartFresh = useCallback(() => { setCorruptError(null) }, []) @@ -311,9 +333,15 @@ export function App() { /> { setSoundtrackFilename(filename); setSoundtrackAttribution(null) }} soundtrackFilename={soundtrackFilename} /> + {import.meta.env.VITE_JAMENDO_CLIENT_ID && ( + + )}
+ + + {state.status === 'results' && ( +
    + {state.tracks.map(track => ( +
  • +
    + {track.name} + {track.artistName} + {formatDuration(track.durationSecs)} +
    +
    + + +
    +
  • + ))} +
+ )} + + {addError && ( +

{addError}

+ )} + + {state.status === 'empty' && ( +

+ No results found. Try different keywords, or drop an audio file into your project folder. +

+ )} + + {state.status === 'error' && ( +

+ {state.message} — or drop an audio file into your project folder instead. +

+ )} +
+ ) +} diff --git a/src/editor-shell/slidePersistence.test.ts b/src/editor-shell/slidePersistence.test.ts index 398f3cb..696c495 100644 --- a/src/editor-shell/slidePersistence.test.ts +++ b/src/editor-shell/slidePersistence.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_GLOBAL_SETTINGS } from '../timeline-core' import type { MediaSlide } from '../timeline-core/types' import { SCHEMA_VERSION } from '../project-store' +import type { JamendoAttribution } from '../jamendo/types' import { slidesToJson } from './slidePersistence' function makeSlide(filename: string): MediaSlide { @@ -36,4 +37,20 @@ describe('slidesToJson', () => { const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null) expect(json.themeName).toBeUndefined() }) + + it('includes soundtrackAttribution when provided', () => { + const attribution: JamendoAttribution = { + jamendoId: '123', + name: 'Great Track', + artist: 'Cool Artist', + licenseUrl: 'https://creativecommons.org/licenses/by/3.0/', + } + const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, attribution) + expect(json.soundtrackAttribution).toEqual(attribution) + }) + + it('omits soundtrackAttribution when null', () => { + const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, null) + expect(json.soundtrackAttribution).toBeUndefined() + }) }) diff --git a/src/editor-shell/slidePersistence.ts b/src/editor-shell/slidePersistence.ts index ea50eb2..b2a3989 100644 --- a/src/editor-shell/slidePersistence.ts +++ b/src/editor-shell/slidePersistence.ts @@ -2,6 +2,7 @@ import type { MediaSlide, Slide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' import type { GlobalSettings, ThemeName } from '../timeline-core' import { SCHEMA_VERSION, type SlideshowJson } from '../project-store' +import type { JamendoAttribution } from '../jamendo/types' export function reconcileSlides(enumerated: MediaSlide[], saved: SlideshowJson): Slide[] { const byFilename = new Map(enumerated.map((slide) => [slide.filename, slide])) @@ -32,12 +33,14 @@ export function slidesToJson( slides: Slide[], soundtrackFilename?: string | null, themeName?: ThemeName | null, + soundtrackAttribution?: JamendoAttribution | null, ): SlideshowJson { return { globalSettings, schemaVersion: SCHEMA_VERSION, ...(soundtrackFilename ? { soundtrackFilename } : {}), ...(themeName ? { themeName } : {}), + ...(soundtrackAttribution ? { soundtrackAttribution } : {}), slides: slides.map(slide => { if (isTitleSlide(slide)) { return { diff --git a/src/env.d.ts b/src/env.d.ts index faa0220..33d01c0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,9 @@ /// +interface ImportMetaEnv { + readonly VITE_JAMENDO_CLIENT_ID?: string +} + // File System Access API — not yet fully in lib.dom.d.ts interface Window { showDirectoryPicker(options?: { mode?: 'read' | 'readwrite' }): Promise diff --git a/src/jamendo/client.test.ts b/src/jamendo/client.test.ts new file mode 100644 index 0000000..aa9e9a3 --- /dev/null +++ b/src/jamendo/client.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { searchTracks } from './client' + +const CLIENT_ID = 'test_client' + +const TRACK_FIXTURE = { + id: '1234', + name: 'Cool Track', + artist_name: 'Cool Artist', + duration: 240, + audio: 'https://mp3l.jamendo.com/?trackid=1234&format=mp32', + license_ccurl: 'https://creativecommons.org/licenses/by/4.0/', +} + +function mockFetch(body: unknown, status = 200) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('searchTracks — happy path', () => { + it('returns parsed tracks for a successful query', async () => { + mockFetch({ + headers: { code: 0, error_message: '' }, + results: [TRACK_FIXTURE], + }) + + const tracks = await searchTracks('jazz', CLIENT_ID) + + expect(tracks).toHaveLength(1) + expect(tracks[0]).toEqual({ + id: '1234', + name: 'Cool Track', + artistName: 'Cool Artist', + durationSecs: 240, + audioUrl: 'https://mp3l.jamendo.com/?trackid=1234&format=mp32', + licenseUrl: 'https://creativecommons.org/licenses/by/4.0/', + }) + }) + + it('includes client_id and search term in the request URL', async () => { + const fakeFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ headers: { code: 0 }, results: [] }), + }) + vi.stubGlobal('fetch', fakeFetch) + + await searchTracks('ambient', CLIENT_ID) + + const calledUrl: string = fakeFetch.mock.calls[0][0] + expect(calledUrl).toContain('client_id=test_client') + expect(calledUrl).toContain('search=ambient') + }) + + it('returns empty array when results is empty', async () => { + mockFetch({ headers: { code: 0 }, results: [] }) + const tracks = await searchTracks('xyzunknown', CLIENT_ID) + expect(tracks).toEqual([]) + }) +}) + +describe('searchTracks — error handling (AC3)', () => { + it('throws a JamendoError on HTTP error', async () => { + mockFetch({ headers: { code: 0 }, results: [] }, 500) + await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow() + }) + + it('throws a JamendoError when API code is non-zero', async () => { + mockFetch({ + headers: { code: 8, error_message: 'Invalid client_id.' }, + results: [], + }) + await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow('Invalid client_id.') + }) + + it('throws when fetch itself rejects (network failure)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow() + }) +}) diff --git a/src/jamendo/client.ts b/src/jamendo/client.ts new file mode 100644 index 0000000..1913ef0 --- /dev/null +++ b/src/jamendo/client.ts @@ -0,0 +1,49 @@ +import type { JamendoTrack } from './types' + +const JAMENDO_API = 'https://api.jamendo.com/v3.0' + +export class JamendoError extends Error { + constructor(message: string) { + super(message) + this.name = 'JamendoError' + } +} + +export async function searchTracks(query: string, clientId: string): Promise { + const url = new URL(`${JAMENDO_API}/tracks/`) + url.searchParams.set('client_id', clientId) + url.searchParams.set('format', 'json') + url.searchParams.set('limit', '20') + url.searchParams.set('search', query) + url.searchParams.set('audioformat', 'mp32') + + const response = await fetch(url.toString()) + if (!response.ok) { + throw new JamendoError(`Jamendo API request failed: ${response.status}`) + } + + const data = await response.json() as { + headers: { code: number; error_message?: string } + results: Array<{ + id: string | number + name: string + artist_name: string + duration: number + audio: string + license_ccurl: string + }> + } + + if (data.headers?.code !== 0) { + throw new JamendoError(data.headers?.error_message || 'Jamendo API error') + } + + return (data.results ?? []).map(r => ({ + id: String(r.id), + name: r.name, + artistName: r.artist_name, + durationSecs: r.duration, + audioUrl: r.audio, + licenseUrl: r.license_ccurl, + })) +} diff --git a/src/jamendo/download.test.ts b/src/jamendo/download.test.ts new file mode 100644 index 0000000..f126c85 --- /dev/null +++ b/src/jamendo/download.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { downloadTrack, sanitizeFilename } from './download' + +class FakeWritable { + public written: Blob | null = null + async write(data: Blob) { this.written = data } + async close() {} +} + +class FakeFileHandle { + public writable = new FakeWritable() + async createWritable() { return this.writable } +} + +class FakeDirHandle { + public createdFiles = new Map() + async getFileHandle(name: string, opts?: { create?: boolean }) { + if (!this.createdFiles.has(name) && opts?.create) { + this.createdFiles.set(name, new FakeFileHandle()) + } + const fh = this.createdFiles.get(name) + if (!fh) throw new Error(`File not found: ${name}`) + return fh + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('downloadTrack', () => { + it('fetches the audio URL and writes a Blob to the folder', async () => { + const fakeBlob = new Blob(['fake audio'], { type: 'audio/mpeg' }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + blob: async () => fakeBlob, + })) + + const dir = new FakeDirHandle() + await downloadTrack('https://example.com/track.mp3', 'Artist - Track.mp3', dir) + + expect(dir.createdFiles.has('Artist - Track.mp3')).toBe(true) + expect(dir.createdFiles.get('Artist - Track.mp3')!.writable.written).toBe(fakeBlob) + }) + + it('throws when the download fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })) + const dir = new FakeDirHandle() + await expect(downloadTrack('https://example.com/track.mp3', 'x.mp3', dir)).rejects.toThrow() + }) +}) + +describe('sanitizeFilename', () => { + it('replaces path-unsafe characters with underscores', () => { + expect(sanitizeFilename('Art/ist: Track?Name')).toBe('Art_ist_ Track_Name.mp3') + }) + + it('appends .mp3 extension if missing', () => { + expect(sanitizeFilename('Cool Track')).toBe('Cool Track.mp3') + }) + + it('keeps .mp3 extension if already present', () => { + expect(sanitizeFilename('Cool Track.mp3')).toBe('Cool Track.mp3') + }) +}) diff --git a/src/jamendo/download.ts b/src/jamendo/download.ts new file mode 100644 index 0000000..e7c4c6e --- /dev/null +++ b/src/jamendo/download.ts @@ -0,0 +1,37 @@ +import { JamendoError } from './client' + +// Narrow interface matching both the real DirHandle and the fake used in tests. +interface WritableHandle { + write(data: Blob): Promise + close(): Promise +} +interface FileHandle { + createWritable(): Promise +} +export interface DownloadDirHandle { + getFileHandle(name: string, options?: { create?: boolean }): Promise +} + +export function sanitizeFilename(name: string): string { + const safe = name.replace(/[/\\:*?"<>|]/g, '_') + return safe.endsWith('.mp3') ? safe : `${safe}.mp3` +} + +export async function downloadTrack( + audioUrl: string, + filename: string, + dirHandle: DownloadDirHandle, +): Promise { + const response = await fetch(audioUrl) + if (!response.ok) { + throw new JamendoError(`Download failed: ${response.status}`) + } + const blob = await response.blob() + if (!blob.type.startsWith('audio/') && blob.type !== 'application/octet-stream' && blob.type !== '') { + throw new JamendoError(`Unexpected content type: ${blob.type}`) + } + const fh = await dirHandle.getFileHandle(filename, { create: true }) + const writable = await fh.createWritable() + await writable.write(blob) + await writable.close() +} diff --git a/src/jamendo/index.ts b/src/jamendo/index.ts new file mode 100644 index 0000000..472d942 --- /dev/null +++ b/src/jamendo/index.ts @@ -0,0 +1,4 @@ +export type { JamendoTrack, JamendoAttribution } from './types' +export { searchTracks, JamendoError } from './client' +export { downloadTrack, sanitizeFilename } from './download' +export type { DownloadDirHandle } from './download' diff --git a/src/jamendo/types.ts b/src/jamendo/types.ts new file mode 100644 index 0000000..970f185 --- /dev/null +++ b/src/jamendo/types.ts @@ -0,0 +1,15 @@ +export type JamendoTrack = { + id: string + name: string + artistName: string + durationSecs: number + audioUrl: string + licenseUrl: string +} + +export type JamendoAttribution = { + jamendoId: string + name: string + artist: string + licenseUrl: string +} diff --git a/src/project-store/schema.ts b/src/project-store/schema.ts index 07491f6..3129af6 100644 --- a/src/project-store/schema.ts +++ b/src/project-store/schema.ts @@ -1,5 +1,6 @@ import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core/settings' import type { BeatGrid } from '../beat-grid/types' +import type { JamendoAttribution } from '../jamendo/types' export const SCHEMA_VERSION = 1 export const SLIDESHOW_FILE = 'slideshow.json' @@ -34,6 +35,7 @@ export type SlideshowJson = { themeName?: ThemeName beatGridCache?: BeatGrid manualBeatGrid?: BeatGrid + soundtrackAttribution?: JamendoAttribution } export function isSlideshowJson(v: unknown): v is SlideshowJson {