diff --git a/.env b/.env index fff40b0..777bbc6 100644 --- a/.env +++ b/.env @@ -1,2 +1 @@ VITE_APP_NAME=Audio Splitter -VITE_OUTPUT_FILE_NAME_TEMPLATE={TITLE} diff --git a/README.MD b/README.MD index 9ecb1ce..0ee02a7 100644 --- a/README.MD +++ b/README.MD @@ -18,7 +18,7 @@ For information on how to contribute to the development of AudioStreamSplitting, ### Setup -1. Generate a valid API key for the [AcoustID](https://acoustid.org/) and/or [Shazam](https://rapidapi.com/apidojo/api/shazam) song recognition APIs. While this program can work if you only provide one of the two, it is strongly recommended to have both in order for the song identification to function properly. More song recognition APIs may be supported in the future. For the AcoustID API, you will need to register an application and use the application API key, rather than just the normal user API key. +1. Generate a valid API key for the [AcoustID](https://acoustid.org/) and/or [Shazam](https://rapidapi.com/apidojo/api/shazam) song recognition APIs. While this program can work if you only provide one of the two, it is strongly recommended to have both in order for the song identification to function properly. More song recognition APIs may be supported in the future. For the AcoustID API, you will need to register an application and use the application API key, rather than just the normal user API key. If you want to submit identified songs to the AcoustID API, you will also need the user API key. 2. Download the latest release of [`fpcalc`](https://acoustid.org/chromaprint) for your system from [the AcoustID website](https://acoustid.org/chromaprint) if you want to use the AcoustID API. Put it in a location of your choosing and add it to your system PATH. 3. Download the latest release of [`ffmpeg`](https://ffmpeg.org/download.html) if you want to use the in-app recording feature. Put it in a location of your choosing and add it to your system PATH. 4. If `ffmpeg` or `fpcalc` were not installed on your system prior to setup, you will probably need to restart your system first before using AudioStreamSplitting. @@ -88,10 +88,14 @@ The "Advanced" section is where the most important settings are situated. Note t You need to provide at least one API Key for the song identification to work, but it is strongly recommended to provide as many as possible (as outlined in the [Setup](#setup) section). +The AcoustID API keys are split into two keys: The "AcoustID Application API Key" is the key you receive when registering an application. This key is necessary for any functionality involving AcoustID. The "AcoustID User API Key" is only required if you also want to submit identified songs to AcoustID. + The "Save Directory" setting should be set to the directory you want to save the resulting files into. Make sure that this is an existing directory on your device, as saving files will not work otherwise. The "Output File Name" setting will control the file name of output files. You can include the placeholders ``{TITLE}``, ``{ARTIST}``, ``{ALBUM}`` and ``{YEAR}``, which will be replaced with the corresponding metadata when saving a song. As an example, saving the song "Thunderstruck" by "2Cellos" with the template ``"{TITLE}_{ARTIST}"`` will result in a file called ``"Thunderstruck_2Cellos.mp3"``. It is strongly recommended to include the ``{TITLE}`` placeholder in the file name to avoid overwriting files. +The "Submit files to APIs on save" setting controls whether files the app identified will also be sent to the AcoustID API (and potentially others in the future) to further improve their database. As far as we can tell, no personally identifying data is submitted, and the data that is submitted is only used to ensure you have access to the API. We kindly ask you to turn this setting on as the AcoustID database depends on user submissions to work as well as it does. + ## License This software is licensed under the GNU GPLv3 license, as found in the LICENSE.txt file or [on the GNU website](https://www.gnu.org/licenses/gpl-3.0.en.html). Note that this license only applies to the software itself, not to audio files generated by it, as they still belong to the songs' original copyright holders. diff --git a/src/backend/api/api.yaml b/src/backend/api/api.yaml index 1f407ed..606f406 100644 --- a/src/backend/api/api.yaml +++ b/src/backend/api/api.yaml @@ -106,6 +106,9 @@ paths: nameTemplate: type: string description: The file name template. Defaults to the one specified in .env if not provided. + submitSavedFiles: + type: boolean + description: Whether to submit saved files to song recognition APIs. responses: "200": description: Store the segment in the target file. @@ -116,6 +119,11 @@ paths: properties: success: type: boolean + services: + type: array + description: Services the file was submitted to. + items: + type: string "400": description: Bad request. Usually means that filePath doesn't point to a valid file, the target directory doesn't exist, or offset/duration are invalid. diff --git a/src/backend/api/app.py b/src/backend/api/app.py index 4fea234..af8b6f4 100644 --- a/src/backend/api/app.py +++ b/src/backend/api/app.py @@ -47,3 +47,13 @@ def internal_error(error): def not_found(error): log_error(error, "404 not found error") return "404 not found" + + +@app.route("/api/pyinstaller") +def pyinstaller(): + """Allow know where built app is excecuted + and set save directory with that location + """ + import sys + + return os.path.dirname(sys.executable), 200 diff --git a/src/backend/api/audio.py b/src/backend/api/audio.py index c18674f..1d1952b 100644 --- a/src/backend/api/audio.py +++ b/src/backend/api/audio.py @@ -3,11 +3,10 @@ import wave from flask import Blueprint, Response, jsonify, request, send_file -from modules.api_service import ApiService +from modules.api_service import ApiService, submit_to_services from modules.audio_stream_io import read_audio_file_to_numpy, save_numpy_as_audio_file from modules.segmentation import Preset, segment_file from pathvalidate import sanitize_filename -from utils.env import get_env from utils.file_name_formatter import format_file_name audio_bp = Blueprint("audio", __name__) @@ -92,6 +91,8 @@ def store(): Add the given metadata to this file. If the provided file or the target directory does not exist, a 400 error is returned. + If songs should be submitted to song identification APIs too, this will also be handled here. + :returns: ``"{success: true}"`` if storing the file worked. A 400 error otherwise. """ data = request.json @@ -105,11 +106,7 @@ def store(): metadata = data["metadata"] file_type = "." + (data["fileType"] if "fileType" in data else "mp3") - file_name_template = ( - data["nameTemplate"] - if "nameTemplate" in data - else get_env("OUTPUT_FILE_NAME_TEMPLATE") - ) + file_name_template = data["nameTemplate"] if "nameTemplate" in data else "{TITLE}" target_file_name = sanitize_filename( format_file_name( file_name_template, @@ -136,7 +133,14 @@ def store(): tags=metadata, extension=file_type, ) - return jsonify({"success": True}) + + submitted_services = [] + if "submitSavedFiles" in data and data["submitSavedFiles"]: + submitted_services = submit_to_services( + os.path.join(target_directory, target_file_name) + file_type, metadata + ) + + return jsonify({"success": True, "services": submitted_services}) @audio_bp.route("/check_path", methods=["POST"]) diff --git a/src/backend/api/env.py b/src/backend/api/env.py index 6b08c08..6c98d45 100644 --- a/src/backend/api/env.py +++ b/src/backend/api/env.py @@ -1,22 +1,9 @@ from flask import Blueprint, jsonify, request -from utils.env import get_env, set_env +from utils.env import set_env env_bp = Blueprint("env", __name__) -@env_bp.route("/get", methods=["GET"]) -def get(): - key = request.args.get("key") - if not key: - return jsonify({"error": "Key parameter missing"}), 400 - - value = get_env(key) - if value is None: - return jsonify({"error": "Key not found"}), 404 - - return jsonify({"value": value}) - - @env_bp.route("/set", methods=["POST"]) def set(): data = request.json diff --git a/src/backend/modules/api_service.py b/src/backend/modules/api_service.py index 595849b..6bf092e 100644 --- a/src/backend/modules/api_service.py +++ b/src/backend/modules/api_service.py @@ -1,10 +1,9 @@ from enum import Enum from typing import Generator +import modules.apis.acoustid +import modules.apis.shazam from acoustid import FingerprintGenerationError, NoBackendError, WebServiceError -from modules.apis.acoustid import create_fingerprint as create_acoustid_fingerprint -from modules.apis.acoustid import lookup as acoustid_lookup -from modules.apis.shazam import lookup as shazam_lookup from modules.audio_stream_io import read_audio_file_to_numpy from requests import exceptions from utils.env import get_env @@ -60,6 +59,35 @@ class SongOptionResult(Enum): """ +def submit_to_services(file_name, metadata): + """Logic to submit a song to song recognition APIs that allow submissions in order to + improve their databases. + + Songs with no metadata or an unknown title are not submitted. + + As of current, this only does so with the AcoustID API. If other services that allow + submissions are supported by api_service in the future, their module should contain a + similar submit() function that should also be called here. + + :param file_name: The name (and absolute path) of the file to submit. + :param metadata: The metadata to submit. + :returns: A ``list`` of services the song was successfully submitted to. This does not + necessarily mean that the submission will be accepted. + """ + successful_submissions = [] + if "title" not in metadata or metadata["title"] == "unknown": + return successful_submissions + + ACOUSTID_API_KEY = get_env("SERVICE_ACOUSTID_API_KEY") + ACOUSTID_USER_KEY = get_env("SERVICE_ACOUSTID_USER_KEY") + if ACOUSTID_API_KEY is not None and ACOUSTID_USER_KEY is not None: + if modules.apis.acoustid.submit( + file_name, metadata, ACOUSTID_API_KEY, ACOUSTID_USER_KEY + ): + successful_submissions.append("AcoustID") + return successful_submissions + + class ApiService: """The ``ApiService`` class contains the business logic for retrieving song metadata via various song identification APIs. @@ -393,10 +421,12 @@ def get_song_options(self, offset: float, duration: float, file_path: str): # first check using acoustID if ACOUSTID_API_KEY is not None: try: - duration, fingerprint = create_acoustid_fingerprint( + duration, fingerprint = modules.apis.acoustid.create_fingerprint( song_data, sample_rate ) - metadata = acoustid_lookup(fingerprint, duration, ACOUSTID_API_KEY) + metadata = modules.apis.acoustid.lookup( + fingerprint, duration, ACOUSTID_API_KEY + ) if len(metadata) != 0: return self._check_song_extended_or_finished( offset, duration, metadata @@ -412,8 +442,12 @@ def get_song_options(self, offset: float, duration: float, file_path: str): # If sample_rate inexplicably becomes something other than 44100Hz, shazam won't work if SHAZAM_API_KEY is not None and sample_rate == SAMPLE_RATE_STANDARD: try: - metadata_start = shazam_lookup(song_data, SHAZAM_API_KEY, True) - metadata_end = shazam_lookup(song_data, SHAZAM_API_KEY, False) + metadata_start = modules.apis.shazam.lookup( + song_data, SHAZAM_API_KEY, True + ) + metadata_end = modules.apis.shazam.lookup( + song_data, SHAZAM_API_KEY, False + ) metadata_start = [metadata_start] if metadata_start is not None else [] metadata_end = [metadata_end] if metadata_end is not None else [] diff --git a/src/backend/modules/apis/acoustid.py b/src/backend/modules/apis/acoustid.py index 015290b..1d9cb1e 100644 --- a/src/backend/modules/apis/acoustid.py +++ b/src/backend/modules/apis/acoustid.py @@ -12,6 +12,7 @@ import acoustid import utils.list_helper from modules.audio_stream_io import save_numpy_as_audio_file +from utils.logger import log_error METADATA_ALL = ["tracks", "recordings", "releasegroups"] """The metadata to query from the AcoustID API. @@ -24,6 +25,11 @@ metadata to make merging matching recordings easier. """ +titles_identified_by_acoustid = [] +"""A list of all titles that were identified by AcoustID. +This is used to prevent duplicate submissions to the AcoustID database. +""" + def create_fingerprint(song_data, samplerate): """Create a chromaprint/AcoustID fingerprint for the given audio data @@ -54,6 +60,56 @@ def create_fingerprint(song_data, samplerate): return (fingerprint_duration, fingerprint) +def submit(file_name: str, metadata: dict, api_key: str, user_key: str): + """Submit a fingerprint for the provided file to be added to the AcoustID database. + + If the song was previously identified using AcoustID, it isn't submitted. This is to avoid + spamming the AcoustID servers with duplicate submissions. + + This uses the ``pyacoustid`` wrapper. All exceptions raised by ``pyacoustid`` are + handled within this function and lead to returning "False". + + :param file_name: The name of the file to submit. + :param metadata: The metadata of the song to submit, formatted as a dict. + :param api_key: The application API key. + :param user_key: The user API key. + :returns: boolean indicating whether the submission was successful. + """ + global timestamp_last_acoustid_request + + titles_identified_key = metadata["title"] + "_" + metadata["artist"] + + # avoid submitting titles that have been identified by acoustid - we don't want duplicates + if titles_identified_key in titles_identified_by_acoustid: + return False + titles_identified_by_acoustid.append(titles_identified_key) + + try: + duration, fingerprint = acoustid.fingerprint_file(file_name, force_fpcalc=True) + except acoustid.FingerprintGenerationError as ex: + log_error(ex, "AcoustID fingerprint generation error") + return False + + query_params = { + "duration": duration, + "fingerprint": fingerprint, + "track": metadata["title"] if "title" in metadata else None, + "artist": metadata["artist"] if "artist" in metadata else None, + "album": metadata["album"] if "album" in metadata else None, + "albumartist": metadata["albumartist"] if "albumartist" in metadata else None, + "year": metadata["year"] if "year" in metadata else None, + } + + try: + acoustid.submit(api_key, user_key, query_params) + return True + except acoustid.FingerprintSubmissionError as ex: + log_error(ex, "AcoustID submission error") + except acoustid.WebServiceError as ex: + log_error(ex, "AcoustID submit error") + return False + + def lookup(fingerprint, fingerprint_duration, api_key): """Get data about the provided fingerprint from the AcoustID API. This uses the ``pyacoustid`` library to make the actual API call, but parsing @@ -223,12 +279,18 @@ def _get_results_for_recordings(recordings): :param results: The list to append the results to. :returns: The list with the appended results. """ + global titles_identified_by_acoustid results = [] for recording in recordings: # Get the artist if available. if "artists" not in recording or "title" not in recording: continue artist_name = _join_artist_names(recording["artists"]) + + titles_identified_key = recording["title"] + "_" + artist_name + if titles_identified_key not in titles_identified_by_acoustid: + titles_identified_by_acoustid.append(titles_identified_key) + for releasegroup in recording["releasegroups"]: results.append( _get_result_for_releasegroup( diff --git a/src/frontend/auto-imports.d.ts b/src/frontend/auto-imports.d.ts index c8f8551..8115890 100644 --- a/src/frontend/auto-imports.d.ts +++ b/src/frontend/auto-imports.d.ts @@ -84,6 +84,7 @@ declare global { const useDateFormat: typeof import('./composables/useDateFormat')['useDateFormat'] const useDocTitle: typeof import('./composables/useDocTitle')['useDocTitle'] const useDriver: typeof import('./composables/useDriver')['useDriver'] + const useEnv: typeof import('./composables/useEnv')['useEnv'] const useEnvStore: typeof import('./stores/useEnvStore')['useEnvStore'] const useGet: typeof import('./composables/useGet')['useGet'] const useGlobalStyleStore: typeof import('./stores/useGloalStyleStore')['useGlobalStyleStore'] @@ -96,6 +97,7 @@ declare global { const useRandomColor: typeof import('./composables/useRandomColor')['useRandomColor'] const useRoute: typeof import('vue-router')['useRoute'] const useRouter: typeof import('vue-router')['useRouter'] + const useSaveSetings: typeof import('./composables/useSaveSettings')['useSaveSetings'] const useSlots: typeof import('vue')['useSlots'] const useToastStore: typeof import('./stores/useToastStore')['useToastStore'] const vi: typeof import('vitest')['vi'] @@ -193,7 +195,7 @@ declare module 'vue' { readonly useDateFormat: UnwrapRef readonly useDocTitle: UnwrapRef readonly useDriver: UnwrapRef - readonly useEnvStore: UnwrapRef + readonly useEnv: UnwrapRef readonly useGet: UnwrapRef readonly useGlobalStyleStore: UnwrapRef readonly useHash: UnwrapRef @@ -205,6 +207,7 @@ declare module 'vue' { readonly useRandomColor: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef + readonly useSaveSetings: UnwrapRef readonly useSlots: UnwrapRef readonly useToastStore: UnwrapRef readonly vi: UnwrapRef @@ -296,7 +299,7 @@ declare module '@vue/runtime-core' { readonly useDateFormat: UnwrapRef readonly useDocTitle: UnwrapRef readonly useDriver: UnwrapRef - readonly useEnvStore: UnwrapRef + readonly useEnv: UnwrapRef readonly useGet: UnwrapRef readonly useGlobalStyleStore: UnwrapRef readonly useHash: UnwrapRef @@ -308,6 +311,7 @@ declare module '@vue/runtime-core' { readonly useRandomColor: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef + readonly useSaveSetings: UnwrapRef readonly useSlots: UnwrapRef readonly useToastStore: UnwrapRef readonly vi: UnwrapRef diff --git a/src/frontend/components.d.ts b/src/frontend/components.d.ts index f2c03b0..2e18c5b 100644 --- a/src/frontend/components.d.ts +++ b/src/frontend/components.d.ts @@ -21,7 +21,6 @@ declare module 'vue' { BaseSelect: typeof import('./components/BaseSelect.vue')['default'] BaseSeparator: typeof import('./components/BaseSeparator.vue')['default'] BaseSkeleton: typeof import('./components/BaseSkeleton.vue')['default'] - BaseSlider: typeof import('./components/BaseSlider.vue')['default'] BaseSwitch: typeof import('./components/BaseSwitch.vue')['default'] BaseTextArea: typeof import('./components/BaseTextArea.vue')['default'] ConfirmModal: typeof import('./components/ConfirmModal.vue')['default'] @@ -33,9 +32,8 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SettingsAdvanced: typeof import('./components/SettingsAdvanced.vue')['default'] - SettingsAppearance: typeof import('./components/SettingsAppearance.vue')['default'] - SettingsClear: typeof import('./components/SettingsClear.vue')['default'] SettingsGeneral: typeof import('./components/SettingsGeneral.vue')['default'] + SettingsSave: typeof import('./components/SettingsSave.vue')['default'] SideBar: typeof import('./components/SideBar.vue')['default'] SideBarRow: typeof import('./components/SideBarRow.vue')['default'] Toast: typeof import('./components/Toast.vue')['default'] diff --git a/src/frontend/components/BaseSwitch.vue b/src/frontend/components/BaseSwitch.vue index 7469789..f7f3124 100644 --- a/src/frontend/components/BaseSwitch.vue +++ b/src/frontend/components/BaseSwitch.vue @@ -1,19 +1,5 @@