diff --git a/.gitignore b/.gitignore index 1266766..fff86a7 100644 --- a/.gitignore +++ b/.gitignore @@ -129,5 +129,4 @@ dist .yarn/install-state.gz .pnp.* -# Miscellaneous *.mp3 diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml deleted file mode 100644 index d1e3d40..0000000 --- a/.idea/git_toolbox_prj.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace-LifeIsARace.xml b/.idea/workspace-LifeIsARace.xml deleted file mode 100644 index ec27889..0000000 --- a/.idea/workspace-LifeIsARace.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - { - "lastFilter": { - "state": "OPEN", - "assignee": "xtrll" - } -} - - - - - - - - { - "associatedIndex": 4 -} - - - - - - - { - "keyToString": { - "ASKED_ADD_EXTERNAL_FILES": "true", - "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", - "Node.js.app.js.executor": "Run", - "Node.js.audioController.js.executor": "Run", - "RunOnceActivity.OpenProjectViewOnStart": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "git-widget-placeholder": "main", - "ignore.virus.scanning.warn.message": "true", - "javascript.nodejs.core.library.configured.version": "21.6.2", - "javascript.nodejs.core.library.typings.version": "20.12.7", - "last_opened_file_path": "C:/Users/XTRLL/OneDrive/Projects/WebstormProjects/MusicMetaFinder", - "node.js.detected.package.eslint": "true", - "node.js.detected.package.tslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "settings.editor.selected.configurable": "terminal", - "vue.rearranger.settings.migration": "true" - } -} - - - - - - - - - - 1713646863779 - - - - - - - - - - - \ No newline at end of file diff --git a/app.js b/cli.js similarity index 63% rename from app.js rename to cli.js index f0fc03b..cc54c90 100644 --- a/app.js +++ b/cli.js @@ -1,34 +1,36 @@ #!/usr/bin/env node -import { checkEnvVariables } from './src/services/checkEnvVariables.js'; -import { checkInputPath } from './src/services/checkInputPath.js'; -import fetchFiles from './src/utils/filesFetcher.js'; -import { validateAudioFiles } from './src/controllers/fileController.js'; -import { recognizeAudioFiles } from './src/controllers/recognitionController.js'; -import { retrieveMetadata } from './src/controllers/metadataController.js'; +import checkEnvVariables from './src/utils/checkEnvVariables.js'; +import checkInputPath from './src/utils/checkInputPath.js'; +import fetchFiles from './src/utils/fetchFiles.js'; +import validateAudioFiles from './src/services/fileService.js'; +import recognizeAudioFiles from './src/services/musicRecognitionService.js'; +import retrieveMetadata from './src/services/metadataRetrievalService.js'; const { ACOUSTID_API_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } = process.env; async function main() { try { // Check for required environment variables - checkEnvVariables(); - // Check for the input path + // checkEnvVariables(); + // Check for required input path const inputPath = process.argv[[2]]; checkInputPath(inputPath); + // Preferred service can be provided + const service = process.argv[[3]] || undefined; // Resolve the input path to get array of file paths (handles one or more files) const files = await fetchFiles(inputPath); // Process the resolved paths to confirm and prepare the audio file for metadata recognition const audioFiles = await validateAudioFiles(files); // Recognize the content of the audio file and obtain the corresponding Spotify track ID - const recordingIds = await recognizeAudioFiles(audioFiles); + const audioIds = await recognizeAudioFiles(audioFiles, service); // Fetch the audio metadata from Spotify using the recognized track IDs - const audioMetadata = await retrieveMetadata(recordingIds); + const audioMetadata = await retrieveMetadata(audioIds, service); console.log(audioMetadata); // Write the fetched metadata into the audio file // const processedAudioFiles = await fileController.writeMetadata(audioMetadata, audioFiles); } catch (e) { - console.error('An error occurred inside app.js:', e); + console.error('An error occurred inside cli.js:', e); process.exit(1); } } diff --git a/package-lock.json b/package-lock.json index 95865c0..4b237eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,17 @@ "dependencies": { "axios": "^1.6.8", "axios-retry": "^4.1.0", - "command-exists": "^1.2.9", + "commander": "^12.0.0", "dotenv": "^16.4.5", + "fluent-ffmpeg": "^2.1.2", "fpcalc": "^1.3.0", "music-metadata": "^7.14.0", + "node-fetch": "^3.3.2", + "node-id3": "^0.2.6", "qs": "^6.12.1" }, "bin": { - "analyze-audio": "node --env-file .env ./app.js" + "analyze-audio": "node --env-file .env ./cli.js" }, "devDependencies": { "@eslint/eslintrc": "^3.0.2", @@ -383,6 +386,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -517,10 +525,13 @@ "node": ">= 0.8" } }, - "node_modules/command-exists": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + "node_modules/commander": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", + "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", + "engines": { + "node": ">=18" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -607,6 +618,14 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -1268,6 +1287,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -1332,6 +1373,29 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz", + "integrity": "sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q==", + "dependencies": { + "async": ">=0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -1373,6 +1437,17 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fpcalc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fpcalc/-/fpcalc-1.3.0.tgz", @@ -1619,6 +1694,17 @@ "node": ">= 0.4" } }, + "node_modules/iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1964,8 +2050,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -2137,6 +2222,49 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-id3": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.6.tgz", + "integrity": "sha512-w8GuKXLlPpDjTxLowCt/uYMhRQzED3cg2GdSG1i6RSGKeDzPvxlXeLQuQInKljahPZ0aDnmyX7FX8BbJOM7REg==", + "dependencies": { + "iconv-lite": "0.6.2" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -2600,6 +2728,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3014,6 +3147,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fb0e3db..e0bc110 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,18 @@ "version": "1.0.0", "description": "CLI utility for music enthusiasts to automatically recognize tracks and enrich file metadata based on the fetched information.", "bin": { - "analyze-audio": "node --env-file .env ./app.js" + "analyze-audio": "node --env-file .env ./cli.js" }, "dependencies": { "axios": "^1.6.8", "axios-retry": "^4.1.0", - "command-exists": "^1.2.9", + "commander": "^12.0.0", "dotenv": "^16.4.5", + "fluent-ffmpeg": "^2.1.2", "fpcalc": "^1.3.0", "music-metadata": "^7.14.0", + "node-fetch": "^3.3.2", + "node-id3": "^0.2.6", "qs": "^6.12.1" }, "devDependencies": { @@ -24,7 +27,7 @@ }, "scripts": { "lint": "eslint . --fix", - "analyze": "node --env-file .env app.js" + "analyze": "node --env-file .env cli.js" }, "type": "module" } diff --git a/src/controllers/metadataController.js b/src/adapters/metadata/musicBrainzAdapter.js similarity index 58% rename from src/controllers/metadataController.js rename to src/adapters/metadata/musicBrainzAdapter.js index e02b08c..776fdd2 100644 --- a/src/controllers/metadataController.js +++ b/src/adapters/metadata/musicBrainzAdapter.js @@ -1,22 +1,22 @@ -import getTrackMetadata from '../api/metadataRetrieval.js'; -import getLyrics from '../api/lyricRetrieval.js'; -import {getAlbumArt} from "../api/imageRetrieval.js"; +import requestMetadata from '../../api/metadata/musicBrainzApi.js'; +import getAlbumArt from "../../api/metadata/coverArtArchiveApi.js"; +import getLyrics from '../../api/metadata/lyricOvhApi.js'; /** - * Controller to handle retrieval of metadata for an array of MusicBrainz recording IDs. + * Adapter to handle retrieval of metadata for an array of MusicBrainz recording IDs. * @function * @param {string[]} recordingIds - An array of MusicBrainz recording IDs. - * @returns {Promise} A promise that resolves with an array of metadata objects. + * @returns {Promise} A promise that resolves with an object of metadata. */ -export async function retrieveMetadata(recordingIds) { +export default async function getMetadata(recordingIds) { try { if (!Array.isArray(recordingIds)) { - throw new Error('MetadataController expects an array of recordingIds'); + throw new Error('musicBrainzAdapter expects an array of recordingIds'); } - // Prepare an array to hold the metadata results initialized as an array of Promises + // Prepare an object to hold the metadata results initialized as an array of Promises const metadataPromises = recordingIds.map(async (recordingId) => { - const trackMetadata = await getTrackMetadata(recordingId); + const trackMetadata = await requestMetadata(recordingId); const artist = trackMetadata['artist-credit']?.[0]?.name; const title = trackMetadata.title; const albumId = trackMetadata.releases[0].id @@ -35,7 +35,7 @@ export async function retrieveMetadata(recordingIds) { return await Promise.all(metadataPromises); } catch (e) { - console.error('Error retrieving metadata from MusicBrainz or lyrics:', e); + console.error('Error retrieving metadata using MetaBrainz:', e); throw e; } } diff --git a/src/adapters/metadata/spotifyAdapter.js b/src/adapters/metadata/spotifyAdapter.js new file mode 100644 index 0000000..7d0664e --- /dev/null +++ b/src/adapters/metadata/spotifyAdapter.js @@ -0,0 +1,36 @@ +import getSpotifyAccessToken from '../../api/spotifyAuthApi.js'; +import requestMetadata from '../../api/metadata/spotifyApi.js'; +import getLyrics from '../../api/metadata/lyricOvhApi.js'; + +/** + * Adapter to handle retrieval of metadata for an array of Spotify track IDs. + * @function + * @param {string[]} trackIds - An array of spotify track IDs. + * @returns {Promise} A promise that resolves with an object of metadata. + */ +export default async function getMetadata(trackIds) { + try { + if (!Array.isArray(trackIds)) { + throw new Error('spotifyAdapter expects an array of trackIds'); + } + + const accessToken = await getSpotifyAccessToken(); + + const metadataPromises = trackIds.map(async (trackId) => { + const trackMetadata = await requestMetadata(trackId, accessToken); + + // const lyrics = await getLyrics(artist, title); + + // Combine the track metadata with the lyrics + return { + ...trackMetadata, + // lyrics + }; + }); + + return await Promise.all(metadataPromises); + } catch (e) { + console.error('Error retrieving metadata using Spotify:', e); + throw e; + } +} diff --git a/src/controllers/recognitionController.js b/src/adapters/recognition/acoustidAdapter.js similarity index 78% rename from src/controllers/recognitionController.js rename to src/adapters/recognition/acoustidAdapter.js index 688ca75..b9e9176 100644 --- a/src/controllers/recognitionController.js +++ b/src/adapters/recognition/acoustidAdapter.js @@ -1,4 +1,4 @@ -import audioRecognition from '../api/audioRecognition.js'; +import recognizeAudio from '../../api/recognition/acoustidApi.js'; /** * Recognizes a list of audio files. @@ -6,8 +6,8 @@ import audioRecognition from '../api/audioRecognition.js'; * @param {string[]} audioFiles An array of file paths of the audio files. * @return {Promise} A promise that resolves to an array of recognition results. */ -export async function recognizeAudioFiles(audioFiles) { - const recognitionPromises = audioFiles.map((filePath) => audioRecognition(filePath) +export default async function recognizeAudioFiles(audioFiles) { + const recognitionPromises = audioFiles.map((filePath) => recognizeAudio(filePath) .catch((error) => { // Log the error and return null // This prevents one failed recognition from stopping the whole process diff --git a/src/adapters/recognition/auddAdapter.js b/src/adapters/recognition/auddAdapter.js new file mode 100644 index 0000000..1d54662 --- /dev/null +++ b/src/adapters/recognition/auddAdapter.js @@ -0,0 +1,22 @@ +import recognizeAudio from '../../api/recognition/auddApi.js'; + +/** + * Recognizes a list of audio files. + * + * @param {string[]} audioFiles An array of file paths of the audio files. + * @return {Promise} A promise that resolves to an array of recognition results. + */ +export default async function recognizeAudioFiles(audioFiles) { + const recognitionPromises = audioFiles.map((filePath) => recognizeAudio(filePath) + .catch((error) => { + // Log the error and return null + // This prevents one failed recognition from stopping the whole process + console.error(`Recognition failed for file ${filePath}:`, error); + return null; + })); + + // Wait for all recognitions to resolve. This will be an array of results or null values + const recognizedAudioFiles = await Promise.all(recognitionPromises); + // Filter out unsuccessful recognitions + return recognizedAudioFiles.filter((result) => result !== null); +} diff --git a/src/api/imageRetrieval.js b/src/api/metadata/coverArtArchiveApi.js similarity index 88% rename from src/api/imageRetrieval.js rename to src/api/metadata/coverArtArchiveApi.js index 8e1dabf..4eb1382 100644 --- a/src/api/imageRetrieval.js +++ b/src/api/metadata/coverArtArchiveApi.js @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axiosRetry from '../../utils/retryAxios.js'; /** * Retrieves the album art image URL for a given album ID. @@ -7,10 +7,10 @@ import axios from 'axios'; * @param {string} albumId - The unique identifier for the album whose art is being retrieved. * @returns {Promise} - A promise that resolves to the image URL or null if not found or in case of an error. */ -export const getAlbumArt = (albumId) => { +export default async function getAlbumArt(albumId) { const endpoint = `http://coverartarchive.org/release/${albumId}/front`; - return axios.get(endpoint, { maxRedirects: 0 }) + return axiosRetry.get(endpoint, { maxRedirects: 0 }) .then((response) => { // If the status code is 200, the image URL should be in the responseURL if (response.status === 200) { @@ -30,4 +30,4 @@ export const getAlbumArt = (albumId) => { console.error('Error retrieving cover art:', error); return null; }); -}; +} diff --git a/src/api/lyricRetrieval.js b/src/api/metadata/lyricOvhApi.js similarity index 84% rename from src/api/lyricRetrieval.js rename to src/api/metadata/lyricOvhApi.js index 09e2ce6..a9c3c5b 100644 --- a/src/api/lyricRetrieval.js +++ b/src/api/metadata/lyricOvhApi.js @@ -1,5 +1,5 @@ -import axiosRetry from '../services/retryAxios.js'; -import { handleError } from '../errors/generalApiErrorHandler.js'; +import axiosRetry from '../../utils/retryAxios.js'; +import handleError from '../../errors/generalApiErrorHandler.js'; /** * Fetches lyrics for a specific song using the provided artist name and title. @@ -9,7 +9,7 @@ import { handleError } from '../errors/generalApiErrorHandler.js'; * @param {string} title - The title of the song. * @returns {Promise} Promise object representing the lyrics for the song or null if not found or in case of an error. */ -export default function fetchLyrics(artist, title) { +export default async function getLyrics(artist, title) { const endpoint = `https://api.lyrics.ovh/v1/${encodeURIComponent(artist)}/${encodeURIComponent(title)}`; return axiosRetry.get(endpoint) diff --git a/src/api/metadataRetrieval.js b/src/api/metadata/musicBrainzApi.js similarity index 76% rename from src/api/metadataRetrieval.js rename to src/api/metadata/musicBrainzApi.js index eabfc56..7e08c3c 100644 --- a/src/api/metadataRetrieval.js +++ b/src/api/metadata/musicBrainzApi.js @@ -1,13 +1,13 @@ -import axiosRetry from '../services/retryAxios.js'; -import { handleError } from '../errors/generalApiErrorHandler.js'; +import axiosRetry from '../../utils/retryAxios.js'; +import handleError from '../../errors/generalApiErrorHandler.js'; /** * Retrieves the metadata for a recording from MusicBrainz. * - * @param {string} recordingId - The MusicBrainz recording ID. + * @param {string} recordingId - The MusicBrainz recording ID (can be obtained from acoustid). * @returns {Promise} - A promise resolving to the track metadata. */ -export default async function getAudioMetadata(recordingId) { +export default async function getMetadata(recordingId) { const baseUrl = 'https://musicbrainz.org'; const query = `/ws/2/recording/${recordingId}?fmt=json&inc=artists+releases+release-groups+isrcs+url-rels+discids+media+artist-credits+aliases+tags+ratings+genres`; @@ -37,6 +37,7 @@ export default async function getAudioMetadata(recordingId) { console.error(`No metadata found for recording ID: ${recordingId}`); return null; } - handleError(error, recordingId); + const errorMessage = handleError(error, recordingId); + throw new Error(errorMessage); }); } diff --git a/src/api/metadata/spotifyApi.js b/src/api/metadata/spotifyApi.js new file mode 100644 index 0000000..361e0bf --- /dev/null +++ b/src/api/metadata/spotifyApi.js @@ -0,0 +1,19 @@ +import axiosRetry from '../../utils/retryAxios.js'; +import handleError from '../../errors/generalApiErrorHandler.js'; + +export default function getMetadata(trackId, accessToken) { + const endpoint = `https://api.spotify.com/v1/tracks/${trackId}`; + + const requestOptions = { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }; + + return axiosRetry.get(endpoint, requestOptions) + .then((response) => response.data) + .catch((error) => { + const errorMessage = handleError(error, trackId); + throw new Error(errorMessage); + }); +} diff --git a/src/api/audioRecognition.js b/src/api/recognition/acoustidApi.js similarity index 84% rename from src/api/audioRecognition.js rename to src/api/recognition/acoustidApi.js index 51310b1..0579535 100644 --- a/src/api/audioRecognition.js +++ b/src/api/recognition/acoustidApi.js @@ -1,14 +1,14 @@ import fpcalc from 'fpcalc'; // Chromaprint MUST be present inside %PATH% as well. https://github.com/acoustid/chromaprint/releases -import axiosRetry from '../services/retryAxios.js'; -import { handleError } from '../errors/acoustidApiErrorHandler.js'; +import axiosRetry from '../../utils/retryAxios.js'; +import handleError from '../../errors/acoustidApiErrorHandler.js'; /** * Identifies an audio file using the AcoustID API. * * @param {string} filePath - The path to the audio file to be recognized. - * @returns {Promise} - A promise resolving to the recognition result. + * @returns {Promise} - A promise resolving to the recording id. */ -export default async function audioRecognition(filePath) { +export default async function acoustdIdAudioRecognition(filePath) { const { duration, fingerprint } = await new Promise((resolve, reject) => { fpcalc(filePath, (err, result) => { if (err) reject(err); diff --git a/src/api/recognition/auddApi.js b/src/api/recognition/auddApi.js new file mode 100644 index 0000000..86fc4a0 --- /dev/null +++ b/src/api/recognition/auddApi.js @@ -0,0 +1,34 @@ +import fs from 'fs'; +import axios from 'axios'; +import axiosRetry from '../../utils/retryAxios.js'; +import handleError from '../../errors/generalApiErrorHandler.js'; + +export default async function auddAudioRecognition(filePath) { + const audioData = fs.readFileSync(filePath); + const base64Audio = Buffer.from(audioData).toString('base64'); + const body = new URLSearchParams({ + api_token: process.env.AUDD_API_TOKEN, + audio: base64Audio, + return: 'spotify', + }); + + const requestOptions = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + + return axios.post('https://api.audd.io/', new URLSearchParams(body), requestOptions) + .then((response) => { + const { data } = response; + + if (!data) throw new Error('No data received from Audd API'); + if (data.error) throw new Error(`Audd API Error: ${JSON.stringify(data.error)}`); + + console.log('Recognition successful for:', filePath); + return data; + }) + .catch((error) => { + console.error('Error recognizing audio'); + const errorMessage = handleError(error, filePath); + throw new Error(errorMessage); + }); +} diff --git a/src/api/spotifyAuth.js b/src/api/spotifyAuth.js deleted file mode 100644 index c05c19c..0000000 --- a/src/api/spotifyAuth.js +++ /dev/null @@ -1,31 +0,0 @@ -import axios from 'axios'; -import qs from 'qs'; - -const client_id = process.env.SPOTIFY_CLIENT_ID; -const client_secret = process.env.SPOTIFY_CLIENT_SECRET; - -export default function getSpotifyAccessToken() { - const authOptions = { - method: 'POST', - url: 'https://accounts.spotify.com/api/token', - headers: { - Authorization: `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString('base64')}`, - 'Content-Type': 'applicaion/x-www-form-urlencoded', - }, - data: qs.stringify({ - grant_type: 'client_credentials', - }), - }; - - return axios(authOptions) - .then((response) => { - if (response.status === 200) { - return response.data.access_token; - } - return Promise.reject(new Error(`Could not get access token for ${response.status}`)); - }) - .catch((error) => { - console.error('Auth Error:', error); - throw error; - }); -} diff --git a/src/api/spotifyAuthApi.js b/src/api/spotifyAuthApi.js new file mode 100644 index 0000000..da8e576 --- /dev/null +++ b/src/api/spotifyAuthApi.js @@ -0,0 +1,32 @@ +import qs from 'qs'; +import axiosRetry from '../utils/retryAxios.js'; +import handleError from '../errors/generalApiErrorHandler.js'; + +export default async function getSpotifyAccessToken() { + const { client_id, client_secret } = process.env; + + const authOptions = { + method: 'POST', + url: 'https://accounts.spotify.com/api/token', + headers: { + Authorization: `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: qs.stringify({ + grant_type: 'client_credentials', + }), + }; + + return axiosRetry(authOptions) + .then((response) => { + if (response.status === 200) { + return response.data.access_token; + } + throw new Error(`Could not get access token for ${response.status}`); + }) + .catch((error) => { + console.error('Spotify authentication error:', error); + const errorMessage = handleError(error); + throw new Error(errorMessage); + }); +} diff --git a/src/errors/acoustidApiErrorHandler.js b/src/errors/acoustidApiErrorHandler.js index 656e624..09d2218 100644 --- a/src/errors/acoustidApiErrorHandler.js +++ b/src/errors/acoustidApiErrorHandler.js @@ -1,4 +1,4 @@ -export function handleError(error, filePath) { +export default function handleError(error, filePath) { if (error.response) { // Detailed error information when the API responds with an error status console.error(`Request failed with status: ${error.response.status}`); console.error('Headers:', error.response.headers); diff --git a/src/errors/generalApiErrorHandler.js b/src/errors/generalApiErrorHandler.js index 09028b9..317447c 100644 --- a/src/errors/generalApiErrorHandler.js +++ b/src/errors/generalApiErrorHandler.js @@ -1,4 +1,4 @@ -export function handleError(error, identifier) { +export default function handleError(error, identifier) { let message = ''; // Handler when the API responds with an error status. diff --git a/src/services/audioTagWriter.js b/src/services/audioTagWriter.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/controllers/fileController.js b/src/services/fileService.js similarity index 87% rename from src/controllers/fileController.js rename to src/services/fileService.js index 4a90dd3..99a7869 100644 --- a/src/controllers/fileController.js +++ b/src/services/fileService.js @@ -1,4 +1,4 @@ -import validateAudioFile from '../services/audioFileValidator.js'; +import validateAudioFile from '../utils/validateAudioFiles.js'; /** * Processes an array of file paths and returns an array containing only valid audio file paths. @@ -6,7 +6,7 @@ import validateAudioFile from '../services/audioFileValidator.js'; * @param {string[]} filePaths - The array of file paths to process. * @returns {Promise} A promise that resolves to an array of valid audio file paths. */ -export async function validateAudioFiles(filePaths) { +export default async function validateAudioFiles(filePaths) { // Validate input type if (!filePaths instanceof Array) { throw new TypeError('Input must be an array of file paths (strings).'); diff --git a/src/services/metadataNormalizerService.js b/src/services/metadataNormalizerService.js new file mode 100644 index 0000000..a2e954a --- /dev/null +++ b/src/services/metadataNormalizerService.js @@ -0,0 +1,14 @@ +function normalize(metadata) { + const normalized = { + title: metadata.title || metadata.name || '', + artist: metadata.artist || '', + album: metadata.album || metadata.collection || '', + year: metadata.year || metadata.releaseDate && metadata.releaseDate.substring(0, 4) || '', + artwork: metadata.albumArt || metadata.artwork || metadata.image || '', + lyrics: metadata.lyrics || '', + genre: metadata.genre || (metadata.tags && metadata.tags.join(', ')) || '', + // ... add any other fields necessary for normalization + }; + + return normalized; +} diff --git a/src/services/metadataRetrievalService.js b/src/services/metadataRetrievalService.js new file mode 100644 index 0000000..c328deb --- /dev/null +++ b/src/services/metadataRetrievalService.js @@ -0,0 +1,18 @@ +import getMetadataUsingSpotify from '../adapters/metadata/spotifyAdapter.js'; +import getMetadataUsingMusicBrainz from '../adapters/metadata/musicBrainzAdapter.js'; + +// List of supported services +const serviceMap = { + audd: getMetadataUsingSpotify, + acoustid: getMetadataUsingMusicBrainz, +}; + +export default async function recognizeAudio(audioIds, source) { + let recognitionService = serviceMap[source]; + if (!recognitionService) { + console.error('Recognition service unknown or not provided, using default configuration'); + recognitionService = getMetadataUsingMusicBrainz; // Default service + } + + return recognitionService(audioIds); +} diff --git a/src/services/musicRecognitionService.js b/src/services/musicRecognitionService.js new file mode 100644 index 0000000..fa168e7 --- /dev/null +++ b/src/services/musicRecognitionService.js @@ -0,0 +1,18 @@ +import recognizeUsingAudd from '../adapters/recognition/auddAdapter.js'; +import recognizeUsingAcoustid from '../adapters/recognition/acoustidAdapter.js'; + +// List of supported services +const serviceMap = { + audd: recognizeUsingAudd, + acoustid: recognizeUsingAcoustid, +}; + +export default async function recognizeAudio(filePaths, source) { + let recognitionService = serviceMap[source]; + if (!recognitionService) { + console.error('Recognition service unknown or not provided, using default configuration'); + recognitionService = recognizeUsingAcoustid; // Default service + } + + return recognitionService(filePaths); +} diff --git a/src/controllers/pathController.js b/src/services/pathService.js similarity index 92% rename from src/controllers/pathController.js rename to src/services/pathService.js index ddf93b3..efc33d7 100644 --- a/src/controllers/pathController.js +++ b/src/services/pathService.js @@ -1,4 +1,4 @@ -import filesFetcher from '../utils/filesFetcher.js'; +import filesFetcher from '../utils/fetchFiles.js'; /** * Handle the input path provided by the user to resolve file paths diff --git a/src/services/checkEnvVariables.js b/src/utils/checkEnvVariables.js similarity index 83% rename from src/services/checkEnvVariables.js rename to src/utils/checkEnvVariables.js index 6626daa..bd8a5be 100644 --- a/src/services/checkEnvVariables.js +++ b/src/utils/checkEnvVariables.js @@ -1,4 +1,4 @@ -export function checkEnvVariables() { +export default function checkEnvVariables() { if (!process.env.ACOUSTID_API_KEY || !process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET) { throw new Error('Please set up ACOUSTID_API_KEY, SPOTIFY_CLIENT_ID, and SPOTIFY_CLIENT_SECRET in your .env file.'); } diff --git a/src/services/checkInputPath.js b/src/utils/checkInputPath.js similarity index 66% rename from src/services/checkInputPath.js rename to src/utils/checkInputPath.js index 19f409e..f9c22ca 100644 --- a/src/services/checkInputPath.js +++ b/src/utils/checkInputPath.js @@ -1,4 +1,4 @@ -export function checkInputPath(inputPath) { +export default function checkInputPath(inputPath) { if (!inputPath) { throw new Error('Please provide the path to an audio file or directory.'); } diff --git a/src/utils/filesFetcher.js b/src/utils/fetchFiles.js similarity index 100% rename from src/utils/filesFetcher.js rename to src/utils/fetchFiles.js diff --git a/src/services/retryAxios.js b/src/utils/retryAxios.js similarity index 100% rename from src/services/retryAxios.js rename to src/utils/retryAxios.js diff --git a/src/services/audioFileValidator.js b/src/utils/validateAudioFiles.js similarity index 100% rename from src/services/audioFileValidator.js rename to src/utils/validateAudioFiles.js diff --git a/track.mp3 b/track.mp3 deleted file mode 100644 index 098b4f6..0000000 Binary files a/track.mp3 and /dev/null differ