diff --git a/electron/Transcoding.ts b/electron/Transcoding.ts index 5c3ac4d..37e61cd 100644 --- a/electron/Transcoding.ts +++ b/electron/Transcoding.ts @@ -16,11 +16,13 @@ import { isFunction, isNumber, isObjectNonNull, + isString, } from '@freik/typechk'; import ocp from 'node:child_process'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { isPromise } from 'node:util/types'; import { rimraf } from 'rimraf'; import { GetAudioDB } from './AudioDatabase'; import { SendToUI } from './SendToUI'; @@ -240,18 +242,31 @@ function reportFailure(file: string, error: string) { } } +type FullPath = [string, string]; + // Get the full song file name from the song's media info in the database -async function getFullSongPathFromSettings( +async function getFullPathFromSettings( settings: TranscodeInfo, - file: string, -): Promise<[string, string] | void> { + file: WorkItem, +): Promise { if (settings.source.type === TranscodeSource.Disk) { const srcdir = settings.source.loc; - if (!path.normalize(file).startsWith(srcdir)) { - reportFailure(file, `${file} doesn't match ${srcdir}`); - return; + if (isString(file)) { + if (!path.normalize(file).startsWith(srcdir)) { + reportFailure(file, `${file} doesn't match ${srcdir}`); + return; + } + return [file, path.join(settings.dest, file.substring(srcdir.length))]; + } else if (file.dest) { + if (!path.normalize(file.dest).startsWith(srcdir)) { + reportFailure(file.dest, `${file.dest} doesn't match ${srcdir}`); + return; + } + return [ + file.dest, + path.join(settings.dest, file.dest.substring(srcdir.length)), + ]; } - return [file, path.join(settings.dest, file.substring(srcdir.length))]; } else { const db = await GetAudioDB(); const filepath = db.getCanonicalFileName(file); @@ -262,83 +277,149 @@ async function getFullSongPathFromSettings( } } -function isImage(filepath: string): boolean { +type WorkItem = + | string // Simple file name- for audio, easy, for cover art, destination comes from the path + | { + // Buffers: For album/artist covers? + buffer: Promise | Buffer; + forSong?: string; + }; + +function isImagePath(filepath: string): boolean { const fp = filepath.toLocaleUpperCase(); return fp.endsWith('.PNG') || fp.endsWith('.JPG'); } +function isImage(wi: WorkItem): boolean { + return !isString(wi) || isImagePath(wi); +} + +const png_header = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +async function getFileType(workItem: WorkItem): Promise { + if (isString(workItem)) { + if (isImagePath(workItem)) { + return workItem.toLocaleLowerCase().substring(workItem.length - 3); + } else { + return 'm4a'; + } + } + if (isPromise(workItem.buffer)) { + const b = await workItem.buffer; + if (b) { + workItem.buffer = b; + } + } + if (!isPromise(workItem.buffer)) { + const s = workItem.buffer.subarray(0, 9); + if (s.compare(png_header)) { + return 'png'; + } + } + return 'jpg'; +} + +async function convertAudio( + [src, relName]: FullPath, + newName: string, + filePairs?: Map, +): Promise { + const res = await toMp4Async(src, newName); + switch (res.code) { + case XcodeResCode.alreadyLowBitRate: + try { + await fsp.copyFile(src, relName); + if (filePairs !== undefined) { + filePairs.set(src, relName); + } + } catch (e) { + reportFailure( + src, + `Unable to copy already mid-quality file ${src} to ${relName}`, + ); + } + reportFileTranscoded(src); + break; + case XcodeResCode.success: + reportFileTranscoded(src); + if (filePairs !== undefined) { + filePairs.set(src, newName); + } + break; + case XcodeResCode.alreadyExists: + reportFileUntouched(); + if (filePairs !== undefined) { + filePairs.set(src, newName); + } + break; + default: + reportFailure( + src, + `Transcode failure ${res.code}: ${res.message} - ${src} => ${newName}`, + ); + } +} + async function convert( settings: TranscodeInfo, - file: string, + workItem: WorkItem, filePairs?: Map, ): Promise { reportFilePending(); - // First, check to see if it's a cover image - if (isImage(file)) { - if (settings.artwork) { - // TOOD: Copy artwork, because we're supposed to: - } - return; - } try { - const fullSongPath = await getFullSongPathFromSettings(settings, file); - if (!fullSongPath) { + const fullPath = await getFullPathFromSettings(settings, workItem); + if (!fullPath) { return; } - const src = fullSongPath[0]; - let relName = fullSongPath[1]; + let relName = fullPath[1]; if (!path.isAbsolute(relName)) { relName = path.join(settings.dest, relName); + fullPath[1] = relName; } - log(`${src} -> ${relName}`); - const newName = PathUtil.changeExt(relName, 'm4a'); + const suffix = await getFileType(workItem); + const newName = PathUtil.changeExt(relName, suffix); + log(`${fullPath[0]} -> ${newName}`); try { const dr = path.dirname(newName); try { await fsp.stat(dr); - } catch { + } catch (e) { await fsp.mkdir(dr, { recursive: true }); } - } catch { + } catch (e) { reportFailure( newName, `Unable to find/create the directory for ${newName}`, ); return; } - const res = await toMp4Async(src, newName); - switch (res.code) { - case XcodeResCode.alreadyLowBitRate: + // First, check to see if it's a cover image + if (isImage(workItem)) { + if (settings.artwork) { + // Copy artwork, because we're supposed to try { - await fsp.copyFile(src, relName); + if (isString(workItem)) { + await fsp.copyFile(workItem, newName); + } else { + const b = await workItem.buffer; + if (b) { + await fsp.writeFile(newName, b); + } + } if (filePairs !== undefined) { - filePairs.set(src, relName); + filePairs.set(fullPath[0], newName); } - } catch { + } catch (e) { reportFailure( - src, - `Unable to copy already mid-quality file ${src} to ${relName}`, + workItem as string, + `Unable to copy artwork ${workItem} to ${newName}`, ); } - reportFileTranscoded(src); - break; - case XcodeResCode.success: - reportFileTranscoded(src); - if (filePairs !== undefined) { - filePairs.set(src, newName); - } - break; - case XcodeResCode.alreadyExists: - reportFileUntouched(); - if (filePairs !== undefined) { - filePairs.set(src, newName); - } - break; - default: - reportFailure( - src, - `Transcode failure ${res.code}: ${res.message} - ${src} => ${newName}`, - ); + } + } else { + convertAudio(fullPath, newName, filePairs); } } finally { removeFilePending(); @@ -414,7 +495,7 @@ async function findExcessDirs(settings: TranscodeInfo): Promise { // Transcode the files from 'files' according to the settings async function handleLots( settings: TranscodeInfo, - files: string[], + files: WorkItem[], ): Promise { // Don't use all the cores if we have multiple cores: const limit = pLimit(Math.max(os.cpus().length - 2, 1)); @@ -459,7 +540,7 @@ export function getXcodeStatus(): Promise { // Read through all the files on the disk to build up the work queue async function ScanSourceFromDisk( settings: TranscodeInfo, - workQueue: string[], + workQueue: WorkItem[], ) { const fileTypes = ['flac', 'mp3', 'wma', 'wav', 'm4a', 'aac']; if (settings.artwork) { @@ -503,7 +584,7 @@ export async function startTranscode(settings: TranscodeInfo): Promise { // Start UI reporting startStatusReporting(); try { - const workQueue: string[] = []; + const workQueue: WorkItem[] = []; if (settings.source.type === TranscodeSource.Disk) { await ScanSourceFromDisk(settings, workQueue); } else { @@ -514,6 +595,13 @@ export async function startTranscode(settings: TranscodeInfo): Promise { const album = db.getAlbum(settings.source.loc); if (album) { workQueue.push(...album.songs); + if (settings.artwork) { + // TODO: Get the album artwork destination + workQueue.push({ + buffer: db.getAlbumPicture(album.key), + forSong: album.songs[0], + }); + } } // TODO: Report no such album break; @@ -522,12 +610,20 @@ export async function startTranscode(settings: TranscodeInfo): Promise { const artist = db.getArtist(settings.source.loc); if (artist) { workQueue.push(...artist.songs); + if (settings.artwork) { + // TODO: Get the album artwork destination + workQueue.push({ + buffer: db.getArtistPicture(artist.key), + forSong: artist.songs[0], + }); + } } // TODO: Report no such album break; } case TranscodeSource.Playlist: workQueue.push(...(await LoadPlaylist(settings.source.loc))); + // TODO: Handle song artwork break; } reportFilesFound(workQueue.length); diff --git a/yarn.lock b/yarn.lock index 468c9a8..16576de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10316,13 +10316,20 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001503, caniuse-lite@npm:^1.0.30001565": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001503": version: 1.0.30001635 resolution: "caniuse-lite@npm:1.0.30001635" checksum: 10/8cbdcad1868da7c1e04ae9ba58e84a2a99dfbbcd5fe2c169065cf602219358e9da5739aa931fabdbc2c884ea43a04e1c1fc18e6bf2507bc05f3fe33143d81b48 languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001565": + version: 1.0.30001570 + resolution: "caniuse-lite@npm:1.0.30001570" + checksum: 10/a9b939e003dd70580cc18bce54627af84f298af7c774415c8d6c99871e7cee13bd8278b67955a979cd338369c73e8821a10b37e607d1fff2fbc8ff92fc489653 + languageName: node + linkType: hard + "case-sensitive-paths-webpack-plugin@npm:^2.4.0": version: 2.4.0 resolution: "case-sensitive-paths-webpack-plugin@npm:2.4.0"