diff --git a/core/metadata/source.py b/core/metadata/source.py index fcd69569..2be1444c 100644 --- a/core/metadata/source.py +++ b/core/metadata/source.py @@ -1122,6 +1122,60 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di return metadata +def embed_known_source_ids(audio_file, metadata: dict) -> list: + """Embed ALREADY-KNOWN source IDs into a file's tags, no API re-fetch. + + For the library re-tag / "Write Tags" paths: ``metadata`` carries flat id + keys already stored in the DB (``spotify_track_id``, ``spotify_album_id``, + ``itunes_track_id``, ``itunes_album_id``, ``musicbrainz_recording_id``, + ``musicbrainz_release_id``, …). Reuses the SAME canonical, format-specific, + Picard-compatible frame writer as the import post-process + (``_write_embedded_metadata``) so the frames never diverge — it just skips + the heavy multi-source re-search that ``embed_source_ids`` does. + + The caller is responsible for saving the file. Returns the canonical tag + names written (for logging/diagnostics). + """ + cfg = get_config_manager() + symbols = get_mutagen_symbols() + if not symbols or audio_file is None: + return [] + try: + id_tags = _collect_source_ids(metadata, cfg) + # MusicBrainz ids aren't in _collect_source_ids (they come from the MB + # processor at import time); add them from the DB when present, using + # the canonical names the frame map already knows. + if cfg.get("musicbrainz.embed_tags", True) is not False: + if metadata.get("musicbrainz_recording_id"): + id_tags["MUSICBRAINZ_RECORDING_ID"] = metadata["musicbrainz_recording_id"] + if metadata.get("musicbrainz_release_id"): + id_tags["MUSICBRAINZ_RELEASE_ID"] = metadata["musicbrainz_release_id"] + if not id_tags: + return [] + pp = _blank_post_process_state() + pp["id_tags"] = id_tags + _write_embedded_metadata(audio_file, metadata, pp, cfg, symbols) + return list(id_tags.keys()) + except Exception as exc: + logger.debug("embed_known_source_ids failed: %s", exc) + return [] + + +def _blank_post_process_state() -> dict: + """A fully-defaulted post-process state so _write_embedded_metadata can run + with only id_tags set (every enrichment branch no-ops on the blanks).""" + return { + "id_tags": {}, "track_title": "", "artist_name": "", "batch_artist_name": None, + "metadata": {}, "recording_mbid": None, "artist_mbid": None, "release_mbid": "", + "mb_genres": [], "isrc": None, "deezer_bpm": None, "deezer_isrc": None, + "tidal_bpm": None, "hifi_bpm": None, "hifi_copyright": None, "audiodb_mood": None, + "audiodb_style": None, "audiodb_genre": None, "tidal_isrc": None, + "tidal_copyright": None, "hifi_isrc": None, "qobuz_isrc": None, + "qobuz_copyright": None, "qobuz_label": None, "lastfm_tags": [], + "lastfm_url": None, "genius_url": None, "release_year": None, + } + + def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None): cfg = get_config_manager() symbols = get_mutagen_symbols() diff --git a/core/repair_jobs/library_retag.py b/core/repair_jobs/library_retag.py index 4f013faf..c6f11353 100644 --- a/core/repair_jobs/library_retag.py +++ b/core/repair_jobs/library_retag.py @@ -45,6 +45,26 @@ def _read_current_tags(file_path): return {} +def _add_source_ids(db_data, source, album_source_id, source_track): + """Stamp the album/track source IDs onto the write payload so the canonical + writer embeds them too (Spotify / iTunes / MusicBrainz).""" + album_key = {'spotify': 'spotify_album_id', 'itunes': 'itunes_album_id', + 'musicbrainz': 'musicbrainz_release_id'}.get(source) + track_key = {'spotify': 'spotify_track_id', 'itunes': 'itunes_track_id', + 'musicbrainz': 'musicbrainz_recording_id'}.get(source) + if album_key and album_source_id: + db_data[album_key] = album_source_id + if track_key: + tid = None + for k in ('id', 'track_id', 'source_track_id'): + v = source_track.get(k) if isinstance(source_track, dict) else getattr(source_track, k, None) + if v: + tid = v + break + if tid: + db_data[track_key] = tid + + def _track_list(result): """Normalize a get_album_tracks result into a plain list of track items.""" if result is None: @@ -222,12 +242,14 @@ class LibraryRetagJob(RepairJob): # Include a track when its tags change, OR when there's a cover action # to apply to it (db_data may be empty — apply embeds art either way). if plan['changes'] or cover_action: + db_data = plan['db_data'] + _add_source_ids(db_data, source, album_source_id, src) track_plans.append({ 'file_path': lib['file_path'], 'track_id': lib['id'], 'title': lib['title'], 'changes': plan['changes'], - 'db_data': plan['db_data'], + 'db_data': db_data, }) tag_change_tracks = sum(1 for tp in track_plans if tp['changes']) diff --git a/core/tag_writer.py b/core/tag_writer.py index e6edbc12..02aa8d1e 100644 --- a/core/tag_writer.py +++ b/core/tag_writer.py @@ -294,6 +294,24 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any], year, genre_str, track_num, total_tracks, disc_num, bpm, artists_list=artists_list) + # Embed already-known source IDs (Spotify / iTunes / MusicBrainz) from + # db_data, reusing the canonical import-time frame writer — no API + # re-fetch. Only fires when db_data carries id keys, so the plain + # "write the core tags" callers are unaffected. + _src_meta = {k: db_data[k] for k in ( + 'source', 'source_track_id', 'source_album_id', 'source_artist_id', + 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id', + 'itunes_track_id', 'itunes_album_id', 'itunes_artist_id', + 'musicbrainz_recording_id', 'musicbrainz_release_id', + ) if db_data.get(k)} + if _src_meta: + try: + from core.metadata.source import embed_known_source_ids + if embed_known_source_ids(audio, _src_meta): + written.append('source_ids') + except Exception as e: + logger.debug("source-id embed skipped for %s: %s", file_path, e) + # Embed cover art if requested if embed_cover: art_ok = False diff --git a/web_server.py b/web_server.py index b107fccf..2dd699b9 100644 --- a/web_server.py +++ b/web_server.py @@ -9336,6 +9336,13 @@ def _build_library_tag_db_data(track_data, album_genres=None): if artists_list: db_data['artists_list'] = artists_list + # Carry the known source IDs through so they get embedded too (the writer + # only acts on the ones present). These come from t.* on the track row. + for _k in ('spotify_track_id', 'itunes_track_id', 'musicbrainz_recording_id'): + _v = track_data.get(_k) + if _v: + db_data[_k] = _v + return db_data