From 35a695e9cf35c4271f0f0b3a93d4885f1cc538ce Mon Sep 17 00:00:00 2001 From: Konstantin Baltsat Date: Tue, 28 Jul 2026 18:58:19 +0800 Subject: [PATCH] feat: add local auto-chart pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a one-click local chart flow with optional official YouTube metadata, OCTAVE/STRUM processing, review-before-import, progress, cancellation, retry, and live end-to-end coverage. User-Request: встроенный локальный импорт и авто-чарт из SightKick | codex:019f9237-cca9-78e3-a7b7-c821f2bc1581 --- e2e/smoke.e2e.ts | 91 ++ src/main/AppState.ts | 14 + src/main/ipc/autoChart.test.ts | 452 +++++++ src/main/ipc/autoChart.ts | 1061 +++++++++++++++++ src/main/ipc/importSong.ts | 81 +- src/preload/index.ts | 6 + .../components/AutoChart/AutoChart.test.tsx | 126 ++ .../components/AutoChart/AutoChart.tsx | 202 ++++ src/renderer/components/AutoChart/index.ts | 1 + .../components/SongImport/SongImport.tsx | 175 ++- .../views/SongListView/SongListView.tsx | 5 + src/types.ts | 36 + 12 files changed, 2157 insertions(+), 93 deletions(-) create mode 100644 src/main/ipc/autoChart.test.ts create mode 100644 src/main/ipc/autoChart.ts create mode 100644 src/renderer/components/AutoChart/AutoChart.test.tsx create mode 100644 src/renderer/components/AutoChart/AutoChart.tsx create mode 100644 src/renderer/components/AutoChart/index.ts diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts index 4bffc084..ab7ee30c 100644 --- a/e2e/smoke.e2e.ts +++ b/e2e/smoke.e2e.ts @@ -79,6 +79,97 @@ test.describe('seeded library', () => { } }); + test('creates and reviews a real local OCTAVE chart when requested', async () => { + test.skip( + !process.env.SIGHTKICK_AUTO_CHART_AUDIO, + 'set SIGHTKICK_AUTO_CHART_AUDIO for the live OCTAVE proof', + ); + test.setTimeout(180_000); + + harness = await launchApp({ seedLibrary: true }); + await harness.app.evaluate(({ dialog }, audioPath) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [audioPath], + }); + }, process.env.SIGHTKICK_AUTO_CHART_AUDIO!); + page = await harness.app.firstWindow(); + + await page.getByRole('button', { name: 'Create chart' }).click(); + await expect(page.getByText('Create local drum chart')).toBeVisible(); + + if (process.env.SIGHTKICK_AUTO_CHART_YOUTUBE_URL) { + await page + .getByTestId('auto-chart-youtube-url') + .fill(process.env.SIGHTKICK_AUTO_CHART_YOUTUBE_URL); + } + + if (process.env.SIGHTKICK_AUTO_CHART_START_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_AUTO_CHART_START_PROOF, + }); + } + + await page.getByRole('button', { name: 'Choose local audio' }).click(); + await expect(page.getByTestId('auto-chart-progress')).toBeVisible(); + + const review = page.getByText('Review generated drum chart'); + const failed = page.getByText('failed', { exact: true }); + + await expect(review.or(failed)).toBeVisible({ timeout: 150_000 }); + + if (await failed.isVisible()) { + throw new Error( + await page.getByTestId('auto-chart-progress').innerText(), + ); + } + + await expect(page.getByText('Auto-charted with STRUM')).toBeVisible(); + + if (process.env.SIGHTKICK_AUTO_CHART_PREVIEW_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_AUTO_CHART_PREVIEW_PROOF, + }); + } + + await page.getByRole('button', { name: 'Add to library' }).click(); + + const generated = page.getByTestId(/song-item-/).filter({ + hasText: + process.env.SIGHTKICK_AUTO_CHART_EXPECTED_NAME ?? 'raging-drop-25s', + }); + + await expect(generated).toBeVisible({ timeout: 30_000 }); + await expect(generated.getByText('Auto-charted with STRUM')).toBeVisible(); + await expect(review).toBeHidden(); + + if (process.env.SIGHTKICK_AUTO_CHART_AFTER_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_AUTO_CHART_AFTER_PROOF, + }); + } + + await generated.click(); + await page.getByRole('button', { name: 'perform' }).click(); + + const generatedSheet = page.locator('svg').first(); + + await expect(generatedSheet).toBeVisible(); + await expect + .poll(async () => page.locator('svg path').count(), { timeout: 30_000 }) + .toBeGreaterThan(0); + await expect(page.getByTestId('play-toggle')).not.toHaveClass( + /ant-btn-loading/, + { timeout: 30_000 }, + ); + + if (process.env.SIGHTKICK_AUTO_CHART_SHEET_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_AUTO_CHART_SHEET_PROOF, + }); + } + }); + test('scans the folder, lists the song, and renders real sheet music', async () => { harness = await launchApp({ seedLibrary: true }); page = await harness.app.firstWindow(); diff --git a/src/main/AppState.ts b/src/main/AppState.ts index c66e02b7..bd792f15 100644 --- a/src/main/AppState.ts +++ b/src/main/AppState.ts @@ -25,6 +25,14 @@ import { updateSong } from './ipc/updateSong'; import { rescanSongs } from './ipc/rescanSongs'; import { exportPdf } from './ipc/exportPdf'; import { importSong, selectImportSong } from './ipc/importSong'; +import { + autoChartQueue, + cancelAutoChart, + createAutoChart, + discardAutoChartPreview, + importAutoChart, + retryAutoChart, +} from './ipc/autoChart'; class AppState { private static instance: AppState; @@ -103,6 +111,11 @@ class AppState { ipcMain.on('download-song', downloadSong); ipcMain.on('select-import-song', selectImportSong); ipcMain.on('import-song', importSong); + ipcMain.on('create-auto-chart', createAutoChart); + ipcMain.on('cancel-auto-chart', cancelAutoChart); + ipcMain.on('retry-auto-chart', retryAutoChart); + ipcMain.on('discard-auto-chart-preview', discardAutoChartPreview); + ipcMain.on('import-auto-chart', importAutoChart); ipcMain.on('check-stem-tools', checkStemTools); ipcMain.on('check-stem-tools-update', checkStemToolsUpdate); @@ -203,6 +216,7 @@ class AppState { stopListenMidi(); killActiveSplit(); cancelStemTools(); + void autoChartQueue.shutdown(); } } diff --git a/src/main/ipc/autoChart.test.ts b/src/main/ipc/autoChart.test.ts new file mode 100644 index 00000000..07f244f0 --- /dev/null +++ b/src/main/ipc/autoChart.test.ts @@ -0,0 +1,452 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AutoChartQueue, + AutoChartRunner, + applyOfficialMetadata, + canonicalizeYoutubeUrl, + fetchOfficialYoutubeMetadata, + parseWorkerLine, + validateLocalAudioFile, +} from './autoChart'; +import { lastReply, makeEvent } from './test-support'; + +interface Run { + payload: Record; + emit: (event: Record) => void; + finish: () => void; + kill: ReturnType; +} + +function preview(sourceDir: string) { + return { + sourceDir, + name: 'Prepared song', + artist: 'Artist', + album: '', + charter: '', + chartFormat: 'mid' as const, + audioCount: 1, + drumDifficulties: ['expert'] as never[], + coverSource: 'none' as const, + }; +} + +function nextTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function latestJob(event: ReturnType) { + return lastReply(event, 'auto-chart-update')!.args[0] as { + id: string; + attempt: number; + stage: string; + percent?: number; + preview?: { sourceDir: string }; + }; +} + +function createHarness(audioPaths: string[]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-chart-test-')); + const runs: Run[] = []; + let index = 0; + let jobIndex = 0; + const importSong = vi.fn(async (sourceDir: string) => ({ + id: 'imported-song', + dir: sourceDir, + name: 'Prepared song', + artist: 'Artist', + album: '', + charter: '', + genre: '', + year: '', + fiveLaneDrums: false, + proDrums: false, + delaySeconds: 0, + drumDifficulty: 0, + format: 'mid' as const, + audio: [], + })); + const runner: AutoChartRunner = { + run(payloadPath, emit) { + const payload = JSON.parse( + fs.readFileSync(payloadPath, 'utf8'), + ) as Record; + let finish = () => {}; + const done = new Promise((resolve) => { + finish = resolve; + }); + const run = { payload, emit, finish, kill: vi.fn() }; + + runs.push(run); + + return { kill: run.kill, done }; + }, + }; + const queue = new AutoChartQueue({ + selectAudio: async () => audioPaths[index++], + resolveMetadata: async () => undefined, + validateAudio: validateLocalAudioFile, + createTempDir: async (id: string) => + fs.promises.mkdtemp(path.join(root, `${id}-`)), + preflight: () => + ({ + cacheDir: root, + pythonPath: '', + workerPath: '', + sourceDir: '', + ffmpegDir: '', + }) as never, + runner, + preview: async (sourceDir: string) => preview(sourceDir), + importSong, + cleanup: async (tempDir?: string) => { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }, + applyMetadata: async () => {}, + makeId: () => `job-${++jobIndex}`, + } as never); + const complete = async (run: Run) => { + const outputDir = run.payload.outputDir as string; + const songDir = path.join(outputDir, 'prepared'); + + fs.mkdirSync(songDir, { recursive: true }); + run.emit({ + kind: 'complete', + runId: run.payload.runId, + success: true, + outputDir, + songFolders: [songDir], + errors: [], + }); + await nextTurn(); + run.finish(); + await nextTurn(); + }; + + return { root, runs, queue, importSong, complete }; +} + +function writeAudio(root: string, name: string): string { + const filePath = path.join(root, name); + + fs.writeFileSync(filePath, 'audio'); + + return filePath; +} + +describe('auto-chart source and worker protocol', () => { + const cleanup: string[] = []; + + afterEach(() => { + vi.unstubAllGlobals(); + + for (const root of cleanup.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('canonicalizes only individual YouTube video identities', () => { + expect(canonicalizeYoutubeUrl('https://youtu.be/abcdefghijk?t=9')).toBe( + 'https://www.youtube.com/watch?v=abcdefghijk', + ); + expect( + canonicalizeYoutubeUrl('https://www.youtube.com/shorts/abcdefghijk'), + ).toBe('https://www.youtube.com/watch?v=abcdefghijk'); + expect( + canonicalizeYoutubeUrl( + 'https://music.youtube.com/watch?v=abcdefghijk&list=album', + ), + ).toBe('https://www.youtube.com/watch?v=abcdefghijk'); + expect(() => + canonicalizeYoutubeUrl('https://example.com/watch?v=abcdefghijk'), + ).toThrow('single YouTube video'); + expect(() => + canonicalizeYoutubeUrl('https://youtube.com/playlist?list=x'), + ).toThrow('single YouTube video'); + }); + + it('uses only official oEmbed metadata and official thumbnails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + title: 'Official title', + author_name: 'Official channel', + thumbnail_url: 'https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg', + }), + })), + ); + + await expect( + fetchOfficialYoutubeMetadata( + 'https://www.youtube.com/watch?v=abcdefghijk', + ), + ).resolves.toEqual({ + title: 'Official title', + authorName: 'Official channel', + songName: 'Official title', + artistName: 'Official channel', + thumbnailUrl: 'https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg', + }); + expect(fetch).toHaveBeenCalledWith( + 'https://www.youtube.com/oembed?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dabcdefghijk&format=json', + { signal: expect.any(AbortSignal) }, + ); + }); + + it('infers a one-click track identity from a conventional official title', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + title: 'Kygo - Raging ft. Kodaline (Official Lyric Video)', + author_name: 'KygoOfficialVEVO', + thumbnail_url: 'https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg', + }), + })), + ); + + await expect( + fetchOfficialYoutubeMetadata( + 'https://www.youtube.com/watch?v=abcdefghijk', + ), + ).resolves.toMatchObject({ + songName: 'Raging', + artistName: 'Kygo feat. Kodaline', + }); + }); + + it('applies official metadata to a generated chart without ini injection', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-chart-metadata-')); + + cleanup.push(root); + fs.writeFileSync( + path.join(root, 'song.ini'), + '[song]\nname = generated filename\nartist = Unknown Artist\n', + ); + + await applyOfficialMetadata(root, { + title: 'Raging\nname = injected', + authorName: 'Kygo\r\nartist = injected', + }); + + expect(fs.readFileSync(path.join(root, 'song.ini'), 'utf8')).toBe( + '[song]\nname = Raging name = injected\nartist = Kygo artist = injected\n', + ); + }); + + it('rejects symlinked and unsupported local input', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-chart-input-')); + + cleanup.push(root); + + const audio = writeAudio(root, 'track.mp3'); + const symlink = path.join(root, 'link.mp3'); + + fs.symlinkSync(audio, symlink); + + expect(() => validateLocalAudioFile(symlink)).toThrow('Symbolic-link'); + expect(() => validateLocalAudioFile(writeAudio(root, 'track.txt'))).toThrow( + 'WAV, MP3, OGG, OPUS, or FLAC', + ); + }); + + it('parses only structured OCTAVE worker events', () => { + expect( + parseWorkerLine( + '__OCTAVE_EVENT__{"kind":"progress","runId":"job-1","percent":55}', + ), + ).toMatchObject({ kind: 'progress', runId: 'job-1', percent: 55 }); + expect(parseWorkerLine('regular worker output')).toBeUndefined(); + expect(parseWorkerLine('__OCTAVE_EVENT__{bad')).toBeUndefined(); + }); +}); + +describe('auto-chart queue', () => { + const cleanup: string[] = []; + + afterEach(() => { + for (const root of cleanup.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('keeps FIFO work active one at a time and emits a review before import', async () => { + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'auto-chart-audio-'), + ); + + cleanup.push(sourceRoot); + + const harness = createHarness([ + writeAudio(sourceRoot, 'one.mp3'), + writeAudio(sourceRoot, 'two.mp3'), + ]); + + cleanup.push(harness.root); + + const first = makeEvent(); + const second = makeEvent(); + + await harness.queue.create(first as never, {}); + await harness.queue.create(second as never, {}); + await vi.waitFor(() => expect(harness.runs).toHaveLength(1)); + await nextTurn(); + expect(harness.runs[0].payload.runId).toBe(latestJob(first).id); + expect(harness.runs[0].payload).toMatchObject({ + files: [fs.realpathSync(path.join(sourceRoot, 'one.mp3'))], + urls: [], + includeKeys: false, + enabledTracks: { + drums: true, + bass: false, + guitar: false, + keys: false, + vocals: false, + proKeys: false, + }, + keepStems: true, + autoTempo: true, + autoTempoDrift: true, + autoTempoSnap: true, + }); + harness.runs[0].emit({ + kind: 'progress', + runId: harness.runs[0].payload.runId, + percent: 25, + }); + await vi.waitFor(() => + expect(latestJob(first)).toMatchObject({ + stage: 'processing', + percent: 25, + }), + ); + + const outputDir = harness.runs[0].payload.outputDir as string; + const songDir = path.join(outputDir, 'prepared'); + + fs.mkdirSync(songDir, { recursive: true }); + harness.runs[0].emit({ + kind: 'complete', + runId: harness.runs[0].payload.runId, + success: true, + outputDir, + songFolders: [songDir], + errors: [], + }); + await vi.waitFor(() => + expect(latestJob(first)).toMatchObject({ stage: 'preview-ready' }), + ); + harness.runs[0].finish(); + await vi.waitFor(() => expect(harness.runs).toHaveLength(2)); + expect(harness.importSong).not.toHaveBeenCalled(); + + const previewDir = latestJob(first).preview?.sourceDir; + + await harness.queue.import(latestJob(first).id); + + expect(harness.importSong).toHaveBeenCalledWith(previewDir, undefined); + expect(latestJob(first)).toMatchObject({ stage: 'imported' }); + }); + + it('cancels queued work without starting it and cleans active work after the child exits', async () => { + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'auto-chart-audio-'), + ); + + cleanup.push(sourceRoot); + + const harness = createHarness([ + writeAudio(sourceRoot, 'one.mp3'), + writeAudio(sourceRoot, 'two.mp3'), + ]); + + cleanup.push(harness.root); + + const first = makeEvent(); + const second = makeEvent(); + + await harness.queue.create(first as never, {}); + await harness.queue.create(second as never, {}); + await vi.waitFor(() => expect(harness.runs).toHaveLength(1)); + await nextTurn(); + + const activeTempDir = harness.runs[0].payload.outputDir as string; + + await harness.queue.cancel(latestJob(second).id); + expect(latestJob(second)).toMatchObject({ stage: 'cancelled' }); + expect(harness.runs).toHaveLength(1); + + await harness.queue.cancel(latestJob(first).id); + expect(harness.runs[0].kill).toHaveBeenCalledOnce(); + harness.runs[0].finish(); + await vi.waitFor(() => + expect(latestJob(first)).toMatchObject({ stage: 'cancelled' }), + ); + + expect(latestJob(first)).toMatchObject({ stage: 'cancelled' }); + expect(fs.existsSync(activeTempDir)).toBe(false); + }); + + it('creates a new attempt on retry and preserves monotonic worker progress', async () => { + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'auto-chart-audio-'), + ); + + cleanup.push(sourceRoot); + + const harness = createHarness([writeAudio(sourceRoot, 'one.mp3')]); + + cleanup.push(harness.root); + + const event = makeEvent(); + + await harness.queue.create(event as never, {}); + await vi.waitFor(() => expect(harness.runs).toHaveLength(1)); + await nextTurn(); + + const firstRun = harness.runs[0]; + const firstJob = latestJob(event); + + firstRun.emit({ + kind: 'progress', + runId: firstRun.payload.runId, + percent: 70, + }); + firstRun.emit({ + kind: 'progress', + runId: firstRun.payload.runId, + percent: 20, + }); + await nextTurn(); + expect(latestJob(event)).toMatchObject({ + stage: 'processing', + percent: 70, + }); + + firstRun.emit({ + kind: 'error', + runId: firstRun.payload.runId, + message: 'model failed', + }); + firstRun.finish(); + await vi.waitFor(() => + expect(latestJob(event)).toMatchObject({ + stage: 'failed', + error: 'model failed', + }), + ); + + await harness.queue.retry(event as never, firstJob.id); + await vi.waitFor(() => expect(harness.runs).toHaveLength(2)); + expect(latestJob(event)).toMatchObject({ attempt: 2, stage: 'processing' }); + expect(latestJob(event).id).not.toBe(firstJob.id); + }); +}); diff --git a/src/main/ipc/autoChart.ts b/src/main/ipc/autoChart.ts new file mode 100644 index 00000000..cc2ec420 --- /dev/null +++ b/src/main/ipc/autoChart.ts @@ -0,0 +1,1061 @@ +import { ChildProcess, spawn } from 'child_process'; +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { app, dialog, IpcMainEvent } from 'electron'; +import { + AutoChartStage, + IpcAutoChartJob, + IpcAutoChartMetadata, + IpcCreateAutoChartRequest, + IpcImportSongPreview, + Song, +} from '../../types'; +import { ingestSongCover } from '../songCover'; +import { importPreparedSong, previewPreparedSong } from './importSong'; + +const EVENT_PREFIX = '__OCTAVE_EVENT__'; +const MAX_AUDIO_BYTES = 2 * 1024 * 1024 * 1024; +const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.ogg', '.opus', '.flac']); +const REQUIRED_CHECKPOINTS = [ + 'drums_cymbal_onset/best_union_f1.pt', + 'drums_mc_onset/best.pt', + 'drums_phase3/best.pt', + 'drums_v14/best.pt', + 'fret_mapper_v4.pt', + 'guitar_v2/guitar_v2_onset/best.pt', + 'onset_classifier_v12_clean/best_f1.pt', + 'onset_classifier_v12c_community/best_f1.pt', + 'onset_classifier_v15/best_f1.pt', + 'onset_classifier_v16/best_f1.pt', + 'onset_classifier_v4/best_f1.pt', + 'onset_classifier_v6/best_f1.pt', + 'onset_classifier/best_f1.pt', + 'section_classifier/best.pt', + 'tom_refinement_demucs/best.pt', +]; + +interface WorkerEvent { + kind: 'progress' | 'complete' | 'error'; + runId: string; + stage?: string; + message?: string; + percent?: number; + success?: boolean; + outputDir?: string; + songFolders?: string[]; + errors?: string[]; +} + +interface WorkerHandle { + kill: () => void; + done: Promise; +} + +interface OctaveRuntime { + pythonPath: string; + workerPath: string; + cacheDir: string; + sourceDir: string; + ffmpegDir: string; +} + +export interface AutoChartRunner { + run: ( + payloadPath: string, + onEvent: (event: WorkerEvent) => void, + ) => WorkerHandle; +} + +interface AutoChartDependencies { + selectAudio: () => Promise; + resolveMetadata: ( + youtubeUrl?: string, + ) => Promise; + validateAudio: (filePath: string) => string; + createTempDir: (id: string) => Promise; + preflight: () => OctaveRuntime; + runner: AutoChartRunner; + preview: ( + sourceDir: string, + thumbnailUrl?: string, + ) => Promise; + importSong: (sourceDir: string, artworkUrl?: string) => Promise; + cleanup: (tempDir?: string) => Promise; + applyMetadata: ( + sourceDir: string, + metadata?: IpcAutoChartMetadata, + ) => Promise; + makeId: () => string; +} + +interface AutoChartJob extends IpcAutoChartJob { + event: IpcMainEvent; + audioPath?: string; + tempDir?: string; + preparedDir?: string; + cancelled: boolean; + worker?: WorkerHandle; +} + +function toPublicJob(job: AutoChartJob): IpcAutoChartJob { + const { + event: _event, + audioPath: _audioPath, + tempDir: _tempDir, + preparedDir: _preparedDir, + cancelled: _cancelled, + worker: _worker, + ...value + } = job; + + return value; +} + +function isTerminal(stage: AutoChartStage): boolean { + return ['imported', 'failed', 'cancelled'].includes(stage); +} + +function isInside(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + + return ( + Boolean(relative) && + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); +} + +function safeMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function canonicalizeYoutubeUrl(value: string): string { + let parsed: URL; + + try { + parsed = new URL(value.trim()); + } catch { + throw new Error('Enter a valid youtube.com or youtu.be video URL'); + } + + if (parsed.protocol !== 'https:') { + throw new Error('Use an HTTPS youtube.com or youtu.be video URL'); + } + + const host = parsed.hostname.toLowerCase(); + let videoId: string | null = null; + + if (host === 'youtu.be') { + const parts = parsed.pathname.split('/').filter(Boolean); + + videoId = parts.length === 1 ? parts[0] : null; + } else if ( + [ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', + ].includes(host) + ) { + if (parsed.pathname === '/watch') { + videoId = parsed.searchParams.get('v'); + } else { + const match = parsed.pathname.match(/^\/shorts\/([^/]+)$/); + + videoId = match?.[1] ?? null; + } + } + + if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId)) { + throw new Error( + 'Use a single YouTube video URL, not a playlist or channel URL', + ); + } + + return `https://www.youtube.com/watch?v=${videoId}`; +} + +function inferTrackIdentity( + title: string, + authorName: string, +): Pick { + const parts = title.match(/^(.+?)\s+[-–—]\s+(.+)$/); + + if (!parts) { + return { songName: title, artistName: authorName }; + } + + let songName = parts[2] + .replace( + /\s*\((?:official\s+(?:music\s+)?(?:video|audio|lyric\s+video)|official\s+visuali[sz]er|lyric\s+video|official\s+audio)\)\s*$/i, + '', + ) + .trim(); + let artistName = parts[1].trim(); + const featured = + songName.match(/\s+(?:ft\.?|feat\.?|featuring)\s+(.+)$/i) ?? + songName.match(/\s+\((?:ft\.?|feat\.?|featuring)\s+([^)]+)\)$/i); + + if (featured) { + songName = songName.slice(0, featured.index).trim(); + artistName = `${artistName} feat. ${featured[1].trim()}`; + } + + return { songName, artistName }; +} + +export function validateLocalAudioFile(filePath: string): string { + if (typeof filePath !== 'string' || !filePath) { + throw new Error('Choose a local audio file'); + } + + const lstat = fs.lstatSync(filePath); + + if (lstat.isSymbolicLink()) { + throw new Error('Symbolic-link audio files are not supported'); + } + + if (!lstat.isFile()) { + throw new Error('Choose a regular local audio file'); + } + + const extension = path.extname(filePath).toLowerCase(); + + if (!AUDIO_EXTENSIONS.has(extension)) { + throw new Error('Choose a WAV, MP3, OGG, OPUS, or FLAC audio file'); + } + + if (lstat.size > MAX_AUDIO_BYTES) { + throw new Error('Choose an audio file smaller than 2 GB'); + } + + return fs.realpathSync(filePath); +} + +export async function fetchOfficialYoutubeMetadata( + youtubeUrl?: string, +): Promise { + if (!youtubeUrl?.trim()) { + return undefined; + } + + const canonicalUrl = canonicalizeYoutubeUrl(youtubeUrl); + let response: Response; + + try { + response = await fetch( + `https://www.youtube.com/oembed?url=${encodeURIComponent( + canonicalUrl, + )}&format=json`, + { signal: AbortSignal.timeout(15_000) }, + ); + } catch { + throw new Error( + 'YouTube metadata request failed; check your connection or omit the URL', + ); + } + + if (!response.ok) { + throw new Error( + 'YouTube could not provide official metadata for this video', + ); + } + + const value: unknown = await response.json(); + + if (!value || typeof value !== 'object') { + throw new Error('YouTube returned invalid official metadata'); + } + + const record = value as Record; + const title = typeof record.title === 'string' ? record.title.trim() : ''; + const authorName = + typeof record.author_name === 'string' ? record.author_name.trim() : ''; + const thumbnailUrl = + typeof record.thumbnail_url === 'string' ? record.thumbnail_url : undefined; + + if (!title || !authorName) { + throw new Error('YouTube returned incomplete official metadata'); + } + + if (thumbnailUrl) { + let thumbnail: URL; + + try { + thumbnail = new URL(thumbnailUrl); + } catch { + throw new Error('YouTube returned an invalid thumbnail URL'); + } + + if ( + thumbnail.protocol !== 'https:' || + thumbnail.hostname !== 'i.ytimg.com' + ) { + throw new Error('YouTube returned a non-official thumbnail URL'); + } + } + + return { + title, + authorName, + ...inferTrackIdentity(title, authorName), + thumbnailUrl, + }; +} + +function resolveOctaveRuntime(): OctaveRuntime { + const home = app.getPath('home'); + const appRoot = [ + '/Applications/OCTAVE.app', + path.join(home, 'Applications', 'OCTAVE.app'), + ].find((candidate) => + fs.existsSync( + path.join( + candidate, + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'resources', + 'strum', + 'strum_worker.py', + ), + ), + ); + + if (!appRoot) { + throw new Error( + 'OCTAVE for macOS is required. Install OCTAVE in /Applications or ~/Applications before creating a chart', + ); + } + + const resources = path.join( + appRoot, + 'Contents', + 'Resources', + 'app.asar.unpacked', + ); + const cacheCandidates = [ + path.join( + home, + 'Library', + 'Application Support', + 'octave', + 'Cache', + 'strum', + ), + path.join( + home, + 'Library', + 'Application Support', + 'octave', + 'cache', + 'strum', + ), + ]; + const cacheDir = cacheCandidates.find((candidate) => + fs.existsSync(candidate), + ); + + if (!cacheDir) { + throw new Error( + 'OCTAVE local STRUM cache is unavailable; open OCTAVE to restore its local runtime', + ); + } + + const canonicalCacheDir = fs.realpathSync(cacheDir); + + return { + pythonPath: path.join( + home, + 'Library', + 'Application Support', + 'octave', + 'python-runtime', + 'python', + 'bin', + 'python3', + ), + workerPath: path.join(resources, 'resources', 'strum', 'strum_worker.py'), + cacheDir: canonicalCacheDir, + sourceDir: path.join(canonicalCacheDir, 'strum-source'), + ffmpegDir: path.join(resources, 'node_modules', 'ffmpeg-static'), + }; +} + +function preflightOctaveRuntime(): OctaveRuntime { + const runtime = resolveOctaveRuntime(); + + for (const filePath of [runtime.pythonPath, runtime.workerPath]) { + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + throw new Error( + 'OCTAVE local Python runtime is unavailable; repair OCTAVE before creating a chart', + ); + } + } + + if ( + !fs.existsSync(path.join(runtime.sourceDir, 'scripts', 'batch_pipeline.py')) + ) { + throw new Error( + 'OCTAVE local STRUM source is unavailable; open OCTAVE to restore its local runtime', + ); + } + + const missing = REQUIRED_CHECKPOINTS.filter( + (checkpoint) => + !fs.existsSync(path.join(runtime.sourceDir, 'checkpoints', checkpoint)), + ); + + if (missing.length > 0) { + throw new Error( + 'OCTAVE local STRUM checkpoints are incomplete; restore them in OCTAVE before creating a chart', + ); + } + + return runtime; +} + +function workerEnvironment(runtime: OctaveRuntime): NodeJS.ProcessEnv { + const runtimePath = [runtime.ffmpegDir, process.env.PATH] + .filter(Boolean) + .join(path.delimiter); + + return { + ...process.env, + PYTHONUTF8: '1', + OCTAVE_PACKAGED: '1', + OCTAVE_STRUM_DISABLE_ONLINE_LOOKUP: '1', + OCTAVE_STRUM_FAST_METADATA_LOOKUP: '0', + OCTAVE_STRUM_SKIP_HARMONIES: '1', + OCTAVE_STRUM_SOURCE_DIR: runtime.sourceDir, + OCTAVE_DEMUCS_CPP_BIN: undefined, + PATH: runtimePath, + }; +} + +export function parseWorkerLine(line: string): WorkerEvent | undefined { + if (!line.startsWith(EVENT_PREFIX)) { + return undefined; + } + + try { + const value = JSON.parse(line.slice(EVENT_PREFIX.length)) as unknown; + + if ( + !value || + typeof value !== 'object' || + !['progress', 'complete', 'error'].includes( + (value as Record).kind as string, + ) || + typeof (value as Record).runId !== 'string' + ) { + return undefined; + } + + return value as WorkerEvent; + } catch { + return undefined; + } +} + +function createChildRunner(): AutoChartRunner { + return { + run(payloadPath, onEvent) { + const runtime = preflightOctaveRuntime(); + const child = spawn( + runtime.pythonPath, + [runtime.workerPath, '--payload-file', payloadPath], + { + cwd: path.dirname(payloadPath), + env: workerEnvironment(runtime), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + const processOutput = (chunk: Buffer) => { + output += chunk.toString('utf8'); + + const lines = output.split(/\r?\n/); + + output = lines.pop() ?? ''; + + for (const line of lines) { + const event = parseWorkerLine(line); + + if (event) { + onEvent(event); + } + } + }; + + child.stdout?.on('data', processOutput); + child.stderr?.pipe(process.stderr); + + return { + kill: () => child.kill('SIGTERM'), + done: waitForChild(child, () => { + const event = parseWorkerLine(output); + + if (event) { + onEvent(event); + } + }), + }; + }, + }; +} + +function waitForChild(child: ChildProcess, flush: () => void): Promise { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', () => { + flush(); + resolve(); + }); + }); +} + +async function createTempDir(id: string): Promise { + const root = path.join(os.tmpdir(), 'sightkick-auto-chart'); + + await fs.promises.mkdir(root, { recursive: true, mode: 0o700 }); + + return fs.promises.mkdtemp(path.join(root, `${id}-`)); +} + +async function cleanupTempDir(tempDir?: string): Promise { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +function cleanIniValue(value: string): string { + return value + .replace(/[\r\n]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function upsertIniField( + source: string, + field: 'name' | 'artist', + value: string, +): string { + const cleanValue = cleanIniValue(value); + const expression = new RegExp(`^(\\s*${field}\\s*=\\s*).*$`, 'im'); + + if (expression.test(source)) { + return source.replace( + expression, + (_match, prefix: string) => `${prefix}${cleanValue}`, + ); + } + + const section = /(\[song\]\s*\r?\n)/i; + + if (!section.test(source)) { + throw new Error('Generated chart has an invalid song.ini file'); + } + + return source.replace( + section, + (_match, prefix: string) => `${prefix}${field} = ${cleanValue}\n`, + ); +} + +export async function applyOfficialMetadata( + sourceDir: string, + metadata?: IpcAutoChartMetadata, +): Promise { + if (!metadata) { + return; + } + + const iniPath = path.join(sourceDir, 'song.ini'); + const source = await fs.promises.readFile(iniPath, 'utf8'); + const updated = upsertIniField( + upsertIniField(source, 'name', metadata.songName ?? metadata.title), + 'artist', + metadata.artistName ?? metadata.authorName, + ); + + if (updated !== source) { + await fs.promises.writeFile(iniPath, updated, 'utf8'); + } + + if (metadata.thumbnailUrl) { + try { + await ingestSongCover(sourceDir, metadata.thumbnailUrl); + } catch { + metadata.thumbnailUrl = undefined; + } + } +} + +function defaultDependencies(): AutoChartDependencies { + return { + selectAudio: async () => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Choose local audio you own or are allowed to process', + filters: [ + { + name: 'Supported audio', + extensions: ['wav', 'mp3', 'ogg', 'opus', 'flac'], + }, + ], + }); + + return result.canceled ? undefined : result.filePaths[0]; + }, + resolveMetadata: fetchOfficialYoutubeMetadata, + validateAudio: validateLocalAudioFile, + createTempDir, + preflight: preflightOctaveRuntime, + runner: createChildRunner(), + preview: (sourceDir, thumbnailUrl) => + previewPreparedSong(sourceDir, { thumbnailUrl }), + importSong: (sourceDir, artworkUrl) => + importPreparedSong({ sourceDir, artworkUrl }), + cleanup: cleanupTempDir, + applyMetadata: applyOfficialMetadata, + makeId: randomUUID, + }; +} + +export class AutoChartQueue { + private readonly jobs = new Map(); + private readonly pending: string[] = []; + private activeId?: string; + + constructor( + private readonly dependencies: AutoChartDependencies = defaultDependencies(), + ) {} + + async create( + event: IpcMainEvent, + request: IpcCreateAutoChartRequest, + ): Promise { + const job: AutoChartJob = { + id: this.dependencies.makeId(), + attempt: 1, + stage: 'resolving', + message: 'Checking optional YouTube metadata', + event, + cancelled: false, + }; + + this.jobs.set(job.id, job); + this.notify(job); + + try { + const youtubeUrl = + request && typeof request.youtubeUrl === 'string' + ? request.youtubeUrl + : undefined; + + job.metadata = await this.dependencies.resolveMetadata(youtubeUrl); + + if (job.cancelled) { + return; + } + + job.message = 'Choose local audio you own or are allowed to process'; + this.notify(job); + + const selectedAudio = await this.dependencies.selectAudio(); + + if (job.cancelled) { + return; + } + + if (!selectedAudio) { + await this.cancelJob(job); + + return; + } + + job.audioPath = this.dependencies.validateAudio(selectedAudio); + job.sourceName = path.basename(job.audioPath); + job.tempDir = await this.dependencies.createTempDir(job.id); + this.transition( + job, + 'queued', + 'Chart queued for local OCTAVE processing', + ); + this.pending.push(job.id); + void this.processNext(); + } catch (error) { + await this.fail(job, safeMessage(error)); + } + } + + async cancel(id: string): Promise { + const job = this.jobs.get(id); + + if (!job || isTerminal(job.stage) || job.stage === 'importing') { + return; + } + + job.cancelled = true; + + const queuedIndex = this.pending.indexOf(id); + + if (queuedIndex >= 0) { + this.pending.splice(queuedIndex, 1); + await this.cancelJob(job); + + return; + } + + job.worker?.kill(); + + if (id !== this.activeId) { + await this.cancelJob(job); + } + } + + async retry(event: IpcMainEvent, id: string): Promise { + const previous = this.jobs.get(id); + + if ( + !previous || + !['failed', 'cancelled'].includes(previous.stage) || + !previous.audioPath + ) { + return; + } + + const job: AutoChartJob = { + id: this.dependencies.makeId(), + attempt: previous.attempt + 1, + stage: 'queued', + message: 'Chart queued for local OCTAVE processing', + event, + audioPath: previous.audioPath, + sourceName: previous.sourceName, + metadata: previous.metadata, + cancelled: false, + }; + + try { + job.audioPath = this.dependencies.validateAudio(job.audioPath); + job.tempDir = await this.dependencies.createTempDir(job.id); + this.jobs.set(job.id, job); + this.notify(job); + this.pending.push(job.id); + void this.processNext(); + } catch (error) { + this.jobs.set(job.id, job); + await this.fail(job, safeMessage(error)); + } + } + + async discardPreview(id: string): Promise { + const job = this.jobs.get(id); + + if (!job || job.stage !== 'preview-ready') { + return; + } + + job.cancelled = true; + await this.cancelJob(job); + } + + async import(id: string): Promise { + const job = this.jobs.get(id); + + if (!job || job.stage !== 'preview-ready' || !job.preparedDir) { + return; + } + + this.transition( + job, + 'importing', + 'Adding reviewed chart to the current library', + ); + + try { + const song = await this.dependencies.importSong( + job.preparedDir, + job.metadata?.thumbnailUrl, + ); + + job.preview = undefined; + job.song = song; + this.transition( + job, + 'imported', + `Added "${song.name}" to the current library`, + ); + await this.dependencies.cleanup(job.tempDir); + job.tempDir = undefined; + job.preparedDir = undefined; + } catch (error) { + await this.fail(job, safeMessage(error)); + } + } + + async shutdown(): Promise { + await Promise.all( + [...this.jobs.values()] + .filter((job) => !isTerminal(job.stage)) + .map(async (job) => { + job.cancelled = true; + job.worker?.kill(); + await this.cancelJob(job); + }), + ); + } + + private async processNext(): Promise { + if (this.activeId || this.pending.length === 0) { + return; + } + + const id = this.pending.shift()!; + const job = this.jobs.get(id); + + if (!job || job.cancelled || job.stage !== 'queued') { + void this.processNext(); + + return; + } + + this.activeId = id; + + try { + await this.run(job); + } finally { + if (this.activeId === id) { + this.activeId = undefined; + } + + void this.processNext(); + } + } + + private async run(job: AutoChartJob): Promise { + try { + if (!job.audioPath || !job.tempDir) { + throw new Error('Auto-chart job has no local audio input'); + } + + job.audioPath = this.dependencies.validateAudio(job.audioPath); + + const runtime = this.dependencies.preflight(); + const payloadPath = path.join(job.tempDir, 'payload.json'); + const payload = { + runId: job.id, + cacheDir: runtime.cacheDir, + outputDir: job.tempDir, + files: [job.audioPath], + urls: [], + includeKeys: false, + enabledTracks: { + drums: true, + bass: false, + guitar: false, + keys: false, + vocals: false, + proKeys: false, + }, + keepStems: true, + autoTempo: true, + autoTempoDrift: true, + autoTempoSnap: true, + }; + + await fs.promises.writeFile(payloadPath, JSON.stringify(payload), { + encoding: 'utf8', + mode: 0o600, + }); + this.transition( + job, + 'processing', + 'OCTAVE is preparing a drum chart locally', + 0, + ); + + let workerEvents = Promise.resolve(); + + job.worker = this.dependencies.runner.run(payloadPath, (event) => { + workerEvents = workerEvents + .then(() => this.handleWorkerEvent(job, event)) + .catch((error) => this.fail(job, safeMessage(error))); + }); + await job.worker.done; + await workerEvents; + + if (job.cancelled && !isTerminal(job.stage)) { + await this.cancelJob(job); + } else if (!isTerminal(job.stage) && job.stage !== 'preview-ready') { + await this.fail(job, 'OCTAVE worker exited before preparing a chart'); + } + } catch (error) { + if (job.cancelled) { + await this.cancelJob(job); + } else { + await this.fail(job, safeMessage(error)); + } + } finally { + job.worker = undefined; + } + } + + private async handleWorkerEvent( + job: AutoChartJob, + event: WorkerEvent, + ): Promise { + if (event.runId !== job.id || isTerminal(job.stage) || job.cancelled) { + return; + } + + if (event.kind === 'progress') { + const percent = + typeof event.percent === 'number' + ? Math.max( + job.percent ?? 0, + Math.min(100, Math.max(0, event.percent)), + ) + : job.percent; + + this.transition( + job, + 'processing', + event.message || 'OCTAVE is processing local audio', + percent, + ); + + return; + } + + if (event.kind === 'error') { + await this.fail(job, event.message || 'OCTAVE could not prepare a chart'); + + return; + } + + if (!event.success) { + await this.fail( + job, + event.errors?.[0] || 'OCTAVE could not prepare a chart', + ); + + return; + } + + const preparedDir = this.validPreparedDir(job, event); + + await this.dependencies.applyMetadata(preparedDir, job.metadata); + job.preparedDir = preparedDir; + job.preview = await this.dependencies.preview( + preparedDir, + job.metadata?.thumbnailUrl, + ); + this.transition( + job, + 'preview-ready', + 'Chart is ready to review before adding it to your library', + 100, + ); + } + + private validPreparedDir(job: AutoChartJob, event: WorkerEvent): string { + if (!job.tempDir || !event.outputDir || event.songFolders?.length !== 1) { + throw new Error('OCTAVE returned an unexpected prepared-chart location'); + } + + const preparedDir = fs.realpathSync(event.songFolders[0]); + const tempDir = fs.realpathSync(job.tempDir); + const outputDir = fs.realpathSync(event.outputDir); + const stat = fs.lstatSync(preparedDir); + + if ( + outputDir !== tempDir || + stat.isSymbolicLink() || + !stat.isDirectory() || + !isInside(tempDir, preparedDir) + ) { + throw new Error('OCTAVE returned an unsafe prepared-chart location'); + } + + return preparedDir; + } + + private transition( + job: AutoChartJob, + stage: AutoChartStage, + message: string, + percent?: number, + ): void { + job.stage = stage; + job.message = message; + job.percent = percent; + job.error = undefined; + this.notify(job); + } + + private async fail(job: AutoChartJob, error: string): Promise { + if (isTerminal(job.stage)) { + return; + } + + job.stage = 'failed'; + job.error = error; + job.message = 'Chart creation failed'; + this.notify(job); + await this.dependencies.cleanup(job.tempDir); + job.tempDir = undefined; + job.preparedDir = undefined; + } + + private async cancelJob(job: AutoChartJob): Promise { + if (isTerminal(job.stage)) { + return; + } + + job.stage = 'cancelled'; + job.error = undefined; + job.message = 'Chart creation cancelled'; + this.notify(job); + await this.dependencies.cleanup(job.tempDir); + job.tempDir = undefined; + job.preparedDir = undefined; + } + + private notify(job: AutoChartJob): void { + job.event.reply('auto-chart-update', toPublicJob(job)); + } +} + +export const autoChartQueue = new AutoChartQueue(); + +export function createAutoChart( + event: IpcMainEvent, + request: IpcCreateAutoChartRequest, +): void { + void autoChartQueue.create(event, request); +} + +export function cancelAutoChart(_event: IpcMainEvent, id: string): void { + void autoChartQueue.cancel(id); +} + +export function retryAutoChart(event: IpcMainEvent, id: string): void { + void autoChartQueue.retry(event, id); +} + +export function discardAutoChartPreview( + _event: IpcMainEvent, + id: string, +): void { + void autoChartQueue.discardPreview(id); +} + +export function importAutoChart(_event: IpcMainEvent, id: string): void { + void autoChartQueue.import(id); +} diff --git a/src/main/ipc/importSong.ts b/src/main/ipc/importSong.ts index 99d08091..f822a8d9 100644 --- a/src/main/ipc/importSong.ts +++ b/src/main/ipc/importSong.ts @@ -6,6 +6,7 @@ import { IpcImportSongRequest, IpcImportSongResponse, IpcSelectImportSongResponse, + Song, SongData, StorageSchema, } from '../../types'; @@ -19,7 +20,7 @@ import { writeSongIdFile, } from '../util'; -function validateSongDir(dir: string): SongData { +export function validateSongDir(dir: string): SongData { const song = buildSongFromDir(dir); if (!song) { @@ -39,6 +40,30 @@ function validateSongDir(dir: string): SongData { return song; } +export async function previewPreparedSong( + sourceDir: string, + options: Pick = {}, +): Promise { + const stored = validateSongDir(sourceDir); + const song = toSong(stored); + const cover = await previewSongCover(sourceDir); + + return { + sourceDir, + name: song.name, + artist: song.artist, + album: song.album, + charter: song.charter, + autoChartTool: song.autoChartTool, + chartFormat: song.format, + audioCount: song.audio.length, + drumDifficulties: song.drumDifficulties ?? [], + albumCoverDataUrl: cover.dataUrl, + thumbnailUrl: options.thumbnailUrl, + coverSource: cover.source, + }; +} + function destinationName(song: SongData, sourceDir: string): string { const sourceName = path.basename(sourceDir); const base = @@ -93,23 +118,8 @@ export async function selectImportSong(event: IpcMainEvent): Promise { } const sourceDir = result.filePaths[0]; - const stored = validateSongDir(sourceDir); - const song = toSong(stored); - const cover = await previewSongCover(sourceDir); const response: IpcSelectImportSongResponse = { - preview: { - sourceDir, - name: song.name, - artist: song.artist, - album: song.album, - charter: song.charter, - autoChartTool: song.autoChartTool, - chartFormat: song.format, - audioCount: song.audio.length, - drumDifficulties: song.drumDifficulties ?? [], - albumCoverDataUrl: cover.dataUrl, - coverSource: cover.source, - }, + preview: await previewPreparedSong(sourceDir), }; event.reply('select-import-song', response); @@ -120,10 +130,10 @@ export async function selectImportSong(event: IpcMainEvent): Promise { } } -export async function importSong( - event: IpcMainEvent, - { sourceDir, artworkUrl }: IpcImportSongRequest, -): Promise { +export async function importPreparedSong({ + sourceDir, + artworkUrl, +}: IpcImportSongRequest): Promise { let outputDir: string | undefined; let outputCreated = false; @@ -177,20 +187,31 @@ export async function importSong( appState.store.set('songs', { ...songs, [id]: songData }); - const response: IpcImportSongResponse = { - success: true, - song: toSong({ - ...songData, - updatedAt: fs.statSync(outputDir).mtime.toISOString(), - }), - }; - - event.reply('import-song', response); + return toSong({ + ...songData, + updatedAt: fs.statSync(outputDir).mtime.toISOString(), + }); } catch (error) { if (outputCreated && outputDir && fs.existsSync(outputDir)) { fs.rmSync(outputDir, { recursive: true, force: true }); } + throw error; + } +} + +export async function importSong( + event: IpcMainEvent, + request: IpcImportSongRequest, +): Promise { + try { + const song = await importPreparedSong(request); + + event.reply('import-song', { + success: true, + song, + } satisfies IpcImportSongResponse); + } catch (error) { event.reply('import-song', { success: false, error: error instanceof Error ? error.message : String(error), diff --git a/src/preload/index.ts b/src/preload/index.ts index 99a52ab0..91c43c63 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,12 @@ export type Channels = | 'download-song' | 'select-import-song' | 'import-song' + | 'create-auto-chart' + | 'auto-chart-update' + | 'cancel-auto-chart' + | 'retry-auto-chart' + | 'discard-auto-chart-preview' + | 'import-auto-chart' | 'check-stem-tools' | 'check-stem-tools-update' | 'download-stem-tools' diff --git a/src/renderer/components/AutoChart/AutoChart.test.tsx b/src/renderer/components/AutoChart/AutoChart.test.tsx new file mode 100644 index 00000000..d520025d --- /dev/null +++ b/src/renderer/components/AutoChart/AutoChart.test.tsx @@ -0,0 +1,126 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { App as AntdApp } from 'antd'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IpcAutoChartJob, Song } from '../../../types'; +import { installIpcMock, IpcMock } from '../../hooks/test-support'; +import { AutoChart } from './AutoChart'; + +let ipc: IpcMock; + +function renderAutoChart(onImported = vi.fn()) { + render( + + + , + ); + + return onImported; +} + +function emit(job: IpcAutoChartJob) { + act(() => { + ipc.emit('auto-chart-update', job); + }); +} + +const preview = { + sourceDir: '/tmp/prepared-song', + name: 'Official title', + artist: 'Official channel', + album: '', + charter: '', + autoChartTool: 'STRUM (OCTAVE AI auto-charter)', + chartFormat: 'mid' as const, + audioCount: 2, + drumDifficulties: ['expert'] as never[], + thumbnailUrl: 'https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg', + coverSource: 'none' as const, +}; +const importedSong: Song = { + id: 'song-1', + dir: '/library/Official title', + name: 'Official title', + artist: 'Official channel', + album: '', + charter: '', + genre: '', + year: '', + fiveLaneDrums: false, + proDrums: false, + delaySeconds: 0, + drumDifficulty: 0, + format: 'mid', + audio: [], +}; + +describe('AutoChart', () => { + beforeEach(() => { + ipc = installIpcMock(); + }); + + it('moves from optional YouTube discovery through local processing to review and import confirmation', () => { + const onImported = renderAutoChart(); + + fireEvent.click(screen.getByTestId('create-chart-trigger')); + expect( + screen.getByText( + 'YouTube is discovery only. SightKick never downloads audiovisual media from it.', + ), + ).toBeInTheDocument(); + fireEvent.change(screen.getByTestId('auto-chart-youtube-url'), { + target: { value: 'https://youtu.be/abcdefghijk' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Choose local audio' })); + + expect(ipc.sent).toContainEqual({ + channel: 'create-auto-chart', + args: [{ youtubeUrl: 'https://youtu.be/abcdefghijk' }], + }); + + emit({ + id: 'job-1', + attempt: 1, + stage: 'processing', + message: 'OCTAVE is preparing a drum chart locally', + sourceName: 'owned-track.mp3', + percent: 42, + }); + expect(screen.getByTestId('auto-chart-progress')).toHaveTextContent( + 'owned-track.mp3', + ); + expect(screen.getByTestId('auto-chart-progress')).toHaveTextContent('42%'); + + emit({ + id: 'job-1', + attempt: 1, + stage: 'preview-ready', + message: 'Chart is ready to review before adding it to your library', + metadata: { + title: 'Official title', + authorName: 'Official channel', + thumbnailUrl: preview.thumbnailUrl, + }, + preview, + }); + expect(screen.getByText('Review generated drum chart')).toBeInTheDocument(); + expect(screen.getByText('Official title')).toBeInTheDocument(); + expect(screen.getByText('Auto-charted with STRUM')).toBeInTheDocument(); + expect(screen.queryByTestId('import-artwork-url')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to library' })); + expect(ipc.sent).toContainEqual({ + channel: 'import-auto-chart', + args: ['job-1'], + }); + expect(onImported).not.toHaveBeenCalled(); + + emit({ + id: 'job-1', + attempt: 1, + stage: 'imported', + message: 'Added "Official title" to the current library', + song: importedSong, + }); + expect(onImported).toHaveBeenCalledWith(importedSong); + }); +}); diff --git a/src/renderer/components/AutoChart/AutoChart.tsx b/src/renderer/components/AutoChart/AutoChart.tsx new file mode 100644 index 00000000..70eb8a29 --- /dev/null +++ b/src/renderer/components/AutoChart/AutoChart.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { App, Button, Input, Modal, Progress, Tag, Tooltip } from 'antd'; +import { IpcAutoChartJob, Song } from '../../../types'; +import { SongImportReview } from '../SongImport/SongImport'; + +interface Props { + disabled: boolean; + onImported: (song: Song) => void; +} + +function progressStatus( + stage: IpcAutoChartJob['stage'], +): 'active' | 'exception' | 'success' { + if (stage === 'failed' || stage === 'cancelled') { + return 'exception'; + } + + return stage === 'imported' ? 'success' : 'active'; +} + +export function AutoChart({ disabled, onImported }: Props) { + const { notification } = App.useApp(); + const [createOpen, setCreateOpen] = useState(false); + const [youtubeUrl, setYoutubeUrl] = useState(''); + const [job, setJob] = useState(); + const [artworkUrl, setArtworkUrl] = useState(''); + const active = Boolean( + job && !['imported', 'failed', 'cancelled'].includes(job.stage), + ); + + useEffect(() => { + return window.electron.ipcRenderer.on( + 'auto-chart-update', + (nextJob) => { + setJob(nextJob); + + if (nextJob.stage === 'preview-ready') { + setArtworkUrl(''); + } + + if (nextJob.stage === 'imported') { + if (nextJob.song) { + onImported(nextJob.song); + } + + notification.success({ + title: nextJob.message, + placement: 'bottomRight', + }); + } + }, + ); + }, [notification, onImported]); + + const createChart = () => { + setCreateOpen(false); + window.electron.ipcRenderer.sendMessage('create-auto-chart', { + ...(youtubeUrl.trim() ? { youtubeUrl: youtubeUrl.trim() } : {}), + }); + }; + const dismiss = () => { + if (job?.stage === 'preview-ready') { + window.electron.ipcRenderer.sendMessage( + 'discard-auto-chart-preview', + job.id, + ); + } + + setJob(undefined); + setArtworkUrl(''); + }; + + return ( + <> + + + + + setCreateOpen(false)} + > +
+ setYoutubeUrl(event.target.value)} + placeholder="YouTube video URL for official metadata and thumbnail (optional)" + /> +
+ YouTube is discovery only. SightKick never downloads audiovisual + media from it. +
+
+ You will choose a local audio file you own or are allowed to + process. OCTAVE runs on this Mac, and the result stays out of your + library until you review and add it. +
+
+
+ + {job && job.stage !== 'preview-ready' && job.stage !== 'imported' && ( +
+
+
Create chart
+ + {job.stage} + +
+
+ {job.error ?? job.message} +
+ {job.sourceName && ( +
{job.sourceName}
+ )} + {typeof job.percent === 'number' && ( + + )} +
+ {['failed', 'cancelled'].includes(job.stage) && job.sourceName && ( + + )} + {['failed', 'cancelled'].includes(job.stage) && ( + + )} + {!['failed', 'cancelled', 'importing'].includes(job.stage) && ( + + )} +
+
+ )} + + { + if (job) { + window.electron.ipcRenderer.sendMessage( + 'import-auto-chart', + job.id, + ); + } + }} + onCancel={dismiss} + /> + + ); +} diff --git a/src/renderer/components/AutoChart/index.ts b/src/renderer/components/AutoChart/index.ts new file mode 100644 index 00000000..3d3a6d3d --- /dev/null +++ b/src/renderer/components/AutoChart/index.ts @@ -0,0 +1 @@ +export * from './AutoChart'; diff --git a/src/renderer/components/SongImport/SongImport.tsx b/src/renderer/components/SongImport/SongImport.tsx index 60dc9206..18c6b0ad 100644 --- a/src/renderer/components/SongImport/SongImport.tsx +++ b/src/renderer/components/SongImport/SongImport.tsx @@ -15,11 +15,29 @@ interface Props { onImported: (song: Song) => void; } +interface SongImportReviewProps { + preview?: IpcImportSongPreview; + importing: boolean; + artworkUrl: string; + title?: string; + allowArtworkUrl?: boolean; + onArtworkUrlChange: (value: string) => void; + onConfirm: () => void; + onCancel: () => void; +} + function autoChartToolName(value?: string): string | undefined { return value?.split('(')[0].trim() || undefined; } -function coverMessage(preview: IpcImportSongPreview): string { +function coverMessage( + preview: IpcImportSongPreview, + allowArtworkUrl: boolean, +): string { + if (preview.thumbnailUrl) { + return 'The official YouTube thumbnail will be cached with this chart.'; + } + switch (preview.coverSource) { case 'existing': return 'Existing album artwork will be preserved.'; @@ -28,10 +46,95 @@ function coverMessage(preview: IpcImportSongPreview): string { return 'Embedded artwork found. It will be cached as album.jpg.'; default: - return 'No local cover found. Add an allowed image URL below if you have one.'; + return allowArtworkUrl + ? 'No local cover found. Add an allowed image URL below if you have one.' + : 'No cover found. Start again with a YouTube URL to fetch its official thumbnail.'; } } +export function SongImportReview({ + preview, + importing, + artworkUrl, + title = 'Review song import', + allowArtworkUrl = true, + onArtworkUrlChange, + onConfirm, + onCancel, +}: SongImportReviewProps) { + const toolName = autoChartToolName(preview?.autoChartTool); + + return ( + + {preview && ( +
+
+ { +
+
+ {preview.name} +
+
{preview.artist}
+ {preview.album && ( +
{preview.album}
+ )} + {preview.charter && ( +
+ Chart by {preview.charter} +
+ )} + {toolName && ( + Auto-charted with {toolName} + )} +
+
+ +
+ {preview.chartFormat.toUpperCase()} chart · {preview.audioCount}{' '} + audio file{preview.audioCount === 1 ? '' : 's'} ·{' '} + {preview.drumDifficulties.join(', ')} +
+ +
+ {coverMessage(preview, allowArtworkUrl)} +
+ + {allowArtworkUrl && ( + onArtworkUrlChange(event.target.value)} + placeholder="Allowed direct cover image URL (optional fallback)" + /> + )} + +
+ {allowArtworkUrl + ? 'Only use artwork you own or are allowed to cache. This import accepts prepared local charts and does not bypass streaming, paywall or DRM restrictions.' + : 'The optional YouTube thumbnail came from official oEmbed metadata. It is never used as audio or video input.'} +
+
+ )} +
+ ); +} + export function SongImport({ disabled, onImported }: Props) { const { notification } = App.useApp(); const [selecting, setSelecting] = useState(false); @@ -108,7 +211,6 @@ export function SongImport({ disabled, onImported }: Props) { ...(artworkUrl.trim() ? { artworkUrl: artworkUrl.trim() } : {}), }); }; - const toolName = autoChartToolName(preview?.autoChartTool); return ( <> @@ -131,72 +233,19 @@ export function SongImport({ disabled, onImported }: Props) { - { if (!importing) { setPreview(undefined); setArtworkUrl(''); } }} - > - {preview && ( -
-
- {preview.albumCoverDataUrl -
-
- {preview.name} -
-
{preview.artist}
- {preview.album && ( -
{preview.album}
- )} - {preview.charter && ( -
- Chart by {preview.charter} -
- )} - {toolName && ( - Auto-charted with {toolName} - )} -
-
- -
- {preview.chartFormat.toUpperCase()} chart · {preview.audioCount}{' '} - audio file{preview.audioCount === 1 ? '' : 's'} ·{' '} - {preview.drumDifficulties.join(', ')} -
- -
- {coverMessage(preview)} -
- - setArtworkUrl(event.target.value)} - placeholder="Allowed direct cover image URL (optional fallback)" - /> - -
- Only use artwork you own or are allowed to cache. This import - accepts prepared local charts and does not bypass streaming, - paywall or DRM restrictions. -
-
- )} -
+ /> ); } diff --git a/src/renderer/views/SongListView/SongListView.tsx b/src/renderer/views/SongListView/SongListView.tsx index 9da8a525..c93b08f6 100644 --- a/src/renderer/views/SongListView/SongListView.tsx +++ b/src/renderer/views/SongListView/SongListView.tsx @@ -8,6 +8,7 @@ import { SettingsButton } from '../../components/SettingsButton'; import { SortButton } from '../../components/SortButton'; import { SplittingQueue } from '../../components/SplittingQueue'; import { EmptySongState } from '../../components/EmptySongState'; +import { AutoChart } from '../../components/AutoChart'; import { SongImport } from '../../components/SongImport'; import { useApp } from '../../context/AppContext'; import { useInput } from '../../context/InputContext'; @@ -208,6 +209,10 @@ export function SongListView() { disabled={currentPath === null} onImported={handleSongImported} /> +