Merge branch 'main' into plex-pin-auth
This commit is contained in:
commit
f8dd846fea
42 changed files with 5073 additions and 3007 deletions
6
.github/workflows/build-and-test.yml
vendored
6
.github/workflows/build-and-test.yml
vendored
|
|
@ -2,6 +2,9 @@ name: Compile the app and run tests
|
|||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
sanity-check:
|
||||
|
|
@ -21,6 +24,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: python -m pip install --upgrade pip && python -m pip install -r requirements-dev.txt
|
||||
|
||||
- name: Lint with ruff
|
||||
run: python -m ruff check --output-format=github .
|
||||
|
||||
- name: Build app
|
||||
run: python -m compileall api core database services scripts web_server.py wsgi.py beatport_unified_scraper.py
|
||||
|
||||
|
|
|
|||
24
.github/workflows/cleanup-dev-images.yml
vendored
Normal file
24
.github/workflows/cleanup-dev-images.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: Cleanup old dev images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Weekly on Sunday at 6 AM UTC
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Delete old dev image tags
|
||||
uses: actions/delete-package-versions@v5
|
||||
with:
|
||||
package-name: soulsync
|
||||
package-type: container
|
||||
min-versions-to-keep: 10
|
||||
delete-only-pre-release-versions: false
|
||||
# Only prune tags matching the dev nightly pattern (keep rolling + version tags)
|
||||
ignore-versions: '^(dev|nightly|latest|\\d+\\.\\d+)$'
|
||||
103
.github/workflows/dev-nightly.yml
vendored
Normal file
103
.github/workflows/dev-nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
name: Dev Nightly Build
|
||||
|
||||
on:
|
||||
# Nightly at 4:00 AM UTC — only if dev branch has new commits
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
# Also build on every push to dev for immediate feedback
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
# Manual trigger for testing
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip scheduled runs if dev branch has no new commits in the last 24h
|
||||
# (pushes and manual triggers always run)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
- name: Set lowercase owner
|
||||
run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check for recent commits (scheduled only)
|
||||
if: github.event_name == 'schedule'
|
||||
id: recent
|
||||
run: |
|
||||
LAST_COMMIT=$(git log -1 --format=%ct)
|
||||
NOW=$(date +%s)
|
||||
DIFF=$(( NOW - LAST_COMMIT ))
|
||||
if [ "$DIFF" -gt 86400 ]; then
|
||||
echo "No commits in the last 24h, skipping build"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies and run tests
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements-dev.txt
|
||||
python -m ruff check --output-format=github .
|
||||
python -m compileall api core database services scripts web_server.py wsgi.py beatport_unified_scraper.py
|
||||
python -m pytest
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate build tag
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
id: tag
|
||||
run: |
|
||||
echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push to GHCR
|
||||
if: steps.recent.outputs.skip != 'true'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
pull: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
tags: |
|
||||
ghcr.io/${{ env.OWNER }}/soulsync:dev
|
||||
ghcr.io/${{ env.OWNER }}/soulsync:dev-${{ steps.tag.outputs.date }}-${{ steps.tag.outputs.short_sha }}
|
||||
${{ github.event_name == 'schedule' && format('ghcr.io/{0}/soulsync:nightly', env.OWNER) || '' }}
|
||||
28
.github/workflows/docker-publish.yml
vendored
28
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,6 +1,11 @@
|
|||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
# Auto-build :latest on every push to main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Manual trigger for tagged releases (e.g. 2.33)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
|
|
@ -12,10 +17,17 @@ jobs:
|
|||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set lowercase owner
|
||||
run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
|
|
@ -28,22 +40,32 @@ jobs:
|
|||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
no-cache: true
|
||||
pull: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
tags: |
|
||||
boulderbadgedad/soulsync:latest
|
||||
boulderbadgedad/soulsync:${{ inputs.version_tag }}
|
||||
ghcr.io/${{ env.OWNER }}/soulsync:latest
|
||||
${{ inputs.version_tag && format('boulderbadgedad/soulsync:{0}', inputs.version_tag) || '' }}
|
||||
${{ inputs.version_tag && format('ghcr.io/{0}/soulsync:{1}', env.OWNER, inputs.version_tag) || '' }}
|
||||
|
||||
- name: Announce release to Discord
|
||||
if: success()
|
||||
if: success() && inputs.version_tag
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_ANNOUNCEMENTS_WEBHOOK }}
|
||||
run: |
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,3 +25,5 @@ bin/
|
|||
|
||||
# Development compose/config files
|
||||
*.dev.yml
|
||||
# Any hidden folders
|
||||
**/.*/
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ environment:
|
|||
- FLASK_ENV=production # Flask environment
|
||||
- PYTHONPATH=/app # Python path
|
||||
- SOULSYNC_CONFIG_PATH=/app/config/config.json # Config file location
|
||||
- SOULSYNC_LOG_LEVEL=INFO # Optional startup log level override, takes precedence over the UI-configured log level
|
||||
- TZ=America/New_York # Timezone
|
||||
```
|
||||
|
||||
|
|
@ -268,4 +269,4 @@ services:
|
|||
- [ ] Configure firewall rules
|
||||
- [ ] Set up backup strategy
|
||||
- [ ] Test health checks
|
||||
- [ ] Verify external service connectivity
|
||||
- [ ] Verify external service connectivity
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def register_routes(bp):
|
|||
try:
|
||||
# Forward to the internal sync endpoint
|
||||
import requests as http_requests
|
||||
internal_url = f"http://127.0.0.1:8008/api/sync/start"
|
||||
internal_url = "http://127.0.0.1:8008/api/sync/start"
|
||||
resp = http_requests.post(internal_url, json={
|
||||
"playlist_id": playlist_id,
|
||||
"playlist_name": playlist_name,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,8 +5,14 @@ import sqlite3
|
|||
from typing import Dict, Any, Optional
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pathlib import Path
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("config")
|
||||
|
||||
class ConfigManager:
|
||||
_VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
|
||||
|
||||
def __init__(self, config_path: str = "config/config.json"):
|
||||
# Determine strict absolute path to settings.py directory to help resolve config.json
|
||||
# This handles cases where CWD is different (e.g. running from /Users vs /Users/project)
|
||||
|
|
@ -33,7 +39,7 @@ class ConfigManager:
|
|||
# Default to project path even if it doesn't exist yet (for creation/fallback)
|
||||
self.config_path = project_path
|
||||
|
||||
print(f"ConfigManager initialized with path: {self.config_path}")
|
||||
logger.info(f"ConfigManager initialized with path: {self.config_path}")
|
||||
|
||||
self.config_data: Dict[str, Any] = {}
|
||||
self._fernet: Optional[Fernet] = None
|
||||
|
|
@ -45,7 +51,7 @@ class ConfigManager:
|
|||
else:
|
||||
self.database_path = self.base_dir / "database" / "music_library.db"
|
||||
|
||||
print(f"Database path set to: {self.database_path}")
|
||||
logger.info(f"Database path set to: {self.database_path}")
|
||||
|
||||
self.load_config(str(self.config_path))
|
||||
|
||||
|
|
@ -107,7 +113,7 @@ class ConfigManager:
|
|||
try:
|
||||
import shutil
|
||||
shutil.move(str(old_key_file), str(key_file))
|
||||
print(f"[MIGRATE] Moved encryption key to {key_file}")
|
||||
logger.info(f"Moved encryption key to {key_file}")
|
||||
except Exception:
|
||||
key_file = old_key_file # Fall back to old location
|
||||
if key_file.exists():
|
||||
|
|
@ -155,8 +161,10 @@ class ConfigManager:
|
|||
return decrypted
|
||||
except InvalidToken:
|
||||
# Key mismatch — encrypted with a different key (key file deleted/replaced)
|
||||
print(f"[ERROR] Failed to decrypt a config value — encryption key may have changed. "
|
||||
f"Re-enter credentials in Settings or restore the original .encryption_key file.")
|
||||
logger.error(
|
||||
"Failed to decrypt a config value — encryption key may have changed. "
|
||||
"Re-enter credentials in Settings or restore the original .encryption_key file."
|
||||
)
|
||||
return value
|
||||
except Exception:
|
||||
return value
|
||||
|
|
@ -243,11 +251,11 @@ class ConfigManager:
|
|||
needs_migration = True
|
||||
break
|
||||
if needs_migration:
|
||||
print("[MIGRATE] Encrypting sensitive config values at rest...")
|
||||
logger.info("Encrypting sensitive config values at rest...")
|
||||
self._save_to_database(self.config_data)
|
||||
print("[OK] Sensitive config values encrypted successfully")
|
||||
logger.info("Sensitive config values encrypted successfully")
|
||||
except Exception as e:
|
||||
print(f"[WARN] Could not migrate encryption: {e}")
|
||||
logger.warning(f"Could not migrate encryption: {e}")
|
||||
|
||||
def _ensure_database_exists(self):
|
||||
"""Ensure database file and metadata table exist"""
|
||||
|
|
@ -271,7 +279,7 @@ class ConfigManager:
|
|||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not ensure database exists: {e}")
|
||||
logger.warning(f"Could not ensure database exists: {e}")
|
||||
|
||||
def _load_from_database(self) -> Optional[Dict[str, Any]]:
|
||||
"""Load configuration from database, decrypting sensitive values."""
|
||||
|
|
@ -289,18 +297,70 @@ class ConfigManager:
|
|||
config_data = json.loads(row[0])
|
||||
# Decrypt sensitive values (gracefully handles plaintext migration)
|
||||
config_data = self._decrypt_sensitive(config_data)
|
||||
print("[OK] Configuration loaded from database")
|
||||
logger.info("Configuration loaded from database")
|
||||
return config_data
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config from database: {e}")
|
||||
logger.warning(f"Could not load config from database: {e}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _load_stored_log_level(self) -> Optional[str]:
|
||||
"""Load the persisted UI log level preference, if one exists."""
|
||||
conn = None
|
||||
try:
|
||||
self._ensure_database_exists()
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'log_level'")
|
||||
row = cursor.fetchone()
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
|
||||
level = str(row[0]).upper()
|
||||
if level not in self._VALID_LOG_LEVELS:
|
||||
logger.warning(f"Ignoring invalid stored log level: {row[0]}")
|
||||
return None
|
||||
return level
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load stored log level from database: {e}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _load_env_log_level(self) -> Optional[str]:
|
||||
"""Load the log level override from the environment, if one exists."""
|
||||
raw_level = os.environ.get("SOULSYNC_LOG_LEVEL")
|
||||
if not raw_level:
|
||||
return None
|
||||
|
||||
level = raw_level.upper()
|
||||
if level not in self._VALID_LOG_LEVELS:
|
||||
logger.warning(f"Ignoring invalid SOULSYNC_LOG_LEVEL value: {raw_level}")
|
||||
return None
|
||||
|
||||
return level
|
||||
|
||||
def _apply_log_level_overrides(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Overlay env and persisted log level preferences onto the loaded config."""
|
||||
env_level = self._load_env_log_level()
|
||||
if env_level:
|
||||
config_data.setdefault("logging", {})["level"] = env_level
|
||||
logger.info(f"Using log level from SOULSYNC_LOG_LEVEL: {env_level}")
|
||||
return config_data
|
||||
|
||||
stored_level = self._load_stored_log_level()
|
||||
if stored_level:
|
||||
config_data.setdefault("logging", {})["level"] = stored_level
|
||||
logger.info(f"Using stored logging level from database: {stored_level}")
|
||||
return config_data
|
||||
|
||||
def _save_to_database(self, config_data: Dict[str, Any]) -> bool:
|
||||
"""Save configuration to database, encrypting sensitive values."""
|
||||
conn = None
|
||||
|
|
@ -325,7 +385,7 @@ class ConfigManager:
|
|||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: Could not save config to database: {e}")
|
||||
logger.error(f"Could not save config to database: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
|
|
@ -337,12 +397,12 @@ class ConfigManager:
|
|||
if self.config_path.exists():
|
||||
with open(self.config_path, 'r') as f:
|
||||
config_data = json.load(f)
|
||||
print(f"[OK] Configuration loaded from {self.config_path}")
|
||||
logger.info(f"Configuration loaded from {self.config_path}")
|
||||
return config_data
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config from file: {e}")
|
||||
logger.warning(f"Could not load config from file: {e}")
|
||||
return None
|
||||
|
||||
def _get_default_config(self) -> Dict[str, Any]:
|
||||
|
|
@ -506,45 +566,45 @@ class ConfigManager:
|
|||
2. config.json (migration from file-based config)
|
||||
3. Defaults (fresh install)
|
||||
"""
|
||||
print(f"Loading configuration...")
|
||||
logger.info("Loading configuration...")
|
||||
|
||||
# Try loading from database first
|
||||
config_data = self._load_from_database()
|
||||
|
||||
if config_data:
|
||||
# Configuration exists in database
|
||||
self.config_data = config_data
|
||||
self.config_data = self._apply_log_level_overrides(config_data)
|
||||
# Ensure sensitive values are encrypted at rest (one-time migration)
|
||||
self._migrate_encrypt_if_needed()
|
||||
return
|
||||
|
||||
# Database is empty - try migration from config.json
|
||||
print(f"Configuration not found in database. Attempting migration from: {self.config_path}")
|
||||
logger.info(f"Configuration not found in database. Attempting migration from: {self.config_path}")
|
||||
config_data = self._load_from_config_file()
|
||||
|
||||
if config_data:
|
||||
# Migrate from config.json to database
|
||||
print("[MIGRATE] Migrating configuration from config.json to database...")
|
||||
logger.info("Migrating configuration from config.json to database...")
|
||||
if self._save_to_database(config_data):
|
||||
print("[OK] Configuration migrated successfully to database.")
|
||||
self.config_data = config_data
|
||||
logger.info("Configuration migrated successfully to database.")
|
||||
self.config_data = self._apply_log_level_overrides(config_data)
|
||||
return
|
||||
else:
|
||||
print("[WARN] Migration failed - using file-based config temporarily.")
|
||||
self.config_data = config_data
|
||||
logger.warning("Migration failed - using file-based config temporarily.")
|
||||
self.config_data = self._apply_log_level_overrides(config_data)
|
||||
return
|
||||
|
||||
# No config.json either - use defaults
|
||||
print("[INFO] ℹ️ No existing configuration found (DB or File) - using defaults")
|
||||
logger.info("No existing configuration found (DB or File) - using defaults")
|
||||
config_data = self._get_default_config()
|
||||
|
||||
# Try to save defaults to database
|
||||
if self._save_to_database(config_data):
|
||||
print("[OK] Default configuration saved to database")
|
||||
logger.info("Default configuration saved to database")
|
||||
else:
|
||||
print("[WARN] Could not save defaults to database - using in-memory config")
|
||||
logger.warning("Could not save defaults to database - using in-memory config")
|
||||
|
||||
self.config_data = config_data
|
||||
self.config_data = self._apply_log_level_overrides(config_data)
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration to database with retry on lock."""
|
||||
|
|
@ -558,14 +618,14 @@ class ConfigManager:
|
|||
|
||||
if not success:
|
||||
# Fallback: Try to save to config.json if database fails
|
||||
print("[WARN] Database save failed - attempting file fallback")
|
||||
logger.warning("Database save failed - attempting file fallback")
|
||||
try:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.config_data, f, indent=2)
|
||||
print("[OK] Configuration saved to config.json as fallback")
|
||||
logger.info("Configuration saved to config.json as fallback")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to save configuration: {e}")
|
||||
logger.error(f"Failed to save configuration: {e}")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
keys = key.split('.')
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing import Dict, List, Optional, Any, Tuple
|
|||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
import logging.handlers
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -29,22 +30,23 @@ from config.settings import config_manager
|
|||
FPCALC_BIN_DIR = Path(__file__).parent.parent / "bin"
|
||||
CHROMAPRINT_VERSION = "1.5.1"
|
||||
|
||||
# Set up dedicated AcoustID logger with its own file
|
||||
logger = get_logger("acoustid_client")
|
||||
|
||||
# Add dedicated file handler for AcoustID logs
|
||||
_acoustid_log_path = Path(__file__).parent.parent / "logs" / "acoustid.log"
|
||||
_acoustid_logger = logging.getLogger("soulsync.acoustid")
|
||||
_acoustid_logger.setLevel(logging.DEBUG)
|
||||
_acoustid_log_path = Path(config_manager.get('logging.path', 'logs/app.log')).parent / "acoustid.log"
|
||||
_acoustid_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_acoustid_file_handler = logging.handlers.RotatingFileHandler(
|
||||
_acoustid_log_path, encoding='utf-8', maxBytes=5*1024*1024, backupCount=2
|
||||
)
|
||||
_acoustid_file_handler.setLevel(logging.DEBUG)
|
||||
_acoustid_file_handler.setFormatter(logging.Formatter(
|
||||
fmt='%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
))
|
||||
logger.addHandler(_acoustid_file_handler)
|
||||
logging.getLogger("newmusic.acoustid_verification").addHandler(_acoustid_file_handler)
|
||||
if not _acoustid_logger.handlers:
|
||||
_acoustid_file_handler = logging.handlers.RotatingFileHandler(
|
||||
_acoustid_log_path, encoding='utf-8', maxBytes=5*1024*1024, backupCount=2
|
||||
)
|
||||
_acoustid_file_handler.setLevel(logging.DEBUG)
|
||||
_acoustid_file_handler.setFormatter(logging.Formatter(
|
||||
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
))
|
||||
_acoustid_logger.addHandler(_acoustid_file_handler)
|
||||
_acoustid_logger.propagate = False
|
||||
|
||||
logger = get_logger("acoustid.client")
|
||||
|
||||
# Check if pyacoustid is available
|
||||
try:
|
||||
|
|
@ -194,7 +196,7 @@ class AcoustIDClient:
|
|||
result = client.fingerprint_and_lookup("/path/to/audio.mp3")
|
||||
if result:
|
||||
for mbid in result['recording_mbids']:
|
||||
print(f"Match: {mbid}")
|
||||
logger.info(f"Match: {mbid}")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from utils.logging_config import get_logger
|
|||
from core.acoustid_client import AcoustIDClient
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
|
||||
logger = get_logger("acoustid_verification")
|
||||
logger = get_logger("acoustid.verification")
|
||||
|
||||
# Thresholds
|
||||
MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import threading
|
|||
import time
|
||||
from collections import deque, defaultdict
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("api_call_tracker")
|
||||
|
||||
|
||||
# Known rate limits per service (calls/minute)
|
||||
RATE_LIMITS = {
|
||||
|
|
@ -281,13 +286,16 @@ class ApiCallTracker:
|
|||
with open(_PERSIST_PATH, 'w') as f:
|
||||
json.dump({'ts': now, 'history': data, 'events': events}, f)
|
||||
except Exception as e:
|
||||
print(f"[ApiCallTracker] Failed to save history: {e}")
|
||||
logger.error(f"[ApiCallTracker] Failed to save history: {e}")
|
||||
|
||||
def _load(self):
|
||||
"""Restore 24h minute history from disk. Called on init."""
|
||||
try:
|
||||
if not os.path.exists(_PERSIST_PATH):
|
||||
return
|
||||
if os.path.getsize(_PERSIST_PATH) == 0:
|
||||
logger.info(f"[ApiCallTracker] History file is empty, starting fresh: {_PERSIST_PATH}")
|
||||
return
|
||||
with open(_PERSIST_PATH, 'r') as f:
|
||||
raw = json.load(f)
|
||||
saved_ts = raw.get('ts', 0)
|
||||
|
|
@ -305,9 +313,11 @@ class ApiCallTracker:
|
|||
for e in events:
|
||||
if e.get('ts', 0) >= cutoff:
|
||||
self._events.append(e)
|
||||
print(f"[ApiCallTracker] Restored history for {len(history)} services, {len(events)} events")
|
||||
logger.info(f"[ApiCallTracker] Restored history for {len(history)} services, {len(events)} events")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[ApiCallTracker] History file is not valid JSON, starting fresh: {_PERSIST_PATH} ({e})")
|
||||
except Exception as e:
|
||||
print(f"[ApiCallTracker] Failed to load history: {e}")
|
||||
logger.error(f"[ApiCallTracker] Failed to load history: {e}")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
|
|
|||
|
|
@ -968,6 +968,34 @@ class AutoImportWorker:
|
|||
artist_name = identification.get('artist_name', 'Unknown')
|
||||
album_name = identification.get('album_name', 'Unknown')
|
||||
image_url = identification.get('image_url', '')
|
||||
|
||||
# Parent folder artist override: if the staging folder structure is
|
||||
# Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder
|
||||
# as the artist name when the tag-extracted artist looks wrong.
|
||||
# This handles mixtapes/compilations where embedded tags have DJ names.
|
||||
try:
|
||||
staging_root = self._resolve_staging_path() or self.staging_path
|
||||
rel_path = os.path.relpath(candidate.path, staging_root)
|
||||
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
|
||||
|
||||
# parts[0] = artist folder, parts[1] = album or category subfolder, etc.
|
||||
# Only attempt override if there's at least 2 levels (artist/album)
|
||||
folder_artist = None
|
||||
if len(parts) >= 2:
|
||||
_category_names = {'albums', 'singles', 'eps', 'compilations', 'mixtapes',
|
||||
'discography', 'music', 'downloads'}
|
||||
if len(parts) >= 3 and parts[1].lower() in _category_names:
|
||||
# Artist/Albums/AlbumFolder → parts[0] is artist
|
||||
folder_artist = parts[0]
|
||||
elif parts[0].lower() not in _category_names:
|
||||
# Artist/AlbumFolder → parts[0] is artist
|
||||
folder_artist = parts[0]
|
||||
|
||||
if folder_artist and folder_artist.lower() != artist_name.lower():
|
||||
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
|
||||
artist_name = folder_artist
|
||||
except Exception:
|
||||
pass
|
||||
release_date = identification.get('release_date', '') or album_data.get('release_date', '')
|
||||
|
||||
# Compute total discs
|
||||
|
|
|
|||
|
|
@ -930,10 +930,16 @@ class DatabaseUpdateWorker:
|
|||
if (db_track.title != current_title or
|
||||
db_track.artist_name != current_artist or
|
||||
db_track.album_title != current_album):
|
||||
logger.debug(f"Metadata change detected for track ID {track_id}:")
|
||||
logger.debug(f" Title: '{db_track.title}' → '{current_title}'")
|
||||
logger.debug(f" Artist: '{db_track.artist_name}' → '{current_artist}'")
|
||||
logger.debug(f" Album: '{db_track.album_title}' → '{current_album}'")
|
||||
logger.debug(
|
||||
"Metadata change detected for track %s: title=%r→%r artist=%r→%r album=%r→%r",
|
||||
track_id,
|
||||
db_track.title,
|
||||
current_title,
|
||||
db_track.artist_name,
|
||||
current_artist,
|
||||
db_track.album_title,
|
||||
current_album,
|
||||
)
|
||||
changes_detected += 1
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -61,10 +61,12 @@ class DownloadOrchestrator:
|
|||
|
||||
logger.info(f"Download Orchestrator initialized - Mode: {self.mode}")
|
||||
if self.mode == 'hybrid':
|
||||
if self.hybrid_order:
|
||||
logger.info(f" Source priority: {' → '.join(self.hybrid_order)}")
|
||||
else:
|
||||
logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}")
|
||||
logger.info(
|
||||
"Hybrid source order: order=%s primary=%s secondary=%s",
|
||||
" → ".join(self.hybrid_order) if self.hybrid_order else "default",
|
||||
self.hybrid_primary,
|
||||
self.hybrid_secondary,
|
||||
)
|
||||
|
||||
def _safe_init(self, name, cls):
|
||||
"""Initialize a download client, returning None on failure instead of crashing."""
|
||||
|
|
@ -154,8 +156,10 @@ class DownloadOrchestrator:
|
|||
except Exception:
|
||||
results[source] = False
|
||||
|
||||
status_parts = [f"{s}: {'' if ok else ''}" for s, ok in results.items()]
|
||||
logger.info(f" {' | '.join(status_parts)}")
|
||||
logger.info(
|
||||
"Hybrid connection check: %s",
|
||||
" | ".join(f"{source}={'ok' if ok else 'fail'}" for source, ok in results.items()),
|
||||
)
|
||||
|
||||
return any(results.values())
|
||||
|
||||
|
|
|
|||
|
|
@ -1633,14 +1633,14 @@ class JellyfinClient:
|
|||
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||
"""Check if Jellyfin library is currently scanning"""
|
||||
if not self.ensure_connection():
|
||||
logger.debug("DEBUG: Not connected to Jellyfin, cannot check scan status")
|
||||
logger.debug("Not connected to Jellyfin, cannot check scan status")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Check scheduled tasks for library scan activities
|
||||
response = self._make_request('/ScheduledTasks')
|
||||
if not response:
|
||||
logger.debug("DEBUG: Could not get scheduled tasks")
|
||||
logger.debug("Could not get scheduled tasks")
|
||||
return False
|
||||
|
||||
for task in response:
|
||||
|
|
@ -1650,10 +1650,14 @@ class JellyfinClient:
|
|||
# Look for library scan related tasks that are running
|
||||
if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name):
|
||||
if task_state in ['Running', 'Cancelling']:
|
||||
logger.debug(f"DEBUG: Found running scan task: {task.get('Name')} (State: {task_state})")
|
||||
logger.debug(
|
||||
"Found running scan task: name=%s state=%s",
|
||||
task.get('Name'),
|
||||
task_state,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug("DEBUG: No active scan tasks detected")
|
||||
logger.debug("No active scan tasks detected")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1819,4 +1823,4 @@ class JellyfinClient:
|
|||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting metadata-only mode: {e}")
|
||||
return False
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -421,7 +421,7 @@ class MusicMatchingEngine:
|
|||
|
||||
if is_likely_album and 4 <= len(potential_album_part) <= 30:
|
||||
cleaned_title = re.sub(dash_pattern, '', track_title).strip()
|
||||
print(f"Heuristic album detection: '{original_title}' → '{cleaned_title}' (removed: '{potential_album_part}')")
|
||||
logger.debug(f"Heuristic album detection: '{original_title}' → '{cleaned_title}' (removed: '{potential_album_part}')")
|
||||
return cleaned_title, True
|
||||
|
||||
return track_title, False
|
||||
|
|
@ -1004,13 +1004,13 @@ class MusicMatchingEngine:
|
|||
|
||||
# Debug logging for troubleshooting
|
||||
if scored_results and not confident_results:
|
||||
print(f"DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.58")
|
||||
logger.debug(f"Found {len(scored_results)} scored results but none met confidence threshold 0.58")
|
||||
for i, result in enumerate(sorted_results[:3]): # Show top 3
|
||||
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||
logger.debug(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||
elif confident_results:
|
||||
print(f"DEBUG: {len(confident_results)} results passed confidence threshold 0.58")
|
||||
logger.debug(f"{len(confident_results)} results passed confidence threshold 0.58")
|
||||
for i, result in enumerate(confident_results[:3]): # Show top 3
|
||||
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||
logger.debug(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
|
||||
|
||||
return confident_results
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ auth checks, or source-fallback behavior.
|
|||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
import requests
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.itunes_client import iTunesClient
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -1032,8 +1033,15 @@ def check_album_completion(
|
|||
artist_name: str,
|
||||
source_override: Optional[str] = None,
|
||||
source_chain: Optional[List[str]] = None,
|
||||
candidate_albums: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Check completion status for a single album."""
|
||||
"""Check completion status for a single album.
|
||||
|
||||
When `candidate_albums` is provided, the DB matcher skips per-album SQL
|
||||
searches and scores every pre-fetched candidate in-memory. Intended for
|
||||
callers iterating a discography that have already loaded the artist's
|
||||
full library once via `db.get_candidate_albums_for_artist(...)`.
|
||||
"""
|
||||
try:
|
||||
source_chain = source_chain or _get_completion_source_chain(source_override)
|
||||
album_name = album_data.get('name', '')
|
||||
|
|
@ -1045,7 +1053,7 @@ def check_album_completion(
|
|||
if total_tracks == 0 and album_id:
|
||||
logger.debug("No track count found for '%s' (%s)", album_name, album_id)
|
||||
|
||||
print(f"Checking album: '{album_name}' ({total_tracks} tracks)")
|
||||
logger.debug(f"Checking album: '{album_name}' ({total_tracks} tracks)")
|
||||
|
||||
formats = []
|
||||
# Check if album exists in database with completeness info
|
||||
|
|
@ -1057,10 +1065,11 @@ def check_album_completion(
|
|||
artist=artist_name,
|
||||
expected_track_count=total_tracks if total_tracks > 0 else None,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server
|
||||
server_source=active_server,
|
||||
candidate_albums=candidate_albums
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"Database error for album '{album_name}': {db_error}")
|
||||
logger.error(f"Database error for album '{album_name}': {db_error}")
|
||||
return {
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
|
|
@ -1088,7 +1097,14 @@ def check_album_completion(
|
|||
else:
|
||||
status = "missing"
|
||||
|
||||
print(f" Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
|
||||
logger.debug(
|
||||
"Album completion result: owned=%s expected=%s total=%s completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
expected_tracks or total_tracks,
|
||||
total_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": album_id,
|
||||
|
|
@ -1103,7 +1119,7 @@ def check_album_completion(
|
|||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
|
||||
logger.error(f"Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": album_data.get('id', ''),
|
||||
"name": album_data.get('name', 'Unknown'),
|
||||
|
|
@ -1123,8 +1139,16 @@ def check_single_completion(
|
|||
artist_name: str,
|
||||
source_override: Optional[str] = None,
|
||||
source_chain: Optional[List[str]] = None,
|
||||
candidate_albums: Optional[List[Any]] = None,
|
||||
candidate_tracks: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Check completion status for a single/EP."""
|
||||
"""Check completion status for a single/EP.
|
||||
|
||||
`candidate_albums` applies to the EP branch (treated as an album lookup).
|
||||
`candidate_tracks` applies to the true-single branch (track-level lookup).
|
||||
Both are optional; None on either preserves the legacy per-item SQL path
|
||||
for that branch.
|
||||
"""
|
||||
try:
|
||||
source_chain = source_chain or _get_completion_source_chain(source_override)
|
||||
single_name = single_data.get('name', '')
|
||||
|
|
@ -1137,7 +1161,12 @@ def check_single_completion(
|
|||
if total_tracks == 0:
|
||||
total_tracks = _resolve_completion_track_total(single_data, source_chain) or 1
|
||||
|
||||
print(f"Checking {album_type}: '{single_name}' ({total_tracks} tracks)")
|
||||
logger.debug(
|
||||
"Checking %s: name=%r tracks=%s",
|
||||
album_type,
|
||||
single_name,
|
||||
total_tracks,
|
||||
)
|
||||
|
||||
if album_type == 'ep' or total_tracks > 1:
|
||||
try:
|
||||
|
|
@ -1148,10 +1177,11 @@ def check_single_completion(
|
|||
artist=artist_name,
|
||||
expected_track_count=total_tracks,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server
|
||||
server_source=active_server,
|
||||
candidate_albums=candidate_albums
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"Database error for EP '{single_name}': {db_error}")
|
||||
logger.error(f"Database error for EP '{single_name}': {db_error}")
|
||||
owned_tracks, expected_tracks, confidence = 0, total_tracks, 0.0
|
||||
db_album = None
|
||||
|
||||
|
|
@ -1167,7 +1197,14 @@ def check_single_completion(
|
|||
else:
|
||||
status = "missing"
|
||||
|
||||
print(f" EP Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
|
||||
logger.debug(
|
||||
"EP completion result: owned=%s expected=%s total=%s completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
expected_tracks or total_tracks,
|
||||
total_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": single_id,
|
||||
|
|
@ -1189,10 +1226,11 @@ def check_single_completion(
|
|||
title=single_name,
|
||||
artist=artist_name,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server
|
||||
server_source=active_server,
|
||||
candidate_tracks=candidate_tracks
|
||||
)
|
||||
except Exception as db_error:
|
||||
print(f"Database error for single '{single_name}': {db_error}")
|
||||
logger.error(f"Database error for single '{single_name}': {db_error}")
|
||||
db_track, confidence = None, 0.0
|
||||
|
||||
owned_tracks = 1 if db_track else 0
|
||||
|
|
@ -1208,7 +1246,12 @@ def check_single_completion(
|
|||
elif ext:
|
||||
formats = [ext]
|
||||
|
||||
print(f" Single Result: {owned_tracks}/1 tracks ({completion_percentage:.1f}%) - {status}")
|
||||
logger.debug(
|
||||
"Single completion result: owned=%s expected=1 completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": single_id,
|
||||
|
|
@ -1224,7 +1267,7 @@ def check_single_completion(
|
|||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
|
||||
logger.error(f"Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": single_data.get('id', ''),
|
||||
"name": single_data.get('name', 'Unknown'),
|
||||
|
|
@ -1258,12 +1301,35 @@ def iter_artist_discography_completion_events(
|
|||
total_items = len(albums) + len(singles)
|
||||
processed_count = 0
|
||||
|
||||
# Pre-fetch the artist's library albums AND tracks ONCE so per-item matching
|
||||
# runs in-memory. Same batching trick as the library completion-stream endpoint.
|
||||
import time as _time_metadata
|
||||
candidate_albums = None
|
||||
candidate_tracks = None
|
||||
try:
|
||||
from config.settings import config_manager as _cm_metadata
|
||||
_active_server = _cm_metadata.get_active_media_server()
|
||||
_t0 = _time_metadata.perf_counter()
|
||||
candidate_albums = db.get_candidate_albums_for_artist(resolved_artist_name, server_source=_active_server)
|
||||
_t1 = _time_metadata.perf_counter()
|
||||
print(f"[artist-completion-stream] Pre-fetched {len(candidate_albums) if candidate_albums is not None else 0} library albums for '{resolved_artist_name}' in {(_t1 - _t0) * 1000:.0f}ms")
|
||||
if candidate_albums:
|
||||
_t2 = _time_metadata.perf_counter()
|
||||
candidate_tracks = db.get_candidate_tracks_for_albums([a.id for a in candidate_albums])
|
||||
_t3 = _time_metadata.perf_counter()
|
||||
print(f"[artist-completion-stream] Pre-fetched {len(candidate_tracks) if candidate_tracks is not None else 0} library tracks in {(_t3 - _t2) * 1000:.0f}ms")
|
||||
except Exception as _pre_err:
|
||||
print(f"[artist-completion-stream] Failed to pre-fetch candidates for '{resolved_artist_name}': {_pre_err}")
|
||||
candidate_albums = None
|
||||
candidate_tracks = None
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'total_items': total_items,
|
||||
'artist_name': resolved_artist_name,
|
||||
}
|
||||
|
||||
_loop_start = _time_metadata.perf_counter()
|
||||
for album in albums:
|
||||
try:
|
||||
completion_data = check_album_completion(
|
||||
|
|
@ -1272,6 +1338,7 @@ def iter_artist_discography_completion_events(
|
|||
resolved_artist_name,
|
||||
source_override=source_override,
|
||||
source_chain=source_chain,
|
||||
candidate_albums=candidate_albums,
|
||||
)
|
||||
completion_data['type'] = 'album_completion'
|
||||
completion_data['container_type'] = 'albums'
|
||||
|
|
@ -1295,6 +1362,8 @@ def iter_artist_discography_completion_events(
|
|||
resolved_artist_name,
|
||||
source_override=source_override,
|
||||
source_chain=source_chain,
|
||||
candidate_albums=candidate_albums,
|
||||
candidate_tracks=candidate_tracks,
|
||||
)
|
||||
completion_data['type'] = 'single_completion'
|
||||
completion_data['container_type'] = 'singles'
|
||||
|
|
@ -1310,6 +1379,9 @@ def iter_artist_discography_completion_events(
|
|||
'error': str(e),
|
||||
}
|
||||
|
||||
_loop_elapsed = _time_metadata.perf_counter() - _loop_start
|
||||
print(f"[artist-completion-stream] Processed {total_items} items for '{resolved_artist_name}' in {_loop_elapsed * 1000:.0f}ms")
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'processed_count': processed_count,
|
||||
|
|
@ -1344,6 +1416,397 @@ def check_artist_discography_completion(
|
|||
}
|
||||
|
||||
|
||||
def _fetch_musicmap_similar_artist_names(artist_name: str) -> List[str]:
|
||||
"""Fetch similar artist names from MusicMap."""
|
||||
if not (artist_name or '').strip():
|
||||
raise ValueError('Artist name is required')
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
url_artist = quote_plus(artist_name.strip())
|
||||
musicmap_url = f'https://www.music-map.com/{url_artist}'
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
|
||||
logger.debug("Fetching MusicMap: %s", musicmap_url)
|
||||
response = requests.get(musicmap_url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
gnod_map = soup.find(id='gnodMap')
|
||||
if not gnod_map:
|
||||
raise ValueError('Could not find artist map on MusicMap')
|
||||
|
||||
searched_artist_lower = _normalize_artist_name(artist_name)
|
||||
similar_artist_names: List[str] = []
|
||||
seen_names = set()
|
||||
|
||||
for anchor in gnod_map.find_all('a'):
|
||||
artist_text = anchor.get_text(strip=True)
|
||||
normalized_name = _normalize_artist_name(artist_text)
|
||||
if not normalized_name or normalized_name == searched_artist_lower or normalized_name in seen_names:
|
||||
continue
|
||||
seen_names.add(normalized_name)
|
||||
similar_artist_names.append(artist_text)
|
||||
|
||||
logger.debug("Found %s similar artists from MusicMap", len(similar_artist_names))
|
||||
return similar_artist_names
|
||||
|
||||
|
||||
def _extract_artist_image_url(artist_data: Any) -> Optional[str]:
|
||||
if not artist_data:
|
||||
return None
|
||||
|
||||
images = _extract_lookup_value(artist_data, 'images', default=[]) or []
|
||||
if not isinstance(images, list):
|
||||
try:
|
||||
images = list(images)
|
||||
except TypeError:
|
||||
images = []
|
||||
|
||||
if images:
|
||||
first_image = images[0]
|
||||
image_url = _extract_lookup_value(first_image, 'url')
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
return _extract_lookup_value(
|
||||
artist_data,
|
||||
'image_url',
|
||||
'thumb_url',
|
||||
'cover_image',
|
||||
'picture_xl',
|
||||
'picture_big',
|
||||
'picture_medium',
|
||||
)
|
||||
|
||||
|
||||
def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dict[str, Any]]:
|
||||
artist_id = _extract_lookup_value(artist_data, 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id')
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
if isinstance(artist_data, dict):
|
||||
name = artist_data.get('name') or artist_data.get('artist_name') or artist_data.get('title')
|
||||
genres = artist_data.get('genres') or []
|
||||
popularity = artist_data.get('popularity') or artist_data.get('rank') or 0
|
||||
else:
|
||||
name = (
|
||||
getattr(artist_data, 'name', None)
|
||||
or getattr(artist_data, 'artist_name', None)
|
||||
or getattr(artist_data, 'title', None)
|
||||
)
|
||||
genres = getattr(artist_data, 'genres', None) or []
|
||||
popularity = getattr(artist_data, 'popularity', None) or getattr(artist_data, 'rank', None) or 0
|
||||
|
||||
if isinstance(genres, str):
|
||||
genres = [genres]
|
||||
elif not isinstance(genres, list):
|
||||
try:
|
||||
genres = list(genres)
|
||||
except TypeError:
|
||||
genres = []
|
||||
|
||||
try:
|
||||
popularity = int(popularity or 0)
|
||||
except Exception:
|
||||
popularity = 0
|
||||
|
||||
return {
|
||||
'id': str(artist_id),
|
||||
'name': str(name or artist_id),
|
||||
'image_url': _extract_artist_image_url(artist_data),
|
||||
'genres': genres,
|
||||
'popularity': popularity,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_musicmap_artist_source_ids(artist_name: str, source_chain: List[str]) -> Dict[str, Optional[str]]:
|
||||
searched_source_ids: Dict[str, Optional[str]] = {}
|
||||
|
||||
for source in source_chain:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
searched_source_ids[source] = None
|
||||
continue
|
||||
|
||||
search_results = _search_artists_for_source(source, client, artist_name, limit=1)
|
||||
searched_source_ids[source] = _extract_lookup_value(search_results[0], 'id', 'artist_id') if search_results else None
|
||||
|
||||
return searched_source_ids
|
||||
|
||||
|
||||
def _match_musicmap_similar_artist(
|
||||
candidate_name: str,
|
||||
source_chain: List[str],
|
||||
searched_artist_name: str,
|
||||
searched_source_ids: Dict[str, Optional[str]],
|
||||
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
|
||||
target_name = _normalize_artist_name(candidate_name)
|
||||
searched_name = _normalize_artist_name(searched_artist_name)
|
||||
|
||||
for source in source_chain:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
search_results = _search_artists_for_source(source, client, candidate_name, limit=1)
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
matched_artist = _pick_best_artist_match(search_results, candidate_name)
|
||||
if not matched_artist:
|
||||
continue
|
||||
|
||||
matched_name = _normalize_artist_name(
|
||||
_extract_lookup_value(matched_artist, 'name', 'artist_name', 'title')
|
||||
)
|
||||
if matched_name and matched_name == searched_name:
|
||||
continue
|
||||
|
||||
matched_id = _extract_lookup_value(matched_artist, 'id', 'artist_id')
|
||||
if not matched_id:
|
||||
continue
|
||||
|
||||
if str(matched_id) == str(searched_source_ids.get(source) or ''):
|
||||
continue
|
||||
|
||||
payload = _build_similar_artist_payload(matched_artist, source)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
if source == 'itunes' and not payload.get('image_url') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
full_artist = client.get_artist(str(matched_id))
|
||||
image_url = _extract_artist_image_url(full_artist)
|
||||
if image_url:
|
||||
payload['image_url'] = image_url
|
||||
elif hasattr(client, '_get_artist_image_from_albums'):
|
||||
album_image_url = client._get_artist_image_from_albums(str(matched_id))
|
||||
if album_image_url:
|
||||
payload['image_url'] = album_image_url
|
||||
except Exception as exc:
|
||||
logger.debug("Could not enrich iTunes image for %s: %s", matched_id, exc)
|
||||
|
||||
if target_name and _normalize_artist_name(payload['name']) == searched_name:
|
||||
continue
|
||||
|
||||
return source, payload
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def iter_musicmap_similar_artist_events(
|
||||
artist_name: str,
|
||||
limit: int = 20,
|
||||
source_override: Optional[str] = None,
|
||||
):
|
||||
"""Yield MusicMap similar-artist events using source priority."""
|
||||
try:
|
||||
source_chain = _get_source_chain_for_lookup(
|
||||
MetadataLookupOptions(source_override=source_override, allow_fallback=True)
|
||||
)
|
||||
available_sources = [source for source in source_chain if get_client_for_source(source)]
|
||||
if not available_sources:
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': 'No metadata providers available for similar artist matching',
|
||||
'status_code': 503,
|
||||
}
|
||||
return
|
||||
|
||||
similar_artist_names = _fetch_musicmap_similar_artist_names(artist_name)
|
||||
searched_source_ids = _resolve_musicmap_artist_source_ids(artist_name, source_chain)
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'artist_name': artist_name,
|
||||
'total_found': len(similar_artist_names),
|
||||
'source_priority': source_chain,
|
||||
}
|
||||
|
||||
matched_count = 0
|
||||
seen_names = set()
|
||||
seen_ids = set()
|
||||
|
||||
for candidate_name in similar_artist_names[:limit]:
|
||||
normalized_candidate = _normalize_artist_name(candidate_name)
|
||||
if not normalized_candidate or normalized_candidate in seen_names:
|
||||
continue
|
||||
|
||||
source, payload = _match_musicmap_similar_artist(
|
||||
candidate_name,
|
||||
source_chain,
|
||||
artist_name,
|
||||
searched_source_ids,
|
||||
)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
payload_id = str(payload.get('id') or '')
|
||||
if payload_id in seen_ids:
|
||||
continue
|
||||
|
||||
seen_names.add(normalized_candidate)
|
||||
seen_ids.add(payload_id)
|
||||
matched_count += 1
|
||||
|
||||
yield {
|
||||
'type': 'artist',
|
||||
'artist': payload,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'complete': True,
|
||||
'total': matched_count,
|
||||
'total_found': len(similar_artist_names),
|
||||
'artist_name': artist_name,
|
||||
'source_priority': source_chain,
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.debug("Error fetching MusicMap for %s: %s", artist_name, exc)
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': f'Failed to fetch from MusicMap: {exc}',
|
||||
'status_code': 502,
|
||||
}
|
||||
except ValueError as exc:
|
||||
status_code = 404 if 'Could not find artist map on MusicMap' in str(exc) else 400
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': str(exc),
|
||||
'status_code': status_code,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Error streaming similar artists for %s: %s", artist_name, exc)
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': str(exc),
|
||||
'status_code': 500,
|
||||
}
|
||||
|
||||
|
||||
def get_musicmap_similar_artists(
|
||||
artist_name: str,
|
||||
limit: int = 20,
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return matched MusicMap similar artists as a single payload."""
|
||||
artists: List[Dict[str, Any]] = []
|
||||
total_found = 0
|
||||
error_message = None
|
||||
status_code = 500
|
||||
source_priority: List[str] = []
|
||||
|
||||
for event in iter_musicmap_similar_artist_events(
|
||||
artist_name,
|
||||
limit=limit,
|
||||
source_override=source_override,
|
||||
):
|
||||
if event.get('type') == 'start':
|
||||
total_found = event.get('total_found', 0)
|
||||
source_priority = event.get('source_priority', [])
|
||||
elif event.get('type') == 'artist' and event.get('artist'):
|
||||
artists.append(event['artist'])
|
||||
elif event.get('type') == 'complete':
|
||||
total_found = event.get('total_found', total_found)
|
||||
source_priority = event.get('source_priority', source_priority)
|
||||
elif event.get('type') == 'error':
|
||||
error_message = event.get('error', 'Unknown error')
|
||||
status_code = int(event.get('status_code') or status_code or 500)
|
||||
break
|
||||
|
||||
if error_message:
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_message,
|
||||
'status_code': status_code,
|
||||
'artist': artist_name,
|
||||
'similar_artists': [],
|
||||
'total_found': total_found,
|
||||
'total_matched': 0,
|
||||
'source_priority': source_priority,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'artist': artist_name,
|
||||
'similar_artists': artists,
|
||||
'total_found': total_found,
|
||||
'total_matched': len(artists),
|
||||
'source_priority': source_priority,
|
||||
}
|
||||
|
||||
|
||||
def _get_artist_image_from_source(source: str, artist_id: str) -> Optional[str]:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
if source == 'spotify':
|
||||
artist_data = client.get_artist(artist_id, allow_fallback=False)
|
||||
else:
|
||||
artist_data = client.get_artist(artist_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not fetch artist image for %s on %s: %s", artist_id, source, exc)
|
||||
artist_data = None
|
||||
|
||||
image_url = _extract_artist_image_url(artist_data)
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
if hasattr(client, '_get_artist_image_from_albums'):
|
||||
try:
|
||||
return client._get_artist_image_from_albums(artist_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not fetch artist album art for %s on %s: %s", artist_id, source, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_artist_image_url(
|
||||
artist_id: str,
|
||||
source_override: Optional[str] = None,
|
||||
plugin: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an artist image URL using the configured source priority."""
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
if artist_id.startswith('soul_'):
|
||||
return None
|
||||
|
||||
source_override = (source_override or '').strip().lower()
|
||||
plugin = (plugin or '').strip().lower()
|
||||
|
||||
if source_override == 'hydrabase':
|
||||
if plugin in ('deezer', 'itunes'):
|
||||
return _get_artist_image_from_source(plugin, artist_id)
|
||||
if artist_id.isdigit():
|
||||
return _get_artist_image_from_source('itunes', artist_id)
|
||||
return None
|
||||
|
||||
if source_override:
|
||||
return _get_artist_image_from_source(source_override, artist_id)
|
||||
|
||||
for source in get_source_priority(get_primary_source()):
|
||||
image_url = _get_artist_image_from_source(source, artist_id)
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_deezer_client():
|
||||
"""Get cached Deezer client.
|
||||
|
||||
|
|
|
|||
|
|
@ -937,7 +937,7 @@ class PlexClient:
|
|||
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||
"""Check if Plex library is currently scanning"""
|
||||
if not self.ensure_connection():
|
||||
logger.debug(f"DEBUG: Not connected to Plex, cannot check scan status")
|
||||
logger.debug("Not connected to Plex, cannot check scan status")
|
||||
return False
|
||||
|
||||
try:
|
||||
|
|
@ -946,31 +946,31 @@ class PlexClient:
|
|||
# Check if library has a scanning attribute or is refreshing
|
||||
# The Plex API exposes this through the library's refreshing property
|
||||
refreshing = hasattr(library, 'refreshing') and library.refreshing
|
||||
logger.debug(f"DEBUG: Library.refreshing = {refreshing}")
|
||||
logger.debug("Library.refreshing = %s", refreshing)
|
||||
|
||||
if refreshing:
|
||||
logger.debug(f"DEBUG: Library is refreshing")
|
||||
logger.debug("Library is refreshing")
|
||||
return True
|
||||
|
||||
# Alternative method: Check server activities for scanning
|
||||
try:
|
||||
activities = self.server.activities()
|
||||
logger.debug(f"DEBUG: Found {len(activities)} server activities")
|
||||
logger.debug("Found %s server activities", len(activities))
|
||||
|
||||
for activity in activities:
|
||||
# Look for library scan activities
|
||||
activity_type = getattr(activity, 'type', 'unknown')
|
||||
activity_title = getattr(activity, 'title', 'unknown')
|
||||
logger.debug(f"DEBUG: Activity - type: {activity_type}, title: {activity_title}")
|
||||
logger.debug("Activity - type=%s title=%s", activity_type, activity_title)
|
||||
|
||||
if (activity_type in ['library.scan', 'library.refresh'] and
|
||||
library_name.lower() in activity_title.lower()):
|
||||
logger.debug(f"DEBUG: Found matching scan activity: {activity_title}")
|
||||
logger.debug("Found matching scan activity: %s", activity_title)
|
||||
return True
|
||||
except Exception as activities_error:
|
||||
logger.debug(f"Could not check server activities: {activities_error}")
|
||||
|
||||
logger.debug(f"DEBUG: No scan activity detected")
|
||||
logger.debug("No scan activity detected")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ _JOB_MODULES = [
|
|||
'core.repair_jobs.album_tag_consistency',
|
||||
'core.repair_jobs.live_commentary_cleaner',
|
||||
'core.repair_jobs.unknown_artist_fixer',
|
||||
'core.repair_jobs.discography_backfill',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
387
core/repair_jobs/discography_backfill.py
Normal file
387
core/repair_jobs/discography_backfill.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"""Discography Backfill Job — finds missing albums/tracks for library artists."""
|
||||
|
||||
from core.metadata_service import (
|
||||
get_album_tracks_for_source,
|
||||
get_artist_discography,
|
||||
get_primary_source,
|
||||
MetadataLookupOptions,
|
||||
)
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
from core.watchlist_scanner import (
|
||||
is_acoustic_version,
|
||||
is_compilation_album,
|
||||
is_instrumental_version,
|
||||
is_live_version,
|
||||
is_remix_version,
|
||||
)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("repair_job.discography_backfill")
|
||||
|
||||
|
||||
@register_job
|
||||
class DiscographyBackfillJob(RepairJob):
|
||||
job_id = 'discography_backfill'
|
||||
display_name = 'Discography Backfill'
|
||||
description = 'Finds missing albums and tracks for artists in your library'
|
||||
help_text = (
|
||||
'Scans each artist in your library, fetches their full discography from '
|
||||
'the configured metadata source, and adds any tracks you don\'t already '
|
||||
'own to the wishlist for automatic download.\n\n'
|
||||
'Respects content filters: live versions, remixes, acoustic versions, '
|
||||
'instrumentals, and compilations are excluded by default.\n\n'
|
||||
'Settings:\n'
|
||||
'- Include Albums/EPs/Singles: Which release types to check\n'
|
||||
'- Include Live/Remixes/Acoustic/Compilations/Instrumentals: Content type filters\n'
|
||||
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)'
|
||||
)
|
||||
icon = 'repair-icon-backfill'
|
||||
default_enabled = False
|
||||
default_interval_hours = 168 # Weekly
|
||||
default_settings = {
|
||||
'include_albums': True,
|
||||
'include_eps': True,
|
||||
'include_singles': False,
|
||||
'include_live': False,
|
||||
'include_remixes': False,
|
||||
'include_acoustic': False,
|
||||
'include_compilations': False,
|
||||
'include_instrumentals': False,
|
||||
'max_artists_per_run': 50,
|
||||
}
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
settings = self._get_settings(context)
|
||||
|
||||
max_artists = settings.get('max_artists_per_run', 50)
|
||||
|
||||
# Fetch all library artists with their metadata source IDs
|
||||
artists = self._get_library_artists(context)
|
||||
if not artists:
|
||||
logger.info("No artists in library to scan")
|
||||
return result
|
||||
|
||||
total = min(len(artists), max_artists)
|
||||
if context.update_progress:
|
||||
context.update_progress(0, total)
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
phase=f'Scanning discography for {total} artists...',
|
||||
total=total,
|
||||
)
|
||||
|
||||
logger.info("Discography backfill: scanning %d artists (of %d total)", total, len(artists))
|
||||
primary_source = get_primary_source()
|
||||
|
||||
for i, artist in enumerate(artists[:max_artists]):
|
||||
if context.check_stop():
|
||||
return result
|
||||
if i % 5 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
artist_id = artist['id']
|
||||
artist_name = artist['name']
|
||||
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
scanned=i + 1, total=total,
|
||||
phase=f'Scanning {i + 1} / {total}',
|
||||
log_line=f'Fetching discography: {artist_name}',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
try:
|
||||
missing_count = self._scan_artist(context, artist, settings, primary_source, result)
|
||||
if missing_count > 0:
|
||||
logger.info("Found %d missing tracks for %s", missing_count, artist_name)
|
||||
except Exception as e:
|
||||
logger.warning("Error scanning discography for %s: %s", artist_name, e)
|
||||
result.errors += 1
|
||||
|
||||
if context.update_progress and (i + 1) % 3 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
|
||||
# Rate limit between artists
|
||||
if context.sleep_or_stop(1.0):
|
||||
return result
|
||||
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
|
||||
logger.info(
|
||||
"Discography backfill complete: %d artists scanned, %d missing tracks found, %d errors",
|
||||
result.scanned, result.findings_created, result.errors,
|
||||
)
|
||||
return result
|
||||
|
||||
def _scan_artist(self, context, artist, settings, primary_source, result):
|
||||
"""Scan one artist's discography and create findings for missing tracks."""
|
||||
artist_name = artist['name']
|
||||
result.scanned += 1
|
||||
|
||||
# Build source ID map for more accurate lookups
|
||||
source_ids = {}
|
||||
if artist.get('spotify_artist_id'):
|
||||
source_ids['spotify'] = artist['spotify_artist_id']
|
||||
if artist.get('itunes_artist_id'):
|
||||
source_ids['itunes'] = artist['itunes_artist_id']
|
||||
if artist.get('deezer_artist_id'):
|
||||
source_ids['deezer'] = artist['deezer_artist_id']
|
||||
|
||||
# Fetch full discography
|
||||
discography = get_artist_discography(
|
||||
artist_id=str(artist['id']),
|
||||
artist_name=artist_name,
|
||||
options=MetadataLookupOptions(
|
||||
allow_fallback=True,
|
||||
skip_cache=False,
|
||||
artist_source_ids=source_ids if source_ids else None,
|
||||
),
|
||||
)
|
||||
|
||||
if not discography:
|
||||
result.skipped += 1
|
||||
return 0
|
||||
|
||||
source = discography.get('source', primary_source)
|
||||
albums = discography.get('albums', [])
|
||||
singles = discography.get('singles', [])
|
||||
missing_count = 0
|
||||
active_server = None
|
||||
if context.config_manager:
|
||||
active_server = context.config_manager.get_active_media_server()
|
||||
|
||||
# Process albums and singles
|
||||
for release in albums + singles:
|
||||
if context.check_stop():
|
||||
return missing_count
|
||||
|
||||
release_name = release.get('name', '')
|
||||
release_id = release.get('id', '')
|
||||
total_tracks = release.get('total_tracks', 0) or 0
|
||||
album_type = release.get('album_type', 'album')
|
||||
release_image = release.get('image_url', '')
|
||||
|
||||
# Filter by release type
|
||||
if not self._should_include_release(total_tracks, album_type, settings):
|
||||
continue
|
||||
|
||||
# Filter compilation albums
|
||||
if not settings.get('include_compilations', False):
|
||||
if is_compilation_album(release_name):
|
||||
continue
|
||||
|
||||
# Fetch tracks for this release
|
||||
try:
|
||||
tracks_data = get_album_tracks_for_source(source, str(release_id))
|
||||
except Exception:
|
||||
tracks_data = None
|
||||
|
||||
if not tracks_data:
|
||||
continue
|
||||
|
||||
# Extract track items
|
||||
items = []
|
||||
if isinstance(tracks_data, dict):
|
||||
items = tracks_data.get('items', [])
|
||||
elif isinstance(tracks_data, list):
|
||||
items = tracks_data
|
||||
|
||||
if not items:
|
||||
continue
|
||||
|
||||
for track_item in items:
|
||||
if context.check_stop():
|
||||
return missing_count
|
||||
|
||||
track_name = track_item.get('name', '')
|
||||
if not track_name:
|
||||
continue
|
||||
|
||||
# Extract artist name from track
|
||||
track_artists = track_item.get('artists', [])
|
||||
if track_artists:
|
||||
first_artist = track_artists[0]
|
||||
if isinstance(first_artist, dict):
|
||||
track_artist = first_artist.get('name', artist_name)
|
||||
else:
|
||||
track_artist = str(first_artist)
|
||||
else:
|
||||
track_artist = artist_name
|
||||
|
||||
# Content type filters
|
||||
if not settings.get('include_live', False):
|
||||
if is_live_version(track_name, release_name):
|
||||
continue
|
||||
if not settings.get('include_remixes', False):
|
||||
if is_remix_version(track_name, release_name):
|
||||
continue
|
||||
if not settings.get('include_acoustic', False):
|
||||
if is_acoustic_version(track_name, release_name):
|
||||
continue
|
||||
if not settings.get('include_instrumentals', False):
|
||||
if is_instrumental_version(track_name, release_name):
|
||||
continue
|
||||
|
||||
# Check if track already exists in library
|
||||
db_track, confidence = context.db.check_track_exists(
|
||||
track_name, track_artist,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server,
|
||||
album=release_name,
|
||||
)
|
||||
if db_track and confidence >= 0.7:
|
||||
continue # Already owned
|
||||
|
||||
# Check if already in wishlist
|
||||
try:
|
||||
track_id = track_item.get('id', '')
|
||||
if track_id and self._is_in_wishlist(context.db, track_id):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build track data for wishlist
|
||||
track_data = {
|
||||
'id': track_item.get('id', f'backfill_{hash(f"{track_artist}_{track_name}") % 100000}'),
|
||||
'name': track_name,
|
||||
'artists': [{'name': track_artist}],
|
||||
'album': {
|
||||
'name': release_name,
|
||||
'id': str(release_id),
|
||||
'images': [{'url': release_image}] if release_image else [],
|
||||
'album_type': album_type,
|
||||
'release_date': release.get('release_date', ''),
|
||||
},
|
||||
'duration_ms': track_item.get('duration_ms', 0),
|
||||
'track_number': track_item.get('track_number', 0),
|
||||
'disc_number': track_item.get('disc_number', 1),
|
||||
}
|
||||
|
||||
# Create finding
|
||||
if context.create_finding:
|
||||
try:
|
||||
context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='missing_discography_track',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(track_data['id']),
|
||||
file_path=None,
|
||||
title=f'Missing: {track_name}',
|
||||
description=(
|
||||
f'"{track_name}" by {track_artist} from '
|
||||
f'"{release_name}" is not in your library.'
|
||||
),
|
||||
details={
|
||||
'track_data': track_data,
|
||||
'artist_name': artist_name,
|
||||
'album_name': release_name,
|
||||
'album_image_url': release_image,
|
||||
'source': source,
|
||||
},
|
||||
)
|
||||
result.findings_created += 1
|
||||
missing_count += 1
|
||||
except Exception as e:
|
||||
logger.debug("Error creating finding for %s: %s", track_name, e)
|
||||
result.errors += 1
|
||||
|
||||
return missing_count
|
||||
|
||||
@staticmethod
|
||||
def _should_include_release(total_tracks, album_type, settings):
|
||||
"""Check if a release should be included based on type settings."""
|
||||
# Use album_type from metadata source when available
|
||||
normalized = (album_type or '').lower()
|
||||
if normalized == 'compilation':
|
||||
return settings.get('include_compilations', False)
|
||||
if normalized in ('single',):
|
||||
return settings.get('include_singles', False)
|
||||
if normalized in ('ep',):
|
||||
return settings.get('include_eps', True)
|
||||
# Fall back to track count heuristic
|
||||
if total_tracks >= 7:
|
||||
return settings.get('include_albums', True)
|
||||
elif total_tracks >= 4:
|
||||
return settings.get('include_eps', True)
|
||||
elif total_tracks >= 1:
|
||||
return settings.get('include_singles', False)
|
||||
return settings.get('include_albums', True)
|
||||
|
||||
@staticmethod
|
||||
def _is_in_wishlist(db, track_id):
|
||||
"""Check if a track ID is already in the wishlist."""
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM wishlist_tracks WHERE spotify_track_id = ?",
|
||||
(str(track_id),),
|
||||
)
|
||||
return cursor.fetchone()[0] > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_library_artists(self, context):
|
||||
"""Get all artists from the library database with source IDs."""
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check which columns exist
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
columns = {col[1] for col in cursor.fetchall()}
|
||||
|
||||
select = ["id", "name"]
|
||||
if 'spotify_artist_id' in columns:
|
||||
select.append("spotify_artist_id")
|
||||
if 'itunes_artist_id' in columns:
|
||||
select.append("itunes_artist_id")
|
||||
if 'deezer_artist_id' in columns:
|
||||
select.append("deezer_artist_id")
|
||||
|
||||
cursor.execute(f"""
|
||||
SELECT {', '.join(select)}
|
||||
FROM artists
|
||||
WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
|
||||
ORDER BY name
|
||||
""")
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error("Error fetching library artists: %s", e, exc_info=True)
|
||||
return []
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _get_settings(self, context: JobContext) -> dict:
|
||||
if not context.config_manager:
|
||||
return self.default_settings.copy()
|
||||
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
|
||||
merged = self.default_settings.copy()
|
||||
merged.update(cfg)
|
||||
return merged
|
||||
|
||||
def estimate_scope(self, context: JobContext) -> int:
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM artists
|
||||
WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
settings = self._get_settings(context)
|
||||
max_artists = settings.get('max_artists_per_run', 50)
|
||||
return min(row[0] if row else 0, max_artists)
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
@ -845,12 +845,33 @@ class RepairWorker:
|
|||
'unwanted_content': self._fix_unwanted_content,
|
||||
'unknown_artist': self._fix_unknown_artist,
|
||||
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
||||
'missing_discography_track': self._fix_discography_backfill,
|
||||
}
|
||||
handler = handlers.get(finding_type)
|
||||
if not handler:
|
||||
return {'success': False, 'error': f'No fix available for finding type: {finding_type}'}
|
||||
return handler(entity_type, entity_id, file_path, details)
|
||||
|
||||
def _fix_discography_backfill(self, entity_type, entity_id, file_path, details):
|
||||
"""Add missing discography track to wishlist."""
|
||||
track_data = details.get('track_data')
|
||||
if not track_data:
|
||||
return {'success': False, 'error': 'No track data in finding'}
|
||||
try:
|
||||
success = self.db.add_to_wishlist(
|
||||
spotify_track_data=track_data,
|
||||
failure_reason='Discography backfill — missing from library',
|
||||
source_type='repair',
|
||||
source_info={'job': 'discography_backfill', 'artist': details.get('artist_name', '')}
|
||||
)
|
||||
track_name = track_data.get('name', '?')
|
||||
if success:
|
||||
return {'success': True, 'action': 'added_to_wishlist',
|
||||
'message': f"Added '{track_name}' to wishlist"}
|
||||
return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist)"}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
|
||||
"""Fix a dead file reference. Action depends on details['_fix_action']:
|
||||
'redownload' (default) — add to wishlist + remove DB entry
|
||||
|
|
|
|||
|
|
@ -455,11 +455,23 @@ class SoulIDWorker:
|
|||
matching.normalize_string(db_name)
|
||||
)
|
||||
if score >= self.album_match_threshold:
|
||||
logger.debug(f" {source_name}: matched '{artist.name}' via album '{api_name}' ↔ '{db_name}' (score={score:.2f})")
|
||||
logger.debug(
|
||||
"%s matched artist=%r via album api=%r db=%r score=%.2f",
|
||||
source_name,
|
||||
artist.name,
|
||||
api_name,
|
||||
db_name,
|
||||
score,
|
||||
)
|
||||
return discog
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f" {source_name}: discography fetch failed for '{artist.name}': {e}")
|
||||
logger.debug(
|
||||
"%s discography fetch failed for artist=%r: %s",
|
||||
source_name,
|
||||
artist.name,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm
|
|||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.apev2 import APEv2, APENoHeaderError
|
||||
|
||||
logger = logging.getLogger("newmusic.tag_writer")
|
||||
logger = logging.getLogger("tag_writer")
|
||||
|
||||
# Supported extensions
|
||||
SUPPORTED_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.oga', '.opus', '.m4a', '.mp4'}
|
||||
|
|
|
|||
|
|
@ -311,15 +311,17 @@ class WishlistService:
|
|||
def _spotify_track_object_to_dict(self, spotify_track) -> Dict[str, Any]:
|
||||
"""Convert a Spotify track object or TrackResult object to a dictionary"""
|
||||
try:
|
||||
# Add debug logging to see what we're dealing with
|
||||
logger.info(f"DEBUG: Converting track object to dict. Type: {type(spotify_track)}")
|
||||
logger.info(f"DEBUG: Has 'title' attribute: {hasattr(spotify_track, 'title')}")
|
||||
logger.info(f"DEBUG: Has 'artist' attribute: {hasattr(spotify_track, 'artist')}")
|
||||
logger.info(f"DEBUG: Has 'id' attribute: {hasattr(spotify_track, 'id')}")
|
||||
logger.debug(
|
||||
"Converting track object to dict: type=%s has_title=%s has_artist=%s has_id=%s",
|
||||
type(spotify_track),
|
||||
hasattr(spotify_track, 'title'),
|
||||
hasattr(spotify_track, 'artist'),
|
||||
hasattr(spotify_track, 'id'),
|
||||
)
|
||||
|
||||
# Check if this is a TrackResult object (has title/artist but no id)
|
||||
if hasattr(spotify_track, 'title') and hasattr(spotify_track, 'artist') and not hasattr(spotify_track, 'id'):
|
||||
logger.info("DEBUG: Detected TrackResult object, converting...")
|
||||
logger.debug("Detected TrackResult object, converting")
|
||||
# Handle TrackResult objects - these don't have Spotify IDs
|
||||
album_name = getattr(spotify_track, 'album', '') or getattr(spotify_track, 'title', 'Unknown Album')
|
||||
result = {
|
||||
|
|
@ -333,19 +335,23 @@ class WishlistService:
|
|||
'popularity': 0,
|
||||
'source': 'trackresult'
|
||||
}
|
||||
logger.info(f"DEBUG: TrackResult converted successfully: {result['name']} by {result['artists'][0]['name']}")
|
||||
logger.debug(
|
||||
"TrackResult converted successfully: name=%s artist=%s",
|
||||
result['name'],
|
||||
result['artists'][0]['name'],
|
||||
)
|
||||
return result
|
||||
|
||||
# Handle regular Spotify Track objects
|
||||
logger.info("DEBUG: Processing as Spotify Track object")
|
||||
logger.debug("Processing as Spotify Track object")
|
||||
|
||||
# Handle artists list carefully to avoid TrackResult serialization issues
|
||||
artists_list = []
|
||||
raw_artists = getattr(spotify_track, 'artists', [])
|
||||
logger.info(f"DEBUG: Raw artists: {raw_artists}, type: {type(raw_artists)}")
|
||||
logger.debug("Raw artists: %r (type=%s)", raw_artists, type(raw_artists))
|
||||
|
||||
for artist in raw_artists:
|
||||
logger.info(f"DEBUG: Processing artist: {artist}, type: {type(artist)}")
|
||||
logger.debug("Processing artist: %r (type=%s)", artist, type(artist))
|
||||
if hasattr(artist, 'name'):
|
||||
artists_list.append({'name': artist.name})
|
||||
elif isinstance(artist, str):
|
||||
|
|
@ -375,16 +381,20 @@ class WishlistService:
|
|||
'disc_number': getattr(spotify_track, 'disc_number', 1)
|
||||
}
|
||||
|
||||
logger.info(f"DEBUG: Spotify Track converted: {result['name']} by {[a['name'] for a in result['artists']]}")
|
||||
logger.debug(
|
||||
"Spotify Track converted: name=%s artists=%s",
|
||||
result['name'],
|
||||
[a['name'] for a in result['artists']],
|
||||
)
|
||||
|
||||
# Test JSON serialization before returning to catch any remaining issues
|
||||
try:
|
||||
import json
|
||||
json.dumps(result)
|
||||
logger.info("DEBUG: Conversion result is JSON serializable")
|
||||
logger.debug("Conversion result is JSON serializable")
|
||||
except Exception as json_error:
|
||||
logger.error(f"DEBUG: Conversion result is NOT JSON serializable: {json_error}")
|
||||
logger.error(f"DEBUG: Result content: {result}")
|
||||
logger.error("Conversion result is NOT JSON serializable: %s", json_error)
|
||||
logger.error("Conversion result content: %r", result)
|
||||
# Return a safe fallback
|
||||
return {
|
||||
'id': f"fallback_{hash(str(spotify_track))}",
|
||||
|
|
@ -413,4 +423,4 @@ def get_wishlist_service() -> WishlistService:
|
|||
global _wishlist_service
|
||||
if _wishlist_service is None:
|
||||
_wishlist_service = WishlistService()
|
||||
return _wishlist_service
|
||||
return _wishlist_service
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ try:
|
|||
except ImportError:
|
||||
logger.warning("Could not import MusicMatchingEngine, falling back to basic similarity")
|
||||
_matching_engine = None
|
||||
# Temporarily enable debug logging for edition matching
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
@dataclass
|
||||
class DatabaseArtist:
|
||||
|
|
@ -5371,48 +5369,63 @@ class MusicDatabase:
|
|||
return list(set(variations))
|
||||
|
||||
|
||||
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None, album: str = None) -> Tuple[Optional[DatabaseTrack], float]:
|
||||
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None, album: str = None, candidate_tracks: Optional[List[DatabaseTrack]] = None) -> Tuple[Optional[DatabaseTrack], float]:
|
||||
"""
|
||||
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
|
||||
|
||||
Args:
|
||||
album: Optional album name — enables album-aware matching for multi-artist albums
|
||||
candidate_tracks: Optional pre-fetched list of tracks to match against in-memory,
|
||||
skipping the per-variation SQL loop. Intended for callers iterating
|
||||
a discography that already fetched the artist's tracks once via
|
||||
get_candidate_tracks_for_albums. None preserves original behavior.
|
||||
|
||||
Returns (track, confidence) tuple where confidence is 0.0-1.0
|
||||
"""
|
||||
try:
|
||||
# Generate title variations for better matching (similar to album approach)
|
||||
title_variations = self._generate_track_title_variations(title)
|
||||
|
||||
logger.debug(f"Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||
for i, var in enumerate(title_variations):
|
||||
logger.debug(f" {i+1}. '{var}'")
|
||||
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
||||
# Try each title variation
|
||||
for title_variation in title_variations:
|
||||
# Search for potential matches with this variation
|
||||
potential_matches = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_variation in artist_variations:
|
||||
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))
|
||||
|
||||
if not potential_matches:
|
||||
continue
|
||||
|
||||
logger.debug(f"Found {len(potential_matches)} tracks for variation '{title_variation}'")
|
||||
|
||||
# Score each potential match
|
||||
for track in potential_matches:
|
||||
|
||||
if candidate_tracks is not None:
|
||||
# BATCHED PATH — score every pre-fetched track in-memory.
|
||||
# _calculate_track_confidence already handles title normalization,
|
||||
# so no need for the per-variation SQL widening.
|
||||
logger.debug(f"Enhanced track matching for '{title}' by '{artist}': batched against {len(candidate_tracks)} candidates")
|
||||
for track in candidate_tracks:
|
||||
confidence = self._calculate_track_confidence(title, artist, track)
|
||||
logger.debug(f" '{track.title}' confidence: {confidence:.3f}")
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = track
|
||||
|
||||
else:
|
||||
# LEGACY PATH — generate title variations and fire SQL per variation.
|
||||
title_variations = self._generate_track_title_variations(title)
|
||||
|
||||
logger.debug(f"Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||
for i, var in enumerate(title_variations):
|
||||
logger.debug(f" {i+1}. '{var}'")
|
||||
|
||||
# Try each title variation
|
||||
for title_variation in title_variations:
|
||||
# Search for potential matches with this variation
|
||||
potential_matches = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_variation in artist_variations:
|
||||
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))
|
||||
|
||||
if not potential_matches:
|
||||
continue
|
||||
|
||||
logger.debug(f"Found {len(potential_matches)} tracks for variation '{title_variation}'")
|
||||
|
||||
# Score each potential match
|
||||
for track in potential_matches:
|
||||
confidence = self._calculate_track_confidence(title, artist, track)
|
||||
logger.debug(f" '{track.title}' confidence: {confidence:.3f}")
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = track
|
||||
|
||||
# Return match only if it meets threshold
|
||||
if best_match and best_confidence >= confidence_threshold:
|
||||
logger.debug(f"Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||
|
|
@ -5686,15 +5699,84 @@ class MusicDatabase:
|
|||
logger.error(f"Error getting album formats: {e}")
|
||||
return []
|
||||
|
||||
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]:
|
||||
def get_candidate_albums_for_artist(self, artist: str, server_source: Optional[str] = None, limit: int = 200) -> List[DatabaseAlbum]:
|
||||
"""
|
||||
Fetch every library album for an artist, merged across artist-name variations
|
||||
and deduplicated by album ID. Intended to be called once per artist page load
|
||||
so subsequent per-album matching can run in-memory against this list without
|
||||
re-hitting SQL for each discography item.
|
||||
"""
|
||||
candidates: List[DatabaseAlbum] = []
|
||||
try:
|
||||
seen_ids = set()
|
||||
for artist_var in self._get_artist_variations(artist):
|
||||
found = self.search_albums(title="", artist=artist_var, limit=limit, server_source=server_source)
|
||||
for album in found:
|
||||
if album.id not in seen_ids:
|
||||
candidates.append(album)
|
||||
seen_ids.add(album.id)
|
||||
return candidates
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching candidate albums for artist '{artist}': {e}")
|
||||
return candidates
|
||||
|
||||
def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]:
|
||||
"""
|
||||
Fetch every track belonging to the given set of album IDs in a single query.
|
||||
Used for batched track-level completion checks (true singles on discography).
|
||||
Returns DatabaseTrack objects with artist_name/album_title/server_source attrs
|
||||
attached, matching the shape produced by search_tracks.
|
||||
"""
|
||||
if not album_ids:
|
||||
return []
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
placeholders = ','.join('?' for _ in album_ids)
|
||||
cursor.execute(f"""
|
||||
SELECT t.*, a.name as artist_name, al.title as album_title, al.thumb_url as album_thumb_url
|
||||
FROM tracks t
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.album_id IN ({placeholders})
|
||||
""", list(album_ids))
|
||||
rows = cursor.fetchall()
|
||||
tracks: List[DatabaseTrack] = []
|
||||
for row in rows:
|
||||
track = DatabaseTrack(
|
||||
id=row['id'],
|
||||
album_id=row['album_id'],
|
||||
artist_id=row['artist_id'],
|
||||
title=row['title'],
|
||||
track_number=row['track_number'],
|
||||
duration=row['duration'],
|
||||
file_path=row['file_path'],
|
||||
bitrate=row['bitrate'],
|
||||
)
|
||||
# Attach joined fields the same way search_tracks does
|
||||
track.artist_name = row['artist_name']
|
||||
track.album_title = row['album_title']
|
||||
track.album_thumb_url = row['album_thumb_url'] if 'album_thumb_url' in row.keys() else ''
|
||||
track.server_source = row['server_source'] if 'server_source' in row.keys() else ''
|
||||
tracks.append(track)
|
||||
return tracks
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching candidate tracks for {len(album_ids)} album IDs: {e}")
|
||||
return []
|
||||
|
||||
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]:
|
||||
"""
|
||||
Check if an album exists in the database with completeness information.
|
||||
Enhanced to handle edition matching (standard <-> deluxe variants).
|
||||
Returns (album, confidence, owned_tracks, expected_tracks, is_complete, formats)
|
||||
|
||||
When `candidate_albums` is provided (via get_candidate_albums_for_artist),
|
||||
the matcher runs in-memory against that list instead of firing per-album
|
||||
SQL searches. `None` preserves the original search-every-time behavior.
|
||||
"""
|
||||
try:
|
||||
# Try enhanced edition-aware matching first with expected track count for Smart Edition Matching
|
||||
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source)
|
||||
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source, candidate_albums=candidate_albums)
|
||||
|
||||
if not album:
|
||||
return None, 0.0, 0, 0, False, []
|
||||
|
|
@ -5708,88 +5790,108 @@ class MusicDatabase:
|
|||
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
|
||||
return None, 0.0, 0, 0, False, []
|
||||
|
||||
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float]:
|
||||
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None) -> Tuple[Optional[DatabaseAlbum], float]:
|
||||
"""
|
||||
Enhanced album existence check that handles edition variants.
|
||||
Matches standard albums with deluxe/platinum/special editions and vice versa.
|
||||
|
||||
When `candidate_albums` is provided, the artist-level SQL searches are
|
||||
skipped and matching runs in-memory against that list — used by callers
|
||||
that already fetched the artist's full library via
|
||||
get_candidate_albums_for_artist, so a discography of N items doesn't
|
||||
trigger N*K SQL queries. The title-only cross-artist fallback for
|
||||
collaborative albums is preserved in both paths.
|
||||
"""
|
||||
try:
|
||||
# Generate album title variations for edition matching
|
||||
title_variations = self._generate_album_title_variations(title)
|
||||
|
||||
logger.debug(f"Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||
for i, var in enumerate(title_variations):
|
||||
logger.debug(f" {i+1}. '{var}'")
|
||||
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
||||
for variation in title_variations:
|
||||
# Search for this variation
|
||||
albums = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_variation in artist_variations:
|
||||
found = self.search_albums(title=variation, artist=artist_variation, limit=10, server_source=server_source)
|
||||
# Deduplicate by ID
|
||||
existing_ids = {a.id for a in albums}
|
||||
for album in found:
|
||||
if album.id not in existing_ids:
|
||||
albums.append(album)
|
||||
existing_ids.add(album.id)
|
||||
|
||||
if albums:
|
||||
logger.debug(f"Found {len(albums)} albums for variation '{variation}'")
|
||||
|
||||
if not albums:
|
||||
continue
|
||||
|
||||
# Score each potential match with Smart Edition Matching
|
||||
for album in albums:
|
||||
|
||||
if candidate_albums is not None:
|
||||
# BATCHED PATH — score every pre-fetched candidate in-memory.
|
||||
# _calculate_album_confidence handles title normalization and
|
||||
# expected-track-count edition matching, so we don't need the
|
||||
# per-variation SQL widening that the legacy path does.
|
||||
logger.debug(f"Edition matching for '{title}' by '{artist}': batched against {len(candidate_albums)} candidates")
|
||||
for album in candidate_albums:
|
||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
||||
logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = album
|
||||
|
||||
# Return match only if it meets threshold
|
||||
if best_match and best_confidence >= confidence_threshold:
|
||||
logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||
return best_match, best_confidence
|
||||
|
||||
# Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
|
||||
# If we haven't found a match yet, fetch broader list from artist and double check
|
||||
if best_confidence < confidence_threshold:
|
||||
logger.debug(f"specific title search failed, trying broad artist search fallback for '{artist}'")
|
||||
try:
|
||||
# Get ALL albums by this artist (limit 100 to be safe)
|
||||
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
|
||||
# And relies on Python-side normalization in _calculate_album_confidence
|
||||
artist_albums = []
|
||||
else:
|
||||
# LEGACY PATH — generate title variations and fire SQL per variation.
|
||||
title_variations = self._generate_album_title_variations(title)
|
||||
|
||||
logger.debug(f"Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
|
||||
for i, var in enumerate(title_variations):
|
||||
logger.debug(f" {i+1}. '{var}'")
|
||||
|
||||
for variation in title_variations:
|
||||
# Search for this variation
|
||||
albums = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_var in artist_variations:
|
||||
found_albums = self.search_albums(title="", artist=artist_var, limit=100, server_source=server_source)
|
||||
# Deduplicate
|
||||
existing_ids = {a.id for a in artist_albums}
|
||||
for album in found_albums:
|
||||
for artist_variation in artist_variations:
|
||||
found = self.search_albums(title=variation, artist=artist_variation, limit=10, server_source=server_source)
|
||||
# Deduplicate by ID
|
||||
existing_ids = {a.id for a in albums}
|
||||
for album in found:
|
||||
if album.id not in existing_ids:
|
||||
artist_albums.append(album)
|
||||
albums.append(album)
|
||||
existing_ids.add(album.id)
|
||||
|
||||
if artist_albums:
|
||||
logger.debug(f" Found {len(artist_albums)} total albums for artist fallback")
|
||||
|
||||
for album in artist_albums:
|
||||
|
||||
if albums:
|
||||
logger.debug(f"Found {len(albums)} albums for variation '{variation}'")
|
||||
|
||||
if not albums:
|
||||
continue
|
||||
|
||||
# Score each potential match with Smart Edition Matching
|
||||
for album in albums:
|
||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
||||
logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = album
|
||||
logger.debug(f" Fallback match: '{album.title}' confidence: {confidence:.3f}")
|
||||
except Exception as fallback_error:
|
||||
logger.warning(f"Fallback artist search failed: {fallback_error}")
|
||||
|
||||
# Return match only if it meets threshold
|
||||
if best_match and best_confidence >= confidence_threshold:
|
||||
logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||
return best_match, best_confidence
|
||||
|
||||
# Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
|
||||
# Only runs in the legacy path — batched callers have already
|
||||
# fetched this broader list via get_candidate_albums_for_artist.
|
||||
if best_confidence < confidence_threshold:
|
||||
logger.debug(f"specific title search failed, trying broad artist search fallback for '{artist}'")
|
||||
try:
|
||||
# Get ALL albums by this artist (limit 100 to be safe)
|
||||
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
|
||||
# And relies on Python-side normalization in _calculate_album_confidence
|
||||
artist_albums = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_var in artist_variations:
|
||||
found_albums = self.search_albums(title="", artist=artist_var, limit=100, server_source=server_source)
|
||||
# Deduplicate
|
||||
existing_ids = {a.id for a in artist_albums}
|
||||
for album in found_albums:
|
||||
if album.id not in existing_ids:
|
||||
artist_albums.append(album)
|
||||
existing_ids.add(album.id)
|
||||
|
||||
if artist_albums:
|
||||
logger.debug(f" Found {len(artist_albums)} total albums for artist fallback")
|
||||
|
||||
for album in artist_albums:
|
||||
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = album
|
||||
logger.debug(f" Fallback match: '{album.title}' confidence: {confidence:.3f}")
|
||||
except Exception as fallback_error:
|
||||
logger.warning(f"Fallback artist search failed: {fallback_error}")
|
||||
|
||||
if best_match and best_confidence >= confidence_threshold:
|
||||
logger.debug(f"Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||
logger.debug(f"Match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
|
||||
return best_match, best_confidence
|
||||
|
||||
# Multi-artist fallback: search by title only (any artist)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ timeout = 120
|
|||
# Keep shutdowns under Docker's stop window so container restarts stay graceful.
|
||||
graceful_timeout = 8
|
||||
|
||||
# Logging goes to stdout/stderr so Docker can collect it.
|
||||
# Logging goes to stdout/stderr and is filtered by the custom logger class.
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
access_log_format = '%(h)s - - "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||
loglevel = "info"
|
||||
logger_class = "utils.gunicorn_logger.FilteredGunicornLogger"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ timeout = 120
|
|||
# Don't let local reloads wait too long for shutdown.
|
||||
graceful_timeout = 1
|
||||
|
||||
# Logging goes to stdout/stderr so the shell launcher can collect it.
|
||||
# Logging goes to stdout/stderr and is filtered by the custom logger class.
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
# Mimic process log format
|
||||
access_log_format = '%(h)s - - "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||
loglevel = "info"
|
||||
logger_class = "utils.gunicorn_logger.FilteredGunicornLogger"
|
||||
|
|
|
|||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 160
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Start lenient — catch real bugs, not style nits
|
||||
# E: pycodestyle errors, F: pyflakes, UP: pyupgrade, B: bugbear
|
||||
select = ["F", "E9", "W6", "B"]
|
||||
# Ignore rules that will flag too much in an existing codebase
|
||||
ignore = [
|
||||
"F401", # unused imports (too noisy initially)
|
||||
"F841", # unused variables
|
||||
"E501", # line length (handled by line-length setting)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Tests can use assert, magic values, etc.
|
||||
"tests/**" = ["B", "F"]
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
# Runtime web dependencies + test runner
|
||||
|
||||
-r requirements.txt
|
||||
ruff
|
||||
|
||||
# Test runner
|
||||
pytest>=9.0.0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reports basic system info — useful for debugging Docker setups."""
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
print(f"Platform: {platform.system()} {platform.release()}")
|
||||
print(f"Python: {platform.python_version()}")
|
||||
print(f"Working Dir: {os.getcwd()}")
|
||||
if not logging.getLogger().handlers:
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
logger = logging.getLogger("system_info")
|
||||
|
||||
logger.info(f"Platform: {platform.system()} {platform.release()}")
|
||||
logger.info(f"Python: {platform.python_version()}")
|
||||
logger.info(f"Working Dir: {os.getcwd()}")
|
||||
|
||||
# Disk usage for common SoulSync paths
|
||||
for path in ['/app/downloads', '/app/Transfer', '/app/data', './downloads', './Transfer']:
|
||||
|
|
@ -14,4 +20,4 @@ for path in ['/app/downloads', '/app/Transfer', '/app/data', './downloads', './T
|
|||
usage = shutil.disk_usage(path)
|
||||
free_gb = usage.free / (1024**3)
|
||||
total_gb = usage.total / (1024**3)
|
||||
print(f"Disk {path}: {free_gb:.1f} GB free / {total_gb:.1f} GB total")
|
||||
logger.info(f"Disk {path}: {free_gb:.1f} GB free / {total_gb:.1f} GB total")
|
||||
|
|
|
|||
155
tests/test_metadata_service_artist_image.py
Normal file
155
tests/test_metadata_service_artist_image.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import sys
|
||||
import types
|
||||
|
||||
|
||||
if "spotipy" not in sys.modules:
|
||||
spotipy = types.ModuleType("spotipy")
|
||||
|
||||
class _DummySpotify:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||
|
||||
class _DummyOAuth:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
spotipy.Spotify = _DummySpotify
|
||||
oauth2.SpotifyOAuth = _DummyOAuth
|
||||
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||
spotipy.oauth2 = oauth2
|
||||
sys.modules["spotipy"] = spotipy
|
||||
sys.modules["spotipy.oauth2"] = oauth2
|
||||
|
||||
if "config.settings" not in sys.modules:
|
||||
config_pkg = types.ModuleType("config")
|
||||
settings_mod = types.ModuleType("config.settings")
|
||||
|
||||
class _DummyConfigManager:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
def get_active_media_server(self):
|
||||
return "primary"
|
||||
|
||||
settings_mod.config_manager = _DummyConfigManager()
|
||||
config_pkg.settings = settings_mod
|
||||
sys.modules["config"] = config_pkg
|
||||
sys.modules["config.settings"] = settings_mod
|
||||
|
||||
from core import metadata_service
|
||||
|
||||
|
||||
class _FakeSpotifyClient:
|
||||
def __init__(self, image_url="https://spotify.example/artist.jpg"):
|
||||
self.image_url = image_url
|
||||
self.calls = []
|
||||
|
||||
def is_spotify_authenticated(self):
|
||||
return True
|
||||
|
||||
def get_artist(self, artist_id, allow_fallback=True):
|
||||
self.calls.append((artist_id, allow_fallback))
|
||||
return {
|
||||
"id": artist_id,
|
||||
"name": "Spotify Artist",
|
||||
"images": [{"url": self.image_url}],
|
||||
"genres": ["rock"],
|
||||
"popularity": 80,
|
||||
}
|
||||
|
||||
|
||||
class _FakeDeezerClient:
|
||||
def __init__(self, image_url="https://deezer.example/artist.jpg"):
|
||||
self.image_url = image_url
|
||||
self.calls = []
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
self.calls.append(artist_id)
|
||||
return types.SimpleNamespace(
|
||||
id=artist_id,
|
||||
name="Deezer Artist",
|
||||
image_url=self.image_url,
|
||||
genres=["indie"],
|
||||
popularity=0,
|
||||
)
|
||||
|
||||
|
||||
class _FakeItunesClient:
|
||||
def __init__(self, album_art_url="https://itunes.example/artist.jpg"):
|
||||
self.album_art_url = album_art_url
|
||||
self.calls = []
|
||||
self.album_art_calls = []
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
self.calls.append(artist_id)
|
||||
return {
|
||||
"id": artist_id,
|
||||
"name": "iTunes Artist",
|
||||
"images": [],
|
||||
"genres": ["alt"],
|
||||
"popularity": 0,
|
||||
}
|
||||
|
||||
def _get_artist_image_from_albums(self, artist_id):
|
||||
self.album_art_calls.append(artist_id)
|
||||
return self.album_art_url
|
||||
|
||||
|
||||
def test_get_artist_image_url_uses_primary_source_priority(monkeypatch):
|
||||
deezer = _FakeDeezerClient()
|
||||
spotify = _FakeSpotifyClient()
|
||||
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "spotify"])
|
||||
monkeypatch.setattr(
|
||||
metadata_service,
|
||||
"get_client_for_source",
|
||||
lambda source: {"deezer": deezer, "spotify": spotify}.get(source),
|
||||
)
|
||||
|
||||
image_url = metadata_service.get_artist_image_url("artist-1")
|
||||
|
||||
assert image_url == "https://deezer.example/artist.jpg"
|
||||
assert deezer.calls == ["artist-1"]
|
||||
assert spotify.calls == []
|
||||
|
||||
|
||||
def test_get_artist_image_url_uses_itunes_album_art_for_explicit_override(monkeypatch):
|
||||
itunes = _FakeItunesClient()
|
||||
spotify = _FakeSpotifyClient()
|
||||
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "spotify")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes"])
|
||||
monkeypatch.setattr(
|
||||
metadata_service,
|
||||
"get_client_for_source",
|
||||
lambda source: {"itunes": itunes, "spotify": spotify}.get(source),
|
||||
)
|
||||
|
||||
image_url = metadata_service.get_artist_image_url("12345", source_override="itunes")
|
||||
|
||||
assert image_url == "https://itunes.example/artist.jpg"
|
||||
assert itunes.calls == ["12345"]
|
||||
assert itunes.album_art_calls == ["12345"]
|
||||
assert spotify.calls == []
|
||||
|
||||
|
||||
def test_get_artist_image_url_handles_hydrabase_plugin(monkeypatch):
|
||||
deezer = _FakeDeezerClient("https://deezer.example/hydra.jpg")
|
||||
spotify = _FakeSpotifyClient()
|
||||
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "spotify")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "deezer"])
|
||||
monkeypatch.setattr(
|
||||
metadata_service,
|
||||
"get_client_for_source",
|
||||
lambda source: {"deezer": deezer, "spotify": spotify}.get(source),
|
||||
)
|
||||
|
||||
image_url = metadata_service.get_artist_image_url("artist-1", source_override="hydrabase", plugin="deezer")
|
||||
|
||||
assert image_url == "https://deezer.example/hydra.jpg"
|
||||
assert deezer.calls == ["artist-1"]
|
||||
assert spotify.calls == []
|
||||
235
tests/test_metadata_service_musicmap.py
Normal file
235
tests/test_metadata_service_musicmap.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import sys
|
||||
import types
|
||||
|
||||
|
||||
if "spotipy" not in sys.modules:
|
||||
spotipy = types.ModuleType("spotipy")
|
||||
|
||||
class _DummySpotify:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||
|
||||
class _DummyOAuth:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
spotipy.Spotify = _DummySpotify
|
||||
oauth2.SpotifyOAuth = _DummyOAuth
|
||||
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||
spotipy.oauth2 = oauth2
|
||||
sys.modules["spotipy"] = spotipy
|
||||
sys.modules["spotipy.oauth2"] = oauth2
|
||||
|
||||
if "config.settings" not in sys.modules:
|
||||
config_pkg = types.ModuleType("config")
|
||||
settings_mod = types.ModuleType("config.settings")
|
||||
|
||||
class _DummyConfigManager:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
def get_active_media_server(self):
|
||||
return "primary"
|
||||
|
||||
settings_mod.config_manager = _DummyConfigManager()
|
||||
config_pkg.settings = settings_mod
|
||||
sys.modules["config"] = config_pkg
|
||||
sys.modules["config.settings"] = settings_mod
|
||||
|
||||
import types as pytypes
|
||||
|
||||
from core import metadata_service
|
||||
|
||||
|
||||
class _FakeMusicMapResponse:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeSourceClient:
|
||||
def __init__(self, results_by_query):
|
||||
self.results_by_query = results_by_query
|
||||
self.search_calls = []
|
||||
|
||||
def search_artists(self, query, **kwargs):
|
||||
self.search_calls.append((query, dict(kwargs)))
|
||||
return list(self.results_by_query.get(query, []))
|
||||
|
||||
|
||||
def test_iter_musicmap_similar_artist_events_uses_source_priority(monkeypatch):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<div id="gnodMap">
|
||||
<a href="/artist/seed">Artist One</a>
|
||||
<a href="/artist/similar">Similar Artist</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
deezer = _FakeSourceClient({
|
||||
"Artist One": [pytypes.SimpleNamespace(id="dz-seed", name="Artist One")],
|
||||
"Similar Artist": [],
|
||||
})
|
||||
itunes = _FakeSourceClient({
|
||||
"Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")],
|
||||
"Similar Artist": [
|
||||
pytypes.SimpleNamespace(
|
||||
id="it-match",
|
||||
name="iTunes Canonical",
|
||||
image_url="https://itunes.example/it-match.jpg",
|
||||
genres=["indie", "alt"],
|
||||
popularity=77,
|
||||
)
|
||||
],
|
||||
})
|
||||
spotify = _FakeSourceClient({})
|
||||
|
||||
monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html))
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes", "spotify"])
|
||||
monkeypatch.setattr(
|
||||
metadata_service,
|
||||
"get_client_for_source",
|
||||
lambda source: {"deezer": deezer, "itunes": itunes, "spotify": spotify}.get(source),
|
||||
)
|
||||
|
||||
events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5))
|
||||
|
||||
assert events[0]["type"] == "start"
|
||||
assert events[0]["source_priority"] == ["deezer", "itunes", "spotify"]
|
||||
assert events[1]["type"] == "artist"
|
||||
assert events[1]["artist"]["id"] == "it-match"
|
||||
assert events[1]["artist"]["name"] == "iTunes Canonical"
|
||||
assert events[1]["artist"]["image_url"] == "https://itunes.example/it-match.jpg"
|
||||
assert events[1]["artist"]["genres"] == ["indie", "alt"]
|
||||
assert events[1]["artist"]["popularity"] == 77
|
||||
assert events[1]["artist"]["source"] == "itunes"
|
||||
assert events[-1]["type"] == "complete"
|
||||
assert events[-1]["complete"] is True
|
||||
assert events[-1]["total"] == 1
|
||||
assert events[-1]["total_found"] == 1
|
||||
|
||||
assert [call[0] for call in deezer.search_calls] == ["Artist One", "Similar Artist"]
|
||||
assert [call[0] for call in itunes.search_calls] == ["Artist One", "Similar Artist"]
|
||||
assert spotify.search_calls == [("Artist One", {"limit": 1, "allow_fallback": False})]
|
||||
|
||||
|
||||
def test_iter_musicmap_similar_artist_events_enriches_itunes_images(monkeypatch):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<div id="gnodMap">
|
||||
<a href="/artist/similar">Similar Artist</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
class _ItunesClient(_FakeSourceClient):
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
"Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")],
|
||||
"Similar Artist": [pytypes.SimpleNamespace(id="it-match", name="iTunes Canonical")],
|
||||
})
|
||||
self.get_artist_calls = []
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
self.get_artist_calls.append(artist_id)
|
||||
return {
|
||||
"id": artist_id,
|
||||
"name": "iTunes Canonical",
|
||||
"images": [{"url": "https://itunes.example/full-art.jpg"}],
|
||||
"genres": ["indie"],
|
||||
"popularity": 0,
|
||||
}
|
||||
|
||||
itunes = _ItunesClient()
|
||||
monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html))
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "itunes")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary])
|
||||
monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: itunes if source == "itunes" else None)
|
||||
|
||||
events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5))
|
||||
|
||||
artist_events = [event for event in events if event.get("type") == "artist"]
|
||||
assert len(artist_events) == 1
|
||||
assert artist_events[0]["artist"]["image_url"] == "https://itunes.example/full-art.jpg"
|
||||
assert itunes.get_artist_calls == ["it-match"]
|
||||
|
||||
|
||||
def test_iter_musicmap_similar_artist_events_falls_back_to_itunes_album_art(monkeypatch):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<div id="gnodMap">
|
||||
<a href="/artist/similar">Similar Artist</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
class _ItunesClient(_FakeSourceClient):
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
"Artist One": [pytypes.SimpleNamespace(id="it-seed", name="Artist One")],
|
||||
"Similar Artist": [pytypes.SimpleNamespace(id="it-match", name="iTunes Canonical")],
|
||||
})
|
||||
self.get_artist_calls = []
|
||||
self.album_art_calls = []
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
self.get_artist_calls.append(artist_id)
|
||||
return {
|
||||
"id": artist_id,
|
||||
"name": "iTunes Canonical",
|
||||
"images": [],
|
||||
"genres": ["indie"],
|
||||
"popularity": 0,
|
||||
}
|
||||
|
||||
def _get_artist_image_from_albums(self, artist_id):
|
||||
self.album_art_calls.append(artist_id)
|
||||
return "https://itunes.example/album-art.jpg"
|
||||
|
||||
itunes = _ItunesClient()
|
||||
monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html))
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "itunes")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary])
|
||||
monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: itunes if source == "itunes" else None)
|
||||
|
||||
events = list(metadata_service.iter_musicmap_similar_artist_events("Artist One", limit=5))
|
||||
|
||||
artist_events = [event for event in events if event.get("type") == "artist"]
|
||||
assert len(artist_events) == 1
|
||||
assert artist_events[0]["artist"]["image_url"] == "https://itunes.example/album-art.jpg"
|
||||
assert itunes.get_artist_calls == ["it-match"]
|
||||
assert itunes.album_art_calls == ["it-match"]
|
||||
|
||||
|
||||
def test_get_musicmap_similar_artists_returns_not_found_when_musicmap_missing(monkeypatch):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<div class="no-map">Nothing here</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(metadata_service.requests, "get", lambda *args, **kwargs: _FakeMusicMapResponse(html))
|
||||
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer")
|
||||
monkeypatch.setattr(metadata_service, "get_source_priority", lambda primary: [primary, "itunes"])
|
||||
monkeypatch.setattr(metadata_service, "get_client_for_source", lambda source: object())
|
||||
|
||||
result = metadata_service.get_musicmap_similar_artists("Artist One", limit=5)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["status_code"] == 404
|
||||
assert "Could not find artist map" in result["error"]
|
||||
assert result["similar_artists"] == []
|
||||
|
|
@ -110,7 +110,7 @@ def test_unknown_artist_fixer_uses_primary_source_track_id_first(monkeypatch):
|
|||
"title": "Unknown Title",
|
||||
"album_title": "Unknown Album",
|
||||
"spotify_track_id": "sp-1",
|
||||
"deezer_track_id": "dz-1",
|
||||
"deezer_id": "dz-1",
|
||||
"itunes_track_id": "",
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ def test_unknown_artist_fixer_searches_primary_source_first(monkeypatch):
|
|||
"title": "Matching Title",
|
||||
"album_title": "Matching Album",
|
||||
"spotify_track_id": "",
|
||||
"deezer_track_id": "",
|
||||
"deezer_id": "",
|
||||
"itunes_track_id": "",
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +229,7 @@ def test_unknown_artist_fixer_supports_hydrabase_title_search(monkeypatch):
|
|||
"title": "Hydra Match",
|
||||
"album_title": "Hydra Album",
|
||||
"spotify_track_id": "",
|
||||
"deezer_track_id": "",
|
||||
"deezer_id": "",
|
||||
"itunes_track_id": "",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ Run this script to identify issues with iTunes data population:
|
|||
- Discovery pool tracks by source
|
||||
- Recent albums by source
|
||||
- Curated playlists status
|
||||
|
||||
Usage:
|
||||
python tools/diagnose_itunes_discover.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
|
@ -22,36 +20,42 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
if not logging.getLogger().handlers:
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
logger = logging.getLogger("diagnose_itunes_discover")
|
||||
|
||||
|
||||
def _section(title: str) -> None:
|
||||
logger.info("")
|
||||
logger.info(title)
|
||||
logger.info("-" * 40)
|
||||
|
||||
|
||||
def diagnose_itunes_discover():
|
||||
"""Run diagnostic checks for iTunes discover data."""
|
||||
|
||||
print("=" * 60)
|
||||
print("iTunes Discover Page Diagnostic Report")
|
||||
print("=" * 60)
|
||||
logger.info("=" * 60)
|
||||
logger.info("iTunes Discover Page Diagnostic Report")
|
||||
logger.info("=" * 60)
|
||||
|
||||
db = MusicDatabase()
|
||||
|
||||
# 1. Check Similar Artists
|
||||
print("\n[1] SIMILAR ARTISTS")
|
||||
print("-" * 40)
|
||||
|
||||
_section("[1] SIMILAR ARTISTS")
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Total similar artists
|
||||
cursor.execute("SELECT COUNT(*) as total FROM similar_artists")
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# With iTunes IDs
|
||||
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_itunes_id IS NOT NULL")
|
||||
with_itunes = cursor.fetchone()['count']
|
||||
|
||||
# With Spotify IDs
|
||||
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_spotify_id IS NOT NULL")
|
||||
with_spotify = cursor.fetchone()['count']
|
||||
|
||||
# With both
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM similar_artists
|
||||
WHERE similar_artist_itunes_id IS NOT NULL
|
||||
|
|
@ -59,34 +63,29 @@ def diagnose_itunes_discover():
|
|||
""")
|
||||
with_both = cursor.fetchone()['count']
|
||||
|
||||
print(f" Total similar artists: {total}")
|
||||
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||
print(f" With BOTH IDs: {with_both} ({100*with_both/total:.1f}%)" if total > 0 else " With BOTH IDs: 0")
|
||||
logger.info(f" Total similar artists: {total}")
|
||||
logger.info(f" With iTunes ID: {with_itunes} ({100 * with_itunes / total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||
logger.info(f" With Spotify ID: {with_spotify} ({100 * with_spotify / total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||
logger.info(f" With BOTH IDs: {with_both} ({100 * with_both / total:.1f}%)" if total > 0 else " With BOTH IDs: 0")
|
||||
|
||||
if with_itunes == 0 and total > 0:
|
||||
print(" [CRITICAL] No similar artists have iTunes IDs - Hero section will be empty!")
|
||||
logger.critical("No similar artists have iTunes IDs - Hero section will be empty!")
|
||||
elif with_itunes < total * 0.5:
|
||||
print(" [WARNING] Less than 50% of similar artists have iTunes IDs")
|
||||
logger.warning("Less than 50% of similar artists have iTunes IDs")
|
||||
else:
|
||||
print(" [OK] iTunes coverage is adequate")
|
||||
|
||||
logger.info("iTunes coverage is adequate")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Could not check similar artists: {e}")
|
||||
logger.error(f"Could not check similar artists: {e}")
|
||||
|
||||
# 2. Check Discovery Pool
|
||||
print("\n[2] DISCOVERY POOL")
|
||||
print("-" * 40)
|
||||
|
||||
_section("[2] DISCOVERY POOL")
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Total tracks
|
||||
cursor.execute("SELECT COUNT(*) as total FROM discovery_pool")
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# By source
|
||||
cursor.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM discovery_pool
|
||||
|
|
@ -94,33 +93,28 @@ def diagnose_itunes_discover():
|
|||
""")
|
||||
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
print(f" Total tracks: {total}")
|
||||
print(f" Spotify tracks: {source_counts.get('spotify', 0)}")
|
||||
print(f" iTunes tracks: {source_counts.get('itunes', 0)}")
|
||||
logger.info(f" Total tracks: {total}")
|
||||
logger.info(f" Spotify tracks: {source_counts.get('spotify', 0)}")
|
||||
logger.info(f" iTunes tracks: {source_counts.get('itunes', 0)}")
|
||||
|
||||
if source_counts.get('itunes', 0) == 0 and total > 0:
|
||||
print(" [CRITICAL] No iTunes tracks in discovery pool - Fresh Tape/Archives will be empty!")
|
||||
logger.critical("No iTunes tracks in discovery pool - Fresh Tape/Archives will be empty!")
|
||||
elif source_counts.get('itunes', 0) < total * 0.3:
|
||||
print(" [WARNING] Low iTunes track count in discovery pool")
|
||||
logger.warning("Low iTunes track count in discovery pool")
|
||||
else:
|
||||
print(" [OK] iTunes tracks present")
|
||||
|
||||
logger.info("iTunes tracks present")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Could not check discovery pool: {e}")
|
||||
logger.error(f"Could not check discovery pool: {e}")
|
||||
|
||||
# 3. Check Recent Albums
|
||||
print("\n[3] RECENT ALBUMS CACHE")
|
||||
print("-" * 40)
|
||||
|
||||
_section("[3] RECENT ALBUMS CACHE")
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Total albums
|
||||
cursor.execute("SELECT COUNT(*) as total FROM discovery_recent_albums")
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# By source
|
||||
cursor.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM discovery_recent_albums
|
||||
|
|
@ -128,24 +122,21 @@ def diagnose_itunes_discover():
|
|||
""")
|
||||
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
print(f" Total recent albums: {total}")
|
||||
print(f" Spotify albums: {source_counts.get('spotify', 0)}")
|
||||
print(f" iTunes albums: {source_counts.get('itunes', 0)}")
|
||||
logger.info(f" Total recent albums: {total}")
|
||||
logger.info(f" Spotify albums: {source_counts.get('spotify', 0)}")
|
||||
logger.info(f" iTunes albums: {source_counts.get('itunes', 0)}")
|
||||
|
||||
if source_counts.get('itunes', 0) == 0 and total > 0:
|
||||
print(" [CRITICAL] No iTunes albums cached - Recent Releases section will be empty!")
|
||||
logger.critical("No iTunes albums cached - Recent Releases section will be empty!")
|
||||
elif source_counts.get('itunes', 0) < 5:
|
||||
print(" [WARNING] Very few iTunes albums cached")
|
||||
logger.warning("Very few iTunes albums cached")
|
||||
else:
|
||||
print(" [OK] iTunes albums cached")
|
||||
|
||||
logger.info("iTunes albums cached")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Could not check recent albums: {e}")
|
||||
logger.error(f"Could not check recent albums: {e}")
|
||||
|
||||
# 4. Check Curated Playlists
|
||||
print("\n[4] CURATED PLAYLISTS")
|
||||
print("-" * 40)
|
||||
|
||||
_section("[4] CURATED PLAYLISTS")
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -156,7 +147,7 @@ def diagnose_itunes_discover():
|
|||
'release_radar_itunes',
|
||||
'discovery_weekly',
|
||||
'discovery_weekly_spotify',
|
||||
'discovery_weekly_itunes'
|
||||
'discovery_weekly_itunes',
|
||||
]
|
||||
|
||||
for playlist_type in playlists_to_check:
|
||||
|
|
@ -174,9 +165,8 @@ def diagnose_itunes_discover():
|
|||
else:
|
||||
status = "[NOT FOUND]"
|
||||
|
||||
print(f" {playlist_type}: {status}")
|
||||
logger.info(f" {playlist_type}: {status}")
|
||||
|
||||
# Check iTunes-specific playlists
|
||||
cursor.execute("""
|
||||
SELECT track_ids_json FROM discovery_curated_playlists
|
||||
WHERE playlist_type = 'release_radar_itunes'
|
||||
|
|
@ -190,49 +180,43 @@ def diagnose_itunes_discover():
|
|||
itunes_dw = cursor.fetchone()
|
||||
|
||||
if not itunes_rr or len(json.loads(itunes_rr['track_ids_json'])) == 0:
|
||||
print("\n [CRITICAL] release_radar_itunes is empty or missing!")
|
||||
logger.critical("release_radar_itunes is empty or missing!")
|
||||
if not itunes_dw or len(json.loads(itunes_dw['track_ids_json'])) == 0:
|
||||
print(" [CRITICAL] discovery_weekly_itunes is empty or missing!")
|
||||
|
||||
logger.critical("discovery_weekly_itunes is empty or missing!")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Could not check curated playlists: {e}")
|
||||
logger.error(f"Could not check curated playlists: {e}")
|
||||
|
||||
# 5. Check Watchlist Artists
|
||||
print("\n[5] WATCHLIST ARTISTS")
|
||||
print("-" * 40)
|
||||
|
||||
_section("[5] WATCHLIST ARTISTS")
|
||||
try:
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Total artists
|
||||
cursor.execute("SELECT COUNT(*) as total FROM watchlist_artists")
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# With iTunes IDs
|
||||
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE itunes_artist_id IS NOT NULL")
|
||||
with_itunes = cursor.fetchone()['count']
|
||||
|
||||
# With Spotify IDs
|
||||
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE spotify_artist_id IS NOT NULL")
|
||||
with_spotify = cursor.fetchone()['count']
|
||||
|
||||
print(f" Total watchlist artists: {total}")
|
||||
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||
logger.info(f" Total watchlist artists: {total}")
|
||||
logger.info(f" With iTunes ID: {with_itunes} ({100 * with_itunes / total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||
logger.info(f" With Spotify ID: {with_spotify} ({100 * with_spotify / total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||
|
||||
if with_itunes == 0 and total > 0:
|
||||
print(" [WARNING] No watchlist artists have iTunes IDs - source artist data limited")
|
||||
|
||||
logger.warning("No watchlist artists have iTunes IDs - source artist data limited")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Could not check watchlist artists: {e}")
|
||||
logger.error(f"Could not check watchlist artists: {e}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY & RECOMMENDED ACTIONS")
|
||||
print("=" * 60)
|
||||
print("""
|
||||
If you see [CRITICAL] or [WARNING] messages above, follow these steps:
|
||||
logger.info("")
|
||||
logger.info("=" * 60)
|
||||
logger.info("SUMMARY & RECOMMENDED ACTIONS")
|
||||
logger.info("=" * 60)
|
||||
logger.info(
|
||||
"""
|
||||
If you see critical or warning messages above, follow these steps:
|
||||
|
||||
QUICK FIX - Force Refresh Discover Data:
|
||||
-----------------------------------------
|
||||
|
|
@ -263,7 +247,8 @@ ROOT CAUSE NOTES:
|
|||
|
||||
The discover page will now fall back to watchlist artists if similar
|
||||
artists are not available, so basic functionality should still work.
|
||||
""")
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
83
utils/gunicorn_logger.py
Normal file
83
utils/gunicorn_logger.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Gunicorn logger tweaks for SoulSync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from gunicorn.glogging import Logger as GunicornLogger
|
||||
from utils.logging_config import ColoredFormatter
|
||||
|
||||
|
||||
class FilteredGunicornLogger(GunicornLogger):
|
||||
"""Gunicorn logger that skips noisy static and Socket.IO access logs."""
|
||||
|
||||
_STATIC_PREFIXES = (
|
||||
"/static/",
|
||||
"/assets/",
|
||||
"/socket.io",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
)
|
||||
|
||||
_STATIC_SUFFIXES = (
|
||||
".css",
|
||||
".js",
|
||||
".map",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".ico",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
)
|
||||
|
||||
_HEALTHCHECK_USER_AGENTS = (
|
||||
"curl/",
|
||||
)
|
||||
|
||||
def _should_skip_access_log(self, environ) -> bool:
|
||||
path = environ.get("PATH_INFO") or ""
|
||||
if not path:
|
||||
return False
|
||||
|
||||
normalized = path if path.startswith("/") else f"/{path}"
|
||||
lower_path = normalized.lower()
|
||||
|
||||
if any(
|
||||
lower_path == prefix.rstrip("/") or lower_path.startswith(prefix)
|
||||
for prefix in self._STATIC_PREFIXES
|
||||
):
|
||||
return True
|
||||
|
||||
if lower_path == "/":
|
||||
user_agent = (environ.get("HTTP_USER_AGENT") or "").lower()
|
||||
if any(token in user_agent for token in self._HEALTHCHECK_USER_AGENTS):
|
||||
return True
|
||||
|
||||
return any(lower_path.endswith(suffix) for suffix in self._STATIC_SUFFIXES)
|
||||
|
||||
def access(self, resp, req, environ, request_time):
|
||||
if self._should_skip_access_log(environ):
|
||||
return
|
||||
super().access(resp, req, environ, request_time)
|
||||
|
||||
def setup(self, cfg):
|
||||
super().setup(cfg)
|
||||
|
||||
app_like_formatter = ColoredFormatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
error_level = getattr(logging, cfg.loglevel.upper(), logging.INFO)
|
||||
|
||||
for handler in self.access_log.handlers:
|
||||
handler.setFormatter(app_like_formatter)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
for handler in self.error_log.handlers:
|
||||
handler.setFormatter(app_like_formatter)
|
||||
handler.setLevel(error_level)
|
||||
|
|
@ -6,6 +6,8 @@ from pathlib import Path
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
LOGGER_NAMESPACE = "soulsync"
|
||||
|
||||
class SafeFormatter(logging.Formatter):
|
||||
"""Formatter that handles Unicode characters safely on Windows"""
|
||||
|
||||
|
|
@ -52,7 +54,7 @@ class ColoredFormatter(SafeFormatter):
|
|||
def setup_logging(level: str = "INFO", log_file: Optional[str] = None) -> logging.Logger:
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
logger = logging.getLogger("newmusic")
|
||||
logger = logging.getLogger(LOGGER_NAMESPACE)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
if logger.handlers:
|
||||
|
|
@ -91,15 +93,15 @@ def setup_logging(level: str = "INFO", log_file: Optional[str] = None) -> loggin
|
|||
return logger
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(f"newmusic.{name}")
|
||||
return logging.getLogger(f"{LOGGER_NAMESPACE}.{name}")
|
||||
|
||||
def set_log_level(level: str) -> bool:
|
||||
"""Dynamically change the log level for all loggers without restart"""
|
||||
try:
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
# Get the root "newmusic" logger
|
||||
root_logger = logging.getLogger("newmusic")
|
||||
# Get the root "soulsync" logger
|
||||
root_logger = logging.getLogger(LOGGER_NAMESPACE)
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Update all handlers
|
||||
|
|
@ -109,12 +111,12 @@ def set_log_level(level: str) -> bool:
|
|||
root_logger.info(f"Log level changed to: {level.upper()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error setting log level: {e}")
|
||||
logging.getLogger(LOGGER_NAMESPACE).error(f"Error setting log level: {e}")
|
||||
return False
|
||||
|
||||
def get_current_log_level() -> str:
|
||||
"""Get the current log level"""
|
||||
root_logger = logging.getLogger("newmusic")
|
||||
root_logger = logging.getLogger(LOGGER_NAMESPACE)
|
||||
return logging.getLevelName(root_logger.level)
|
||||
|
||||
main_logger = get_logger("main")
|
||||
main_logger = get_logger("main")
|
||||
|
|
|
|||
4566
web_server.py
4566
web_server.py
File diff suppressed because it is too large
Load diff
|
|
@ -5098,28 +5098,28 @@
|
|||
<input type="text" id="template-album-path"
|
||||
placeholder="$albumartist/$albumartist - $album/$track - $title">
|
||||
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
|
||||
$title, $track, $disc (01), $discnum (1), $year, $quality (filename only)</small>
|
||||
$title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Single Path Template:</label>
|
||||
<input type="text" id="template-single-path"
|
||||
placeholder="$artist/$artist - $title/$title">
|
||||
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only)</small>
|
||||
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Playlist Path Template:</label>
|
||||
<input type="text" id="template-playlist-path"
|
||||
placeholder="$playlist/$artist - $title">
|
||||
<small class="settings-hint">Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only)</small>
|
||||
<small class="settings-hint">Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only). Use ${var} to append text</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Music Video Path Template:</label>
|
||||
<input type="text" id="template-video-path"
|
||||
placeholder="$artist/$title-video">
|
||||
<small class="settings-hint">Variables: $artist, $artistletter, $title, $year. Default: $artist/$title-video → Artist/Title-video.mp4</small>
|
||||
<small class="settings-hint">Variables: $artist, $artistletter, $title, $year. Use ${var} to append text. Default: $artist/$title-video</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -5142,6 +5142,30 @@
|
|||
Full artist list is always preserved in file metadata tags.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Artist Tag Separator:</label>
|
||||
<select id="artist-separator" class="form-select">
|
||||
<option value=", ">, (comma)</option>
|
||||
<option value="; ">; (semicolon)</option>
|
||||
<option value=" / ">/ (slash)</option>
|
||||
</select>
|
||||
<small class="settings-hint">Separator between multiple artists in the ARTIST tag</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="write-multi-artist">
|
||||
<span>Write multi-value ARTISTS tag</span>
|
||||
</label>
|
||||
<small class="settings-hint">Write each artist as a separate tag value for Navidrome/Jellyfin multi-artist support</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="feat-in-title">
|
||||
<span>Move featured artists to title</span>
|
||||
</label>
|
||||
<small class="settings-hint">Keep only primary artist in ARTIST tag, append others as (feat. ...) in title</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="allow-duplicate-tracks" checked>
|
||||
|
|
|
|||
|
|
@ -3599,7 +3599,33 @@ function closeHelperSearch() {
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const WHATS_NEW = {
|
||||
'2.34': [
|
||||
'2.35': [
|
||||
// --- April 21, 2026 ---
|
||||
{ date: 'April 21, 2026' },
|
||||
{ title: 'Massively Faster Artist Detail Page Loads', desc: 'Artist discography completion checks used to fire hundreds of SQL queries per page load — 15+ fuzzy title/artist searches per album times 30 albums per artist. Now pre-fetches the artist\'s library albums and tracks ONCE upfront, then matches everything in-memory. Same matching logic and accuracy, roughly 100x fewer SQL round-trips. Applies to both the Library artist page and the Artists search page', page: 'library' },
|
||||
{ title: 'Fix Reorganize All Ignoring Album Type', desc: 'Reorganize All was sending every album — EPs, singles, and compilations — into the "Albums" folder because the $albumtype template variable silently defaulted to "Album". The variable is now resolved from the album\'s record_type (with track-count fallback) so ${albumtype}s produces the expected Albums/Singles/EPs/Compilations split', page: 'library' },
|
||||
|
||||
// --- April 20, 2026 ---
|
||||
{ date: 'April 20, 2026' },
|
||||
{ title: 'Discography Backfill Maintenance Job', desc: 'New library maintenance job that scans each artist in your library, fetches their full discography from metadata sources, and creates findings for any missing tracks. Review findings and click "Add to Wishlist" to queue them for download. Respects content filters (live/remix/acoustic/compilation) and release type filters. Opt-in, disabled by default', page: 'library' },
|
||||
{ title: 'Multi-Artist Tagging Options', desc: 'Three new settings: configurable artist separator (comma/semicolon/slash), multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking, and "Move featured artists to title" mode. All opt-in with defaults matching current behavior', page: 'settings' },
|
||||
{ title: 'Reorganize All Albums for Artist', desc: 'New "Reorganize All" button in the enhanced library artist header. Processes all albums for an artist sequentially using the configured path template. Shows progress per album, continues on error', page: 'library' },
|
||||
{ title: 'Enriched Downloads Page Cards', desc: 'Download cards now show album artwork thumbnail, artist name, album name, source badge, and quality badge — all pulled from existing metadata context. No extra API calls', page: 'downloads' },
|
||||
{ title: 'Template Variable Delimiter Syntax', desc: 'Use ${var} syntax to append literal text to template variables: ${albumtype}s produces "Albums", "Singles", "EPs". Both $var and ${var} syntaxes work. Updated validation and hint text for all templates', page: 'settings' },
|
||||
{ title: 'AcoustID Fix Action Prompt', desc: 'AcoustID mismatch findings now show a 3-option fix prompt (Retag/Re-download/Delete) instead of silently defaulting to retag. Works for both individual and bulk fix', page: 'library' },
|
||||
{ title: 'Fix Sync Buttons on Undiscovered Playlists', desc: 'Sync buttons on ListenBrainz/Last.fm Radio playlists were visible before discovery due to the standalone mode handler resetting display:none on every WebSocket push. Now only restores buttons it specifically hid' },
|
||||
{ title: 'Fix Wing It Tracks Added to Wishlist During Sync', desc: 'Wing It fallback tracks with no real metadata were being added to wishlist when they failed to match on the media server during playlist sync. Now skipped by checking the wing_it_ ID prefix' },
|
||||
{ title: 'Fix iTunes Region-Restricted Albums', desc: 'iTunes API sometimes returns album metadata without song tracks for region-restricted releases. The empty result was cached permanently. Now tries fallback storefronts for actual songs, and skips caching empty results' },
|
||||
{ title: 'Fix Disc Subfolder Missing on Single-Track Downloads', desc: 'Downloading a single track from search for a multi-disc album placed it without the Disc N/ subfolder. Now resolves total_discs from the album tracklist when not already known' },
|
||||
{ title: 'Fix Allow Duplicate Tracks Setting Not Working', desc: 'The "Allow duplicate tracks across albums" setting was ignored during album download analysis. Tracks found in other albums were marked as owned and skipped. Now only checks ownership within the target album when duplicates are allowed' },
|
||||
{ title: 'Stop slskd Log Spam When Not Active', desc: 'Download monitor and transfer cache were polling slskd every second during active downloads regardless of whether Soulseek was configured. Now skips slskd API calls entirely when Soulseek is not in the active download source' },
|
||||
{ title: 'Fix AcoustID High-Confidence Skip', desc: 'AcoustID verification was letting wrong files through when the fingerprint score was high (0.95+) even with very low title/artist similarity. Now requires at least partial title or artist match before skipping verification' },
|
||||
{ title: 'Fix Navidrome Multi-Library Import', desc: 'Full database refresh was importing albums from all Navidrome music folders even when only one was selected in settings. Now filters albums to the selected music folder using a cached album ID set' },
|
||||
{ title: 'Fix Repair Worker Crash on Zero Interval', desc: 'Jobs with interval_hours set to 0 caused ZeroDivisionError in the repair worker staleness calculation. Now skips jobs with invalid intervals' },
|
||||
{ title: 'Fix Playlist Mode Missing Metadata and Cover Art', desc: 'Playlist folder mode passed null album_info to metadata enhancement, causing the entire function to crash silently. All metadata was wiped from the file. Now normalizes null to empty dict and falls back to spotify_album context for cover art' },
|
||||
{ title: 'Fix Unknown Artist Fixer Column Name', desc: 'The unknown_artist_fixer repair job crashed with "no such column: t.deezer_track_id". The tracks table uses deezer_id, not deezer_track_id' },
|
||||
{ title: 'Fix Auto-Import Using Wrong Artist from Tags', desc: 'Auto-import trusted embedded file tags for artist names even when the parent folder clearly indicated the correct artist. Mixtapes tagged with DJ names (e.g. "Slim" instead of "2Pac") got organized under the wrong artist. Now uses parent folder structure as artist override when folder depth indicates an Artist/Album layout' },
|
||||
|
||||
// --- April 19, 2026 ---
|
||||
{ date: 'April 19, 2026' },
|
||||
{ title: 'Fix Wishlist Albums Cycle Stuck at 1 Concurrent', desc: 'Auto-wishlist processing during the "albums" cycle was limited to 1 concurrent download even with higher configured settings. The max_concurrent=1 restriction is only needed for Soulseek folder-based album grabs, not individual wishlist track downloads. Albums cycle now uses the configured concurrency like singles' },
|
||||
|
|
@ -3744,12 +3770,12 @@ const WHATS_NEW = {
|
|||
|
||||
function _getCurrentVersion() {
|
||||
const btn = document.querySelector('.version-button');
|
||||
return btn ? btn.textContent.trim().replace('v', '') : '2.34';
|
||||
return btn ? btn.textContent.trim().replace('v', '') : '2.35';
|
||||
}
|
||||
|
||||
function _getLatestWhatsNewVersion() {
|
||||
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
|
||||
return versions[0] || '2.34';
|
||||
return versions[0] || '2.35';
|
||||
}
|
||||
|
||||
function openWhatsNew() {
|
||||
|
|
|
|||
|
|
@ -5703,17 +5703,19 @@ function validateFileOrganizationTemplates() {
|
|||
errors.push('Album template cannot have consecutive slashes //');
|
||||
}
|
||||
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
|
||||
const albumVarPattern = /\$[a-zA-Z]+/g;
|
||||
const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
|
||||
const foundVars = albumPath.match(albumVarPattern) || [];
|
||||
foundVars.forEach(v => {
|
||||
const lowerVar = v.toLowerCase();
|
||||
// Normalize ${var} to $var for validation
|
||||
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
|
||||
const lowerVar = normalized.toLowerCase();
|
||||
// Check if lowercase version exists in valid vars
|
||||
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
|
||||
if (!isValid) {
|
||||
errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`);
|
||||
} else if (v !== lowerVar && validVars.album.includes(lowerVar)) {
|
||||
errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`);
|
||||
} else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) {
|
||||
// Variable is valid but has wrong case
|
||||
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
|
||||
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -5730,15 +5732,16 @@ function validateFileOrganizationTemplates() {
|
|||
if (singlePath.includes('//')) {
|
||||
errors.push('Single template cannot have consecutive slashes //');
|
||||
}
|
||||
const singleVarPattern = /\$[a-zA-Z]+/g;
|
||||
const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
|
||||
const foundVars = singlePath.match(singleVarPattern) || [];
|
||||
foundVars.forEach(v => {
|
||||
const lowerVar = v.toLowerCase();
|
||||
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
|
||||
const lowerVar = normalized.toLowerCase();
|
||||
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
|
||||
if (!isValid) {
|
||||
errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`);
|
||||
} else if (v !== lowerVar && validVars.single.includes(lowerVar)) {
|
||||
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
|
||||
errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`);
|
||||
} else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) {
|
||||
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -5757,15 +5760,16 @@ function validateFileOrganizationTemplates() {
|
|||
if (playlistPath.includes('//')) {
|
||||
errors.push('Playlist template cannot have consecutive slashes //');
|
||||
}
|
||||
const playlistVarPattern = /\$[a-zA-Z]+/g;
|
||||
const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
|
||||
const foundVars = playlistPath.match(playlistVarPattern) || [];
|
||||
foundVars.forEach(v => {
|
||||
const lowerVar = v.toLowerCase();
|
||||
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
|
||||
const lowerVar = normalized.toLowerCase();
|
||||
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
|
||||
if (!isValid) {
|
||||
errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
|
||||
} else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) {
|
||||
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
|
||||
errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
|
||||
} else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) {
|
||||
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -6229,6 +6233,9 @@ async function loadSettingsData() {
|
|||
document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video';
|
||||
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
|
||||
document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first';
|
||||
document.getElementById('artist-separator').value = settings.metadata_enhancement?.tags?.artist_separator || ', ';
|
||||
document.getElementById('write-multi-artist').checked = settings.metadata_enhancement?.tags?.write_multi_artist || false;
|
||||
document.getElementById('feat-in-title').checked = settings.metadata_enhancement?.tags?.feat_in_title || false;
|
||||
document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false;
|
||||
|
||||
// Populate Playlist Sync settings
|
||||
|
|
@ -7868,7 +7875,10 @@ async function saveSettings(quiet = false) {
|
|||
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
|
||||
tags: {
|
||||
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
|
||||
genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge')
|
||||
genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge'),
|
||||
artist_separator: document.getElementById('artist-separator').value,
|
||||
write_multi_artist: document.getElementById('write-multi-artist').checked,
|
||||
feat_in_title: document.getElementById('feat-in-title').checked
|
||||
}
|
||||
},
|
||||
musicbrainz: {
|
||||
|
|
@ -17493,11 +17503,31 @@ function renderQueue(containerId, downloads, isActiveQueue) {
|
|||
`;
|
||||
}
|
||||
|
||||
// Enrich with metadata from backend context (artist, album, artwork)
|
||||
const meta = item._meta || {};
|
||||
const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' };
|
||||
const sourceBadge = sourceLabels[item.username] || item.username;
|
||||
|
||||
html += `
|
||||
<div class="download-item" data-id="${item.id}">
|
||||
<div class="download-item__header">
|
||||
<div class="download-item__art">
|
||||
${meta.artwork_url
|
||||
? `<img src="${meta.artwork_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'download-item__art-placeholder\\'>♫</div>'">`
|
||||
: '<div class="download-item__art-placeholder">♫</div>'}
|
||||
</div>
|
||||
<div class="download-item__info">
|
||||
<div class="download-item__title" title="${title}">${title}</div>
|
||||
<div class="download-item__uploader" title="from ${item.username}">from ${item.username}</div>
|
||||
${meta.artist || meta.album ? `
|
||||
<div class="download-item__meta">
|
||||
${meta.artist ? `<span>${escapeHtml(meta.artist)}</span>` : ''}
|
||||
${meta.artist && meta.album ? '<span class="download-item__sep">·</span>' : ''}
|
||||
${meta.album ? `<span class="download-item__album">${escapeHtml(meta.album)}</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="download-item__badges">
|
||||
<span class="download-item__source">${sourceBadge}</span>
|
||||
${meta.quality ? `<span class="download-item__quality">${escapeHtml(meta.quality)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item__content">
|
||||
${actionButtonHTML}
|
||||
|
|
@ -36516,10 +36546,20 @@ async function lazyLoadSimilarArtistImages(container) {
|
|||
|
||||
await Promise.all(batch.map(async (bubble) => {
|
||||
const artistId = bubble.getAttribute('data-artist-id');
|
||||
const artistSource = bubble.getAttribute('data-artist-source') || '';
|
||||
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
|
||||
if (!artistId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/artist/${artistId}/image`);
|
||||
const params = new URLSearchParams();
|
||||
if (artistSource) params.set('source', artistSource);
|
||||
if (artistPlugin) params.set('plugin', artistPlugin);
|
||||
|
||||
const imageUrl = params.toString()
|
||||
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
|
||||
: `/api/artist/${encodeURIComponent(artistId)}/image`;
|
||||
|
||||
const response = await fetch(imageUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.image_url) {
|
||||
|
|
@ -36600,6 +36640,10 @@ function createSimilarArtistBubble(artist) {
|
|||
const bubble = document.createElement('div');
|
||||
bubble.className = 'similar-artist-bubble';
|
||||
bubble.setAttribute('data-artist-id', artist.id);
|
||||
bubble.setAttribute('data-artist-source', artist.source || '');
|
||||
if (artist.plugin) {
|
||||
bubble.setAttribute('data-artist-plugin', artist.plugin);
|
||||
}
|
||||
|
||||
// Track if image needs lazy loading
|
||||
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||||
|
|
@ -36651,7 +36695,10 @@ function createSimilarArtistBubble(artist) {
|
|||
bubble.addEventListener('click', () => {
|
||||
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
|
||||
// Navigate to this artist's detail page (same as clicking from search results)
|
||||
selectArtistForDetail(artist);
|
||||
selectArtistForDetail(
|
||||
artist,
|
||||
artist.source ? { source: artist.source, plugin: artist.plugin } : {}
|
||||
);
|
||||
});
|
||||
|
||||
return bubble;
|
||||
|
|
@ -46662,6 +46709,13 @@ function renderArtistMetaPanel(artist) {
|
|||
};
|
||||
headerRight.appendChild(syncBtn);
|
||||
|
||||
const reorgAllBtn = document.createElement('button');
|
||||
reorgAllBtn.className = 'enhanced-sync-btn';
|
||||
reorgAllBtn.innerHTML = '📁 Reorganize All';
|
||||
reorgAllBtn.title = 'Reorganize all albums for this artist using path template';
|
||||
reorgAllBtn.onclick = () => _showReorganizeAllModal();
|
||||
headerRight.appendChild(reorgAllBtn);
|
||||
|
||||
header.appendChild(headerRight);
|
||||
|
||||
panel.appendChild(header);
|
||||
|
|
@ -49979,7 +50033,11 @@ async function showReorganizeModal(albumId) {
|
|||
}
|
||||
|
||||
title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`;
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
if (applyBtn) {
|
||||
applyBtn.disabled = true;
|
||||
applyBtn.textContent = 'Apply';
|
||||
applyBtn.onclick = () => executeReorganize();
|
||||
}
|
||||
|
||||
// Build modal content
|
||||
const variables = [
|
||||
|
|
@ -50215,6 +50273,164 @@ function _pollReorganizeStatus() {
|
|||
_reorganizePollTimer = setTimeout(poll, 600);
|
||||
}
|
||||
|
||||
// ── Reorganize All Albums for Artist ──
|
||||
|
||||
let _reorganizeAllRunning = false;
|
||||
|
||||
async function _showReorganizeAllModal() {
|
||||
if (!artistDetailPageState.enhancedData) {
|
||||
showToast('No album data loaded', 'error');
|
||||
return;
|
||||
}
|
||||
const albums = artistDetailPageState.enhancedData.albums || [];
|
||||
const artistName = artistDetailPageState.enhancedData.artist.name || 'Artist';
|
||||
|
||||
if (albums.length === 0) {
|
||||
showToast('No albums to reorganize', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('reorganize-overlay');
|
||||
const body = document.getElementById('reorganize-modal-body');
|
||||
const title = document.getElementById('reorganize-modal-title');
|
||||
const applyBtn = document.getElementById('reorganize-apply-btn');
|
||||
if (!overlay || !body) return;
|
||||
|
||||
title.textContent = `Reorganize All Albums — ${artistName}`;
|
||||
|
||||
// Load saved template
|
||||
let savedTemplate = '$albumartist/$albumartist - $album/$track - $title';
|
||||
try {
|
||||
const settingsResp = await fetch('/api/settings');
|
||||
if (settingsResp.ok) {
|
||||
const settings = await settingsResp.json();
|
||||
savedTemplate = settings.file_organization?.templates?.album_path || savedTemplate;
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
let html = '<div class="reorganize-content">';
|
||||
|
||||
// Template input
|
||||
html += '<div class="reorganize-template-section">';
|
||||
html += '<label class="reorganize-label">Path Template</label>';
|
||||
html += '<div class="reorganize-template-hint">This template will be applied to all albums below. Use <code>/</code> to separate folders.</div>';
|
||||
html += `<input type="text" id="reorganize-template-input" class="reorganize-template-input" value="${savedTemplate.replace(/"/g, '"')}" placeholder="$albumartist/$album/$track - $title" spellcheck="false">`;
|
||||
html += '</div>';
|
||||
|
||||
// Album list
|
||||
html += '<div style="margin-top:14px;">';
|
||||
html += `<label class="reorganize-label">${albums.length} album${albums.length !== 1 ? 's' : ''} will be reorganized:</label>`;
|
||||
html += '<div style="max-height:200px;overflow-y:auto;margin-top:6px;border:1px solid rgba(255,255,255,0.08);border-radius:8px;padding:6px 10px;">';
|
||||
albums.forEach((a, i) => {
|
||||
const trackCount = a.tracks ? a.tracks.length : '?';
|
||||
html += `<div style="padding:4px 0;font-size:0.88em;color:rgba(255,255,255,0.7);border-bottom:${i < albums.length - 1 ? '1px solid rgba(255,255,255,0.04)' : 'none'};">`;
|
||||
html += `${escapeHtml(a.title)} <span style="color:rgba(255,255,255,0.3);">(${trackCount} tracks)</span>`;
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
||||
html += '</div>';
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire apply button for bulk mode
|
||||
if (applyBtn) {
|
||||
applyBtn.disabled = false;
|
||||
applyBtn.textContent = 'Reorganize All';
|
||||
applyBtn.onclick = () => _executeReorganizeAll();
|
||||
}
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function _executeReorganizeAll() {
|
||||
if (_reorganizeAllRunning) return;
|
||||
|
||||
const templateInput = document.getElementById('reorganize-template-input');
|
||||
const template = templateInput ? templateInput.value.trim() : '';
|
||||
if (!template) {
|
||||
showToast('Template cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const albums = artistDetailPageState.enhancedData.albums || [];
|
||||
const total = albums.length;
|
||||
const artistName = artistDetailPageState.enhancedData.artist?.name || 'this artist';
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Reorganize All Albums',
|
||||
message: `This will reorganize ${total} album${total !== 1 ? 's' : ''} for ${artistName} using the template:\n\n${template}\n\nFiles will be moved and renamed. This cannot be undone.`,
|
||||
confirmText: 'Reorganize All',
|
||||
destructive: false,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
_reorganizeAllRunning = true;
|
||||
const applyBtn = document.getElementById('reorganize-apply-btn');
|
||||
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Working...'; }
|
||||
|
||||
// Close modal
|
||||
const overlay = document.getElementById('reorganize-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
|
||||
let succeeded = 0, failed = 0;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const album = albums[i];
|
||||
showToast(`Reorganizing album ${i + 1}/${total}: ${album.title}`, 'info');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/library/album/${album.id}/reorganize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!result.success) {
|
||||
showToast(`Failed: ${album.title} — ${result.error || 'unknown error'}`, 'error');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for this album to finish
|
||||
await _waitForReorganizeComplete();
|
||||
succeeded++;
|
||||
} catch (err) {
|
||||
showToast(`Error: ${album.title} — ${err.message}`, 'error');
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
let msg = `Reorganized ${succeeded} of ${total} album${total !== 1 ? 's' : ''}`;
|
||||
if (failed > 0) msg += ` (${failed} failed)`;
|
||||
showToast(msg, failed > 0 ? 'warning' : 'success');
|
||||
|
||||
_reorganizeAllRunning = false;
|
||||
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; }
|
||||
|
||||
// Refresh enhanced view
|
||||
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
|
||||
loadEnhancedViewData(artistDetailPageState.currentArtistId);
|
||||
}
|
||||
}
|
||||
|
||||
function _waitForReorganizeComplete() {
|
||||
return new Promise(resolve => {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/library/album/reorganize/status');
|
||||
const state = await resp.json();
|
||||
if (state.status === 'done' || state.status === 'idle') {
|
||||
clearInterval(poll);
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
clearInterval(poll);
|
||||
resolve();
|
||||
}
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
|
||||
async function playLibraryTrack(track, albumTitle, artistName) {
|
||||
if (!track.file_path) {
|
||||
showToast('No file available for this track', 'error');
|
||||
|
|
@ -65780,6 +65996,7 @@ async function loadRepairFindings() {
|
|||
incomplete_album: 'Auto-Fill',
|
||||
missing_lossy_copy: 'Convert',
|
||||
acoustid_mismatch: 'Fix',
|
||||
missing_discography_track: 'Add to Wishlist',
|
||||
};
|
||||
|
||||
container.innerHTML = items.map(f => {
|
||||
|
|
|
|||
|
|
@ -6874,6 +6874,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
margin: 6px 0;
|
||||
padding: 12px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.download-item:hover {
|
||||
|
|
@ -6883,6 +6886,35 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
border-color: rgba(var(--accent-rgb), 0.6);
|
||||
}
|
||||
|
||||
.download-item__art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.download-item__art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.download-item__art-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255,255,255,0.25);
|
||||
font-size: 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.download-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.download-item__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -6894,12 +6926,45 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.download-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
font-size: 0.82em;
|
||||
color: rgba(255,255,255,0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.download-item__sep {
|
||||
margin: 0 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.download-item__album {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.download-item__badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.download-item__source,
|
||||
.download-item__quality {
|
||||
font-size: 0.7em;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
color: rgba(255,255,255,0.45);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-item__uploader {
|
||||
font-size: 10px;
|
||||
padding-left: 15px;
|
||||
|
|
@ -6912,6 +6977,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
|
|
|
|||
Loading…
Reference in a new issue