diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts index 74856d13..4bffc084 100644 --- a/e2e/smoke.e2e.ts +++ b/e2e/smoke.e2e.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { existsSync } from 'fs'; import { test, expect, Page } from '@playwright/test'; import { launchApp, Harness } from './support'; import { toAssetUrl } from '../src/main/util'; @@ -29,6 +30,55 @@ test.describe('first run', () => { }); test.describe('seeded library', () => { + test('previews and imports a prepared local auto-chart', async () => { + harness = await launchApp({ seedLibrary: true }); + await harness.app.evaluate(({ dialog }, importDir) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [importDir], + }); + }, harness.importDir); + page = await harness.app.firstWindow(); + + await page.getByRole('button', { name: 'Import song' }).click(); + await expect(page.getByText('Review song import')).toBeVisible(); + await expect(page.getByText('Auto-charted with STRUM')).toBeVisible(); + await expect( + page.getByText('Existing album artwork will be preserved.'), + ).toBeVisible(); + + if (process.env.SIGHTKICK_IMPORT_PREVIEW_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_IMPORT_PREVIEW_PROOF, + }); + } + + await page.getByRole('button', { name: 'Add to library' }).click(); + + const row = page.getByTestId(/song-item-/).filter({ hasText: 'Raging' }); + + await expect(row).toBeVisible(); + await expect(row.getByText('Auto-charted with STRUM')).toBeVisible(); + await expect(row.getByText('play once to earn stars')).toBeVisible(); + + await page.getByTestId('song-search').fill('STRUM'); + await expect(row).toBeVisible(); + + const importedDir = path.join( + harness.libraryDir, + 'Kygo feat. Kodaline - Raging', + ); + + expect(existsSync(path.join(importedDir, 'album.png'))).toBe(true); + expect(existsSync(path.join(importedDir, '.sightkick'))).toBe(true); + + if (process.env.SIGHTKICK_IMPORT_AFTER_PROOF) { + await page.screenshot({ + path: process.env.SIGHTKICK_IMPORT_AFTER_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/e2e/support.ts b/e2e/support.ts index 41aa044c..926efd56 100644 --- a/e2e/support.ts +++ b/e2e/support.ts @@ -7,6 +7,7 @@ const MAIN_ENTRY = path.join(__dirname, '..', 'out', 'main', 'index.js'); export interface Harness { app: ElectronApplication; + importDir: string; libraryDir: string; } @@ -14,6 +15,30 @@ const ALBUM_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64', ); +const EXPERT_DRUM_CHART = [ + '[Song]', + '{', + ' Resolution = 480', + '}', + '[SyncTrack]', + '{', + ' 0 = TS 4', + ' 0 = B 120000', + '}', + '[ExpertDrums]', + '{', + ' 0 = N 0 0', + ' 480 = N 1 0', + ' 960 = N 0 0', + ' 1440 = N 1 0', + ' 1920 = N 0 0', + ' 2400 = N 2 0', + ' 2400 = N 66 0', + ' 2880 = N 0 0', + ' 3360 = N 1 0', + '}', + '', +].join('\n'); function writeFixtureLibrary(): string { const libraryDir = mkdtempSync(path.join(tmpdir(), 'sightkick-library-')); @@ -37,35 +62,37 @@ function writeFixtureLibrary(): string { ].join('\n'), ); + writeFileSync(path.join(songDir, 'notes.chart'), EXPERT_DRUM_CHART); + + return libraryDir; +} + +function writeImportFixture(): string { + const root = mkdtempSync(path.join(tmpdir(), 'sightkick-import-')); + const songDir = path.join(root, 'prepared-song'); + + mkdirSync(songDir); + writeFileSync(path.join(songDir, 'album.png'), ALBUM_PNG); + writeFileSync(path.join(songDir, 'notes.chart'), EXPERT_DRUM_CHART); + writeFileSync(path.join(songDir, 'song.mp3'), 'test audio'); writeFileSync( - path.join(songDir, 'notes.chart'), + path.join(songDir, 'song.ini'), [ - '[Song]', - '{', - ' Resolution = 480', - '}', - '[SyncTrack]', - '{', - ' 0 = TS 4', - ' 0 = B 120000', - '}', - '[ExpertDrums]', - '{', - ' 0 = N 0 0', - ' 480 = N 1 0', - ' 960 = N 0 0', - ' 1440 = N 1 0', - ' 1920 = N 0 0', - ' 2400 = N 2 0', - ' 2400 = N 66 0', - ' 2880 = N 0 0', - ' 3360 = N 1 0', - '}', + '[song]', + 'name = Raging', + 'artist = Kygo feat. Kodaline', + 'album = Cloud Nine', + 'auto_chart = True', + 'auto_chart_tool = STRUM (OCTAVE AI auto-charter)', + 'charter = STRUM', + 'pro_drums = True', + 'five_lane_drums = False', + 'diff_drums = 2', '', ].join('\n'), ); - return libraryDir; + return songDir; } function seedUserData(seed: Record): string { @@ -83,6 +110,7 @@ export async function launchApp( options: { seedLibrary?: boolean } = {}, ): Promise { const libraryDir = writeFixtureLibrary(); + const importDir = writeImportFixture(); const userDataDir = seedUserData( options.seedLibrary ? { lastOpenedPath: libraryDir } : {}, ); @@ -95,5 +123,5 @@ export async function launchApp( }, }); - return { app, libraryDir }; + return { app, importDir, libraryDir }; } diff --git a/package.json b/package.json index 9c3b8010..f48fa2a9 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "es-toolkit": "^1.47.1", "fuse.js": "^7.0.0", "ini": "^4.1.1", + "music-metadata": "^11.14.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.18.1", diff --git a/src/main/AppState.ts b/src/main/AppState.ts index 0adcf5f0..c66e02b7 100644 --- a/src/main/AppState.ts +++ b/src/main/AppState.ts @@ -24,6 +24,7 @@ import { listenMidi, loadMidiDeviceList, stopListenMidi } from './ipc/midi'; import { updateSong } from './ipc/updateSong'; import { rescanSongs } from './ipc/rescanSongs'; import { exportPdf } from './ipc/exportPdf'; +import { importSong, selectImportSong } from './ipc/importSong'; class AppState { private static instance: AppState; @@ -100,6 +101,8 @@ class AppState { ipcMain.on('load-song-list', loadSongList); ipcMain.on('rescan-songs', rescanSongs); ipcMain.on('download-song', downloadSong); + ipcMain.on('select-import-song', selectImportSong); + ipcMain.on('import-song', importSong); ipcMain.on('check-stem-tools', checkStemTools); ipcMain.on('check-stem-tools-update', checkStemToolsUpdate); diff --git a/src/main/ipc/importSong.test.ts b/src/main/ipc/importSong.test.ts new file mode 100644 index 00000000..3e2fa2c7 --- /dev/null +++ b/src/main/ipc/importSong.test.ts @@ -0,0 +1,200 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeStore, lastReply, makeEvent, makeStore } from './test-support'; + +const storeHolder = vi.hoisted(() => ({ + current: undefined as FakeStore | undefined, +})); +const dialogHolder = vi.hoisted(() => ({ + sourceDir: '', + canceled: false, +})); +const coverHolder = vi.hoisted(() => ({ + preview: { + dataUrl: 'data:image/jpeg;base64,cHJldmlldw==', + source: 'embedded' as const, + }, +})); + +vi.mock('../AppState', () => ({ + appState: { + store: { + get: (key: string) => storeHolder.current!.get(key), + set: (key: string, value: unknown) => + storeHolder.current!.set(key, value), + }, + }, +})); + +vi.mock('electron', () => ({ + dialog: { + showOpenDialog: vi.fn(async () => ({ + canceled: dialogHolder.canceled, + filePaths: dialogHolder.canceled ? [] : [dialogHolder.sourceDir], + })), + }, +})); + +vi.mock('../songCover', () => ({ + previewSongCover: vi.fn(async () => coverHolder.preview), + ingestSongCover: vi.fn(async () => coverHolder.preview.source), +})); + +const { importSong, selectImportSong } = await import('./importSong'); +const CHART = `[Song] +{ + Resolution = 192 +} +[SyncTrack] +{ + 0 = TS 4 + 0 = B 120000 +} +[ExpertDrums] +{ + 0 = N 0 0 +} +`; + +describe('local song import', () => { + let root: string; + let library: string; + let sourceDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'import-song-')); + library = path.join(root, 'library'); + sourceDir = path.join(root, 'source'); + fs.mkdirSync(library); + fs.mkdirSync(sourceDir); + fs.writeFileSync( + path.join(sourceDir, 'song.ini'), + [ + '[song]', + 'name = Raging', + 'artist = Kygo feat. Kodaline', + 'album = Cloud Nine', + 'auto_chart = True', + 'auto_chart_tool = STRUM (OCTAVE AI auto-charter)', + 'charter = STRUM', + 'diff_drums = 2', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(sourceDir, 'notes.chart'), CHART); + fs.writeFileSync(path.join(sourceDir, 'song.mp3'), 'audio'); + storeHolder.current = makeStore({ lastOpenedPath: library, songs: {} }); + dialogHolder.sourceDir = sourceDir; + dialogHolder.canceled = false; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('validates a selected chart folder and returns a metadata preview', async () => { + const event = makeEvent(); + + await selectImportSong(event as never); + + expect(lastReply(event, 'select-import-song')!.args[0]).toMatchObject({ + preview: { + sourceDir, + name: 'Raging', + artist: 'Kygo feat. Kodaline', + album: 'Cloud Nine', + charter: '', + autoChartTool: 'STRUM (OCTAVE AI auto-charter)', + chartFormat: 'chart', + audioCount: 1, + coverSource: 'embedded', + }, + }); + }); + + it('copies the confirmed folder, ingests its cover and persists the song', async () => { + const event = makeEvent(); + + await importSong(event as never, { + sourceDir, + artworkUrl: 'https://example.com/permitted-cover.jpg', + }); + + const reply = lastReply(event, 'import-song')!.args[0] as { + success: boolean; + song: { id: string; dir: string; charter: string }; + }; + + expect(reply.success).toBe(true); + expect(reply.song.charter).toBe(''); + expect(reply.song.dir.startsWith(library)).toBe(true); + expect(fs.existsSync(path.join(reply.song.dir, 'song.ini'))).toBe(true); + expect(fs.existsSync(path.join(reply.song.dir, '.sightkick'))).toBe(true); + expect( + fs.readFileSync(path.join(reply.song.dir, 'song.ini'), 'utf-8'), + ).toMatch(/^charter\s*=\s*$/m); + expect(storeHolder.current.get(`songs.${reply.song.id}`)).toMatchObject({ + charter: '', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + }); + }); + + it('rejects a folder that has no playable audio', async () => { + fs.rmSync(path.join(sourceDir, 'song.mp3')); + + const event = makeEvent(); + + await selectImportSong(event as never); + + expect(lastReply(event, 'select-import-song')!.args[0]).toMatchObject({ + error: 'This folder has no playable audio file', + }); + }); + + it('never deletes an existing library folder when the name collides', async () => { + const existing = path.join(library, 'Kygo feat. Kodaline - Raging'); + const sentinel = path.join(existing, 'keep.txt'); + + fs.mkdirSync(existing); + fs.writeFileSync(sentinel, 'manual library data'); + + const event = makeEvent(); + + await importSong(event as never, { sourceDir }); + + expect(lastReply(event, 'import-song')!.args[0]).toMatchObject({ + success: false, + error: + 'A library folder named "Kygo feat. Kodaline - Raging" already exists', + }); + expect(fs.readFileSync(sentinel, 'utf-8')).toBe('manual library data'); + }); + + it('rejects a source folder that contains the selected library', async () => { + sourceDir = root; + fs.renameSync( + path.join(root, 'source', 'song.ini'), + path.join(root, 'song.ini'), + ); + fs.renameSync( + path.join(root, 'source', 'notes.chart'), + path.join(root, 'notes.chart'), + ); + fs.renameSync( + path.join(root, 'source', 'song.mp3'), + path.join(root, 'song.mp3'), + ); + + const event = makeEvent(); + + await importSong(event as never, { sourceDir }); + + expect(lastReply(event, 'import-song')!.args[0]).toMatchObject({ + success: false, + error: 'The selected song folder cannot contain the library', + }); + expect(fs.existsSync(library)).toBe(true); + }); +}); diff --git a/src/main/ipc/importSong.ts b/src/main/ipc/importSong.ts new file mode 100644 index 00000000..99d08091 --- /dev/null +++ b/src/main/ipc/importSong.ts @@ -0,0 +1,199 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { dialog, IpcMainEvent } from 'electron'; +import { + IpcImportSongRequest, + IpcImportSongResponse, + IpcSelectImportSongResponse, + SongData, + StorageSchema, +} from '../../types'; +import { appState } from '../AppState'; +import { ingestSongCover, previewSongCover } from '../songCover'; +import { + buildSongFromDir, + hasDuplicatedAutoCharter, + isUnderDirectory, + toSong, + writeSongIdFile, +} from '../util'; + +function validateSongDir(dir: string): SongData { + const song = buildSongFromDir(dir); + + if (!song) { + throw new Error( + 'Choose a folder with song.ini and notes.mid or notes.chart', + ); + } + + if (song.audio.length === 0) { + throw new Error('This folder has no playable audio file'); + } + + if (!song.drumDifficulties?.length) { + throw new Error('This chart has no playable drum difficulty'); + } + + return song; +} + +function destinationName(song: SongData, sourceDir: string): string { + const sourceName = path.basename(sourceDir); + const base = + [song.artist, song.name].filter(Boolean).join(' - ') || sourceName; + const safe = base.replace(/[\\/:*?"<>|]/g, '').trim(); + + return safe.slice(0, 180) || 'Imported song'; +} + +function copySongDirectory(sourceDir: string, destinationDir: string): void { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const source = path.join(sourceDir, entry.name); + const destination = path.join(destinationDir, entry.name); + + if (entry.isSymbolicLink()) { + throw new Error('Song folders with symbolic links are not supported'); + } + + if (entry.isDirectory()) { + fs.mkdirSync(destination); + copySongDirectory(source, destination); + } else if (entry.isFile()) { + fs.copyFileSync(source, destination); + } + } +} + +function normalizeImportedProvenance(dir: string, song: SongData): void { + if (!hasDuplicatedAutoCharter(song)) { + return; + } + + const iniPath = path.join(dir, 'song.ini'); + const original = fs.readFileSync(iniPath, 'utf-8'); + const normalized = original.replace(/^(\s*charter\s*=\s*).*$/im, '$1'); + + fs.writeFileSync(iniPath, normalized); +} + +export async function selectImportSong(event: IpcMainEvent): Promise { + try { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Choose a prepared Clone Hero song folder', + message: 'Choose a folder containing song.ini, a chart and audio', + }); + + if (result.canceled || !result.filePaths[0]) { + event.reply('select-import-song', { cancelled: true }); + + return; + } + + 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, + }, + }; + + event.reply('select-import-song', response); + } catch (error) { + event.reply('select-import-song', { + error: error instanceof Error ? error.message : String(error), + } satisfies IpcSelectImportSongResponse); + } +} + +export async function importSong( + event: IpcMainEvent, + { sourceDir, artworkUrl }: IpcImportSongRequest, +): Promise { + let outputDir: string | undefined; + let outputCreated = false; + + try { + const libraryRoot = appState.store.get('lastOpenedPath') as + | string + | undefined; + + if (!libraryRoot) { + throw new Error('Select a library folder before importing'); + } + + if (isUnderDirectory(sourceDir, libraryRoot)) { + throw new Error('This song is already inside the selected library'); + } + + if (isUnderDirectory(libraryRoot, sourceDir)) { + throw new Error('The selected song folder cannot contain the library'); + } + + const sourceSong = validateSongDir(sourceDir); + const folderName = destinationName(sourceSong, sourceDir); + + outputDir = path.join(libraryRoot, folderName); + + if (!isUnderDirectory(outputDir, libraryRoot)) { + throw new Error('Invalid import destination'); + } + + if (fs.existsSync(outputDir)) { + throw new Error(`A library folder named "${folderName}" already exists`); + } + + fs.mkdirSync(outputDir); + outputCreated = true; + copySongDirectory(sourceDir, outputDir); + normalizeImportedProvenance(outputDir, sourceSong); + await ingestSongCover(outputDir, artworkUrl); + + const id = randomUUID(); + + writeSongIdFile(outputDir, id); + + const songData = buildSongFromDir(outputDir, { id }); + + if (!songData) { + throw new Error('Imported files could not be read as a song'); + } + + const songs = (appState.store.get('songs') as StorageSchema['songs']) ?? {}; + + 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); + } catch (error) { + if (outputCreated && outputDir && fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + + event.reply('import-song', { + success: false, + error: error instanceof Error ? error.message : String(error), + } satisfies IpcImportSongResponse); + } +} diff --git a/src/main/songCover.test.ts b/src/main/songCover.test.ts new file mode 100644 index 00000000..006ef9d1 --- /dev/null +++ b/src/main/songCover.test.ts @@ -0,0 +1,93 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const metadataHolder = vi.hoisted(() => ({ + picture: undefined as number[] | undefined, +})); + +vi.mock('music-metadata', () => ({ + parseFile: vi.fn(async () => ({ + common: { + picture: metadataHolder.picture + ? [{ data: Uint8Array.from(metadataHolder.picture) }] + : [], + }, + })), +})); + +vi.mock('electron', () => ({ + nativeImage: { + createFromBuffer: vi.fn(() => ({ + isEmpty: () => false, + toJPEG: () => Buffer.from('jpeg'), + toDataURL: () => 'data:image/jpeg;base64,cHJldmlldw==', + })), + }, +})); + +const { ingestSongCover, previewSongCover } = await import('./songCover'); + +describe('song cover ingestion', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')); + metadataHolder.picture = undefined; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('preserves an existing manual cover', async () => { + fs.writeFileSync(path.join(dir, 'album.png'), 'manual'); + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + const result = await ingestSongCover(dir, 'https://example.com/remote.jpg'); + + expect(result).toBe('existing'); + expect(fs.readFileSync(path.join(dir, 'album.png'), 'utf-8')).toBe( + 'manual', + ); + expect(fs.existsSync(path.join(dir, 'album.jpg'))).toBe(false); + }); + + it('normalizes embedded artwork to album.jpg', async () => { + metadataHolder.picture = [1, 2, 3]; + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + expect(await ingestSongCover(dir)).toBe('embedded'); + expect(fs.readFileSync(path.join(dir, 'album.jpg'), 'utf-8')).toBe('jpeg'); + }); + + it('uses an explicit remote image only when local artwork is absent', async () => { + fs.writeFileSync(path.join(dir, 'song.ogg'), ''); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'image/jpeg' }), + arrayBuffer: async () => Uint8Array.from([4, 5, 6]).buffer, + })), + ); + + expect( + await ingestSongCover(dir, 'https://example.com/permitted-cover.jpg'), + ).toBe('remote'); + expect(fs.readFileSync(path.join(dir, 'album.jpg'), 'utf-8')).toBe('jpeg'); + }); + + it('previews embedded artwork without writing into the source folder', async () => { + metadataHolder.picture = [1, 2, 3]; + fs.writeFileSync(path.join(dir, 'song.mp3'), ''); + + expect(await previewSongCover(dir)).toEqual({ + dataUrl: 'data:image/jpeg;base64,cHJldmlldw==', + source: 'embedded', + }); + expect(fs.existsSync(path.join(dir, 'album.jpg'))).toBe(false); + }); +}); diff --git a/src/main/songCover.ts b/src/main/songCover.ts new file mode 100644 index 00000000..35ed7c7a --- /dev/null +++ b/src/main/songCover.ts @@ -0,0 +1,144 @@ +import fs from 'fs'; +import path from 'path'; +import { nativeImage } from 'electron'; +import { parseFile } from 'music-metadata'; + +export type SongCoverSource = 'existing' | 'embedded' | 'remote' | 'none'; + +const COVER_EXTENSIONS = ['png', 'jpg', 'jpeg']; +const AUDIO_EXTENSIONS = new Set(['.mp3', '.ogg', '.opus']); +const MAX_REMOTE_IMAGE_BYTES = 10_000_000; + +function existingCoverPath(dir: string): string | undefined { + return COVER_EXTENSIONS.map((extension) => + path.join(dir, `album.${extension}`), + ).find((file) => fs.existsSync(file)); +} + +function imageDataUrl(data: Uint8Array): string | undefined { + const image = nativeImage.createFromBuffer(Buffer.from(data)); + + return image.isEmpty() ? undefined : image.toDataURL(); +} + +function jpegData(data: Uint8Array): Buffer { + const image = nativeImage.createFromBuffer(Buffer.from(data)); + + if (image.isEmpty()) { + throw new Error('Artwork is not a supported image'); + } + + return image.toJPEG(90); +} + +function audioFiles(dir: string): string[] { + return fs + .readdirSync(dir) + .filter((file) => AUDIO_EXTENSIONS.has(path.extname(file).toLowerCase())) + .sort((a, b) => { + const aSong = path.parse(a).name === 'song' ? 0 : 1; + const bSong = path.parse(b).name === 'song' ? 0 : 1; + + return aSong - bSong || a.localeCompare(b); + }) + .map((file) => path.join(dir, file)); +} + +async function embeddedArtwork(dir: string): Promise { + for (const file of audioFiles(dir)) { + try { + const metadata = await parseFile(file, { duration: false }); + const picture = metadata.common.picture?.[0]; + + if (picture?.data?.length) { + return picture.data; + } + } catch { + continue; + } + } + + return undefined; +} + +async function remoteArtwork(url: string): Promise { + const parsed = new URL(url); + + if (parsed.protocol !== 'https:') { + throw new Error('Artwork URL must use HTTPS'); + } + + const response = await fetch(parsed); + + if (!response.ok) { + throw new Error(`Artwork download failed: ${response.status}`); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.toLowerCase().startsWith('image/')) { + throw new Error('Artwork URL did not return an image'); + } + + const contentLength = Number(response.headers.get('content-length') ?? 0); + + if (contentLength > MAX_REMOTE_IMAGE_BYTES) { + throw new Error('Artwork image is larger than 10 MB'); + } + + const data = new Uint8Array(await response.arrayBuffer()); + + if (data.byteLength > MAX_REMOTE_IMAGE_BYTES) { + throw new Error('Artwork image is larger than 10 MB'); + } + + return data; +} + +export async function previewSongCover( + dir: string, +): Promise<{ dataUrl?: string; source: SongCoverSource }> { + const existing = existingCoverPath(dir); + + if (existing) { + return { + dataUrl: imageDataUrl(fs.readFileSync(existing)), + source: 'existing', + }; + } + + const embedded = await embeddedArtwork(dir); + + if (embedded) { + return { dataUrl: imageDataUrl(embedded), source: 'embedded' }; + } + + return { source: 'none' }; +} + +export async function ingestSongCover( + dir: string, + artworkUrl?: string, +): Promise { + if (existingCoverPath(dir)) { + return 'existing'; + } + + const embedded = await embeddedArtwork(dir); + + if (embedded) { + fs.writeFileSync(path.join(dir, 'album.jpg'), jpegData(embedded)); + + return 'embedded'; + } + + if (artworkUrl?.trim()) { + const remote = await remoteArtwork(artworkUrl.trim()); + + fs.writeFileSync(path.join(dir, 'album.jpg'), jpegData(remote)); + + return 'remote'; + } + + return 'none'; +} diff --git a/src/main/util.test.ts b/src/main/util.test.ts index 6d96cc64..ffe4f03d 100644 --- a/src/main/util.test.ts +++ b/src/main/util.test.ts @@ -277,6 +277,45 @@ describe('toSong', () => { expect(song.drumDifficulties).toEqual(['hard', 'expert']); expect(song.scoreData).toEqual(scoreData); }); + + it('keeps auto-chart provenance separate from a human charter', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'Jane Doe', + }), + ); + + expect(song.charter).toBe('Jane Doe'); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); + + it('hides a duplicated AI engine from the human charter field', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'STRUM', + }), + ); + + expect(song.charter).toBe(''); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); + + it('hides a parenthetical AI label from the human charter field', () => { + const song = toSong( + stored({ + auto_chart: 'True', + auto_chart_tool: 'STRUM (OCTAVE AI auto-charter)', + charter: 'STRUM (AI auto-charted)', + }), + ); + + expect(song.charter).toBe(''); + expect(song.autoChartTool).toBe('STRUM (OCTAVE AI auto-charter)'); + }); }); describe('chartGlobPattern', () => { diff --git a/src/main/util.ts b/src/main/util.ts index 69e7dbb2..b623ea09 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -26,6 +26,8 @@ function readSongIdFile(dir: string): string | undefined { export function toSong(stored: SongData): Song { const rating = parseInt(stored.diff_drums ?? '', 10); + const autoChartTool = stored.auto_chart_tool?.trim(); + const charter = stored.charter?.trim() ?? ''; return { id: stored.id, @@ -34,7 +36,8 @@ export function toSong(stored: SongData): Song { name: stored.name ?? '', artist: stored.artist ?? '', album: stored.album ?? '', - charter: stored.charter ?? '', + charter: hasDuplicatedAutoCharter(stored) ? '' : charter, + autoChartTool: autoChartTool || undefined, genre: stored.genre ?? '', year: stored.year ?? '', fiveLaneDrums: stored.five_lane_drums === 'True', @@ -50,6 +53,21 @@ export function toSong(stored: SongData): Song { }; } +export function hasDuplicatedAutoCharter( + stored: Pick, +): boolean { + const autoChartToolName = stored.auto_chart_tool?.split('(')[0].trim() ?? ''; + const charterName = stored.charter?.split('(')[0].trim() ?? ''; + + return ( + Boolean(autoChartToolName) && + stored.auto_chart?.toLowerCase() === 'true' && + charterName.localeCompare(autoChartToolName, undefined, { + sensitivity: 'accent', + }) === 0 + ); +} + function readDrumDifficulties( dir: string, format: 'mid' | 'chart', diff --git a/src/preload/index.ts b/src/preload/index.ts index e6da6a33..99a52ab0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ export type Channels = | 'resume-sleep' | 'check-dev' | 'download-song' + | 'select-import-song' + | 'import-song' | 'check-stem-tools' | 'check-stem-tools-update' | 'download-stem-tools' diff --git a/src/renderer/components/SongImport/SongImport.tsx b/src/renderer/components/SongImport/SongImport.tsx new file mode 100644 index 00000000..60dc9206 --- /dev/null +++ b/src/renderer/components/SongImport/SongImport.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import { faFileImport } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { App, Button, Input, Modal, Tag, Tooltip } from 'antd'; +import appIcon from '../../../../assets/icon.png'; +import { + IpcImportSongPreview, + IpcImportSongResponse, + IpcSelectImportSongResponse, + Song, +} from '../../../types'; + +interface Props { + disabled: boolean; + onImported: (song: Song) => void; +} + +function autoChartToolName(value?: string): string | undefined { + return value?.split('(')[0].trim() || undefined; +} + +function coverMessage(preview: IpcImportSongPreview): string { + switch (preview.coverSource) { + case 'existing': + return 'Existing album artwork will be preserved.'; + + case 'embedded': + 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.'; + } +} + +export function SongImport({ disabled, onImported }: Props) { + const { notification } = App.useApp(); + const [selecting, setSelecting] = useState(false); + const [importing, setImporting] = useState(false); + const [preview, setPreview] = useState(); + const [artworkUrl, setArtworkUrl] = useState(''); + + useEffect(() => { + const stopSelect = + window.electron.ipcRenderer.on( + 'select-import-song', + (response) => { + setSelecting(false); + + if (response.error) { + notification.error({ + title: "Couldn't import this folder", + description: response.error, + placement: 'bottomRight', + }); + + return; + } + + if (response.preview) { + setArtworkUrl(''); + setPreview(response.preview); + } + }, + ); + const stopImport = window.electron.ipcRenderer.on( + 'import-song', + (response) => { + setImporting(false); + + if (!response.success || !response.song) { + notification.error({ + title: 'Import failed', + description: response.error, + placement: 'bottomRight', + }); + + return; + } + + setPreview(undefined); + setArtworkUrl(''); + onImported(response.song); + notification.success({ + title: `"${response.song.name}" added to your library`, + placement: 'bottomRight', + }); + }, + ); + + return () => { + stopSelect(); + stopImport(); + }; + }, [notification, onImported]); + + const selectSong = () => { + setSelecting(true); + window.electron.ipcRenderer.sendMessage('select-import-song'); + }; + const confirmImport = () => { + if (!preview) { + return; + } + + setImporting(true); + window.electron.ipcRenderer.sendMessage('import-song', { + sourceDir: preview.sourceDir, + ...(artworkUrl.trim() ? { artworkUrl: artworkUrl.trim() } : {}), + }); + }; + const toolName = autoChartToolName(preview?.autoChartTool); + + return ( + <> + + + + + { + 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/components/SongImport/index.ts b/src/renderer/components/SongImport/index.ts new file mode 100644 index 00000000..ead77753 --- /dev/null +++ b/src/renderer/components/SongImport/index.ts @@ -0,0 +1 @@ +export * from './SongImport'; diff --git a/src/renderer/components/SongListItem/SongListItem.stories.tsx b/src/renderer/components/SongListItem/SongListItem.stories.tsx index 5235f61d..fa38bc8b 100644 --- a/src/renderer/components/SongListItem/SongListItem.stories.tsx +++ b/src/renderer/components/SongListItem/SongListItem.stories.tsx @@ -56,6 +56,20 @@ export const Liked: Story = { args: { songData: { ...songData, liked: true } }, }; +export const Unplayed: Story = { + args: { songData: { ...songData, scoreData: undefined } }, +}; + +export const AutoCharted: Story = { + args: { + songData: { + ...songData, + charter: '', + autoChartTool: 'STRUM (OCTAVE AI auto-charter)', + }, + }, +}; + export const Focused: Story = { args: { focused: true } }; export const Online: Story = { args: { songData: onlineSongData } }; diff --git a/src/renderer/components/SongListItem/SongListItem.tsx b/src/renderer/components/SongListItem/SongListItem.tsx index 7862d162..7a7fa145 100644 --- a/src/renderer/components/SongListItem/SongListItem.tsx +++ b/src/renderer/components/SongListItem/SongListItem.tsx @@ -9,7 +9,7 @@ import { faHeart } from '@fortawesome/free-regular-svg-icons'; import appIcon from '../../../../assets/icon.png'; import { Song } from '../../../types'; import { cn } from '../../cn'; -import { Button, Tooltip } from 'antd'; +import { Button, Tag, Tooltip } from 'antd'; import { useMemo } from 'react'; import { SongMenu } from '../SongMenu'; import { Stars } from '../Stars'; @@ -48,6 +48,9 @@ export function SongListItem({ }: SongListItemProps) { const local = 'source' in songData ? undefined : songData; const { albumCover, id, name, artist, charter, drumDifficulty } = songData; + const autoChartTool = + 'autoChartTool' in songData ? songData.autoChartTool : undefined; + const autoChartToolName = autoChartTool?.split('(')[0].trim(); const score = useMemo(() => { const result = local?.scoreData?.[difficulty]; @@ -152,6 +155,7 @@ export function SongListItem({
{albumCover { e.currentTarget.src = appIcon; }} @@ -176,16 +180,48 @@ export function SongListItem({
)} + {autoChartToolName && ( + Auto-charted with {autoChartToolName} + )} + {local && (
{difficulty}
- + +
+ {score ? ( + + ) : ( + 'play once to earn stars' + )} +
+
)} diff --git a/src/renderer/components/StemTools/StemTools.tsx b/src/renderer/components/StemTools/StemTools.tsx index b38b4771..e32fef20 100644 --- a/src/renderer/components/StemTools/StemTools.tsx +++ b/src/renderer/components/StemTools/StemTools.tsx @@ -140,6 +140,10 @@ export function StemTools({ onClick={onDeleteStemTools} > +
+ Open ⋮ on a local song with one mixed audio file, then choose Split + stems. +
{updateAvailable && (