Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Submit metadata to AcoustID #111

Merged
merged 9 commits into from
Sep 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
VITE_APP_NAME=Audio Splitter
VITE_OUTPUT_FILE_NAME_TEMPLATE={TITLE}
6 changes: 5 additions & 1 deletion README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/backend/api/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions src/backend/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 12 additions & 8 deletions src/backend/api/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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"])
Expand Down
15 changes: 1 addition & 14 deletions src/backend/api/env.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
48 changes: 41 additions & 7 deletions src/backend/modules/api_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 []

Expand Down
62 changes: 62 additions & 0 deletions src/backend/modules/apis/acoustid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions src/frontend/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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']
Expand Down Expand Up @@ -193,7 +195,7 @@ declare module 'vue' {
readonly useDateFormat: UnwrapRef<typeof import('./composables/useDateFormat')['useDateFormat']>
readonly useDocTitle: UnwrapRef<typeof import('./composables/useDocTitle')['useDocTitle']>
readonly useDriver: UnwrapRef<typeof import('./composables/useDriver')['useDriver']>
readonly useEnvStore: UnwrapRef<typeof import('./stores/useEnvStore')['useEnvStore']>
readonly useEnv: UnwrapRef<typeof import('./composables/useEnv')['useEnv']>
readonly useGet: UnwrapRef<typeof import('./composables/useGet')['useGet']>
readonly useGlobalStyleStore: UnwrapRef<typeof import('./stores/useGloalStyleStore')['useGlobalStyleStore']>
readonly useHash: UnwrapRef<typeof import('./composables/useHash')['useHash']>
Expand All @@ -205,6 +207,7 @@ declare module 'vue' {
readonly useRandomColor: UnwrapRef<typeof import('./composables/useRandomColor')['useRandomColor']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSaveSetings: UnwrapRef<typeof import('./composables/useSaveSettings')['useSaveSetings']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useToastStore: UnwrapRef<typeof import('./stores/useToastStore')['useToastStore']>
readonly vi: UnwrapRef<typeof import('vitest')['vi']>
Expand Down Expand Up @@ -296,7 +299,7 @@ declare module '@vue/runtime-core' {
readonly useDateFormat: UnwrapRef<typeof import('./composables/useDateFormat')['useDateFormat']>
readonly useDocTitle: UnwrapRef<typeof import('./composables/useDocTitle')['useDocTitle']>
readonly useDriver: UnwrapRef<typeof import('./composables/useDriver')['useDriver']>
readonly useEnvStore: UnwrapRef<typeof import('./stores/useEnvStore')['useEnvStore']>
readonly useEnv: UnwrapRef<typeof import('./composables/useEnv')['useEnv']>
readonly useGet: UnwrapRef<typeof import('./composables/useGet')['useGet']>
readonly useGlobalStyleStore: UnwrapRef<typeof import('./stores/useGloalStyleStore')['useGlobalStyleStore']>
readonly useHash: UnwrapRef<typeof import('./composables/useHash')['useHash']>
Expand All @@ -308,6 +311,7 @@ declare module '@vue/runtime-core' {
readonly useRandomColor: UnwrapRef<typeof import('./composables/useRandomColor')['useRandomColor']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSaveSetings: UnwrapRef<typeof import('./composables/useSaveSettings')['useSaveSetings']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useToastStore: UnwrapRef<typeof import('./stores/useToastStore')['useToastStore']>
readonly vi: UnwrapRef<typeof import('vitest')['vi']>
Expand Down
Loading