# Background
SoulSync standalone is meant to be a full replacement for Plex /
Jellyfin / Navidrome — files imported via auto-import (or any other
import path) should land in the database with the same field richness
a media-server scan would write. They weren't.
# Gaps fixed
The auto-import worker built a context dict for each track and handed
it to `_post_process_matched_download` (the same callback the regular
download flow uses). That dict was missing three things downstream
needed:
1. **No `source` field anywhere.** `record_soulsync_library_entry`
reads `get_import_source(context)` to pick the source-aware ID
columns (`spotify_track_id` / `deezer_id` / `itunes_track_id` /
etc.) on the artists / albums / tracks rows. With no source, the
resolver returned an empty string → `get_library_source_id_columns("")`
returned an empty dict → the `UPDATE tracks SET <source>_id = ?`
blocks were silently skipped. Result: every auto-imported track
landed with NULL on every source-id column. Watchlist scans
(which match by stable source IDs to detect "this track is already
in library") couldn't recognise these rows and would re-download
them on the next pass.
2. **No `_download_username='auto_import'`.** Both
`record_library_history_download` and `record_download_provenance`
default to "Soulseek" when no `username` is in the context. Every
staging-folder import was being labelled as a Soulseek download
in library history + provenance — false signal in the UI.
3. **No per-recording IDs (`isrc`, `musicbrainz_recording_id`) on
track_info.** The Navidrome scanner already writes
`musicbrainz_recording_id` directly to the tracks row when present.
Picard-tagged libraries always carry MBID; metadata sources
(Spotify via MusicBrainz enrichment, Deezer, etc.) carry ISRC.
Auto-import had access to both via the metadata-source response
but didn't propagate them — so the soulsync row went in with
NULL on both columns.
# Changes
**`core/auto_import_worker.py` — `_process_matches`:**
- Top-level `'source': source` (from `identification['source']`)
- `'_download_username': 'auto_import'`
- `track_info['isrc']`, `track_info['musicbrainz_recording_id']` —
pulled from the per-track payload returned by the metadata source
- `track_info['album_id']` — back-reference so source-aware ID
resolution works on sources whose API nests album under
`track.album.id` rather than `track.album_id`
- `spotify_artist['id']` now correctly carries the artist's source ID
(was `identification['album_id']`, a copy-paste bug from the
original implementation that made artist-id resolution fall back
to fuzzy matching)
- `spotify_album['artists'][0]['id']` carries artist source ID for
the same resolution path
**`core/imports/side_effects.py`:**
- `record_library_history_download` source_map: add
`"auto_import": "Auto-Import"` — tags imported tracks correctly
- `record_download_provenance` source_service: add
`"auto_import": "auto_import"` — provenance shows real source
- `record_soulsync_library_entry` track INSERT: now includes
`musicbrainz_recording_id` + `isrc` columns (matches
`insert_or_update_media_track`'s shape for Navidrome /
Plex / Jellyfin scans). Both default to NULL when not present.
# Behavior preserved
- Files still land in the same library template path (no path-build
change)
- Other media-server flows (Plex / Jellyfin / Navidrome users)
unaffected — `record_soulsync_library_entry` still gates on
`get_active_media_server() == "soulsync"`. Auto-import on those
servers continues to drop the file in the library folder + emits
`batch_complete` for the scan-trigger automation, same as before.
- Direct downloads (search → Download button) unaffected — they
already passed `source` + `username` correctly.
# Tests added
`tests/imports/test_auto_import_context_shape.py` (8 tests, new file):
- Worker context carries `source` for every metadata source
(parametrised across spotify / deezer / itunes / discogs)
- `_download_username='auto_import'` set unconditionally
- ISRC + MBID propagate from track payload to track_info when present
- ISRC + MBID default to empty string when absent (downstream
normalises to NULL at write time)
- track_info includes album-id back-reference
`tests/imports/test_import_side_effects.py` (4 new tests + 2 schema
column adds):
- `record_soulsync_library_entry` writes mbid + isrc columns when
present in track_info
- Deezer source maps to deezer_id column (regression case for
source-aware column resolver)
- `record_library_history_download` labels `_download_username=
'auto_import'` as "Auto-Import" not "Soulseek"
- `record_download_provenance` registers source_service as
"auto_import" not "soulseek"
# Verification
- 8/8 new context-shape tests pass
- 6/6 side-effects tests pass (4 new + 2 existing)
- 207 imports tests pass
- 2359 full suite passes (+12 from baseline 2347, no regressions)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
passes in isolation, unrelated to this change)
- Ruff clean
574 lines
25 KiB
Python
574 lines
25 KiB
Python
"""Import post-processing side effects that do not need web runtime state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from typing import Any, Dict
|
|
|
|
from config.settings import config_manager
|
|
from core.imports.context import (
|
|
extract_artist_name,
|
|
get_import_clean_album,
|
|
get_import_clean_artist,
|
|
get_import_clean_title,
|
|
get_import_context_album,
|
|
get_import_context_artist,
|
|
get_import_original_search,
|
|
get_import_search_result,
|
|
get_import_source,
|
|
get_import_source_ids,
|
|
get_import_track_info,
|
|
normalize_import_context,
|
|
get_library_source_id_columns,
|
|
)
|
|
from database.music_database import get_database
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
logger = get_logger("imports.side_effects")
|
|
|
|
|
|
def _get_config_manager():
|
|
return config_manager
|
|
|
|
|
|
def _primary_track_artist_name(track_info: Dict[str, Any]) -> str:
|
|
artists = (track_info or {}).get("artists", [])
|
|
if isinstance(artists, list) and artists:
|
|
first = artists[0]
|
|
if isinstance(first, dict):
|
|
return str(first.get("name", "") or "")
|
|
return str(first or "")
|
|
if isinstance(artists, str):
|
|
return artists
|
|
return str((track_info or {}).get("artist", "") or "")
|
|
|
|
|
|
def _stable_soulsync_id(text: str) -> str:
|
|
return str(abs(int(hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest(), 16)) % (10 ** 9))
|
|
|
|
|
|
def emit_track_downloaded(context: Dict[str, Any], automation_engine=None) -> None:
|
|
"""Emit the track_downloaded automation event."""
|
|
try:
|
|
if not automation_engine:
|
|
return
|
|
|
|
ti = context.get("track_info") or context.get("search_result") or {}
|
|
artist_name = ""
|
|
artists = ti.get("artists", [])
|
|
if artists:
|
|
first = artists[0]
|
|
artist_name = first.get("name", str(first)) if isinstance(first, dict) else str(first)
|
|
|
|
automation_engine.emit(
|
|
"track_downloaded",
|
|
{
|
|
"artist": artist_name,
|
|
"title": ti.get("name", ti.get("title", "")),
|
|
"album": ti.get("album", ""),
|
|
"quality": context.get("_audio_quality", "Unknown"),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.debug("track_downloaded emit failed: %s", e)
|
|
|
|
|
|
def record_library_history_download(context: Dict[str, Any]) -> None:
|
|
"""Record a completed download to the library_history table."""
|
|
try:
|
|
search_result = context.get("original_search_result") or context.get("search_result") or {}
|
|
username = search_result.get("username", context.get("_download_username", ""))
|
|
source_map = {
|
|
"youtube": "YouTube",
|
|
"tidal": "Tidal",
|
|
"qobuz": "Qobuz",
|
|
"hifi": "HiFi",
|
|
"deezer_dl": "Deezer",
|
|
"lidarr": "Lidarr",
|
|
"soundcloud": "SoundCloud",
|
|
# Auto-import isn't a download source, but flows through the
|
|
# same post-process pipeline (file lands → record provenance
|
|
# + history → write to library DB). Tagging it as "Auto-Import"
|
|
# in history avoids mislabeling staging-folder imports as
|
|
# Soulseek downloads.
|
|
"auto_import": "Auto-Import",
|
|
}
|
|
download_source = source_map.get(username, "Soulseek")
|
|
|
|
ti = context.get("track_info") or context.get("search_result") or {}
|
|
artist_name = _primary_track_artist_name(ti)
|
|
if not artist_name:
|
|
artist_name = ti.get("artist", "")
|
|
|
|
album_raw = ti.get("album", "")
|
|
album_name = album_raw.get("name", "") if isinstance(album_raw, dict) else str(album_raw or "")
|
|
title = ti.get("name", ti.get("title", ""))
|
|
quality = context.get("_audio_quality", "")
|
|
file_path = context.get("_final_processed_path", context.get("_final_path", ""))
|
|
|
|
thumb_url = ""
|
|
album_context = get_import_context_album(context)
|
|
if album_context:
|
|
thumb_url = album_context.get("image_url", "")
|
|
if not thumb_url:
|
|
images = album_context.get("images", [])
|
|
if images:
|
|
thumb_url = images[0].get("url", "")
|
|
if not thumb_url:
|
|
album_info = context.get("album_info", {})
|
|
if isinstance(album_info, dict):
|
|
thumb_url = album_info.get("album_image_url", "")
|
|
|
|
source_filename = search_result.get("filename", "")
|
|
source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "")
|
|
source_track_title = search_result.get("title", "") or search_result.get("name", "")
|
|
source_artist = search_result.get("artist", "")
|
|
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"):
|
|
stream_id = source_filename.split("||")[0]
|
|
if stream_id and not source_track_id:
|
|
source_track_id = stream_id
|
|
|
|
acoustid_result = context.get("_acoustid_result", "")
|
|
|
|
db = get_database()
|
|
db.add_library_history_entry(
|
|
event_type="download",
|
|
title=title,
|
|
artist_name=artist_name,
|
|
album_name=album_name,
|
|
quality=quality,
|
|
file_path=file_path,
|
|
thumb_url=thumb_url,
|
|
download_source=download_source,
|
|
source_track_id=source_track_id,
|
|
source_track_title=source_track_title,
|
|
source_filename=source_filename,
|
|
acoustid_result=acoustid_result,
|
|
source_artist=source_artist,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("library history record failed: %s", e)
|
|
|
|
|
|
def record_download_provenance(context: Dict[str, Any]) -> None:
|
|
"""Record source provenance for a completed download."""
|
|
try:
|
|
search_result = context.get("original_search_result") or context.get("search_result") or {}
|
|
username = search_result.get("username", context.get("_download_username", ""))
|
|
filename = search_result.get("filename", "")
|
|
source_service = {
|
|
"youtube": "youtube",
|
|
"tidal": "tidal",
|
|
"qobuz": "qobuz",
|
|
"hifi": "hifi",
|
|
"deezer_dl": "deezer",
|
|
"lidarr": "lidarr",
|
|
"soundcloud": "soundcloud",
|
|
# Auto-import: surfaced in provenance so the redownload modal
|
|
# can tell the user "this came from staging on <date>" instead
|
|
# of falsely listing soulseek as the source. The underlying
|
|
# metadata source (spotify / deezer / itunes) is recorded
|
|
# separately via the source-aware ID columns on the tracks
|
|
# row itself.
|
|
"auto_import": "auto_import",
|
|
}.get(username, "soulseek")
|
|
|
|
ti = context.get("track_info") or context.get("search_result") or {}
|
|
artist_name = _primary_track_artist_name(ti)
|
|
if not artist_name:
|
|
artist_name = ti.get("artist", "")
|
|
|
|
album_raw = ti.get("album", "")
|
|
album_name = album_raw.get("name", "") if isinstance(album_raw, dict) else str(album_raw or "")
|
|
title = ti.get("name", ti.get("title", ""))
|
|
|
|
file_path = context.get("_final_processed_path", context.get("_final_path", ""))
|
|
quality = context.get("_audio_quality", "")
|
|
size = search_result.get("size", 0)
|
|
|
|
bit_depth = None
|
|
sample_rate = None
|
|
bitrate = None
|
|
try:
|
|
if file_path and os.path.isfile(file_path):
|
|
from mutagen import File as MutagenFile
|
|
|
|
audio = MutagenFile(file_path)
|
|
if audio and audio.info:
|
|
sample_rate = getattr(audio.info, "sample_rate", None)
|
|
bitrate = getattr(audio.info, "bitrate", None)
|
|
bit_depth = getattr(audio.info, "bits_per_sample", None)
|
|
except Exception as e:
|
|
logger.debug("audio info probe failed: %s", e)
|
|
|
|
# Pull the metadata-source IDs out of context. ``embed_source_ids``
|
|
# in core/metadata/source.py wrote them to ``_embedded_id_tags``
|
|
# at the end of post-processing — we persist them here so the
|
|
# watchlist scanner can recognize freshly downloaded files
|
|
# without waiting for the async enrichment workers.
|
|
embedded = context.get("_embedded_id_tags") or {}
|
|
|
|
def _embedded(*keys):
|
|
for k in keys:
|
|
v = embedded.get(k)
|
|
if v:
|
|
return str(v)
|
|
return None
|
|
|
|
spotify_track_id = _embedded("SPOTIFY_TRACK_ID")
|
|
itunes_track_id = _embedded("ITUNES_TRACK_ID")
|
|
deezer_track_id = _embedded("DEEZER_TRACK_ID")
|
|
tidal_track_id = _embedded("TIDAL_TRACK_ID")
|
|
qobuz_track_id = _embedded("QOBUZ_TRACK_ID")
|
|
musicbrainz_recording_id = _embedded("MUSICBRAINZ_RECORDING_ID")
|
|
audiodb_id = _embedded("AUDIODB_TRACK_ID")
|
|
soul_id = _embedded("SOUL_ID")
|
|
isrc = context.get("_isrc")
|
|
|
|
db = get_database()
|
|
db.record_track_download(
|
|
file_path=file_path,
|
|
source_service=source_service,
|
|
source_username=username,
|
|
source_filename=filename,
|
|
source_size=size or 0,
|
|
audio_quality=quality,
|
|
track_title=title,
|
|
track_artist=artist_name,
|
|
track_album=album_name,
|
|
bit_depth=bit_depth,
|
|
sample_rate=sample_rate,
|
|
bitrate=bitrate,
|
|
spotify_track_id=spotify_track_id,
|
|
itunes_track_id=itunes_track_id,
|
|
deezer_track_id=deezer_track_id,
|
|
tidal_track_id=tidal_track_id,
|
|
qobuz_track_id=qobuz_track_id,
|
|
musicbrainz_recording_id=musicbrainz_recording_id,
|
|
audiodb_id=audiodb_id,
|
|
soul_id=soul_id,
|
|
isrc=isrc,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("record_download_provenance failed: %s", e)
|
|
|
|
|
|
def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any]) -> None:
|
|
"""Write imported media to the SoulSync library tables when the active server is SoulSync."""
|
|
try:
|
|
if _get_config_manager().get_active_media_server() != "soulsync":
|
|
return
|
|
|
|
context = normalize_import_context(context)
|
|
final_path = context.get("_final_processed_path")
|
|
if not final_path:
|
|
return
|
|
|
|
album_ctx = get_import_context_album(context)
|
|
track_info = get_import_track_info(context)
|
|
original_search = get_import_original_search(context)
|
|
source = get_import_source(context)
|
|
source_ids = get_import_source_ids(context)
|
|
source_columns = get_library_source_id_columns(source)
|
|
|
|
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="")
|
|
if not artist_name or artist_name in ("Unknown", "Unknown Artist"):
|
|
return
|
|
|
|
album_name = ""
|
|
if album_info and isinstance(album_info, dict):
|
|
album_name = album_info.get("album_name", "")
|
|
if not album_name:
|
|
album_name = album_ctx.get("name", "") or original_search.get("album", "")
|
|
if not album_name:
|
|
album_name = track_info.get("name", "Unknown")
|
|
|
|
track_name = get_import_clean_title(
|
|
context,
|
|
album_info=album_info,
|
|
default=track_info.get("name", "") or original_search.get("title", ""),
|
|
)
|
|
track_number = (track_info.get("track_number") or (album_info.get("track_number") if isinstance(album_info, dict) else None)) or 1
|
|
duration_ms = track_info.get("duration_ms", 0) or 0
|
|
|
|
year = None
|
|
release_date = album_ctx.get("release_date", "")
|
|
if release_date and len(release_date) >= 4:
|
|
try:
|
|
year = int(release_date[:4])
|
|
except ValueError:
|
|
pass
|
|
|
|
image_url = album_ctx.get("image_url", "")
|
|
if not image_url:
|
|
images = album_ctx.get("images", [])
|
|
if images and isinstance(images, list) and len(images) > 0:
|
|
img = images[0]
|
|
image_url = img.get("url", "") if isinstance(img, dict) else str(img)
|
|
|
|
artist_source_id = source_ids.get("artist_id", "")
|
|
album_source_id = source_ids.get("album_id", "")
|
|
track_source_id = source_ids.get("track_id", "")
|
|
for key in ("auto_import", "from_sync_modal", "explicit_artist", "explicit_album", ""):
|
|
if artist_source_id == key:
|
|
artist_source_id = ""
|
|
if album_source_id == key:
|
|
album_source_id = ""
|
|
if track_source_id == key:
|
|
track_source_id = ""
|
|
|
|
genres = (artist_context or {}).get("genres", []) if isinstance(artist_context, dict) else []
|
|
if genres:
|
|
from core.genre_filter import filter_genres as _filter_genres
|
|
|
|
genres = _filter_genres(genres, _get_config_manager())
|
|
genres_json = json.dumps(genres) if genres else ""
|
|
|
|
bitrate = 0
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
|
|
audio = MutagenFile(final_path)
|
|
if audio and hasattr(audio, "info") and audio.info and hasattr(audio.info, "bitrate"):
|
|
bitrate = int(audio.info.bitrate / 1000) if audio.info.bitrate else 0
|
|
except Exception as e:
|
|
logger.debug("bitrate read failed: %s", e)
|
|
|
|
# File size on disk (powers Library Disk Usage card on Stats).
|
|
# SoulSync standalone is the only path where the file is local
|
|
# at insert time, so we read it directly via os.path.getsize.
|
|
# Mirrors what JellyfinTrack/NavidromeTrack pull from API
|
|
# responses for the media-server flows.
|
|
file_size = None
|
|
try:
|
|
file_size = os.path.getsize(final_path) or None
|
|
except OSError:
|
|
pass
|
|
|
|
artist_id = _stable_soulsync_id(artist_name.lower().strip())
|
|
album_id = _stable_soulsync_id(f"{artist_name}::{album_name}".lower().strip())
|
|
track_id = _stable_soulsync_id(final_path)
|
|
total_tracks = album_ctx.get("total_tracks", 0) or 0
|
|
|
|
db = get_database()
|
|
with db._get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = 'soulsync'", (artist_id,))
|
|
if not cursor.fetchone():
|
|
cursor.execute(
|
|
"SELECT id FROM artists WHERE name COLLATE NOCASE = ? AND server_source = 'soulsync' LIMIT 1",
|
|
(artist_name,),
|
|
)
|
|
existing_by_name = cursor.fetchone()
|
|
if existing_by_name:
|
|
artist_id = existing_by_name[0]
|
|
else:
|
|
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,))
|
|
if cursor.fetchone():
|
|
artist_id = _stable_soulsync_id(artist_name.lower().strip() + "::soulsync")
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO artists (id, name, genres, thumb_url, server_source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
""",
|
|
(artist_id, artist_name, genres_json, image_url),
|
|
)
|
|
artist_source_col = source_columns.get("artist")
|
|
if artist_source_col and artist_source_id:
|
|
try:
|
|
cursor.execute(
|
|
f"UPDATE artists SET {artist_source_col} = ? WHERE id = ?",
|
|
(artist_source_id, artist_id),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("artist source-id update failed: %s", e)
|
|
|
|
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", (album_id,))
|
|
if not cursor.fetchone():
|
|
cursor.execute(
|
|
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? AND server_source = 'soulsync' LIMIT 1",
|
|
(album_name, artist_id),
|
|
)
|
|
existing_album_by_name = cursor.fetchone()
|
|
if existing_album_by_name:
|
|
album_id = existing_album_by_name[0]
|
|
else:
|
|
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,))
|
|
if cursor.fetchone():
|
|
album_id = _stable_soulsync_id(f"{artist_name}::{album_name}::soulsync".lower().strip())
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count,
|
|
duration, server_source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
""",
|
|
(album_id, artist_id, album_name, year, image_url, genres_json, total_tracks, duration_ms),
|
|
)
|
|
album_source_col = source_columns.get("album")
|
|
if album_source_col and album_source_id:
|
|
try:
|
|
cursor.execute(
|
|
f"UPDATE albums SET {album_source_col} = ? WHERE id = ?",
|
|
(album_source_id, album_id),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("album source-id update failed: %s", e)
|
|
|
|
track_artist = None
|
|
track_artists_list = track_info.get("artists", []) or original_search.get("artists", [])
|
|
if track_artists_list:
|
|
first_track_artist = track_artists_list[0]
|
|
if isinstance(first_track_artist, dict):
|
|
ta_name = first_track_artist.get("name", "")
|
|
else:
|
|
ta_name = str(first_track_artist)
|
|
if ta_name and ta_name.lower() != artist_name.lower():
|
|
track_artist = ta_name
|
|
|
|
# Per-recording identifiers — scanner picks `musicbrainz_recording_id`
|
|
# off the Navidrome track wrapper; auto-import has the same field
|
|
# available from the metadata-source response (Spotify exposes
|
|
# `musicbrainz_recording_id` via the MusicBrainz client, Picard-
|
|
# tagged files surface it via `_read_file_tags`). `isrc` is even
|
|
# better signal for cross-source dedup — it's the per-recording
|
|
# ID labels embed in the audio. Both land in dedicated columns
|
|
# so the watchlist scanner's stable-ID match path recognises
|
|
# auto-imported tracks the next time the user adds the artist
|
|
# to a watchlist.
|
|
track_mbid = (track_info.get("musicbrainz_recording_id") or "").strip().lower() or None
|
|
track_isrc = (track_info.get("isrc") or "").strip().upper() or None
|
|
|
|
cursor.execute("SELECT id FROM tracks WHERE file_path = ?", (final_path,))
|
|
if not cursor.fetchone():
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO tracks (id, album_id, artist_id, title, track_number,
|
|
duration, file_path, bitrate, file_size, track_artist,
|
|
musicbrainz_recording_id, isrc, server_source,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
""",
|
|
(
|
|
track_id,
|
|
album_id,
|
|
artist_id,
|
|
track_name,
|
|
track_number,
|
|
duration_ms,
|
|
final_path,
|
|
bitrate,
|
|
file_size,
|
|
track_artist,
|
|
track_mbid,
|
|
track_isrc,
|
|
),
|
|
)
|
|
track_source_col = source_columns.get("track")
|
|
if track_source_col and track_source_id:
|
|
try:
|
|
cursor.execute(
|
|
f"UPDATE tracks SET {track_source_col} = ? WHERE id = ?",
|
|
(track_source_id, track_id),
|
|
)
|
|
track_album_col = source_columns.get("track_album")
|
|
if track_album_col and album_source_id:
|
|
cursor.execute(
|
|
f"UPDATE tracks SET {track_album_col} = ? WHERE id = ?",
|
|
(album_source_id, track_id),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("track source-id update failed: %s", e)
|
|
|
|
conn.commit()
|
|
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
|
|
except Exception as exc:
|
|
logger.error("[SoulSync Library] Could not record library entry: %s", exc)
|
|
|
|
|
|
def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any], final_path: str) -> None:
|
|
"""Record a completed download for later re-tagging."""
|
|
try:
|
|
db = get_database()
|
|
|
|
context = normalize_import_context(context)
|
|
artist_context = get_import_context_artist(context) or (artist_context if isinstance(artist_context, dict) else {})
|
|
album_context = get_import_context_album(context)
|
|
track_info = get_import_track_info(context)
|
|
original_search = get_import_original_search(context)
|
|
source = get_import_source(context)
|
|
source_ids = get_import_source_ids(context)
|
|
|
|
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="Unknown Artist")
|
|
is_album = album_info and album_info.get("is_album", False)
|
|
group_type = "album" if is_album else "single"
|
|
album_name = album_info.get("album_name", "") if album_info else get_import_clean_album(context, default=original_search.get("album", "Unknown"))
|
|
|
|
image_url = album_info.get("album_image_url") if album_info else None
|
|
if not image_url:
|
|
image_url = album_context.get("image_url", "")
|
|
if not image_url and album_context.get("images"):
|
|
images = album_context.get("images", [])
|
|
if images and isinstance(images[0], dict):
|
|
image_url = images[0].get("url", "")
|
|
|
|
total_tracks = album_context.get("total_tracks", 1) if album_context else 1
|
|
release_date = album_context.get("release_date", "") if album_context else ""
|
|
|
|
spotify_album_id = None
|
|
itunes_album_id = None
|
|
if source == "spotify":
|
|
spotify_album_id = source_ids.get("album_id", "") or None
|
|
elif source == "itunes":
|
|
itunes_album_id = source_ids.get("album_id", "") or None
|
|
|
|
group_id = db.find_retag_group(artist_name, album_name)
|
|
if group_id is None:
|
|
group_id = db.add_retag_group(
|
|
group_type=group_type,
|
|
artist_name=artist_name,
|
|
album_name=album_name,
|
|
image_url=image_url,
|
|
spotify_album_id=spotify_album_id,
|
|
itunes_album_id=itunes_album_id,
|
|
total_tracks=total_tracks,
|
|
release_date=release_date,
|
|
)
|
|
if group_id is None:
|
|
return
|
|
|
|
track_number = album_info.get("track_number", 1) if album_info else (track_info.get("track_number", 1) or 1)
|
|
disc_number = original_search.get("disc_number") or (album_info.get("disc_number", 1) if album_info else track_info.get("disc_number", 1) or 1)
|
|
title = get_import_clean_title(
|
|
context,
|
|
album_info=album_info,
|
|
default=album_info.get("clean_track_name", "Unknown Track") if album_info else "Unknown Track",
|
|
)
|
|
file_format = os.path.splitext(str(final_path))[1].lstrip(".").lower()
|
|
|
|
source_track_id = None
|
|
itunes_track_id = None
|
|
if source == "spotify":
|
|
source_track_id = source_ids.get("track_id", "") or None
|
|
elif source == "itunes":
|
|
itunes_track_id = source_ids.get("track_id", "") or None
|
|
|
|
if not db.retag_track_exists(group_id, str(final_path)):
|
|
db.add_retag_track(
|
|
group_id=group_id,
|
|
track_number=track_number,
|
|
disc_number=disc_number,
|
|
title=title,
|
|
file_path=str(final_path),
|
|
file_format=file_format,
|
|
spotify_track_id=source_track_id,
|
|
itunes_track_id=itunes_track_id,
|
|
)
|
|
logger.info("[Retag] Recorded track for retag: '%s' in '%s'", title, album_name)
|
|
|
|
db.trim_retag_groups(100)
|
|
except Exception as exc:
|
|
logger.error("[Retag] Could not record track for retag: %s", exc)
|