From 86515c4ab5589f8fc96b0705485dde4fa7883139 Mon Sep 17 00:00:00 2001 From: mewizard Date: Tue, 1 Apr 2025 18:08:39 +0900 Subject: [PATCH] Feat: Use the Last.fm API to show album thumbnails in the list. --- .env.example | 6 + .gitignore | 8 + package.json | 7 +- src/components/main-rows.tsx | 316 ++++++++++++++++++++++++++++++- src/index.tsx | 4 +- src/redux/actions.ts | 208 ++++++++++++-------- src/services/atracdenc-worker.ts | 229 +++++++++++++++++----- src/services/audio-export.ts | 230 ++++++++++++++-------- src/services/registry.ts | 120 ++++++++++++ 9 files changed, 915 insertions(+), 213 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d07339 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# This is an environment variable example file. Copy it to create a .env file for use. +SKIP_PREFLIGHT_CHECK=true +PORT=3000 + +# Last.fm API key for album thumbnail feature +REACT_APP_LASTFM_API_KEY=your_api_key_here \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d29575..a5b479f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +/.idea/caches/deviceStreaming.xml +/.idea/.gitignore +/.idea/misc.xml +/.idea/modules.xml +/.idea/vcs.xml +/.idea/webminidisc.iml +/src/components/.main-rows.tsx.swp +/.env diff --git a/package.json b/package.json index b564b24..62521f7 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "test": "react-scripts test", "eject": "react-scripts eject", "predeploy": "npm run build", - "deploy": "gh-pages -d build" + "deploy": "gh-pages -d build", + "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,json,scss,md}'" }, "eslintConfig": { "extends": [ @@ -68,7 +69,7 @@ }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,json,scss,md}": [ - "prettier --check" + "prettier --write" ] }, "devDependencies": { @@ -78,4 +79,4 @@ "async-mutex": "^0.2.6", "gh-pages": "^2.2.0" } -} +} \ No newline at end of file diff --git a/src/components/main-rows.tsx b/src/components/main-rows.tsx index cd72f4f..b7cc12a 100644 --- a/src/components/main-rows.tsx +++ b/src/components/main-rows.tsx @@ -1,14 +1,15 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import clsx from 'clsx'; import { EncodingName } from '../utils'; import { formatTimeFromFrames, Track, Group } from 'netmd-js'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; import TableCell from '@material-ui/core/TableCell'; import TableRow from '@material-ui/core/TableRow'; import * as BadgeImpl from '@material-ui/core/Badge/Badge'; +import Tooltip from '@material-ui/core/Tooltip'; import DragIndicator from '@material-ui/icons/DragIndicator'; import PlayArrowIcon from '@material-ui/icons/PlayArrow'; @@ -16,8 +17,10 @@ import PauseIcon from '@material-ui/icons/Pause'; import IconButton from '@material-ui/core/IconButton'; import FolderIcon from '@material-ui/icons/Folder'; import DeleteIcon from '@material-ui/icons/Delete'; +import AlbumIcon from '@material-ui/icons/Album'; import { DraggableProvided } from 'react-beautiful-dnd'; +import ServiceRegistry from '../services/registry'; const useStyles = makeStyles(theme => ({ currentTrackRow: { @@ -80,6 +83,35 @@ const useStyles = makeStyles(theme => ({ textOverflow: 'ellipsis', // whiteSpace: 'nowrap', }, + titleCellContent: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }, + titleContent: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + minWidth: 0, + flex: 1, + }, + trackTitle: { + fontWeight: 500, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + lineHeight: '1.2', + }, + trackInfo: { + fontSize: '0.85em', + color: theme.palette.text.secondary, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + lineHeight: '1.2', + marginTop: theme.spacing(0.5), + }, deleteGroupButton: { display: 'none', }, @@ -112,6 +144,20 @@ const useStyles = makeStyles(theme => ({ }, }, }, + albumAvatar: { + width: theme.spacing(4), + height: theme.spacing(4), + marginRight: theme.spacing(1), + fontSize: '0.75rem', + borderRadius: theme.shape.borderRadius, + flexShrink: 0, + }, + albumCover: { + width: '100%', + height: '100%', + objectFit: 'cover', + borderRadius: theme.shape.borderRadius, + }, })); interface TrackRowProps { @@ -125,6 +171,55 @@ interface TrackRowProps { onTogglePlayPause: (event: React.MouseEvent, trackIdx: number) => void; } +// Album cover cache (globally managed to persist between component re-renders) +const albumCoverCache = new Map< + string, + { + albumImage: string | null; + artist: string; + trackName: string; + timestamp: number; // Timestamp for cache lifetime management + } +>(); + +// Create unique cache key based on track title only (excluding index) +function createCacheKey(track: Track): string { + // Use only track title to ensure same tracks have same key even if index changes + return track.title || 'untitled'; +} + +// Cache management (maintain max 100 items, remove oldest entries) +function manageCache(): void { + if (albumCoverCache.size > 100) { + // Sort by oldest timestamp + const entries = [...albumCoverCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp); + // Remove 20 oldest entries + for (let i = 0; i < 20; i++) { + if (entries[i]) { + albumCoverCache.delete(entries[i][0]); + } + } + } +} + +// Debounce function implementation +function debounce any>(func: T, wait: number): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function(...args: Parameters) { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + func(...args); + }, wait); + }; +} + +// Function to load album cover with low priority +function loadWithLowPriority(callback: () => void): void { + // Execute during browser's next idle time + setTimeout(callback, 50); +} + export function TrackRow({ track, inGroup, @@ -136,6 +231,195 @@ export function TrackRow({ onTogglePlayPause, }: TrackRowProps) { const classes = useStyles(); + const theme = useTheme(); + const [albumImage, setAlbumImage] = useState(null); + const [hasLoaded, setHasLoaded] = useState(false); + const [artist, setArtist] = useState(''); + const [trackName, setTrackName] = useState(''); + const isLoadingRef = useRef(false); + const trackTitleRef = useRef(track.title || ''); + const prevTrackRef = useRef(null); + + // Check cache and initial load on component mount + useEffect(() => { + const cacheKey = createCacheKey(track); + const cachedInfo = albumCoverCache.get(cacheKey); + + // Check if track changed or is being loaded for the first time + const isNewTrack = prevTrackRef.current?.title !== track.title; + prevTrackRef.current = track; + + console.log( + `[Track ${track.index}] Cache check for "${track.title}":`, + cachedInfo ? 'Found in cache' : 'Not in cache', + `(Cache size: ${albumCoverCache.size})` + ); + + if (cachedInfo) { + // Update state if cache has info + console.log( + `[Track ${track.index}] Using cached image for "${track.title}":`, + cachedInfo.albumImage ? 'Has image' : 'No image' + ); + setAlbumImage(cachedInfo.albumImage); + setArtist(cachedInfo.artist); + setTrackName(cachedInfo.trackName); + setHasLoaded(true); + + // Update timestamp when cache entry is used + albumCoverCache.set(cacheKey, { + ...cachedInfo, + timestamp: Date.now(), + }); + } else if (isNewTrack) { + // Reset states for new track without cache entry + console.log(`[Track ${track.index}] New track, resetting states for "${track.title}"`); + setAlbumImage(null); + setArtist(''); + setTrackName(''); + setHasLoaded(false); + isLoadingRef.current = false; + trackTitleRef.current = track.title || ''; + } + + // Manage cache + manageCache(); + }, [track]); + + // Attempt to extract artist and album info from track name (with debounce and low priority) + useEffect(() => { + // Skip if already loaded, loading in progress, or no title + if (!track.title || hasLoaded || isLoadingRef.current) return; + + isLoadingRef.current = true; + trackTitleRef.current = track.title || ''; + + // Execute with low priority to let other UI tasks take precedence + loadWithLowPriority(() => { + // Debounce album cover loading (300ms) + const debouncedFetchAlbumCover = debounce(async () => { + // Stop if image already exists or loading completed + if (albumImage || hasLoaded) { + isLoadingRef.current = false; + return; + } + + if (!track.title) return; + + try { + const lastFmService = ServiceRegistry.lastFmService; + if (!lastFmService) { + isLoadingRef.current = false; + setHasLoaded(true); + return; + } + + // Extract artist and track name + let extractedArtist = ''; + let extractedTrackName = track.title; + + const dashIndex = track.title.indexOf(' - '); + if (dashIndex !== -1) { + const parts = [track.title.substring(0, dashIndex).trim(), track.title.substring(dashIndex + 3).trim()]; + extractedArtist = parts[0]; + extractedTrackName = parts[1]; + } + + // Store current track title to check if track changed during async operation + const currentTrackTitle = trackTitleRef.current; + + // Update state regardless of extraction success + if (currentTrackTitle === trackTitleRef.current) { + setArtist(extractedArtist); + setTrackName(extractedTrackName); + } + + // Search with artist+track if artist name exists + let foundImage = false; + let imageUrl = null; + if (extractedArtist) { + try { + const result = await lastFmService.getTrackInfo(extractedArtist, extractedTrackName); + if (result.album?.images?.small && currentTrackTitle === trackTitleRef.current) { + imageUrl = result.album.images.small; + setAlbumImage(imageUrl); + foundImage = true; + } + } catch (error) { + console.log(`Track info search failed (artist+track): ${extractedArtist} - ${extractedTrackName}`); + } + + // Try artist+album if track search failed + if (!foundImage) { + try { + const albumResult = await lastFmService.getAlbumInfo(extractedArtist, extractedTrackName); + if (albumResult.images?.small && currentTrackTitle === trackTitleRef.current) { + imageUrl = albumResult.images.small; + setAlbumImage(imageUrl); + foundImage = true; + } + } catch (error) { + console.log(`Album info search failed (artist+album): ${extractedArtist} - ${extractedTrackName}`); + } + } + } + + // If image not found yet, search with full track name + if (!foundImage) { + try { + const result = await lastFmService.getTrackInfo('', track.title); + if (result.album?.images?.small && currentTrackTitle === trackTitleRef.current) { + imageUrl = result.album.images.small; + setAlbumImage(imageUrl); + foundImage = true; + } + } catch (error) { + console.log(`Track info search failed (full title): ${track.title}`); + } + } + + // Last resort: try full title as album name + if (!foundImage) { + try { + const albumResult = await lastFmService.getAlbumInfo('', track.title); + if (albumResult.images?.small && currentTrackTitle === trackTitleRef.current) { + imageUrl = albumResult.images.small; + setAlbumImage(imageUrl); + foundImage = true; + } + } catch (error) { + console.log(`Album info search failed (full title): ${track.title}`); + } + } + + // Save result to cache (only if image exists) + if (currentTrackTitle === trackTitleRef.current && imageUrl) { + const cacheKey = createCacheKey(track); + albumCoverCache.set(cacheKey, { + albumImage: imageUrl, + artist: extractedArtist, + trackName: extractedTrackName, + timestamp: Date.now(), + }); + } + + // Mark loading as complete + setHasLoaded(true); + } catch (error) { + console.error('Failed to fetch album cover:', error); + } finally { + isLoadingRef.current = false; + } + }, 300); + + debouncedFetchAlbumCover(); + }); + + // Cleanup function + return () => { + isLoadingRef.current = false; + }; + }, [track, hasLoaded, albumImage]); const handleRename = useCallback(event => onRename(event, track.index), [track.index, onRename]); const handleSelect = useCallback(event => onSelect(event, track.index), [track.index, onSelect]); @@ -179,9 +463,31 @@ export function TrackRow({ )} - - {track.fullWidthTitle ? `${track.fullWidthTitle} / ` : ``} - {track.title || `No Title`} + +
+ {albumImage ? ( + +
+ Album +
+
+ ) : ( +
+ +
+ )} +
+
{trackName || track.title || `No Title`}
+
+ {artist && artist} + {artist && track.fullWidthTitle && ' / '} + {track.fullWidthTitle && track.fullWidthTitle} +
+
+
{EncodingName[track.encoding]} diff --git a/src/index.tsx b/src/index.tsx index bc32a5c..60506f8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import * as serviceWorker from './serviceWorker'; import { NetMDUSBService } from './services/netmd'; import { NetMDMockService } from './services/netmd-mock'; -import serviceRegistry from './services/registry'; +import serviceRegistry, { LastFmAPIService } from './services/registry'; import { store } from './redux/store'; import { actions as appActions } from './redux/app-feature'; @@ -25,6 +25,8 @@ serviceRegistry.netmdService = (window as any).native?.interface || new NetMDUSB serviceRegistry.audioExportService = new FFMpegAudioExportService(); serviceRegistry.mediaRecorderService = new MediaRecorderService(); serviceRegistry.mediaSessionService = new BrowserMediaSessionService(store); +// Initialize Last.fm API service +serviceRegistry.lastFmService = new LastFmAPIService(); (function setupEventHandlers() { window.addEventListener('beforeunload', ev => { diff --git a/src/redux/actions.ts b/src/redux/actions.ts index b130490..3092526 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -101,95 +101,105 @@ export function dragDropTrack(sourceList: number, sourceIndex: number, targetLis // This code is here, because it would need to be duplicated in both netmd and netmd-mock. return async function(dispatch: AppDispatch, getState: () => RootState) { if (sourceList === targetList && sourceIndex === targetIndex) return; + dispatch(appStateActions.setLoading(true)); - const groupedTracks = getGroupedTracks(await serviceRegistry.netmdService!.listContent()); - // Remove the moved item from its current list - let movedItem = groupedTracks[sourceList].tracks.splice(sourceIndex, 1)[0]; - let newIndex: number; - - // Calculate bounds - let boundsStartList, boundsEndList, boundsStartIndex, boundsEndIndex, offset; - - if (sourceList < targetList) { - boundsStartList = sourceList; - boundsStartIndex = sourceIndex; - boundsEndList = targetList; - boundsEndIndex = targetIndex; - offset = -1; - } else if (sourceList > targetList) { - boundsStartList = targetList; - boundsStartIndex = targetIndex; - boundsEndList = sourceList; - boundsEndIndex = sourceIndex; - offset = 1; - } else { - if (sourceIndex < targetIndex) { - boundsStartList = boundsEndList = sourceList; + + try { + const groupedTracks = getGroupedTracks(await serviceRegistry.netmdService!.listContent()); + // Remove the moved item from its current list + let movedItem = groupedTracks[sourceList].tracks.splice(sourceIndex, 1)[0]; + let newIndex: number; + + // Calculate bounds + let boundsStartList, boundsEndList, boundsStartIndex, boundsEndIndex, offset; + + if (sourceList < targetList) { + boundsStartList = sourceList; boundsStartIndex = sourceIndex; + boundsEndList = targetList; boundsEndIndex = targetIndex; offset = -1; - } else { - boundsStartList = boundsEndList = targetList; + } else if (sourceList > targetList) { + boundsStartList = targetList; boundsStartIndex = targetIndex; + boundsEndList = sourceList; boundsEndIndex = sourceIndex; offset = 1; + } else { + if (sourceIndex < targetIndex) { + boundsStartList = boundsEndList = sourceList; + boundsStartIndex = sourceIndex; + boundsEndIndex = targetIndex; + offset = -1; + } else { + boundsStartList = boundsEndList = targetList; + boundsStartIndex = targetIndex; + boundsEndIndex = sourceIndex; + offset = 1; + } } - } - // Shift indices - for (let i = boundsStartList; i <= boundsEndList; i++) { - let startingIndex = i === boundsStartList ? boundsStartIndex : 0; - let endingIndex = i === boundsEndList ? boundsEndIndex : groupedTracks[i].tracks.length; - for (let j = startingIndex; j < endingIndex; j++) { - groupedTracks[i].tracks[j].index += offset; + // Shift indices + for (let i = boundsStartList; i <= boundsEndList; i++) { + let startingIndex = i === boundsStartList ? boundsStartIndex : 0; + let endingIndex = i === boundsEndList ? boundsEndIndex : groupedTracks[i].tracks.length; + for (let j = startingIndex; j < endingIndex; j++) { + groupedTracks[i].tracks[j].index += offset; + } } - } - // Calculate the moved track's destination index - if (targetList === 0) { - newIndex = targetIndex; - } else { - if (targetIndex === 0) { - let prevList = groupedTracks[targetList - 1]; - let i = 2; - while (prevList && prevList.tracks.length === 0) { - // Skip past all the empty lists - prevList = groupedTracks[targetList - i++]; - } - if (prevList) { - // If there's a previous list, make this tracks's index previous list's last item's index + 1 - let lastIndexOfPrevList = prevList.tracks[prevList.tracks.length - 1].index; - newIndex = lastIndexOfPrevList + 1; - } else newIndex = 0; // Else default to index 0 + // Calculate the moved track's destination index + if (targetList === 0) { + newIndex = targetIndex; } else { - newIndex = groupedTracks[targetList].tracks[0].index + targetIndex; + if (targetIndex === 0) { + let prevList = groupedTracks[targetList - 1]; + let i = 2; + while (prevList && prevList.tracks.length === 0) { + // Skip past all the empty lists + prevList = groupedTracks[targetList - i++]; + } + if (prevList) { + // If there's a previous list, make this tracks's index previous list's last item's index + 1 + let lastIndexOfPrevList = prevList.tracks[prevList.tracks.length - 1].index; + newIndex = lastIndexOfPrevList + 1; + } else newIndex = 0; // Else default to index 0 + } else { + newIndex = groupedTracks[targetList].tracks[0].index + targetIndex; + } } - } - if (movedItem.index !== newIndex) { - await serviceRegistry!.netmdService!.moveTrack(movedItem.index, newIndex, false); - } + if (movedItem.index !== newIndex) { + await serviceRegistry!.netmdService!.moveTrack(movedItem.index, newIndex, false); + } - movedItem.index = newIndex; - groupedTracks[targetList].tracks.splice(targetIndex, 0, movedItem); - let ungrouped = []; + movedItem.index = newIndex; + groupedTracks[targetList].tracks.splice(targetIndex, 0, movedItem); + let ungrouped = []; - // Recompile the groups and update them on the player - let normalGroups = []; - for (let group of groupedTracks) { - if (group.tracks.length === 0) continue; - if (group.index === -1) ungrouped.push(...group.tracks); - else normalGroups.push(group); + // Recompile the groups and update them on the player + let normalGroups = []; + for (let group of groupedTracks) { + if (group.tracks.length === 0) continue; + if (group.index === -1) ungrouped.push(...group.tracks); + else normalGroups.push(group); + } + if (ungrouped.length) + normalGroups.unshift({ + index: 0, + title: null, + fullWidthTitle: null, + tracks: ungrouped, + }); + await serviceRegistry.netmdService!.rewriteGroups(normalGroups); + + // Update content list without resetting the loading state + listContent(true)(dispatch); + } catch (error) { + console.error('Error during drag and drop operation:', error); + dispatch(appStateActions.setLoading(false)); + listContent()(dispatch); } - if (ungrouped.length) - normalGroups.unshift({ - index: 0, - title: null, - fullWidthTitle: null, - tracks: ungrouped, - }); - await serviceRegistry.netmdService!.rewriteGroups(normalGroups); - listContent()(dispatch); }; } @@ -226,10 +236,12 @@ export function pair() { }; } -export function listContent() { +export function listContent(skipLoadingState = false) { return async function(dispatch: AppDispatch) { // Issue loading - dispatch(appStateActions.setLoading(true)); + if (!skipLoadingState) { + dispatch(appStateActions.setLoading(true)); + } let disc; try { disc = await serviceRegistry.netmdService!.listContent(); @@ -313,9 +325,18 @@ export function wipeDisc() { export function moveTrack(srcIndex: number, destIndex: number) { return async function(dispatch: AppDispatch) { - const { netmdService } = serviceRegistry; - await netmdService!.moveTrack(srcIndex, destIndex); - listContent()(dispatch); + dispatch(appStateActions.setLoading(true)); + try { + const { netmdService } = serviceRegistry; + await netmdService!.moveTrack(srcIndex, destIndex); + + // Update content list without resetting the loading state + listContent(true)(dispatch); + } catch (error) { + console.error('Error moving track:', error); + dispatch(appStateActions.setLoading(false)); + listContent()(dispatch); + } }; } @@ -497,11 +518,20 @@ export function convertAndUpload(files: File[], requestedFormat: UploadFormat, t }; let conversionIterator = async function*(files: File[]) { + console.log(`[convertAndUpload] Starting conversion of ${files.length} files`); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + console.log( + `[convertAndUpload] Processing file ${i + 1}/${files.length}: ${file.name}, size: ${file.size}bytes, type: ${file.type}` + ); + } + let converted: Promise<{ file: File; data: ArrayBuffer; format: Wireformat }>[] = []; let i = 0; function convertNext() { if (i === files.length || hasUploadBeenCancelled()) { + console.log(`[convertAndUpload] Conversion queue completed or cancelled. Current index: ${i}/${files.length}`); trackUpdate.converting = i; trackUpdate.titleConverting = ``; updateTrack(); @@ -509,6 +539,7 @@ export function convertAndUpload(files: File[], requestedFormat: UploadFormat, t } let f = files[i]; + console.log(`[convertAndUpload] Starting conversion of file ${i + 1}/${files.length}: ${f.name}`); trackUpdate.converting = i; trackUpdate.titleConverting = f.name; updateTrack(); @@ -519,23 +550,42 @@ export function convertAndUpload(files: File[], requestedFormat: UploadFormat, t let data: ArrayBuffer; let format: Wireformat; try { + console.log(`[convertAndUpload] Preparing file for conversion: ${f.name}`); await audioExportService!.prepare(f); + console.log(`[convertAndUpload] File prepared successfully, starting export with format: ${requestedFormat}`); ({ data, format } = await audioExportService!.export({ requestedFormat })); + console.log( + `[convertAndUpload] Export completed successfully, data size: ${data.byteLength}bytes, format: ${format}` + ); convertNext(); resolve({ file: f, data: data, format: format }); } catch (err) { + console.error(`[convertAndUpload] Error converting file ${f.name}:`, err); error = err; - errorMessage = `${f.name}: Unsupported or unrecognized format`; + if (typeof err === 'object' && err !== null) { + errorMessage = `${f.name}: ${err.message || 'Unsupported or unrecognized format'}`; + } else { + errorMessage = `${f.name}: Unsupported or unrecognized format`; + } reject(err); } }) ); } + console.log(`[convertAndUpload] Starting conversion queue`); convertNext(); let j = 0; while (j < converted.length) { - yield await converted[j]; + try { + console.log(`[convertAndUpload] Waiting for conversion result ${j + 1}/${converted.length}`); + const result = await converted[j]; + console.log(`[convertAndUpload] Conversion result ready: ${result.file.name}`); + yield result; + } catch (err) { + console.error(`[convertAndUpload] Error in conversion queue for item ${j + 1}:`, err); + // Skip this item and continue with the next one + } delete converted[j]; j++; } diff --git a/src/services/atracdenc-worker.ts b/src/services/atracdenc-worker.ts index d632d02..b7e9e70 100644 --- a/src/services/atracdenc-worker.ts +++ b/src/services/atracdenc-worker.ts @@ -4,31 +4,100 @@ export class AtracdencProcess { private messageCallback?: (ev: MessageEvent) => void; constructor(public worker: Worker) { + console.log('[AtracdencProcess] Constructor - Creating worker'); worker.onmessage = this.handleMessage.bind(this); + // Add error handling + worker.onerror = error => { + console.error('[AtracdencProcess] Worker error:', error); + // If callback exists, reject with error message + if (this.messageCallback) { + this.messageCallback(new MessageEvent('error', { data: { error } })); + this.messageCallback = undefined; + } + }; } async init() { - await new Promise(resolve => { - this.messageCallback = resolve; - this.worker.postMessage({ action: 'init' }); - }); + console.log('[AtracdencProcess] Initializing worker'); + try { + const result = await new Promise((resolve, reject) => { + // Initialization timeout (10 seconds) + const timeoutId = setTimeout(() => { + reject(new Error('Worker initialization timeout after 10 seconds')); + }, 10000); + + this.messageCallback = (ev: MessageEvent) => { + clearTimeout(timeoutId); + if (ev.type === 'error') { + reject(new Error('Worker initialization failed: ' + JSON.stringify(ev.data))); + } else { + resolve(ev); + } + }; + + console.log('[AtracdencProcess] Sending init message to worker'); + this.worker.postMessage({ action: 'init' }); + }); + + console.log('[AtracdencProcess] Worker initialized successfully:', result.data); + return result; + } catch (error) { + console.error('[AtracdencProcess] Error initializing worker:', error); + throw error; + } } async encode(data: ArrayBuffer, bitrate: string) { - let eventData = await new Promise(resolve => { - this.messageCallback = resolve; - this.worker.postMessage({ action: 'encode', bitrate, data }, [data]); - }); - return eventData.data.result as Uint8Array; + console.log(`[AtracdencProcess] Encoding audio with bitrate: ${bitrate}, data size: ${data.byteLength}bytes`); + try { + const eventData = await new Promise((resolve, reject) => { + // Encoding timeout (30 seconds) + const timeoutId = setTimeout(() => { + reject(new Error('Encoding operation timeout after 30 seconds')); + }, 30000); + + this.messageCallback = (ev: MessageEvent) => { + clearTimeout(timeoutId); + if (ev.type === 'error') { + reject(new Error('Encoding failed: ' + JSON.stringify(ev.data))); + } else { + resolve(ev); + } + }; + + console.log('[AtracdencProcess] Sending encode message to worker'); + this.worker.postMessage({ action: 'encode', bitrate, data }, [data]); + }); + + console.log( + '[AtracdencProcess] Encoding completed successfully, result size:', + eventData.data.result ? eventData.data.result.byteLength + 'bytes' : 'unknown' + ); + return eventData.data.result as Uint8Array; + } catch (error) { + console.error('[AtracdencProcess] Error encoding audio:', error); + throw error; + } } terminate() { - this.worker.terminate(); + console.log('[AtracdencProcess] Terminating worker'); + try { + this.worker.terminate(); + console.log('[AtracdencProcess] Worker terminated successfully'); + } catch (error) { + console.error('[AtracdencProcess] Error terminating worker:', error); + } } handleMessage(ev: MessageEvent) { - this.messageCallback!(ev); - this.messageCallback = undefined; + console.log('[AtracdencProcess] Message received from worker:', ev.data?.action || 'unknown action'); + if (this.messageCallback) { + this.messageCallback(ev); + this.messageCallback = undefined; + } else { + console.warn('[AtracdencProcess] Received message but no callback is registered'); + } } } @@ -36,39 +105,111 @@ if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScop // Worker let Module: any; onmessage = async (ev: MessageEvent) => { - const { action, ...others } = ev.data; - if (action === 'init') { - self.importScripts(getPublicPathFor(`atracdenc.js`)); - (self as any).Module().then((m: any) => { - Module = m; - self.postMessage({ action: 'init' }); - Module.setLogger && Module.setLogger((msg: string, stream: string) => console.log(`${stream}: ${msg}`)); + try { + const { action, ...others } = ev.data; + console.log(`[AtracdencWorker] Received action: ${action}`); + + if (action === 'init') { + try { + console.log('[AtracdencWorker] Importing atracdenc.js script'); + const scriptPath = getPublicPathFor(`atracdenc.js`); + console.log(`[AtracdencWorker] Script path: ${scriptPath}`); + self.importScripts(scriptPath); + + console.log('[AtracdencWorker] Initializing Module'); + (self as any) + .Module() + .then((m: any) => { + Module = m; + console.log('[AtracdencWorker] Module initialized successfully'); + + // Set up logging + if (Module.setLogger) { + Module.setLogger((msg: string, stream: string) => { + console.log(`[Atracdenc ${stream}] ${msg}`); + }); + console.log('[AtracdencWorker] Logger set up'); + } else { + console.warn('[AtracdencWorker] Module.setLogger is not available'); + } + + self.postMessage({ action: 'init' }); + }) + .catch((error: any) => { + console.error('[AtracdencWorker] Error initializing Module:', error); + self.postMessage({ + action: 'error', + error: `Module initialization failed: ${error.message || 'Unknown error'}`, + }); + }); + } catch (error) { + console.error('[AtracdencWorker] Error in init action:', error); + self.postMessage({ action: 'error', error: `Init failed: ${error.message || 'Unknown error'}` }); + } + } else if (action === 'encode') { + try { + if (!Module) { + throw new Error('Module not initialized. Call init first.'); + } + + const { bitrate, data } = others; + console.log(`[AtracdencWorker] Encoding with bitrate: ${bitrate}, data size: ${data.byteLength}bytes`); + + // Prepare files + const inWavFile = `inWavFile.wav`; + const outAt3File = `outAt3File.aea`; + const dataArray = new Uint8Array(data); + + console.log(`[AtracdencWorker] Writing input WAV file: ${inWavFile}`); + Module.FS.writeFile(`${inWavFile}`, dataArray); + + // Execute encoding + console.log('[AtracdencWorker] Starting ATRAC3 encoding process'); + Module.callMain([`-e`, `atrac3`, `-i`, inWavFile, `-o`, outAt3File, `--bitrate`, bitrate]); + + // Read result file + console.log(`[AtracdencWorker] Reading output file: ${outAt3File}`); + let fileStat = Module.FS.stat(outAt3File); + let size = fileStat.size; + console.log(`[AtracdencWorker] Output file size: ${size}bytes`); + + // Remove header (96 bytes) + let tmp = new Uint8Array(size - 96); + let outAt3FileStream = Module.FS.open(outAt3File, 'r'); + Module.FS.read(outAt3FileStream, tmp, 0, tmp.length, 96); + Module.FS.close(outAt3FileStream); + + let result = tmp.buffer; + console.log(`[AtracdencWorker] Encoding completed, result size: ${result.byteLength}bytes`); + + // Return completed result + self.postMessage( + { + action: 'encode', + result, + }, + [result] + ); + } catch (error) { + console.error('[AtracdencWorker] Error in encode action:', error); + self.postMessage({ + action: 'error', + error: `Encode failed: ${error.message || 'Unknown error'}`, + }); + } + } else { + console.warn(`[AtracdencWorker] Unknown action: ${action}`); + self.postMessage({ + action: 'error', + error: `Unknown action: ${action}`, + }); + } + } catch (error) { + console.error('[AtracdencWorker] Unhandled error in worker:', error); + self.postMessage({ + action: 'error', + error: `Unhandled error: ${error.message || 'Unknown error'}`, }); - } else if (action === 'encode') { - const { bitrate, data } = others; - const inWavFile = `inWavFile.wav`; - const outAt3File = `outAt3File.aea`; - const dataArray = new Uint8Array(data); - Module.FS.writeFile(`${inWavFile}`, dataArray); - Module.callMain([`-e`, `atrac3`, `-i`, inWavFile, `-o`, outAt3File, `--bitrate`, bitrate]); - - // Read file and trim header (96 bytes) - let fileStat = Module.FS.stat(outAt3File); - let size = fileStat.size; - let tmp = new Uint8Array(size - 96); - let outAt3FileStream = Module.FS.open(outAt3File, 'r'); - Module.FS.read(outAt3FileStream, tmp, 0, tmp.length, 96); - Module.FS.close(outAt3FileStream); - - let result = tmp.buffer; - - self.postMessage( - { - action: 'encode', - result, - }, - [result] - ); } }; } else { diff --git a/src/services/audio-export.ts b/src/services/audio-export.ts index ba0b00a..31cec66 100644 --- a/src/services/audio-export.ts +++ b/src/services/audio-export.ts @@ -26,102 +26,170 @@ export class FFMpegAudioExportService implements AudioExportService { public inFile?: File; async init() { + console.log('[AudioExport] Initializing FFMpegAudioExportService'); setLogging(true); } async prepare(file: File) { - this.inFile = file; - this.loglines = []; - this.ffmpegProcess = createWorker({ - logger: (payload: LogPayload) => { - this.loglines.push(payload); - console.log(payload.action, payload.message); - }, - corePath: getPublicPathFor('ffmpeg-core.js'), - workerPath: getPublicPathFor('worker.min.js'), - }); - await this.ffmpegProcess.load(); - - this.atracdencProcess = new AtracdencProcess(new AtracdencWorker()); - await this.atracdencProcess.init(); - - let ext = file.name.split('.').slice(-1); - if (ext.length === 0) { - throw new Error(`Unrecognized file format: ${file.name}`); - } + try { + console.log(`[AudioExport] Preparing file: ${file.name}, size: ${file.size}bytes, type: ${file.type}`); + this.inFile = file; + this.loglines = []; + + // Initialize FFmpeg web worker + console.log('[AudioExport] Creating FFmpeg worker'); + this.ffmpegProcess = createWorker({ + logger: (payload: LogPayload) => { + this.loglines.push(payload); + console.log(`[FFmpeg] ${payload.action}: ${payload.message}`); + }, + corePath: getPublicPathFor('ffmpeg-core.js'), + workerPath: getPublicPathFor('worker.min.js'), + }); - this.inFileName = `inAudioFile.${ext[0]}`; - this.outFileNameNoExt = `outAudioFile`; + console.log('[AudioExport] Loading FFmpeg worker'); + await this.ffmpegProcess.load(); + console.log('[AudioExport] FFmpeg worker loaded successfully'); - await this.ffmpegProcess.write(this.inFileName, file); + // Initialize Atracdenc web worker + console.log('[AudioExport] Creating Atracdenc worker'); + this.atracdencProcess = new AtracdencProcess(new AtracdencWorker()); + console.log('[AudioExport] Initializing Atracdenc worker'); + await this.atracdencProcess.init(); + console.log('[AudioExport] Atracdenc worker initialized successfully'); + + // Extract file extension + let ext = file.name.split('.').slice(-1); + if (ext.length === 0) { + throw new Error(`Unrecognized file format: ${file.name}`); + } + + this.inFileName = `inAudioFile.${ext[0]}`; + this.outFileNameNoExt = `outAudioFile`; + + console.log(`[AudioExport] Writing file to FFmpeg: ${this.inFileName}`); + await this.ffmpegProcess.write(this.inFileName, file); + console.log('[AudioExport] File written to FFmpeg successfully'); + } catch (error) { + console.error('[AudioExport] Error in prepare:', error); + throw error; + } } async info() { - await this.ffmpegProcess.transcode(this.inFileName, `${this.outFileNameNoExt}.metadata`, `-f ffmetadata`); - - let audioFormatRegex = /Audio:\s(.*?),/; // Actual content - let inputFormatRegex = /Input #0,\s(.*?),/; // Container - let format: string | null = null; - let input: string | null = null; - - for (let line of this.loglines) { - let match = line.message.match(audioFormatRegex); - if (match !== null) { - format = match[1]; - continue; - } - match = line.message.match(inputFormatRegex); - if (match !== null) { - input = match[1]; - continue; - } - if (format !== null && input !== null) { - break; + try { + console.log(`[AudioExport] Getting file info for: ${this.inFileName}`); + await this.ffmpegProcess.transcode(this.inFileName, `${this.outFileNameNoExt}.metadata`, `-f ffmetadata`); + + let audioFormatRegex = /Audio:\s(.*?),/; // Actual content + let inputFormatRegex = /Input #0,\s(.*?),/; // Container + let format: string | null = null; + let input: string | null = null; + + for (let line of this.loglines) { + let match = line.message.match(audioFormatRegex); + if (match !== null) { + format = match[1]; + continue; + } + match = line.message.match(inputFormatRegex); + if (match !== null) { + input = match[1]; + continue; + } + if (format !== null && input !== null) { + break; + } } - } - return { format, input }; + console.log(`[AudioExport] File info - format: ${format}, input: ${input}`); + return { format, input }; + } catch (error) { + console.error('[AudioExport] Error in info:', error); + throw error; + } } async export({ requestedFormat }: { requestedFormat: 'SP' | 'LP2' | 'LP4' | 'LP105' }) { - let result: ArrayBuffer; - let format: Wireformat; - const atrac3Info = await getAtrac3Info(this.inFile!); - if (atrac3Info) { - format = WireformatDict[atrac3Info.mode]; - result = (await this.inFile!.arrayBuffer()).slice(atrac3Info.dataOffset); - } else if (requestedFormat === `SP`) { - const outFileName = `${this.outFileNameNoExt}.raw`; - await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-ac 2 -ar 44100 -f s16be'); - let { data } = await this.ffmpegProcess.read(outFileName); - result = data.buffer; - format = Wireformat.pcm; - } else { - const outFileName = `${this.outFileNameNoExt}.wav`; - await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f wav -ar 44100 -ac 2'); - let { data } = await this.ffmpegProcess.read(outFileName); - let bitrate: string = `0`; - switch (requestedFormat) { - case `LP2`: - bitrate = `128`; - format = Wireformat.lp2; - break; - case `LP105`: - bitrate = `102`; - format = Wireformat.l105kbps; - break; - case `LP4`: - bitrate = `64`; - format = Wireformat.lp4; - break; + try { + console.log(`[AudioExport] Exporting with format: ${requestedFormat}`); + let result: ArrayBuffer; + let format: Wireformat; + + // Check if file is already in ATRAC3 format + console.log('[AudioExport] Checking if file is already in ATRAC3 format'); + const atrac3Info = await getAtrac3Info(this.inFile!); + + if (atrac3Info) { + console.log(`[AudioExport] File is already in ATRAC3 format: ${atrac3Info.mode}`); + format = WireformatDict[atrac3Info.mode]; + result = (await this.inFile!.arrayBuffer()).slice(atrac3Info.dataOffset); + } else if (requestedFormat === `SP`) { + console.log('[AudioExport] Converting to SP format (PCM)'); + const outFileName = `${this.outFileNameNoExt}.raw`; + await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-ac 2 -ar 44100 -f s16be'); + console.log('[AudioExport] Transcode to SP completed, reading output file'); + let { data } = await this.ffmpegProcess.read(outFileName); + result = data.buffer; + format = Wireformat.pcm; + console.log('[AudioExport] SP conversion completed successfully'); + } else { + console.log('[AudioExport] Converting to compressed format (LP2/LP4)'); + // First convert to WAV + const outFileName = `${this.outFileNameNoExt}.wav`; + console.log(`[AudioExport] Transcoding to WAV: ${outFileName}`); + await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f wav -ar 44100 -ac 2'); + console.log('[AudioExport] Transcode to WAV completed, reading output file'); + let { data } = await this.ffmpegProcess.read(outFileName); + console.log(`[AudioExport] WAV file read, size: ${data.buffer.byteLength}bytes`); + + let bitrate: string = `0`; + switch (requestedFormat) { + case `LP2`: + bitrate = `128`; + format = Wireformat.lp2; + break; + case `LP105`: + bitrate = `102`; + format = Wireformat.l105kbps; + break; + case `LP4`: + bitrate = `64`; + format = Wireformat.lp4; + break; + } + + console.log(`[AudioExport] Encoding to ATRAC3 with bitrate: ${bitrate}`); + result = await this.atracdencProcess!.encode(data.buffer, bitrate); + console.log(`[AudioExport] ATRAC3 encoding completed, size: ${result.byteLength}bytes`); + } + + console.log('[AudioExport] Terminating workers'); + this.ffmpegProcess.worker.terminate(); + this.atracdencProcess!.terminate(); + + console.log('[AudioExport] Export completed successfully'); + return { + data: result, + format, + }; + } catch (error) { + console.error('[AudioExport] Error in export:', error); + if (this.ffmpegProcess) { + try { + this.ffmpegProcess.worker.terminate(); + } catch (e) { + console.error('[AudioExport] Error terminating FFmpeg worker:', e); + } + } + if (this.atracdencProcess) { + try { + this.atracdencProcess.terminate(); + } catch (e) { + console.error('[AudioExport] Error terminating Atracdenc worker:', e); + } } - result = await this.atracdencProcess!.encode(data.buffer, bitrate); + throw error; } - this.ffmpegProcess.worker.terminate(); - this.atracdencProcess!.terminate(); - return { - data: result, - format, - }; } } diff --git a/src/services/registry.ts b/src/services/registry.ts index 758965d..121abed 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -3,11 +3,131 @@ import { AudioExportService } from './audio-export'; import { MediaRecorderService } from './mediarecorder'; import { MediaSessionService } from './media-session'; +// Last.fm API service interface +export interface LastFmService { + getAlbumInfo( + artist: string, + album: string + ): Promise<{ + images?: { small?: string; medium?: string; large?: string; extralarge?: string; mega?: string }; + }>; + getTrackInfo( + artist: string, + track: string + ): Promise<{ + album?: { + title?: string; + artist?: string; + images?: { small?: string; medium?: string; large?: string; extralarge?: string; mega?: string }; + }; + }>; +} + +// Last.fm API service implementation class +export class LastFmAPIService implements LastFmService { + // Get API key from environment variable + private readonly API_KEY = process.env.REACT_APP_LASTFM_API_KEY; + private readonly API_URL = 'https://ws.audioscrobbler.com/2.0/'; + private readonly MAX_RETRIES = 1; // Maximum retries for API calls + + async getAlbumInfo(artist: string, album: string): Promise<{ images?: any }> { + // Return empty result if album name is missing + if (!album) { + console.log('Last.fm API: Album name is missing'); + return {}; + } + + try { + // If artist name is missing, search by album name only + const url = `${this.API_URL}?method=album.getinfo&api_key=${this.API_KEY}${ + artist ? `&artist=${encodeURIComponent(artist)}` : '' + }&album=${encodeURIComponent(album)}&autocorrect=1&format=json`; + const response = await fetch(url); + const data = await response.json(); + + if (data.error) { + console.error('Last.fm API error:', data.message); + return {}; + } + + // Extract image information from result + const images: any = {}; + if (data.album && data.album.image) { + data.album.image.forEach((img: any) => { + if (img.size && img['#text']) { + images[img.size] = img['#text']; + } + }); + } + + return { images }; + } catch (error) { + console.error('Last.fm API call error:', error); + return {}; + } + } + + async getTrackInfo(artist: string, track: string): Promise<{ album?: any }> { + // 트랙 이름이 없으면 빈 결과 반환 + if (!track) { + console.log('Last.fm API: Track name is missing'); + return {}; + } + + try { + // 아티스트 이름이 없으면 트랙 이름만으로 검색 + const url = `${this.API_URL}?method=track.getinfo&api_key=${this.API_KEY}&track=${encodeURIComponent(track)}${ + artist ? `&artist=${encodeURIComponent(artist)}` : '' + }&autocorrect=1&format=json`; + const response = await fetch(url); + + // 서버가 응답하지 않는 경우 + if (!response.ok) { + console.error(`Last.fm API response error: ${response.status} ${response.statusText}`); + return {}; + } + + const data = await response.json(); + + if (data.error) { + console.error('Last.fm API error:', data.message); + return {}; + } + + // 트랙에서 앨범 정보 추출 + if (!data.track || !data.track.album) { + return {}; + } + + const album: any = { + title: data.track.album.title, + artist: data.track.album.artist, + images: {}, + }; + + // 앨범 이미지 추출 + if (data.track.album.image) { + data.track.album.image.forEach((img: any) => { + if (img.size && img['#text']) { + album.images[img.size] = img['#text']; + } + }); + } + + return { album }; + } catch (error) { + console.error('Last.fm API call error:', error); + return {}; + } + } +} + interface ServiceRegistry { netmdService?: NetMDService; audioExportService?: AudioExportService; mediaRecorderService?: MediaRecorderService; mediaSessionService?: MediaSessionService; + lastFmService?: LastFmService; } const ServiceRegistry: ServiceRegistry = {};