Compare commits
No commits in common. "main" and "2.2" have entirely different histories.
1214 changed files with 153951 additions and 410068 deletions
|
|
@ -1,8 +1,5 @@
|
||||||
# Docker ignore file for SoulSync WebUI
|
# Docker ignore file for SoulSync WebUI
|
||||||
|
|
||||||
# Hidden folders and files
|
|
||||||
.*
|
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
@ -25,13 +22,6 @@ __pycache__/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# Frontend build artifacts and local dependency caches
|
|
||||||
webui/.tanstack/
|
|
||||||
webui/.vite/
|
|
||||||
webui/node_modules/
|
|
||||||
webui/test-results/
|
|
||||||
webui/static/dist/
|
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
@ -58,20 +48,6 @@ Incomplete/*
|
||||||
artist_bubble_snapshots.json
|
artist_bubble_snapshots.json
|
||||||
.spotify_cache
|
.spotify_cache
|
||||||
|
|
||||||
# Auto-downloaded ffmpeg binaries — the YouTube client downloads these
|
|
||||||
# into tools/ when system ffmpeg isn't on PATH. The Dockerfile installs
|
|
||||||
# system ffmpeg via apt, so the container never needs the bundled
|
|
||||||
# binaries. If a CI run leaves them in the workspace before the docker
|
|
||||||
# build (e.g. because a test imported web_server which initialized the
|
|
||||||
# YouTube client), they'd otherwise get baked into the image — adding
|
|
||||||
# ~388 MB and getting duplicated again by the chown layer.
|
|
||||||
tools/ffmpeg
|
|
||||||
tools/ffprobe
|
|
||||||
tools/ffmpeg.exe
|
|
||||||
tools/ffprobe.exe
|
|
||||||
tools/*.zip
|
|
||||||
tools/*.tar.xz
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
*.md
|
*.md
|
||||||
README.md
|
README.md
|
||||||
|
|
@ -80,12 +56,10 @@ multi-server-database-plan.md
|
||||||
server-source.md
|
server-source.md
|
||||||
plans.md
|
plans.md
|
||||||
|
|
||||||
# GUI-specific files (removed by PR #311)
|
# GUI-specific files
|
||||||
main.py
|
main.py
|
||||||
ui/
|
ui/
|
||||||
|
requirements.txt
|
||||||
# Dev-specific files
|
|
||||||
requirements-dev.txt
|
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
36
.github/workflows/build-and-test.yml
vendored
36
.github/workflows/build-and-test.yml
vendored
|
|
@ -1,36 +0,0 @@
|
||||||
name: Compile the app and run tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sanity-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: requirements-dev.txt
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
PYTHONPATH: ${{ github.workspace }}
|
|
||||||
run: python -m pytest
|
|
||||||
25
.github/workflows/cleanup-dev-images.yml
vendored
25
.github/workflows/cleanup-dev-images.yml
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
name: Cleanup old dev images
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Weekly on Sunday at 6 AM UTC
|
|
||||||
- cron: '0 6 * * 0'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
if: github.repository == 'Nezreka/SoulSync'
|
|
||||||
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+)$'
|
|
||||||
104
.github/workflows/dev-nightly.yml
vendored
104
.github/workflows/dev-nightly.yml
vendored
|
|
@ -1,104 +0,0 @@
|
||||||
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:
|
|
||||||
if: github.repository == 'Nezreka/SoulSync'
|
|
||||||
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) || '' }}
|
|
||||||
51
.github/workflows/docker-publish.yml
vendored
51
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,76 +1,43 @@
|
||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Auto-build :latest on every push to main
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
# Manual trigger for tagged releases (e.g. 2.33)
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_tag:
|
version_tag:
|
||||||
description: 'Version tag (e.g. 2.8.2)'
|
description: 'Version tag (e.g. 1.6, 1.7)'
|
||||||
required: true
|
required: true
|
||||||
default: '2.8.2'
|
default: '2.2'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
if: github.repository == 'Nezreka/SoulSync'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set lowercase owner
|
|
||||||
run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
no-cache: true
|
||||||
pull: true
|
pull: true
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_SHA=${{ github.sha }}
|
COMMIT_SHA=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
boulderbadgedad/soulsync:latest
|
boulderbadgedad/soulsync:latest
|
||||||
ghcr.io/${{ env.OWNER }}/soulsync:latest
|
boulderbadgedad/soulsync:${{ inputs.version_tag }}
|
||||||
${{ 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() && inputs.version_tag
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_ANNOUNCEMENTS_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$DISCORD_WEBHOOK" ]; then echo "No webhook configured, skipping"; exit 0; fi
|
|
||||||
curl -s -H "Content-Type: application/json" \
|
|
||||||
-d "{\"embeds\": [{\"title\": \"SoulSync v${{ inputs.version_tag }} Released\", \"description\": \"A new version of SoulSync is available! Pull the latest Docker image to update.\n\n\`\`\`\ndocker pull boulderbadgedad/soulsync:${{ inputs.version_tag }}\n\`\`\`\", \"color\": 5025616, \"footer\": {\"text\": \"SoulSync Auto-Release\"}, \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}]}" \
|
|
||||||
"$DISCORD_WEBHOOK"
|
|
||||||
|
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -10,23 +10,5 @@ __pycache__/
|
||||||
# Encryption key (generated per-instance, lives next to database)
|
# Encryption key (generated per-instance, lives next to database)
|
||||||
.encryption_key
|
.encryption_key
|
||||||
|
|
||||||
# User-specific files (auto-created by the app if missing)
|
|
||||||
config/config.json
|
|
||||||
config/youtube_cookies.txt
|
|
||||||
# All app databases are live user data — never commit (music_library, video_library, …)
|
|
||||||
database/*.db
|
|
||||||
database/*.db-shm
|
|
||||||
database/*.db-wal
|
|
||||||
database/*.db.backup_*
|
|
||||||
database/api_call_history.json
|
|
||||||
storage/image_cache/
|
|
||||||
logs/*.log
|
|
||||||
logs/*.log.*
|
|
||||||
|
|
||||||
# Auto-downloaded binaries
|
# Auto-downloaded binaries
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Development compose/config files
|
|
||||||
*.dev.yml
|
|
||||||
# Any hidden folders
|
|
||||||
**/.*/
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# discover page — best in class plan (#913 + full generator audit)
|
|
||||||
|
|
||||||
morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested.
|
|
||||||
|
|
||||||
## what i shipped tonight (done, tested, safe)
|
|
||||||
|
|
||||||
### 1. listening recommendations (#913) — went from BROKEN to best-in-class
|
|
||||||
|
|
||||||
the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation:
|
|
||||||
|
|
||||||
- **wrong id key (the killer).** `similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows.
|
|
||||||
- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker.
|
|
||||||
- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally).
|
|
||||||
|
|
||||||
the fix (all in the pure, tested core + thin scan wiring):
|
|
||||||
- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more.
|
|
||||||
- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals.
|
|
||||||
|
|
||||||
result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable.
|
|
||||||
|
|
||||||
### 2. its own row on the discover page
|
|
||||||
|
|
||||||
new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty.
|
|
||||||
|
|
||||||
**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in.
|
|
||||||
|
|
||||||
> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own.
|
|
||||||
|
|
||||||
### 3. Fresh Tape "only 5-10 tracks" — fixed
|
|
||||||
|
|
||||||
root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged.
|
|
||||||
|
|
||||||
**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## best-in-class roadmap for listening recs (next phases — your call)
|
|
||||||
|
|
||||||
these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own.
|
|
||||||
|
|
||||||
| phase | what | value | risk | notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). |
|
|
||||||
| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. |
|
|
||||||
| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. |
|
|
||||||
| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. |
|
|
||||||
| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. |
|
|
||||||
|
|
||||||
the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## full discover-page generator audit (every soulsync-built row, excluding last.fm + listenbrainz)
|
|
||||||
|
|
||||||
how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character).
|
|
||||||
|
|
||||||
### curated (built during the scan, then hydrated)
|
|
||||||
- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time.
|
|
||||||
- **The Archives / Discovery Weekly** — strong already. nice 3-tier popularity split + serendipity scoring (boost never-played artists, penalize overplayed). same hydration-drop caveat as Fresh Tape; same cheap fallback fix.
|
|
||||||
- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug.
|
|
||||||
|
|
||||||
### discovery-pool generators (live queries)
|
|
||||||
- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most.
|
|
||||||
- **Hidden Gems** — *clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.)
|
|
||||||
- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary.
|
|
||||||
- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing).
|
|
||||||
- **Time Machine (by decade)** — *clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data.
|
|
||||||
- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*).
|
|
||||||
|
|
||||||
### cross-cutting
|
|
||||||
- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win.
|
|
||||||
- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work.
|
|
||||||
|
|
||||||
want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next.
|
|
||||||
110
Dockerfile
110
Dockerfile
|
|
@ -1,114 +1,42 @@
|
||||||
# SoulSync WebUI Dockerfile
|
# SoulSync WebUI Dockerfile
|
||||||
# Multi-architecture support for AMD64 and ARM64
|
# Multi-architecture support for AMD64 and ARM64
|
||||||
|
|
||||||
FROM node:24-slim AS webui-builder
|
|
||||||
|
|
||||||
WORKDIR /app/webui
|
|
||||||
|
|
||||||
COPY webui/package.json webui/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY webui/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 1: Builder — install Python dependencies with compilation tools
|
|
||||||
FROM python:3.11-slim AS builder
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gcc \
|
|
||||||
libc6-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create virtualenv and install dependencies
|
|
||||||
RUN python -m venv /opt/venv
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# yt-dlp must track YouTube faster than its stable channel ships — stable can
|
|
||||||
# lag months behind a breaking YouTube change while extraction is broken
|
|
||||||
# ("Requested format is not available"). Build images with the NIGHTLY channel.
|
|
||||||
# COMMIT_SHA is referenced in the RUN so CI's layer cache (cache-from: gha)
|
|
||||||
# busts on every new commit — otherwise this layer could pin a stale "nightly"
|
|
||||||
# for months, silently defeating its purpose.
|
|
||||||
ARG COMMIT_SHA=""
|
|
||||||
RUN echo "yt-dlp nightly for build ${COMMIT_SHA}" && \
|
|
||||||
pip install --no-cache-dir -U --pre "yt-dlp[default]"
|
|
||||||
|
|
||||||
# Stage 2: Runtime — only runtime dependencies, no build tools
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Build-time commit SHA for update detection
|
# Build-time commit SHA for update detection
|
||||||
ARG COMMIT_SHA=""
|
ARG COMMIT_SHA=""
|
||||||
ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA}
|
ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA}
|
||||||
|
|
||||||
# Copy pre-built virtualenv from builder
|
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime-only system dependencies (no gcc/build tools).
|
# Install system dependencies
|
||||||
# unzip is needed by the Deno installer below.
|
RUN apt-get update && apt-get install -y \
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
curl \
|
curl \
|
||||||
gosu \
|
gosu \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libchromaprint-tools \
|
libchromaprint-tools \
|
||||||
unzip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Deno — JavaScript runtime for yt-dlp. YouTube gates its downloadable formats
|
|
||||||
# behind JS challenges (nsig); without a JS runtime, yt-dlp's extraction is
|
|
||||||
# deprecated and streams / music-video downloads fail with "Requested format
|
|
||||||
# is not available". Deno is yt-dlp's default-enabled runtime; the official
|
|
||||||
# installer auto-detects amd64/arm64. `deno --version` fails the build early
|
|
||||||
# if the install ever breaks.
|
|
||||||
RUN curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh && \
|
|
||||||
deno --version
|
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
||||||
|
|
||||||
# Copy application code with ownership baked in.
|
# Copy requirements and install Python dependencies
|
||||||
# Using `COPY --chown` instead of `COPY` + `chown -R /app` avoids an
|
COPY requirements-webui.txt .
|
||||||
# extra image layer that duplicates the entire /app tree just to flip
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
# ownership bits — Docker layers are immutable, so chown -R rewrites
|
pip install --no-cache-dir -r requirements-webui.txt
|
||||||
# every file into a new layer. On a clean repo that's small; if any
|
|
||||||
# bulky workspace file slips in (e.g. auto-downloaded ffmpeg binaries
|
|
||||||
# in tools/), it gets counted twice in the image. Cin caught this on
|
|
||||||
# 2026-05-08 — see the .dockerignore comment for the same incident.
|
|
||||||
COPY --chown=soulsync:soulsync . .
|
|
||||||
COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/webui/static/dist
|
|
||||||
|
|
||||||
# Create runtime mount-point directories the app expects to exist.
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories with proper permissions
|
||||||
# NOTE: /app/data is for database FILES, /app/database is the Python package
|
# NOTE: /app/data is for database FILES, /app/database is the Python package
|
||||||
# NOTE: /app/Staging is required even though most users bind-mount it —
|
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer && \
|
||||||
# the entrypoint mkdir runs early and is gated by `set -e`, so a missing
|
chown -R soulsync:soulsync /app
|
||||||
# pre-baked directory would crash the container into a restart loop on
|
|
||||||
# rootless Docker/Podman where in-container "root" can't write to /app.
|
|
||||||
# Pre-baking the dir here makes the entrypoint mkdir a guaranteed no-op.
|
|
||||||
# NOTE: /app/Stream is the transient single-file streaming cache used by
|
|
||||||
# the basic-search "Play" flow (cleared per use, never persistent). It's
|
|
||||||
# created lazily by `core/streaming/prepare.py` via `os.makedirs`, which
|
|
||||||
# fails silently on rootless Docker where the soulsync UID can't write
|
|
||||||
# to /app — playback then errors out with no obvious cause. Pre-baking
|
|
||||||
# at build time (when the layer is owned by root) avoids that path.
|
|
||||||
# NOTE: /app/storage is the PRIVATE album-bundle staging area for the
|
|
||||||
# torrent / usenet whole-release flow (download_source.album_bundle_staging_path
|
|
||||||
# defaults to 'storage/album_bundle_staging'). Like /app/Stream it's created
|
|
||||||
# lazily at runtime via mkdir(parents=True); without pre-baking it owned by
|
|
||||||
# soulsync, the album-bundle copy fails with "[Errno 13] Permission denied:
|
|
||||||
# 'storage'" because /app itself is root-owned and the soulsync UID can't
|
|
||||||
# create a top-level dir there.
|
|
||||||
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts && \
|
|
||||||
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts
|
|
||||||
|
|
||||||
# Create defaults directory and copy template files
|
# Create defaults directory and copy template files
|
||||||
# These will be used by entrypoint.sh to initialize empty volumes
|
# These will be used by entrypoint.sh to initialize empty volumes
|
||||||
|
|
@ -119,7 +47,7 @@ RUN mkdir -p /defaults && \
|
||||||
|
|
||||||
# Create volume mount points
|
# Create volume mount points
|
||||||
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package
|
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package
|
||||||
VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/MusicVideos", "/app/scripts"]
|
VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer"]
|
||||||
|
|
||||||
# Copy and set up entrypoint script
|
# Copy and set up entrypoint script
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
@ -138,6 +66,8 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV FLASK_APP=web_server.py
|
||||||
|
ENV FLASK_ENV=production
|
||||||
ENV DATABASE_PATH=/app/data/music_library.db
|
ENV DATABASE_PATH=/app/data/music_library.db
|
||||||
ENV PUID=1000
|
ENV PUID=1000
|
||||||
ENV PGID=1000
|
ENV PGID=1000
|
||||||
|
|
@ -145,4 +75,4 @@ ENV UMASK=022
|
||||||
|
|
||||||
# Set entrypoint and default command
|
# Set entrypoint and default command
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:application"]
|
CMD ["python", "web_server.py"]
|
||||||
219
README.md
219
README.md
|
|
@ -8,22 +8,22 @@
|
||||||
|
|
||||||
> **IMPORTANT**: Configure file sharing in slskd to avoid Soulseek bans. Set up shared folders at `http://localhost:5030/shares`.
|
> **IMPORTANT**: Configure file sharing in slskd to avoid Soulseek bans. Set up shared folders at `http://localhost:5030/shares`.
|
||||||
|
|
||||||
**Community**: [Discord](https://discord.gg/wGvKqVQwmy) | **Website**: [ssync.net](https://www.ssync.net/) | **Support**: [GitHub Issues](https://github.com/Nezreka/SoulSync/issues) | **Donate**: [Ko-fi](https://ko-fi.com/boulderbadgedad)
|
**Community**: [Discord](https://discord.gg/wGvKqVQwmy) | **Support**: [GitHub Issues](https://github.com/Nezreka/SoulSync/issues) | **Donate**: [Ko-fi](https://ko-fi.com/boulderbadgedad)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
SoulSync bridges streaming services to your music library with automated discovery:
|
SoulSync bridges streaming services to your media server with automated discovery:
|
||||||
|
|
||||||
1. **Monitors artists** → Automatically detects new releases from your watchlist
|
1. **Monitors artists** → Automatically detects new releases from your watchlist
|
||||||
2. **Generates playlists** → Release Radar, Discovery Weekly, Seasonal, Decade/Genre mixes, Cache-powered discovery
|
2. **Generates playlists** → Release Radar, Discovery Weekly, Seasonal, Decade/Genre mixes, Cache-powered discovery
|
||||||
3. **Downloads missing tracks** → From Soulseek, Deezer, Tidal, Qobuz, HiFi, YouTube, or any combination via Hybrid mode
|
3. **Downloads missing tracks** → From Soulseek, Deezer, Tidal, Qobuz, HiFi, YouTube, or any combination via Hybrid mode
|
||||||
4. **Verifies downloads** → AcoustID fingerprinting for all download sources
|
4. **Verifies downloads** → AcoustID fingerprinting for P2P sources (skipped for trusted API sources)
|
||||||
5. **Enriches metadata** → 10 enrichment workers (Spotify, MusicBrainz, iTunes, Deezer, Discogs, AudioDB, Last.fm, Genius, Tidal, Qobuz)
|
5. **Enriches metadata** → 9 enrichment workers (Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz)
|
||||||
6. **Tags consistently** → Picard-style MusicBrainz release preflight ensures all album tracks get the same release ID
|
6. **Tags consistently** → Picard-style MusicBrainz release preflight ensures all album tracks get the same release ID
|
||||||
7. **Organizes files** → Custom templates for clean folder structures
|
7. **Organizes files** → Custom templates for clean folder structures
|
||||||
8. **Manages library** → Plex, Jellyfin, Navidrome, or SoulSync Standalone (no media server required)
|
8. **Syncs media server** → Plex, Jellyfin, or Navidrome stay updated automatically
|
||||||
9. **Scrobbles plays** → Automatic scrobbling to Last.fm and ListenBrainz from your media server
|
9. **Scrobbles plays** → Automatic scrobbling to Last.fm and ListenBrainz from your media server
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -84,7 +84,6 @@ SoulSync bridges streaming services to your music library with automated discove
|
||||||
- Hi-Res FLAC downsampling to 16-bit/44.1kHz CD quality
|
- Hi-Res FLAC downsampling to 16-bit/44.1kHz CD quality
|
||||||
- Blasphemy Mode — delete original FLAC after conversion
|
- Blasphemy Mode — delete original FLAC after conversion
|
||||||
- Synchronized lyrics (LRC) via LRClib
|
- Synchronized lyrics (LRC) via LRClib
|
||||||
- ReplayGain analysis — optional track-level loudness tagging via ffmpeg, runs before lossy copy so both files get tagged
|
|
||||||
- Picard-style album consistency — pre-flight MusicBrainz release lookup ensures all tracks get the same release ID
|
- Picard-style album consistency — pre-flight MusicBrainz release lookup ensures all tracks get the same release ID
|
||||||
|
|
||||||
### Listening Stats & Scrobbling
|
### Listening Stats & Scrobbling
|
||||||
|
|
@ -101,45 +100,24 @@ SoulSync bridges streaming services to your music library with automated discove
|
||||||
### Audio Verification
|
### Audio Verification
|
||||||
|
|
||||||
**AcoustID Fingerprinting** (optional) — Verifies downloaded files match expected tracks
|
**AcoustID Fingerprinting** (optional) — Verifies downloaded files match expected tracks
|
||||||
- Runs for all download sources (Soulseek, Tidal, Qobuz, HiFi, Deezer, YouTube)
|
- Automatically skipped for trusted API sources (Deezer, Tidal, Qobuz, HiFi)
|
||||||
- Catches wrong versions (live, remix, cover) even from streaming API sources
|
- Only runs for P2P (Soulseek) and extracted audio (YouTube) where mislabeling is possible
|
||||||
- Fail-open design: verification errors never block downloads
|
- Fail-open design: verification errors never block downloads
|
||||||
|
|
||||||
#### AcoustID API key
|
|
||||||
|
|
||||||
AcoustID verification is opt-in. To enable it, request a free API key
|
|
||||||
at <https://acoustid.org/new-application> and paste it into
|
|
||||||
Settings → AcoustID. Without a key, downloads still complete but the
|
|
||||||
verification step is skipped silently.
|
|
||||||
|
|
||||||
If a track was previously tagged by AcoustID but the retag action in
|
|
||||||
the AcoustID Scanner no longer changes anything, see issue #704 — the
|
|
||||||
most common cause is that the file already carries a
|
|
||||||
`MUSICBRAINZ_TRACKID` tag, which the retag step uses as a short-circuit
|
|
||||||
and therefore never overwrites. Removing the cached
|
|
||||||
`MUSICBRAINZ_TRACKID` (and the `ACOUSTID_ID` if present) from the file
|
|
||||||
restores the retag.
|
|
||||||
|
|
||||||
### Metadata & Enrichment
|
### Metadata & Enrichment
|
||||||
|
|
||||||
**10 Background Enrichment Workers**: Spotify, MusicBrainz, iTunes, Deezer, Discogs, AudioDB, Last.fm, Genius, Tidal, Qobuz
|
**9 Background Enrichment Workers**: Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz
|
||||||
- Each worker independently processes artists, albums, and tracks
|
- Each worker independently processes artists, albums, and tracks
|
||||||
- Pause/resume controls on dashboard, auto-pause during database scans
|
- Pause/resume controls on dashboard, auto-pause during database scans
|
||||||
- Error items don't auto-retry in infinite loops (fixed in v2.1)
|
- Error items don't auto-retry in infinite loops (fixed in v2.1)
|
||||||
|
|
||||||
**Multi-Source Metadata**
|
**Dual-Source Metadata**
|
||||||
- Primary source selectable: Spotify, iTunes/Apple Music, Deezer, or Discogs
|
- Primary: Spotify — richer data, discovery features, playlist sync
|
||||||
- Spotify no longer auto-overrides — user chooses their preferred source in Settings
|
- Fallback: iTunes/Apple Music or Deezer — configurable, no authentication required
|
||||||
- Spotify auth still enables playlists, followed artists, and enrichment
|
|
||||||
- MusicBrainz enrichment with Picard-style album consistency
|
- MusicBrainz enrichment with Picard-style album consistency
|
||||||
|
|
||||||
**Hydrabase** (optional P2P metadata network) — replaces iTunes as the metadata source when connected. Federated lookup with community-matched results, falls back automatically if disconnected. Dev-mode feature, enable in Settings → Connections.
|
|
||||||
|
|
||||||
**Genre Whitelist** — filter junk genre tags (artist names, radio show names, playlist names) from all 10 enrichment sources. 272 curated default genres, fully customizable. Off by default for backward compatibility.
|
|
||||||
|
|
||||||
**Post-Processing Tag Embedding**
|
**Post-Processing Tag Embedding**
|
||||||
- Granular per-service tag toggles (18+ MusicBrainz tags, Spotify/iTunes/Deezer IDs, AudioDB mood/style, Tidal/Qobuz ISRCs, Last.fm tags, Genius URLs)
|
- Granular per-service tag toggles (18+ MusicBrainz tags, Spotify/iTunes/Deezer IDs, AudioDB mood/style, Tidal/Qobuz ISRCs, Last.fm tags, Genius URLs)
|
||||||
- Multi-artist tagging options: configurable separator (comma/semicolon/slash), multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking, optional "move featured artists to title" mode
|
|
||||||
- Album art embedding, cover.jpg download
|
- Album art embedding, cover.jpg download
|
||||||
- Spotify rate limit protection across all API calls
|
- Spotify rate limit protection across all API calls
|
||||||
|
|
||||||
|
|
@ -149,19 +127,17 @@ restores the retag.
|
||||||
- Unicode and accent handling (KoЯn, Bjork, A$AP Rocky)
|
- Unicode and accent handling (KoЯn, Bjork, A$AP Rocky)
|
||||||
- Fuzzy matching with weighted confidence scoring (title, artist, duration)
|
- Fuzzy matching with weighted confidence scoring (title, artist, duration)
|
||||||
- Album variation detection (Deluxe, Remastered, Taylor's Version, etc.)
|
- Album variation detection (Deluxe, Remastered, Taylor's Version, etc.)
|
||||||
- Streaming source match validation: same confidence scoring applied to Tidal/Qobuz/HiFi/Deezer results as Soulseek
|
- Streaming source results bypass filename-matching engine (API results trusted directly)
|
||||||
- Short title protection: prevents "Love" from matching "Loveless"
|
- Short title protection: prevents "Love" from matching "Loveless"
|
||||||
|
|
||||||
### Automation
|
### Automation
|
||||||
|
|
||||||
**Automation Engine** — Visual drag-and-drop builder for custom workflows
|
**Automation Engine** — Visual drag-and-drop builder for custom workflows
|
||||||
- **Triggers**: Schedule, Daily/Weekly Time, Track Downloaded, Batch Complete, Playlist Changed, Discovery Complete, Signal Received, Library Scan Complete, Watchlist Match, Wishlist Item Added, and more
|
- **Triggers**: Schedule, Daily/Weekly Time, Track Downloaded, Batch Complete, Playlist Changed, Discovery Complete, Signal Received, and 10+ more
|
||||||
- **Actions**: Process Wishlist, Scan Watchlist, Refresh Mirrored, Discover Playlist, Sync Playlist, Scan Library, Database Update, Quality Scan, Full Cleanup, and 10+ more
|
- **Actions**: Process Wishlist, Scan Watchlist, Refresh Mirrored, Discover Playlist, Sync Playlist, Scan Library, Database Update, Quality Scan, Full Cleanup, and more
|
||||||
- **Then Actions** (up to 3 per automation): Fire Signal (chain to other automations), Discord/Telegram/Pushbullet notifications, audible chimes
|
- **Then Actions**: Fire Signal (chain automations), Discord, Telegram, Pushbullet notifications
|
||||||
- **Signal Chains** — One automation fires `signal:foo`, another listens for it. Cycle detection + chain depth limit + cooldown prevent runaway chains.
|
- **Pipelines**: 11 pre-built one-click pipeline deployments (Release Radar, Discovery Weekly, Nightly Operations, etc.)
|
||||||
- **Playlist Pipeline** — Single automation for full playlist lifecycle: refresh → discover → sync → download missing. No manual signal wiring.
|
- **Signal Chains**: playlist_id forwarded from events to action handlers for proper chain execution
|
||||||
- **Pipelines** — Pre-built one-click deployments (New Music, Nightly Operations, Full Library Maintenance, etc.) that install a linked group of automations at once
|
|
||||||
- **Automation Groups** — Drag-and-drop organization, bulk enable/disable, rename, right-click context menus
|
|
||||||
|
|
||||||
**Watchlist** — Monitor unlimited artists with per-artist configuration
|
**Watchlist** — Monitor unlimited artists with per-artist configuration
|
||||||
- Release type filters: Albums, EPs, Singles
|
- Release type filters: Albums, EPs, Singles
|
||||||
|
|
@ -171,11 +147,8 @@ restores the retag.
|
||||||
**Wishlist** — Failed downloads automatically queued for retry with auto-processing
|
**Wishlist** — Failed downloads automatically queued for retry with auto-processing
|
||||||
|
|
||||||
**Mirrored Playlists** — Mirror from Spotify, Tidal, YouTube, Deezer and keep synced
|
**Mirrored Playlists** — Mirror from Spotify, Tidal, YouTube, Deezer and keep synced
|
||||||
- Auto-refresh detects source changes via URL/ID tracking in playlist metadata
|
- Automatic refresh detects changes, discovery pipeline matches metadata
|
||||||
- Discovery pipeline matches source tracks to user's primary metadata source (Spotify/iTunes/Deezer/Discogs)
|
- Followed Spotify playlists with 403 errors fall back to public embed scraper
|
||||||
- Auto Wing It fallback — tracks that fail all metadata APIs get stub metadata from the raw source title and flow through the normal download pipeline anyway
|
|
||||||
- Followed Spotify playlists that hit 403 errors fall back to public embed scraper
|
|
||||||
- Unmatch button on found tracks with DB persistence for mirrored playlists
|
|
||||||
|
|
||||||
**Local Profiles** — Multiple configuration profiles with isolated settings, watchlists, and playlists
|
**Local Profiles** — Multiple configuration profiles with isolated settings, watchlists, and playlists
|
||||||
|
|
||||||
|
|
@ -195,23 +168,13 @@ restores the retag.
|
||||||
- Bulk operations, sortable columns, multi-disc support
|
- Bulk operations, sortable columns, multi-disc support
|
||||||
|
|
||||||
**Library Maintenance** — 10+ automated repair jobs
|
**Library Maintenance** — 10+ automated repair jobs
|
||||||
- Track Number, Dead Files, Duplicates, Metadata Gaps, Album Completeness, Missing Cover Art, AcoustID Scanner, Orphan Files, Fake Lossless, Library Reorganize, Lossy Converter, MBID Mismatch, Album Tag Consistency, Live/Commentary Cleaner
|
- Track Number, Dead Files, Duplicates, Metadata Gaps, Album Completeness, Missing Cover Art, AcoustID Scanner, Orphan Files, Fake Lossless, Library Reorganize, Lossy Converter, MBID Mismatch, Album Tag Consistency
|
||||||
- Enrichment workers auto-pause during database scans
|
- Enrichment workers auto-pause during database scans
|
||||||
- One-click Fix All with findings dashboard
|
- One-click Fix All with findings dashboard
|
||||||
|
|
||||||
**Database Storage Visualization** — Donut chart showing per-table storage breakdown
|
**Database Storage Visualization** — Donut chart showing per-table storage breakdown
|
||||||
|
|
||||||
**Live Log Viewer** — Real-time terminal-style log viewer on Settings → Logs. Color-coded levels (DEBUG/INFO/WARNING/ERROR), live filter + search, switch between log files (app, post-processing, AcoustID, source reuse). Auto-scroll, copy, clear. Updates via WebSocket every 0.5s.
|
|
||||||
|
|
||||||
**Import System** — Tag-first matching, auto-grouped album cards, staging folder workflow
|
**Import System** — Tag-first matching, auto-grouped album cards, staging folder workflow
|
||||||
- Auto-Import worker: recursive scan, single file support, AcoustID fingerprinting fallback
|
|
||||||
- Confidence-gated: 90%+ auto-imports, 70-90% queued for review
|
|
||||||
|
|
||||||
**SoulSync Standalone Mode** — Use SoulSync without Plex, Jellyfin, or Navidrome
|
|
||||||
- Downloads and imports write directly to the library database
|
|
||||||
- Filesystem scanner for incremental and deep scan of Transfer folder
|
|
||||||
- Pre-populated enrichment IDs from download context (Spotify, Deezer, MusicBrainz)
|
|
||||||
- Select in Settings → Connections → Standalone
|
|
||||||
|
|
||||||
**Template Organization** — `$albumartist/$album/$track - $title` and 10+ variables
|
**Template Organization** — `$albumartist/$album/$track - $title` and 10+ variables
|
||||||
|
|
||||||
|
|
@ -240,43 +203,6 @@ docker-compose up -d
|
||||||
# Access at http://localhost:8008
|
# Access at http://localhost:8008
|
||||||
```
|
```
|
||||||
|
|
||||||
### Release Channels
|
|
||||||
|
|
||||||
SoulSync publishes two Docker image tracks so you can choose your level of stability.
|
|
||||||
|
|
||||||
**Stable — `:latest`** (recommended for most users). Hand-promoted from the `dev` branch to `main` when a batch of changes is ready for release. Published to Docker Hub. Your `docker-compose.yml` pulls this by default — no changes needed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull boulderbadgedad/soulsync:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nightly — `:dev`**. Rebuilt every night from the `dev` branch (and on every push to dev). Published to GitHub Container Registry. Gets new features and bug fixes before they reach `:latest`, at the cost of occasional instability as changes settle. Good for early adopters, contributors validating their own merges, and anyone helping shake out bugs on Discord before a stable release.
|
|
||||||
|
|
||||||
To switch, edit `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
image: ghcr.io/nezreka/soulsync:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run `docker-compose pull && docker-compose up -d`.
|
|
||||||
|
|
||||||
Pinned dev builds are also published as `ghcr.io/nezreka/soulsync:dev-YYYYMMDD-<sha>` if you want to stick with an exact known-good snapshot.
|
|
||||||
|
|
||||||
**Version-tagged releases** (e.g. `:2.3`, `:2.4`) are permanent tags published on both registries when a stable release is promoted:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull boulderbadgedad/soulsync:2.4
|
|
||||||
# or
|
|
||||||
docker pull ghcr.io/nezreka/soulsync:2.4
|
|
||||||
```
|
|
||||||
|
|
||||||
| You are... | Use |
|
|
||||||
|---|---|
|
|
||||||
| A typical user who wants things to work | `:latest` |
|
|
||||||
| Pinning to a specific version for stability | `:2.3`, `:2.4`, etc. |
|
|
||||||
| An early adopter who wants new features early and is OK reporting bugs | `:dev` |
|
|
||||||
| A contributor testing post-merge behavior | `:dev` or a pinned dev build |
|
|
||||||
|
|
||||||
### Unraid
|
### Unraid
|
||||||
|
|
||||||
SoulSync is available as an Unraid template. Install from Community Applications or manually add the template from:
|
SoulSync is available as an Unraid template. Install from Community Applications or manually add the template from:
|
||||||
|
|
@ -286,71 +212,16 @@ https://raw.githubusercontent.com/Nezreka/SoulSync/main/templates/soulsync.xml
|
||||||
|
|
||||||
PUID/PGID are exposed in the template — set them to match your Unraid permissions (default: 99/100 for nobody/users).
|
PUID/PGID are exposed in the template — set them to match your Unraid permissions (default: 99/100 for nobody/users).
|
||||||
|
|
||||||
The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To use the nightly `:dev` channel on Unraid, edit the container's **Repository** field to `ghcr.io/nezreka/soulsync:dev` after installing from the template.
|
|
||||||
|
|
||||||
### Python (No Docker)
|
### Python (No Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Nezreka/SoulSync
|
git clone https://github.com/Nezreka/SoulSync
|
||||||
cd SoulSync
|
cd SoulSync
|
||||||
python -m pip install -r requirements.txt
|
pip install -r requirements-webui.txt
|
||||||
|
python web_server.py
|
||||||
# Build the React WebUI bundle used by the Python server.
|
|
||||||
# Docker does this automatically; Python installs must do it manually.
|
|
||||||
cd webui
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
gunicorn -c gunicorn.conf.py wsgi:application
|
|
||||||
# Open http://localhost:8008
|
# Open http://localhost:8008
|
||||||
```
|
```
|
||||||
|
|
||||||
When updating a Python/no-Docker install with `git pull`, rebuild the WebUI before restarting SoulSync:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd webui
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
If `webui/static/dist/.vite/manifest.json` is missing or stale, React-owned routes and route handoffs may not load correctly.
|
|
||||||
|
|
||||||
**YouTube streaming / music videos** need two extra things on bare-metal installs (Docker bundles both):
|
|
||||||
|
|
||||||
- **Deno** — yt-dlp now requires a JavaScript runtime to unlock YouTube formats. Without it, streams and music-video downloads fail with `Requested format is not available`. Install: `winget install DenoLand.Deno` (Windows) or see [deno.com](https://docs.deno.com/runtime/), then restart SoulSync.
|
|
||||||
- **yt-dlp nightly** — the stable release can lag months behind YouTube changes. If YouTube breaks, update with: `python -m pip install -U --pre "yt-dlp[default]"`
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
This is only for contributors working on the WebUI with hot reload. Normal Python/no-Docker installs should build once with `npm run build` as shown above, then run only Gunicorn.
|
|
||||||
|
|
||||||
For active frontend development, use two terminals so the backend and Vite stay independent:
|
|
||||||
|
|
||||||
1. Backend
|
|
||||||
```bash
|
|
||||||
python -m pip install -r requirements-dev.txt
|
|
||||||
gunicorn -c gunicorn.dev.conf.py wsgi:application
|
|
||||||
```
|
|
||||||
The dev Gunicorn config watches backend files and restarts the Python server when they change.
|
|
||||||
2. Frontend
|
|
||||||
```bash
|
|
||||||
cd webui
|
|
||||||
npm ci
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Vite hot reloads the React side when you change webui files.
|
|
||||||
|
|
||||||
Run tests separately when needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want a convenience launcher, `python dev.py` starts both halves together
|
|
||||||
on any OS. `./dev.sh` remains available as a Unix shell wrapper.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Guide
|
## Setup Guide
|
||||||
|
|
@ -360,7 +231,6 @@ on any OS. `./dev.sh` remains available as a Unix shell wrapper.
|
||||||
- **slskd** running and accessible ([Download](https://github.com/slskd/slskd/releases)) — required for Soulseek downloads
|
- **slskd** running and accessible ([Download](https://github.com/slskd/slskd/releases)) — required for Soulseek downloads
|
||||||
- **Spotify API** credentials ([Dashboard](https://developer.spotify.com/dashboard)) — optional but recommended for discovery
|
- **Spotify API** credentials ([Dashboard](https://developer.spotify.com/dashboard)) — optional but recommended for discovery
|
||||||
- **Media Server** (optional): Plex, Jellyfin, or Navidrome
|
- **Media Server** (optional): Plex, Jellyfin, or Navidrome
|
||||||
- **Deno** (Python/no-Docker installs only): JavaScript runtime required by yt-dlp for YouTube streaming/music videos — `winget install DenoLand.Deno` or [deno.com](https://docs.deno.com/runtime/). Docker images bundle it.
|
|
||||||
- **Deezer ARL token** (optional): For Deezer downloads — get from browser cookies after logging into deezer.com
|
- **Deezer ARL token** (optional): For Deezer downloads — get from browser cookies after logging into deezer.com
|
||||||
- **Tidal account** (optional): For Tidal downloads — authenticate via device flow in Settings
|
- **Tidal account** (optional): For Tidal downloads — authenticate via device flow in Settings
|
||||||
- **Qobuz account** (optional): For Qobuz downloads — email/password login in Settings
|
- **Qobuz account** (optional): For Qobuz downloads — email/password login in Settings
|
||||||
|
|
@ -392,9 +262,9 @@ Open SoulSync at `http://localhost:8008` and go to Settings.
|
||||||
**Download Source**: Choose your preferred source (Soulseek, Deezer, Tidal, Qobuz, HiFi, YouTube, or Hybrid)
|
**Download Source**: Choose your preferred source (Soulseek, Deezer, Tidal, Qobuz, HiFi, YouTube, or Hybrid)
|
||||||
|
|
||||||
**Paths**:
|
**Paths**:
|
||||||
- **Input Folder**: Container path to slskd's download folder (e.g., `/app/downloads`)
|
- **Download Path**: Container path to slskd's download folder (e.g., `/app/downloads`)
|
||||||
- **Output Folder**: Where organized music goes (e.g., `/app/Transfer`)
|
- **Transfer Path**: Where organized music goes (e.g., `/app/Transfer`)
|
||||||
- **Import Folder**: Optional folder for importing existing music (e.g., `/app/Staging`)
|
- **Staging Path**: Optional import folder (e.g., `/app/Staging`)
|
||||||
|
|
||||||
**Media Server** (optional): Use your machine's actual IP (not `localhost` — that means inside the container)
|
**Media Server** (optional): Use your machine's actual IP (not `localhost` — that means inside the container)
|
||||||
|
|
||||||
|
|
@ -405,9 +275,9 @@ Open SoulSync at `http://localhost:8008` and go to Settings.
|
||||||
| Config | `/app/config` | Your config folder |
|
| Config | `/app/config` | Your config folder |
|
||||||
| Logs | `/app/logs` | Your logs folder |
|
| Logs | `/app/logs` | Your logs folder |
|
||||||
| Database | `/app/data` | Named volume (recommended) |
|
| Database | `/app/data` | Named volume (recommended) |
|
||||||
| Input | `/app/downloads` | Same folder slskd downloads to |
|
| Downloads | `/app/downloads` | Same folder slskd downloads to |
|
||||||
| Output | `/app/Transfer` | Where organized music goes |
|
| Transfer | `/app/Transfer` | Where organized music goes |
|
||||||
| Import | `/app/Staging` | Optional folder for importing music |
|
| Staging | `/app/Staging` | Optional import folder |
|
||||||
|
|
||||||
**Important:** Use a named volume for the database (`soulsync_database:/app/data`). Direct host path mounts to `/app/data` can overwrite Python module files.
|
**Important:** Use a named volume for the database (`soulsync_database:/app/data`). Direct host path mounts to `/app/data` can overwrite Python module files.
|
||||||
|
|
||||||
|
|
@ -455,36 +325,3 @@ Open SoulSync at `http://localhost:8008` and go to Settings.
|
||||||
- **Album Consistency** — pre-flight MusicBrainz release lookup before album downloads
|
- **Album Consistency** — pre-flight MusicBrainz release lookup before album downloads
|
||||||
- **Automation Engine** — event-driven workflows with signal chains and pipeline deployment
|
- **Automation Engine** — event-driven workflows with signal chains and pipeline deployment
|
||||||
- **SoulID System** — deterministic cross-instance artist/album/track identifiers via track-verified API lookup
|
- **SoulID System** — deterministic cross-instance artist/album/track identifiers via track-verified API lookup
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
### Branch workflow
|
|
||||||
|
|
||||||
SoulSync uses a `dev` → `main` flow:
|
|
||||||
|
|
||||||
- **`main`** — release branch. `:latest` images auto-build from this. Only receives merges from `dev`.
|
|
||||||
- **`dev`** — integration branch. Nightly `:dev` images build from here. PRs land here first for validation before being promoted to `main`.
|
|
||||||
- **Feature branches** — branched from `dev`. PRs target `dev`.
|
|
||||||
|
|
||||||
### Opening a PR
|
|
||||||
|
|
||||||
1. Fork and clone the repo
|
|
||||||
2. Branch off `dev`: `git checkout -b fix/your-change dev`
|
|
||||||
3. Make your changes and commit
|
|
||||||
4. Push and open a PR against **`dev`** (not `main`)
|
|
||||||
5. CI (`build-and-test.yml`) runs ruff lint + compile + `python -m pytest` on your branch — wait for green
|
|
||||||
6. A maintainer reviews and merges
|
|
||||||
|
|
||||||
### Running locally
|
|
||||||
|
|
||||||
Use the [Local Development](#local-development) section above for the full repo-wide setup and the portable dev launcher.
|
|
||||||
|
|
||||||
For web UI work, see [webui/README.md](webui/README.md). It keeps the React-side notes close to the app while this file stays the single place for repo-wide dev instructions.
|
|
||||||
|
|
||||||
Ruff config lives in `pyproject.toml`. The ruleset is intentionally lenient — it catches real bugs (undefined names, import shadowing, closure-in-loop) without style nits.
|
|
||||||
|
|
||||||
### Reporting bugs / requesting features
|
|
||||||
|
|
||||||
Open an issue on GitHub. For user-side support, the Discord community is the fastest place to ask.
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
**SoulSync 2.7.9** is out 🎉 a big one.
|
|
||||||
|
|
||||||
🎚️ **Best-quality downloads** — downloads now follow a ranked quality profile you drag to order (FLAC 24/192 → mp3). best-quality mode grabs the highest-quality copy across *every* source; priority mode gets an opt-in rank-based order toggle. quarantine is folded into the Downloads page + safer imports (AcoustID fail-closed, silence/truncation guards).
|
|
||||||
|
|
||||||
🎧 **Discover got smart** — "Based On Your Listening" ranks artists from who you *actually play*, and "Your Listening Mix" is a playable track playlist of their top tracks (works on any source). Fresh Tape fills properly now.
|
|
||||||
|
|
||||||
⚡ **Wing It Pool** — a new spot next to Discovery Pool to review + re-match the tracks Wing It guessed at (they used to be invisible).
|
|
||||||
|
|
||||||
🔁 **Auto-Sync redesign** — the scheduling board is now clean horizontal lanes instead of a side-scrolling column wall.
|
|
||||||
|
|
||||||
🐛 **Fixes** — multi-disc albums no longer show disc-2 as "missing" (#927), playlists no longer stuck on "Never Synced" (#925), and tracks can't import while quarantined (#928). thanks @ramonskie + @nick2000713 🙏
|
|
||||||
|
|
||||||
⚠️ **re-scan your library once** so the multi-disc fix can backfill disc numbers on existing tracks. enjoy! 🎶
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
**SoulSync 2.8.0** is out 🎉 a quality + reliability release.
|
|
||||||
|
|
||||||
🧹 **The Unverified queue, finally under control** — if you saw thousands of "unverified" rows piling up, this is for you. the AcoustID scan stops duplicating history rows, a one-time reconcile on startup clears the existing backlog from your library (no re-scan), and a new 🧹 *Clean orphaned* button sweeps dead rows whose file is gone. (#934 — thanks @nick2000713 for #938)
|
|
||||||
|
|
||||||
✂️ **Preview Clip Cleanup** — a new Tools job that finds the ~30s preview clips the HiFi source sometimes hands back instead of the full song, then deletes them and re-wishlists the real version. each finding has a ▶ Play button so you can confirm before approving.
|
|
||||||
|
|
||||||
💿 **Album Completeness handles split albums** — an album split across multiple library rows no longer shows every fragment as falsely "incomplete"; it groups the validated fragments into one correct finding. (#936 — thanks @ragnarlotus)
|
|
||||||
|
|
||||||
🐛 **Fixes** — pasted YouTube cookies no longer throw `unsupported browser: "custom"` on Docker (thanks HellRa1SeR); longer remasters aren't quarantined as "truncated" anymore (#937, thanks @diegocade1); "Add to Wishlist" from a discography went from ~15–30s *per track* to instant; wishlist art renders for re-downloads; and **Clear Completed** is back on the Downloads page.
|
|
||||||
|
|
||||||
⚡ **Performance** — trimmed the dashboard GPU usage that was hammering Firefox/Zen (and Background Particles are OFF by default now), plus bounded the runaway memory growth that could lock the app up on big libraries. (#935 / #802)
|
|
||||||
|
|
||||||
enjoy! 🎶
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
**SoulSync 2.8.1** is out 🎉 a feature + reliability release.
|
|
||||||
|
|
||||||
🎧 **Export playlists to Spotify & Deezer** — the mirrored-playlist export now has **Sync to Spotify** and **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. the first Spotify export asks permission once. (#945)
|
|
||||||
|
|
||||||
🏷️ **Library Reorganize — Rename only** — a lighter action that just **renames your files** to your naming scheme: no re-tag, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes. pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)
|
|
||||||
|
|
||||||
💿 **Broader lossless handling** — lossy-copy now covers **all lossless formats**, not just FLAC (#941); and **DSD** (`.dsf`/`.dff`) is recognized as lossless instead of false-flagged "truncated" (#939).
|
|
||||||
|
|
||||||
🐛 **Download + search fixes** — an unbalanced bracket no longer false-fails as "file not found"; a file we couldn't quarantine is left for retry instead of deleted; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); the Wing It pool "Fix Match" works again.
|
|
||||||
|
|
||||||
⚡ **Reduce visual effects, refined** — it no longer freezes functional motion (spinners, progress), only the expensive GPU stuff (blur, shadows, glow). worker orbs default OFF on Firefox and run at ~30fps under reduce-effects. plus a jellyfin scan watchdog fix for big libraries.
|
|
||||||
|
|
||||||
🔧 **Under the hood** — settings cleanup (#943, @nick2000713), spotify oauth hardening (#942) + npm security fixes (#944, HellRa1SeR).
|
|
||||||
|
|
||||||
enjoy! 🎶
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
**SoulSync 2.8.2** is out 🎉 a stability + performance release.
|
|
||||||
|
|
||||||
🎧 **Spotify, reliably** — the Docker boot hang is fixed: with Spotify as your primary source, an unreachable Spotify API could block startup so the container bound `:8008` but never served the UI. auth probes are now deferred during boot + capped with a timeout. the "re-auth didn't stick" bug is fixed too (the OAuth callback and the app were reading different token caches), and **Sync to Spotify** now works — it asks for playlist-write permission once, on-demand, leaving your normal login untouched. (#949 — thanks HellRa1SeR)
|
|
||||||
|
|
||||||
⚡ **The "slow after update" fix** — the post-update lag wasn't SoulSync, it was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on *every* DOM change. non-credential fields are now marked so they skip them — **~110× less main-thread blocking** in the reporter's benchmark. plus a new **Max Performance** mode (Settings → Appearance) that kills every effect for no-GPU / Docker setups. (#948 — thanks @nick2000713)
|
|
||||||
|
|
||||||
📥 **Large-library imports no longer time out** — dropping a whole library into staging used to make the import page scan every file synchronously and never load. the scan runs in the background now with a live "Scanning N of M…" progress, and fills in when done. (#947 — thanks @ramonskie)
|
|
||||||
|
|
||||||
enjoy! 🎶
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
# Spec: Canonical Album Version (fixes #765 + #767-Bug2)
|
|
||||||
|
|
||||||
**Status:** design only — no code yet.
|
|
||||||
**Goal:** Pin ONE canonical `(source, album_id)` per album, chosen by best-fit to
|
|
||||||
the user's actual files, so the Library Reorganizer, Track Number Repair, and
|
|
||||||
tagging/enrichment all agree on the same release. Today each re-resolves
|
|
||||||
independently and they contradict each other (Spotify Believer=4 vs MusicBrainz
|
|
||||||
Believer=3; standard album mislinked to a deluxe release).
|
|
||||||
|
|
||||||
**Canonical-selection rule (decided):** *match the user's actual files.* The
|
|
||||||
canonical release is the candidate whose track count + per-track durations +
|
|
||||||
titles best fit what's on disk. Self-correcting: picks standard when you own the
|
|
||||||
standard, deluxe when you own the deluxe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hard requirement: don't disrupt the running app
|
|
||||||
|
|
||||||
Every stage below is **additive and dormant until explicitly consumed**, and
|
|
||||||
every consumer **falls back to today's behavior when no canonical is set**. So:
|
|
||||||
- albums with no resolved canonical behave EXACTLY as they do now;
|
|
||||||
- each stage is independently shippable and reversible;
|
|
||||||
- nothing big-bangs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 1 — Schema + pure scorer (ships dormant, zero behavior change)
|
|
||||||
|
|
||||||
### Schema (additive, nullable → migration-safe)
|
|
||||||
Add to `albums` (guarded `ALTER TABLE ... ADD COLUMN`, idempotent — mirror the
|
|
||||||
existing column-exists checks; see [[db-schema-review]] migration-safety notes):
|
|
||||||
- `canonical_source TEXT` — e.g. 'spotify' / 'itunes' / 'musicbrainz'
|
|
||||||
- `canonical_album_id TEXT`
|
|
||||||
- `canonical_score REAL` — best-fit score (for transparency / re-resolve gating)
|
|
||||||
- `canonical_resolved_at TIMESTAMP`
|
|
||||||
|
|
||||||
All nullable. Existing rows = NULL → "unresolved" → consumers fall back. No
|
|
||||||
backfill in this stage. No reads in this stage.
|
|
||||||
|
|
||||||
### Pure core helper (the testable heart) — `core/metadata/canonical_version.py`
|
|
||||||
```
|
|
||||||
score_release_against_files(file_tracks, release_tracks) -> float
|
|
||||||
pick_canonical_release(file_tracks, candidates) -> (best, score) | (None, 0)
|
|
||||||
```
|
|
||||||
- `file_tracks`: list of {duration_ms, title, track_number?} read from disk.
|
|
||||||
- `release_tracks`: a candidate release's tracklist (same shape).
|
|
||||||
- Scoring (tunable weights):
|
|
||||||
- **track-count fit** — exact match strongly preferred; |Δcount| penalized.
|
|
||||||
- **duration alignment** — greedily match each file to its closest release
|
|
||||||
track by duration (within a tolerance, e.g. ±3s); reward coverage.
|
|
||||||
- **title overlap** — token/fuzzy overlap as a tiebreaker.
|
|
||||||
- **graceful degradation** — if a source gives no per-track durations, fall
|
|
||||||
back to count + title only (never crash, never force-pick).
|
|
||||||
- Returns the best candidate + score, or (None, 0) when nothing clears a floor
|
|
||||||
(so we never pin a bad guess — leave it unresolved, consumers fall back).
|
|
||||||
|
|
||||||
### Tests (extreme, like the rest of this codebase)
|
|
||||||
- standard (11) vs deluxe (17) with 11 files on disk → picks standard.
|
|
||||||
- same album, 17 files → picks deluxe.
|
|
||||||
- duration disambiguation when track counts tie (e.g. radio edit vs album).
|
|
||||||
- missing-duration source → count+title fallback still picks sanely.
|
|
||||||
- no candidate clears the floor → (None, 0).
|
|
||||||
- "Believer" standard(=track 3 listing) vs Spotify(=4) with the user's files →
|
|
||||||
whichever the files actually match.
|
|
||||||
|
|
||||||
**End of Stage 1: scorer exists + tested, columns exist, NOTHING reads/writes
|
|
||||||
them yet. Provably zero behavior change.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 2 — Resolver populates canonical (writes, still no consumers)
|
|
||||||
|
|
||||||
A function `resolve_canonical_for_album(album_id, db, ...)`:
|
|
||||||
1. Gather on-disk file metadata for the album (durations/titles) via the
|
|
||||||
library's known file paths.
|
|
||||||
2. Gather candidate releases: every source the album has an ID for
|
|
||||||
(spotify/itunes/deezer/discogs/soul/musicbrainz) AND — for the deluxe/standard
|
|
||||||
case — sibling editions discoverable from those. Fetch each tracklist
|
|
||||||
(cached, rate-limited).
|
|
||||||
3. `pick_canonical_release(files, candidates)` → store `(source, album_id, score)`
|
|
||||||
on the album row if it clears the floor.
|
|
||||||
|
|
||||||
Wiring: a small **backfill repair job** (dry-run-capable) + a hook in enrichment
|
|
||||||
when an album is (re)enriched. Still **no tool READS canonical**, so behavior is
|
|
||||||
unchanged — this stage only populates the new columns. Reversible: clearing the
|
|
||||||
columns reverts to unresolved.
|
|
||||||
|
|
||||||
Tests: resolver picks the right release for the standard/deluxe fixtures; stores
|
|
||||||
nothing when below floor; idempotent re-resolve.
|
|
||||||
|
|
||||||
Cost note: fetching multiple candidate releases = more API calls. Mitigate via
|
|
||||||
cache + only-on-(re)enrich + the existing rate trackers. Surface in the job's
|
|
||||||
progress so it's not silent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 3 — Reorganizer reads canonical (first real behavior change, gated)
|
|
||||||
|
|
||||||
In `library_reorganize._resolve_source`: if the album has
|
|
||||||
`canonical_source`/`canonical_album_id`, use THAT first; else fall back to the
|
|
||||||
current `get_source_priority` walk. One-line precedence change, fully gated on
|
|
||||||
non-NULL.
|
|
||||||
|
|
||||||
Tests: with canonical set → resolves to it; with canonical NULL → byte-identical
|
|
||||||
to today. Re-run the existing reorganize battery (148 tests) — must stay green.
|
|
||||||
|
|
||||||
**This alone fixes #767-Bug2** (a standard album whose files match the standard
|
|
||||||
release pins the standard, so reorganize stops targeting the deluxe folder).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 4 — Track Number Repair reads canonical (closes #765)
|
|
||||||
|
|
||||||
In `track_number_repair._resolve_album_tracklist`: add **Fallback -1** (before
|
|
||||||
everything) — if the album has a canonical `(source, album_id)`, use it. The
|
|
||||||
existing 6-level cascade stays as the fallback for albums with no canonical
|
|
||||||
(preserves its all-01-album rescue ability — the regression risk we refused to
|
|
||||||
take in the reactive fix).
|
|
||||||
|
|
||||||
Now both tools resolve the SAME release → same track numbers → no contradiction.
|
|
||||||
|
|
||||||
Tests: canonical present → both tools agree (shared-release test); canonical
|
|
||||||
NULL → existing cascade unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks & mitigations
|
|
||||||
- **Extra API calls** (Stage 2 fetches multiple releases) → cache, rate-limit,
|
|
||||||
only-on-(re)enrich, progress-logged.
|
|
||||||
- **Sources without per-track durations** → scorer degrades to count+title.
|
|
||||||
- **Schema migration** → additive nullable columns only; idempotent guards.
|
|
||||||
- **Wrong pick** → floor gate (never pin a low-confidence guess); `canonical_score`
|
|
||||||
stored for inspection/re-resolve; manual override possible later.
|
|
||||||
- **Backward-compat** → every consumer falls back to today's path when NULL, so
|
|
||||||
un-resolved albums (incl. all existing albums until backfilled) are unaffected.
|
|
||||||
|
|
||||||
## Out of scope (for now)
|
|
||||||
- Per-album manual version override UI (can layer on later — the columns support it).
|
|
||||||
- Merging the two tools into one (the reporter's alt suggestion) — unnecessary
|
|
||||||
once they share the canonical.
|
|
||||||
|
|
||||||
## Suggested order to build
|
|
||||||
1, then 2, then 3, then 4 — each shippable and verifiable on its own. We can stop
|
|
||||||
after any stage and the app is consistent (just with fewer consumers wired).
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
# SoulSync WebUI - Docker Deployment Guide
|
# SoulSync WebUI - Docker Deployment Guide
|
||||||
|
|
||||||
## Release Channels
|
|
||||||
|
|
||||||
SoulSync publishes two Docker image tracks:
|
|
||||||
|
|
||||||
- **Stable — `boulderbadgedad/soulsync:latest`** (Docker Hub). Hand-promoted from the `dev` branch when a batch of changes is ready. Default in `docker-compose.yml`.
|
|
||||||
- **Nightly — `ghcr.io/nezreka/soulsync:dev`** (GHCR). Rebuilt every night and on every push to `dev`. Faster access to new features at the cost of occasional instability.
|
|
||||||
- **Version-tagged — `:2.3`, `:2.4`, etc.** on both registries for pinning to a specific release.
|
|
||||||
|
|
||||||
To switch a running install to the nightly channel, edit the `image:` line in `docker-compose.yml` to `ghcr.io/nezreka/soulsync:dev` and run `docker-compose pull && docker-compose up -d`. See the [main README](../README.md#release-channels) for the full channel guide.
|
|
||||||
|
|
||||||
## 🐳 Quick Start
|
## 🐳 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
@ -73,7 +63,6 @@ environment:
|
||||||
- FLASK_ENV=production # Flask environment
|
- FLASK_ENV=production # Flask environment
|
||||||
- PYTHONPATH=/app # Python path
|
- PYTHONPATH=/app # Python path
|
||||||
- SOULSYNC_CONFIG_PATH=/app/config/config.json # Config file location
|
- 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
|
- TZ=America/New_York # Timezone
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
# Running SoulSync behind a reverse proxy (nginx / Caddy / Traefik)
|
|
||||||
|
|
||||||
Putting SoulSync behind a reverse proxy lets you serve it over **HTTPS** and — the
|
|
||||||
important part — put **authentication** in front of it before exposing it to the
|
|
||||||
internet. This guide covers the safe setup.
|
|
||||||
|
|
||||||
> **The golden rule:** the safest way to expose *any* self-hosted app publicly is
|
|
||||||
> to require authentication at the proxy (an auth layer), **not** to rely on the
|
|
||||||
> app's own protection. SoulSync's launch PIN is a useful fallback, but it is not
|
|
||||||
> a substitute for a real auth layer on a public instance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Turn on reverse-proxy mode
|
|
||||||
|
|
||||||
By default SoulSync does **not** trust proxy headers (so a direct client can't spoof
|
|
||||||
its IP or pretend the connection is HTTPS). If you're behind a proxy that
|
|
||||||
terminates TLS, turn on **Settings → Security → "Behind a reverse proxy"** and
|
|
||||||
**restart SoulSync** (this option applies at startup).
|
|
||||||
|
|
||||||
When enabled, SoulSync:
|
|
||||||
- trusts `X-Forwarded-For/Proto/Host/Port` from **one** proxy hop (correct client
|
|
||||||
IP, HTTPS detection, redirects),
|
|
||||||
- marks its session cookie `Secure` (HTTPS-only) + `SameSite=Lax`, and
|
|
||||||
- sends conservative security headers (`X-Content-Type-Options: nosniff`,
|
|
||||||
`X-Frame-Options: SAMEORIGIN`, `Strict-Transport-Security`). No CSP is set — tune
|
|
||||||
one at your proxy if you want it.
|
|
||||||
|
|
||||||
**Leave it off if you access SoulSync directly over http:// on your LAN** — turning
|
|
||||||
it on would make the session cookie HTTPS-only and break plain-HTTP access. With it
|
|
||||||
off, none of the above applies and SoulSync behaves exactly as before.
|
|
||||||
|
|
||||||
> The launch PIN is also brute-force limited (10 wrong attempts from an IP → a
|
|
||||||
> short cooldown), regardless of this setting — a correct PIN is never affected.
|
|
||||||
|
|
||||||
Restart SoulSync after changing it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. nginx
|
|
||||||
|
|
||||||
SoulSync uses WebSockets (Socket.IO), so the `Upgrade`/`Connection` headers are
|
|
||||||
**required** — without them live updates silently stop working.
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name soulsync.example.com;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/soulsync.example.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/soulsync.example.com/privkey.pem;
|
|
||||||
|
|
||||||
# Large library scans / uploads
|
|
||||||
client_max_body_size 0;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8008;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
# Required for Socket.IO / live updates
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s; # long-running scans
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Caddy
|
|
||||||
|
|
||||||
Caddy handles TLS automatically and proxies WebSockets out of the box:
|
|
||||||
|
|
||||||
```caddy
|
|
||||||
soulsync.example.com {
|
|
||||||
reverse_proxy 127.0.0.1:8008
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Caddy sets `X-Forwarded-*` for you. (Add an auth provider directive if you want
|
|
||||||
auth at the proxy — see below.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Traefik
|
|
||||||
|
|
||||||
Traefik proxies WebSockets automatically and forwards the headers. Point a router
|
|
||||||
at the SoulSync service on port `8008` with your TLS resolver; no extra WebSocket
|
|
||||||
config is needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Add authentication in front (recommended for public instances)
|
|
||||||
|
|
||||||
Pick one:
|
|
||||||
|
|
||||||
- **Auth proxy** — [Authelia](https://www.authelia.com/),
|
|
||||||
[Authentik](https://goauthentik.io/), or
|
|
||||||
[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). These sit in front
|
|
||||||
of SoulSync and force a login (with 2FA) before any request reaches it. Best
|
|
||||||
option for internet exposure.
|
|
||||||
|
|
||||||
SoulSync can **trust the proxy's authenticated-user header** so the launch PIN is
|
|
||||||
skipped once the proxy has logged you in. Set the header name in **Settings →
|
|
||||||
Security → "Auth proxy user header"** (e.g. `Remote-User`).
|
|
||||||
|
|
||||||
> ⚠️ **Only enable this behind a proxy you control that STRIPS any client-supplied
|
|
||||||
> copy of that header.** Otherwise a direct visitor could send `Remote-User: admin`
|
|
||||||
> and walk straight in. It's **off by default** — an unset header name means
|
|
||||||
> SoulSync ignores the header entirely (a spoofed one does nothing).
|
|
||||||
- **HTTP Basic Auth** — quick and simple (nginx `auth_basic` / Caddy `basicauth`).
|
|
||||||
Better than nothing; weaker than an auth proxy.
|
|
||||||
- **SoulSync launch PIN** — set an admin PIN in Settings. Enforced server-side, so
|
|
||||||
it can't be bypassed by hitting the API directly — but it's a shared PIN, so
|
|
||||||
treat it as a fallback, not your only gate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **Live updates / progress bars don't move** → the WebSocket `Upgrade`/`Connection`
|
|
||||||
headers are missing (nginx) or your proxy is buffering. Check section 2.
|
|
||||||
- **Login won't stick / "session expired"** → you enabled `trust_reverse_proxy` but
|
|
||||||
are reaching SoulSync over plain `http://`. The session cookie is now HTTPS-only;
|
|
||||||
use `https://`, or turn the setting off for direct HTTP access.
|
|
||||||
- **Scans time out** → raise `proxy_read_timeout` / `proxy_send_timeout`.
|
|
||||||
|
|
@ -42,8 +42,6 @@ def create_api_blueprint():
|
||||||
from .retag import register_routes as reg_retag
|
from .retag import register_routes as reg_retag
|
||||||
from .listenbrainz import register_routes as reg_listenbrainz
|
from .listenbrainz import register_routes as reg_listenbrainz
|
||||||
from .cache import register_routes as reg_cache
|
from .cache import register_routes as reg_cache
|
||||||
from .request import register_routes as reg_request
|
|
||||||
from .request import start_cleanup_thread as _start_request_cleanup
|
|
||||||
|
|
||||||
# ---- rate-limit only /api/v1 routes (not the whole app) ----
|
# ---- rate-limit only /api/v1 routes (not the whole app) ----
|
||||||
limiter.limit("60 per minute")(bp)
|
limiter.limit("60 per minute")(bp)
|
||||||
|
|
@ -61,12 +59,6 @@ def create_api_blueprint():
|
||||||
reg_retag(bp)
|
reg_retag(bp)
|
||||||
reg_listenbrainz(bp)
|
reg_listenbrainz(bp)
|
||||||
reg_cache(bp)
|
reg_cache(bp)
|
||||||
reg_request(bp)
|
|
||||||
|
|
||||||
# Start the periodic cleanup timer for in-memory request tracking so
|
|
||||||
# idle periods don't leave stale entries in memory. Idempotent across
|
|
||||||
# calls; safe with multi-blueprint registration.
|
|
||||||
_start_request_cleanup()
|
|
||||||
|
|
||||||
# ---- error handlers (scoped to this Blueprint) ----
|
# ---- error handlers (scoped to this Blueprint) ----
|
||||||
@bp.errorhandler(400)
|
@bp.errorhandler(400)
|
||||||
|
|
|
||||||
33
api/auth.py
33
api/auth.py
|
|
@ -4,9 +4,8 @@ API key authentication for the SoulSync public API.
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import request, current_app
|
from flask import request, current_app
|
||||||
|
|
@ -14,27 +13,6 @@ from flask import request, current_app
|
||||||
from .helpers import api_error
|
from .helpers import api_error
|
||||||
|
|
||||||
|
|
||||||
# Throttle persistence of `last_used_at` so every authenticated request
|
|
||||||
# does not rewrite the full app config. Maps key_hash -> last-persisted datetime.
|
|
||||||
_USAGE_WRITE_INTERVAL = timedelta(minutes=15)
|
|
||||||
_last_persisted_usage: dict[str, datetime] = {}
|
|
||||||
_usage_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _should_persist_usage(key_hash: str, now: datetime) -> bool:
|
|
||||||
"""Return True if `last_used_at` for the given key should be written to disk.
|
|
||||||
|
|
||||||
Thread-safe: tracks the last write per key hash in memory and only returns
|
|
||||||
True once per `_USAGE_WRITE_INTERVAL`.
|
|
||||||
"""
|
|
||||||
with _usage_lock:
|
|
||||||
previous = _last_persisted_usage.get(key_hash)
|
|
||||||
if previous is None or (now - previous) >= _USAGE_WRITE_INTERVAL:
|
|
||||||
_last_persisted_usage[key_hash] = now
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def generate_api_key(label=""):
|
def generate_api_key(label=""):
|
||||||
"""Generate a new API key.
|
"""Generate a new API key.
|
||||||
|
|
||||||
|
|
@ -89,12 +67,9 @@ def require_api_key(f):
|
||||||
if not matched:
|
if not matched:
|
||||||
return api_error("INVALID_KEY", "Invalid API key.", 403)
|
return api_error("INVALID_KEY", "Invalid API key.", 403)
|
||||||
|
|
||||||
# Update last-used timestamp (best-effort, throttled to avoid rewriting
|
# Update last-used timestamp (best-effort)
|
||||||
# the full app config on every authenticated request).
|
matched["last_used_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
now = datetime.now(timezone.utc)
|
config_mgr.set("api_keys", stored_keys)
|
||||||
matched["last_used_at"] = now.isoformat()
|
|
||||||
if _should_persist_usage(key_hash, now):
|
|
||||||
config_mgr.set("api_keys", stored_keys)
|
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ Download management endpoints — list, cancel active downloads.
|
||||||
from flask import request, current_app
|
from flask import request, current_app
|
||||||
from .auth import require_api_key
|
from .auth import require_api_key
|
||||||
from .helpers import api_success, api_error
|
from .helpers import api_success, api_error
|
||||||
from core.runtime_state import download_tasks, tasks_lock
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_download(task_id, task):
|
def _serialize_download(task_id, task):
|
||||||
|
|
@ -41,66 +40,16 @@ def register_routes(bp):
|
||||||
@bp.route("/downloads", methods=["GET"])
|
@bp.route("/downloads", methods=["GET"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def list_downloads():
|
def list_downloads():
|
||||||
"""List download tasks with optional filtering and pagination.
|
"""List active and recent download tasks."""
|
||||||
|
|
||||||
Query params:
|
|
||||||
status: comma-separated statuses to include (e.g. "downloading,queued").
|
|
||||||
Default includes all.
|
|
||||||
limit: max tasks to return (default 100, max 500).
|
|
||||||
offset: skip the first N tasks (default 0).
|
|
||||||
|
|
||||||
Response includes `total` (post-filter count) so clients can paginate
|
|
||||||
without fetching everything. Tasks are sorted by `status_change_time`
|
|
||||||
descending so newest/in-flight tasks appear first.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Parse pagination params
|
from web_server import download_tasks, tasks_lock
|
||||||
try:
|
|
||||||
limit = int(request.args.get("limit", 100))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
limit = 100
|
|
||||||
try:
|
|
||||||
offset = int(request.args.get("offset", 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
offset = 0
|
|
||||||
# Clamp to sensible bounds
|
|
||||||
limit = max(1, min(limit, 500))
|
|
||||||
offset = max(0, offset)
|
|
||||||
|
|
||||||
status_param = request.args.get("status", "").strip()
|
tasks = []
|
||||||
status_filter = (
|
|
||||||
{s.strip() for s in status_param.split(",") if s.strip()}
|
|
||||||
if status_param
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Snapshot under the lock, then sort/slice outside.
|
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
snapshot = list(download_tasks.items())
|
for task_id, task in download_tasks.items():
|
||||||
|
tasks.append(_serialize_download(task_id, task))
|
||||||
|
|
||||||
if status_filter:
|
return api_success({"downloads": tasks})
|
||||||
snapshot = [
|
|
||||||
(tid, t) for tid, t in snapshot
|
|
||||||
if (t.get("status") or "") in status_filter
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sort newest-first by status_change_time; fall back to string id
|
|
||||||
# so ordering is stable when timestamps are missing or tied.
|
|
||||||
snapshot.sort(
|
|
||||||
key=lambda item: (item[1].get("status_change_time") or "", item[0]),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
total = len(snapshot)
|
|
||||||
page = snapshot[offset:offset + limit]
|
|
||||||
tasks = [_serialize_download(tid, t) for tid, t in page]
|
|
||||||
|
|
||||||
return api_success({
|
|
||||||
"downloads": tasks,
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
})
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return api_error("NOT_AVAILABLE", "Download tracking not available.", 501)
|
return api_error("NOT_AVAILABLE", "Download tracking not available.", 501)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -121,13 +70,10 @@ def register_routes(bp):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from utils.async_helpers import run_async
|
from utils.async_helpers import run_async
|
||||||
soulseek = current_app.soulsync.get("download_orchestrator")
|
soulseek = current_app.soulsync.get("soulseek_client")
|
||||||
if not soulseek:
|
if not soulseek:
|
||||||
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
||||||
|
|
||||||
current_app.logger.info(
|
|
||||||
f"[CancelTrigger:api.public_cancel] download_id={download_id} username={username}"
|
|
||||||
)
|
|
||||||
ok = run_async(soulseek.cancel_download(download_id, username, remove=True))
|
ok = run_async(soulseek.cancel_download(download_id, username, remove=True))
|
||||||
if ok:
|
if ok:
|
||||||
return api_success({"message": "Download cancelled."})
|
return api_success({"message": "Download cancelled."})
|
||||||
|
|
@ -141,7 +87,7 @@ def register_routes(bp):
|
||||||
"""Cancel all active downloads and clear completed ones."""
|
"""Cancel all active downloads and clear completed ones."""
|
||||||
try:
|
try:
|
||||||
from utils.async_helpers import run_async
|
from utils.async_helpers import run_async
|
||||||
soulseek = current_app.soulsync.get("download_orchestrator")
|
soulseek = current_app.soulsync.get("soulseek_client")
|
||||||
if not soulseek:
|
if not soulseek:
|
||||||
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,15 @@ def register_routes(bp):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db = get_database()
|
db = get_database()
|
||||||
tracks = db.api_search_tracks(title=title, artist=artist, limit=limit)
|
tracks = db.search_tracks(title=title, artist=artist, limit=limit)
|
||||||
return api_success({"tracks": [serialize_track(t, fields) for t in tracks]})
|
if not tracks:
|
||||||
|
return api_success({"tracks": []})
|
||||||
|
|
||||||
|
# Re-query by IDs to get full row data
|
||||||
|
track_ids = [t.id for t in tracks]
|
||||||
|
full_tracks = db.api_get_tracks_by_ids(track_ids)
|
||||||
|
|
||||||
|
return api_success({"tracks": [serialize_track(t, fields) for t in full_tracks]})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("LIBRARY_ERROR", str(e), 500)
|
return api_error("LIBRARY_ERROR", str(e), 500)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
# Forward to the internal sync endpoint
|
# Forward to the internal sync endpoint
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
internal_url = "http://127.0.0.1:8008/api/sync/start"
|
internal_url = f"http://127.0.0.1:8008/api/sync/start"
|
||||||
resp = http_requests.post(internal_url, json={
|
resp = http_requests.post(internal_url, json={
|
||||||
"playlist_id": playlist_id,
|
"playlist_id": playlist_id,
|
||||||
"playlist_name": playlist_name,
|
"playlist_name": playlist_name,
|
||||||
|
|
|
||||||
237
api/request.py
237
api/request.py
|
|
@ -1,237 +0,0 @@
|
||||||
"""
|
|
||||||
Inbound music request endpoint — accept a search query from external sources
|
|
||||||
(Discord bots, curl, etc.) and trigger the search-match-download pipeline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import requests as http_requests
|
|
||||||
from flask import request, current_app
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
from .auth import require_api_key
|
|
||||||
from .helpers import api_success, api_error
|
|
||||||
|
|
||||||
logger = get_logger("api_request")
|
|
||||||
|
|
||||||
# In-memory request tracking (ephemeral — survives until restart)
|
|
||||||
_pending_requests = {}
|
|
||||||
_requests_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Max age before auto-cleanup
|
|
||||||
_MAX_REQUEST_AGE = timedelta(hours=1)
|
|
||||||
|
|
||||||
# How often the background cleanup timer runs. Short enough to keep memory
|
|
||||||
# bounded during idle periods, long enough that slow-polling external clients
|
|
||||||
# still see their request for close to the TTL.
|
|
||||||
_CLEANUP_INTERVAL_SECONDS = 300 # 5 minutes
|
|
||||||
|
|
||||||
# Guards for the singleton background cleanup thread.
|
|
||||||
_cleanup_thread: "threading.Thread | None" = None
|
|
||||||
_cleanup_stop_event = threading.Event()
|
|
||||||
_cleanup_thread_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_old_requests():
|
|
||||||
"""Remove requests older than 1 hour to prevent unbounded growth."""
|
|
||||||
cutoff = datetime.now() - _MAX_REQUEST_AGE
|
|
||||||
with _requests_lock:
|
|
||||||
expired = [rid for rid, r in _pending_requests.items()
|
|
||||||
if r.get('created_at', datetime.now()) < cutoff]
|
|
||||||
for rid in expired:
|
|
||||||
del _pending_requests[rid]
|
|
||||||
return len(expired) if expired else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_loop():
|
|
||||||
"""Background thread: periodically evict expired requests."""
|
|
||||||
while not _cleanup_stop_event.is_set():
|
|
||||||
# wait() returns True if the event was set (shutdown), False on timeout
|
|
||||||
if _cleanup_stop_event.wait(timeout=_CLEANUP_INTERVAL_SECONDS):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
removed = _cleanup_old_requests()
|
|
||||||
if removed:
|
|
||||||
logger.debug(f"Request cleanup: evicted {removed} stale entries")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Request cleanup loop error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def start_cleanup_thread() -> bool:
|
|
||||||
"""Start the background cleanup timer once per process.
|
|
||||||
|
|
||||||
Returns True if a new thread was started, False if one was already
|
|
||||||
running. Safe to call multiple times; callers in multi-worker setups
|
|
||||||
should still gate on worker identity if they want exactly one thread
|
|
||||||
across the entire deployment.
|
|
||||||
"""
|
|
||||||
global _cleanup_thread
|
|
||||||
with _cleanup_thread_lock:
|
|
||||||
if _cleanup_thread is not None and _cleanup_thread.is_alive():
|
|
||||||
return False
|
|
||||||
_cleanup_stop_event.clear()
|
|
||||||
_cleanup_thread = threading.Thread(
|
|
||||||
target=_cleanup_loop,
|
|
||||||
name="api-request-cleanup",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
_cleanup_thread.start()
|
|
||||||
logger.info("Started api/request cleanup timer (interval=%ss)" % _CLEANUP_INTERVAL_SECONDS)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def stop_cleanup_thread(timeout: float = 2.0) -> None:
|
|
||||||
"""Signal the cleanup thread to exit. Used in tests and shutdown paths."""
|
|
||||||
global _cleanup_thread
|
|
||||||
with _cleanup_thread_lock:
|
|
||||||
thread = _cleanup_thread
|
|
||||||
_cleanup_stop_event.set()
|
|
||||||
if thread is not None and thread.is_alive():
|
|
||||||
thread.join(timeout=timeout)
|
|
||||||
with _cleanup_thread_lock:
|
|
||||||
_cleanup_thread = None
|
|
||||||
_cleanup_stop_event.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_search_and_download(request_id, query, notify_url):
|
|
||||||
"""Background worker: search, download, update status, notify."""
|
|
||||||
try:
|
|
||||||
from utils.async_helpers import run_async
|
|
||||||
|
|
||||||
with _requests_lock:
|
|
||||||
if request_id in _pending_requests:
|
|
||||||
_pending_requests[request_id]['status'] = 'searching'
|
|
||||||
|
|
||||||
soulseek = current_app._get_current_object().soulsync.get('download_orchestrator')
|
|
||||||
if not soulseek:
|
|
||||||
with _requests_lock:
|
|
||||||
if request_id in _pending_requests:
|
|
||||||
_pending_requests[request_id]['status'] = 'failed'
|
|
||||||
_pending_requests[request_id]['error'] = 'Download source not configured'
|
|
||||||
return
|
|
||||||
|
|
||||||
result = run_async(soulseek.search_and_download_best(query))
|
|
||||||
|
|
||||||
with _requests_lock:
|
|
||||||
if request_id in _pending_requests:
|
|
||||||
if result:
|
|
||||||
_pending_requests[request_id]['status'] = 'downloading'
|
|
||||||
_pending_requests[request_id]['download_id'] = result
|
|
||||||
else:
|
|
||||||
_pending_requests[request_id]['status'] = 'not_found'
|
|
||||||
_pending_requests[request_id]['error'] = 'No match found'
|
|
||||||
_pending_requests[request_id]['completed_at'] = datetime.now().isoformat()
|
|
||||||
|
|
||||||
# Send notification to callback URL if provided
|
|
||||||
if notify_url:
|
|
||||||
try:
|
|
||||||
with _requests_lock:
|
|
||||||
payload = dict(_pending_requests.get(request_id, {}))
|
|
||||||
# Remove non-serializable datetime
|
|
||||||
payload.pop('created_at', None)
|
|
||||||
http_requests.post(notify_url, json=payload, timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to POST to notify_url {notify_url}: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Request {request_id} failed: {e}")
|
|
||||||
with _requests_lock:
|
|
||||||
if request_id in _pending_requests:
|
|
||||||
_pending_requests[request_id]['status'] = 'failed'
|
|
||||||
_pending_requests[request_id]['error'] = str(e)
|
|
||||||
_pending_requests[request_id]['completed_at'] = datetime.now().isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def register_routes(bp):
|
|
||||||
|
|
||||||
@bp.route("/request", methods=["POST"])
|
|
||||||
@require_api_key
|
|
||||||
def create_request():
|
|
||||||
"""Accept a music search query and trigger the download pipeline.
|
|
||||||
|
|
||||||
Body:
|
|
||||||
query (str, required): Search query, e.g. "Artist - Track Name"
|
|
||||||
notify_url (str, optional): URL to POST results to on completion
|
|
||||||
metadata (dict, optional): Passthrough data included in automation events
|
|
||||||
|
|
||||||
Returns 202 with request_id for async status polling.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
query = (body.get("query") or "").strip()
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400)
|
|
||||||
|
|
||||||
# Cleanup old requests on each new request
|
|
||||||
_cleanup_old_requests()
|
|
||||||
|
|
||||||
request_id = str(uuid.uuid4())
|
|
||||||
notify_url = (body.get("notify_url") or "").strip() or None
|
|
||||||
metadata = body.get("metadata") or {}
|
|
||||||
|
|
||||||
with _requests_lock:
|
|
||||||
_pending_requests[request_id] = {
|
|
||||||
'request_id': request_id,
|
|
||||||
'query': query,
|
|
||||||
'status': 'queued',
|
|
||||||
'created_at': datetime.now(),
|
|
||||||
'completed_at': None,
|
|
||||||
'download_id': None,
|
|
||||||
'error': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Emit webhook_received event so automation engine triggers fire
|
|
||||||
engine = current_app.soulsync.get('automation_engine')
|
|
||||||
if engine:
|
|
||||||
engine.emit('webhook_received', {
|
|
||||||
'query': query,
|
|
||||||
'request_id': request_id,
|
|
||||||
'source': 'api',
|
|
||||||
'metadata': metadata,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Start background search-download (Feature A: works without automations)
|
|
||||||
app = current_app._get_current_object()
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=lambda: _run_with_app_context(app, request_id, query, notify_url),
|
|
||||||
daemon=True
|
|
||||||
)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
logger.info(f"Music request queued: '{query}' (id={request_id})")
|
|
||||||
|
|
||||||
return api_success({
|
|
||||||
"request_id": request_id,
|
|
||||||
"status": "queued",
|
|
||||||
"query": query,
|
|
||||||
}), 202
|
|
||||||
|
|
||||||
@bp.route("/request/<request_id>", methods=["GET"])
|
|
||||||
@require_api_key
|
|
||||||
def get_request_status(request_id):
|
|
||||||
"""Check the status of a music request.
|
|
||||||
|
|
||||||
Returns current status: queued → searching → downloading → completed/not_found/failed
|
|
||||||
"""
|
|
||||||
with _requests_lock:
|
|
||||||
req = _pending_requests.get(request_id)
|
|
||||||
|
|
||||||
if not req:
|
|
||||||
return api_error("NOT_FOUND", "Request not found or expired.", 404)
|
|
||||||
|
|
||||||
return api_success({
|
|
||||||
"request_id": req['request_id'],
|
|
||||||
"query": req['query'],
|
|
||||||
"status": req['status'],
|
|
||||||
"download_id": req.get('download_id'),
|
|
||||||
"error": req.get('error'),
|
|
||||||
"completed_at": req.get('completed_at'),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _run_with_app_context(app, request_id, query, notify_url):
|
|
||||||
"""Run the background worker within Flask app context."""
|
|
||||||
with app.app_context():
|
|
||||||
_run_search_and_download(request_id, query, notify_url)
|
|
||||||
|
|
@ -2,14 +2,10 @@
|
||||||
Search endpoints — search external sources (Spotify, iTunes, Hydrabase).
|
Search endpoints — search external sources (Spotify, iTunes, Hydrabase).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from flask import request, current_app
|
from flask import request, current_app
|
||||||
from .auth import require_api_key
|
from .auth import require_api_key
|
||||||
from .helpers import api_success, api_error
|
from .helpers import api_success, api_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def register_routes(bp):
|
def register_routes(bp):
|
||||||
|
|
||||||
|
|
@ -42,21 +38,20 @@ def register_routes(bp):
|
||||||
if hydra_results:
|
if hydra_results:
|
||||||
tracks = [_serialize_track(t) for t in hydra_results]
|
tracks = [_serialize_track(t) for t in hydra_results]
|
||||||
return api_success({"tracks": tracks, "source": "hydrabase"})
|
return api_success({"tracks": tracks, "source": "hydrabase"})
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("hydrabase search failed: %s", e)
|
pass
|
||||||
|
|
||||||
spotify = ctx.get("spotify_client")
|
spotify = ctx.get("spotify_client")
|
||||||
from core.metadata_service import get_primary_source, get_primary_client
|
if source in ("spotify", "auto") and spotify and spotify.is_authenticated():
|
||||||
primary = get_primary_source()
|
|
||||||
if source in ("spotify", "auto") and primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
|
|
||||||
results = spotify.search_tracks(query, limit=limit)
|
results = spotify.search_tracks(query, limit=limit)
|
||||||
if results:
|
if results:
|
||||||
tracks = [_serialize_track(t) for t in results]
|
tracks = [_serialize_track(t) for t in results]
|
||||||
return api_success({"tracks": tracks, "source": "spotify"})
|
return api_success({"tracks": tracks, "source": "spotify"})
|
||||||
|
|
||||||
if source in ("itunes", "deezer", "auto"):
|
if source in ("itunes", "deezer", "auto"):
|
||||||
fallback = get_primary_client()
|
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
|
||||||
fallback_source = get_primary_source()
|
fallback = _create_fallback_client()
|
||||||
|
fallback_source = _get_configured_fallback_source()
|
||||||
results = fallback.search_tracks(query, limit=limit)
|
results = fallback.search_tracks(query, limit=limit)
|
||||||
if results:
|
if results:
|
||||||
tracks = [_serialize_track(t) for t in results]
|
tracks = [_serialize_track(t) for t in results]
|
||||||
|
|
@ -83,9 +78,7 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
ctx = current_app.soulsync
|
ctx = current_app.soulsync
|
||||||
spotify = ctx.get("spotify_client")
|
spotify = ctx.get("spotify_client")
|
||||||
from core.metadata_service import get_primary_source, get_primary_client
|
if spotify and spotify.is_authenticated():
|
||||||
primary = get_primary_source()
|
|
||||||
if primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
|
|
||||||
results = spotify.search_albums(query, limit=limit)
|
results = spotify.search_albums(query, limit=limit)
|
||||||
if results:
|
if results:
|
||||||
return api_success({
|
return api_success({
|
||||||
|
|
@ -93,8 +86,9 @@ def register_routes(bp):
|
||||||
"source": "spotify",
|
"source": "spotify",
|
||||||
})
|
})
|
||||||
|
|
||||||
fallback = get_primary_client()
|
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
|
||||||
fallback_source = get_primary_source()
|
fallback = _create_fallback_client()
|
||||||
|
fallback_source = _get_configured_fallback_source()
|
||||||
results = fallback.search_albums(query, limit=limit)
|
results = fallback.search_albums(query, limit=limit)
|
||||||
return api_success({
|
return api_success({
|
||||||
"albums": [_serialize_album(a) for a in results] if results else [],
|
"albums": [_serialize_album(a) for a in results] if results else [],
|
||||||
|
|
@ -120,9 +114,7 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
ctx = current_app.soulsync
|
ctx = current_app.soulsync
|
||||||
spotify = ctx.get("spotify_client")
|
spotify = ctx.get("spotify_client")
|
||||||
from core.metadata_service import get_primary_source, get_primary_client
|
if spotify and spotify.is_authenticated():
|
||||||
primary = get_primary_source()
|
|
||||||
if primary == 'spotify' and spotify and spotify.is_spotify_authenticated():
|
|
||||||
results = spotify.search_artists(query, limit=limit)
|
results = spotify.search_artists(query, limit=limit)
|
||||||
if results:
|
if results:
|
||||||
return api_success({
|
return api_success({
|
||||||
|
|
@ -130,8 +122,9 @@ def register_routes(bp):
|
||||||
"source": "spotify",
|
"source": "spotify",
|
||||||
})
|
})
|
||||||
|
|
||||||
fallback = get_primary_client()
|
from core.metadata_service import _create_fallback_client, _get_configured_fallback_source
|
||||||
fallback_source = get_primary_source()
|
fallback = _create_fallback_client()
|
||||||
|
fallback_source = _get_configured_fallback_source()
|
||||||
results = fallback.search_artists(query, limit=limit)
|
results = fallback.search_artists(query, limit=limit)
|
||||||
return api_success({
|
return api_success({
|
||||||
"artists": [_serialize_artist(a) for a in results] if results else [],
|
"artists": [_serialize_artist(a) for a in results] if results else [],
|
||||||
|
|
|
||||||
|
|
@ -287,12 +287,12 @@ def serialize_watchlist_artist(obj, fields: Optional[Set[str]] = None) -> dict:
|
||||||
def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
|
def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
|
||||||
"""Standardized wishlist track serialization."""
|
"""Standardized wishlist track serialization."""
|
||||||
d = _to_dict(obj)
|
d = _to_dict(obj)
|
||||||
track_data = d.get("track_data", d.get("spotify_data", {}))
|
spotify_data = d.get("spotify_data", {})
|
||||||
if isinstance(track_data, str):
|
if isinstance(spotify_data, str):
|
||||||
try:
|
try:
|
||||||
track_data = json.loads(track_data)
|
spotify_data = json.loads(spotify_data)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
track_data = {}
|
spotify_data = {}
|
||||||
|
|
||||||
source_info = d.get("source_info")
|
source_info = d.get("source_info")
|
||||||
if isinstance(source_info, str):
|
if isinstance(source_info, str):
|
||||||
|
|
@ -303,23 +303,17 @@ def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"id": d.get("id"),
|
"id": d.get("id"),
|
||||||
"track_id": d.get("track_id") or d.get("spotify_track_id") or d.get("id"),
|
|
||||||
"spotify_track_id": d.get("spotify_track_id"),
|
"spotify_track_id": d.get("spotify_track_id"),
|
||||||
"track_name": (
|
"track_name": spotify_data.get("name", "Unknown") if isinstance(spotify_data, dict) else "Unknown",
|
||||||
track_data.get("name", "Unknown") if isinstance(track_data, dict) else d.get("track_name", "Unknown")
|
|
||||||
),
|
|
||||||
"artist_name": ", ".join(
|
"artist_name": ", ".join(
|
||||||
a.get("name", "") if isinstance(a, dict) else str(a)
|
a.get("name", "") for a in spotify_data.get("artists", [])
|
||||||
for a in track_data.get("artists", [])
|
) if isinstance(spotify_data, dict) and isinstance(spotify_data.get("artists"), list) else "",
|
||||||
) if isinstance(track_data, dict) and isinstance(track_data.get("artists"), list) else "",
|
|
||||||
"album_name": (
|
"album_name": (
|
||||||
track_data.get("album", {}).get("name")
|
spotify_data.get("album", {}).get("name")
|
||||||
if isinstance(track_data, dict) and isinstance(track_data.get("album"), dict)
|
if isinstance(spotify_data, dict) and isinstance(spotify_data.get("album"), dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"track_data": track_data,
|
"spotify_data": spotify_data,
|
||||||
"spotify_data": track_data,
|
|
||||||
"provider": track_data.get("provider") if isinstance(track_data, dict) else d.get("provider"),
|
|
||||||
"failure_reason": d.get("failure_reason"),
|
"failure_reason": d.get("failure_reason"),
|
||||||
"retry_count": d.get("retry_count", 0),
|
"retry_count": d.get("retry_count", 0),
|
||||||
"last_attempted": _isoformat(d.get("last_attempted")),
|
"last_attempted": _isoformat(d.get("last_attempted")),
|
||||||
|
|
@ -368,7 +362,6 @@ def serialize_similar_artist(obj, fields: Optional[Set[str]] = None) -> dict:
|
||||||
"source_artist_id": d.get("source_artist_id"),
|
"source_artist_id": d.get("source_artist_id"),
|
||||||
"similar_artist_spotify_id": d.get("similar_artist_spotify_id"),
|
"similar_artist_spotify_id": d.get("similar_artist_spotify_id"),
|
||||||
"similar_artist_itunes_id": d.get("similar_artist_itunes_id"),
|
"similar_artist_itunes_id": d.get("similar_artist_itunes_id"),
|
||||||
"similar_artist_musicbrainz_id": d.get("similar_artist_musicbrainz_id"),
|
|
||||||
"similar_artist_name": d.get("similar_artist_name"),
|
"similar_artist_name": d.get("similar_artist_name"),
|
||||||
"similarity_rank": d.get("similarity_rank"),
|
"similarity_rank": d.get("similarity_rank"),
|
||||||
"occurrence_count": d.get("occurrence_count"),
|
"occurrence_count": d.get("occurrence_count"),
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@
|
||||||
System endpoints — status, activity feed, stats.
|
System endpoints — status, activity feed, stats.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from .auth import require_api_key
|
from .auth import require_api_key
|
||||||
from .helpers import api_success, api_error
|
from .helpers import api_success, api_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def register_routes(bp):
|
def register_routes(bp):
|
||||||
|
|
||||||
|
|
@ -29,7 +26,7 @@ def register_routes(bp):
|
||||||
spotify = ctx.get("spotify_client")
|
spotify = ctx.get("spotify_client")
|
||||||
spotify_ok = bool(spotify and spotify.is_authenticated())
|
spotify_ok = bool(spotify and spotify.is_authenticated())
|
||||||
|
|
||||||
soulseek = ctx.get("download_orchestrator")
|
soulseek = ctx.get("soulseek_client")
|
||||||
soulseek_ok = bool(soulseek)
|
soulseek_ok = bool(soulseek)
|
||||||
|
|
||||||
hydrabase = ctx.get("hydrabase_client")
|
hydrabase = ctx.get("hydrabase_client")
|
||||||
|
|
@ -38,8 +35,8 @@ def register_routes(bp):
|
||||||
try:
|
try:
|
||||||
ws, _ = hydrabase.get_ws_and_lock()
|
ws, _ = hydrabase.get_ws_and_lock()
|
||||||
hydrabase_ok = ws is not None and ws.connected
|
hydrabase_ok = ws is not None and ws.connected
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("hydrabase status probe failed: %s", e)
|
pass
|
||||||
|
|
||||||
return api_success({
|
return api_success({
|
||||||
"uptime": f"{hours}h {minutes}m {seconds}s",
|
"uptime": f"{hours}h {minutes}m {seconds}s",
|
||||||
|
|
@ -58,7 +55,7 @@ def register_routes(bp):
|
||||||
def system_activity():
|
def system_activity():
|
||||||
"""Recent activity feed."""
|
"""Recent activity feed."""
|
||||||
try:
|
try:
|
||||||
from core.runtime_state import activity_feed
|
from web_server import activity_feed
|
||||||
items = list(activity_feed) if activity_feed else []
|
items = list(activity_feed) if activity_feed else []
|
||||||
return api_success({"activities": items})
|
return api_success({"activities": items})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -77,7 +74,7 @@ def register_routes(bp):
|
||||||
# Active download count
|
# Active download count
|
||||||
download_count = 0
|
download_count = 0
|
||||||
try:
|
try:
|
||||||
from core.runtime_state import download_tasks, tasks_lock
|
from web_server import download_tasks, tasks_lock
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
download_count = sum(
|
download_count = sum(
|
||||||
1 for t in download_tasks.values()
|
1 for t in download_tasks.values()
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,21 @@ def register_routes(bp):
|
||||||
fields = parse_fields(request)
|
fields = parse_fields(request)
|
||||||
profile_id = parse_profile_id(request)
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
category_filter = category if category in ("singles", "albums") else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
db = get_database()
|
db = get_database()
|
||||||
offset = (page - 1) * limit
|
raw_tracks = db.get_wishlist_tracks(profile_id=profile_id)
|
||||||
tracks = db.get_wishlist_tracks(
|
|
||||||
profile_id=profile_id,
|
# Category filter
|
||||||
category=category_filter,
|
if category in ("singles", "albums"):
|
||||||
limit=limit,
|
raw_tracks = [
|
||||||
offset=offset,
|
t for t in raw_tracks
|
||||||
)
|
if _track_category(t) == category
|
||||||
total = db.get_wishlist_count(profile_id=profile_id, category=category_filter)
|
]
|
||||||
|
|
||||||
|
total = len(raw_tracks)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
tracks = raw_tracks[start:start + limit]
|
||||||
|
|
||||||
return api_success(
|
return api_success(
|
||||||
{"tracks": [serialize_wishlist_track(t, fields) for t in tracks]},
|
{"tracks": [serialize_wishlist_track(t, fields) for t in tracks]},
|
||||||
|
|
@ -45,16 +47,16 @@ def register_routes(bp):
|
||||||
def add_to_wishlist():
|
def add_to_wishlist():
|
||||||
"""Add a track to the wishlist.
|
"""Add a track to the wishlist.
|
||||||
|
|
||||||
Body: {"track_data": {...}, "failure_reason": "...", "source_type": "..."}
|
Body: {"spotify_track_data": {...}, "failure_reason": "...", "source_type": "..."}
|
||||||
"""
|
"""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
track_data = body.get("track_data") or body.get("spotify_track_data")
|
track_data = body.get("spotify_track_data")
|
||||||
reason = body.get("failure_reason", "Added via API")
|
reason = body.get("failure_reason", "Added via API")
|
||||||
source_type = body.get("source_type", "api")
|
source_type = body.get("source_type", "api")
|
||||||
profile_id = parse_profile_id(request)
|
profile_id = parse_profile_id(request)
|
||||||
|
|
||||||
if not track_data:
|
if not track_data:
|
||||||
return api_error("BAD_REQUEST", "Missing 'track_data' in body.", 400)
|
return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
|
|
@ -74,7 +76,7 @@ def register_routes(bp):
|
||||||
@bp.route("/wishlist/<track_id>", methods=["DELETE"])
|
@bp.route("/wishlist/<track_id>", methods=["DELETE"])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def remove_from_wishlist(track_id):
|
def remove_from_wishlist(track_id):
|
||||||
"""Remove a track from the wishlist by its track ID."""
|
"""Remove a track from the wishlist by its Spotify track ID."""
|
||||||
profile_id = parse_profile_id(request)
|
profile_id = parse_profile_id(request)
|
||||||
try:
|
try:
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
|
|
@ -102,3 +104,15 @@ def register_routes(bp):
|
||||||
return api_error("NOT_AVAILABLE", "Wishlist processing function not available.", 501)
|
return api_error("NOT_AVAILABLE", "Wishlist processing function not available.", 501)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error("WISHLIST_ERROR", str(e), 500)
|
return api_error("WISHLIST_ERROR", str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
def _track_category(track):
|
||||||
|
"""Determine if a wishlist track is a single or album track."""
|
||||||
|
album_type = ""
|
||||||
|
if isinstance(track, dict):
|
||||||
|
sd = track.get("spotify_data", {})
|
||||||
|
if isinstance(sd, dict):
|
||||||
|
album = sd.get("album", {})
|
||||||
|
if isinstance(album, dict):
|
||||||
|
album_type = album.get("album_type", "")
|
||||||
|
return "albums" if album_type == "album" else "singles"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -44,8 +44,7 @@
|
||||||
},
|
},
|
||||||
"metadata_enhancement": {
|
"metadata_enhancement": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"embed_album_art": true,
|
"embed_album_art": true
|
||||||
"single_to_album": false
|
|
||||||
},
|
},
|
||||||
"file_organization": {
|
"file_organization": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -57,11 +56,6 @@
|
||||||
"playlist_path": "$playlist/$artist - $title"
|
"playlist_path": "$playlist/$artist - $title"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
|
||||||
"staging_path": "./Staging",
|
|
||||||
"replace_lower_quality": false,
|
|
||||||
"folder_artist_override": true
|
|
||||||
},
|
|
||||||
"lossy_copy": {
|
"lossy_copy": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bitrate": "320",
|
"bitrate": "320",
|
||||||
|
|
|
||||||
65
config/config.json
Normal file
65
config/config.json
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"active_media_server": "plex",
|
||||||
|
"spotify": {
|
||||||
|
"client_id": "SpotifyClientID",
|
||||||
|
"client_secret": "SpotifyClientSecret",
|
||||||
|
"redirect_uri": "http://127.0.0.1:8888/callback"
|
||||||
|
},
|
||||||
|
"tidal": {
|
||||||
|
"client_id": "TidalClientID",
|
||||||
|
"client_secret": "TidalClientSecret",
|
||||||
|
"redirect_uri": "http://127.0.0.1:8889/tidal/callback"
|
||||||
|
},
|
||||||
|
"plex": {
|
||||||
|
"base_url": "http://192.168.86.36:32400",
|
||||||
|
"token": "PLEX_API_TOKEN",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
|
"jellyfin": {
|
||||||
|
"base_url": "http://localhost:8096",
|
||||||
|
"api_key": "JELLYFIN_API_KEY",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
|
"navidrome": {
|
||||||
|
"base_url": "http://localhost:4533",
|
||||||
|
"username": "NAVIDROME_USERNAME",
|
||||||
|
"password": "NAVIDROME_PASSWORD",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
|
"soulseek": {
|
||||||
|
"slskd_url": "http://host.docker.internal:5030",
|
||||||
|
"api_key": "SoulseekAPIKey",
|
||||||
|
"download_path": "/app/downloads",
|
||||||
|
"transfer_path": "/app/Transfer",
|
||||||
|
"search_timeout": 60,
|
||||||
|
"search_timeout_buffer": 15
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"path": "logs/app.log",
|
||||||
|
"level": "INFO"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"path": "database/music_library.db",
|
||||||
|
"max_workers": 5
|
||||||
|
},
|
||||||
|
"metadata_enhancement": {
|
||||||
|
"enabled": true,
|
||||||
|
"embed_album_art": true
|
||||||
|
},
|
||||||
|
"file_organization": {
|
||||||
|
"enabled": true,
|
||||||
|
"_template_variables": "Available: $artist, $albumartist, $artistletter, $album, $title, $track, $disc, $year, $playlist, $quality (filename only)",
|
||||||
|
"templates": {
|
||||||
|
"album_path": "$albumartist/$albumartist - $album/$track - $title",
|
||||||
|
"single_path": "$artist/$artist - $title/$title",
|
||||||
|
"compilation_path": "Compilations/$album/$track - $artist - $title",
|
||||||
|
"playlist_path": "$playlist/$artist - $title"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playlist_sync": {
|
||||||
|
"create_backup": true
|
||||||
|
},
|
||||||
|
"listenbrainz": {
|
||||||
|
"token": "LISTENBRAINZ_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,11 @@ import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger("config")
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
_VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
|
|
||||||
|
|
||||||
def __init__(self, config_path: str = "config/config.json"):
|
def __init__(self, config_path: str = "config/config.json"):
|
||||||
# Determine strict absolute path to settings.py directory to help resolve 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)
|
# This handles cases where CWD is different (e.g. running from /Users vs /Users/project)
|
||||||
|
|
@ -40,7 +33,7 @@ class ConfigManager:
|
||||||
# Default to project path even if it doesn't exist yet (for creation/fallback)
|
# Default to project path even if it doesn't exist yet (for creation/fallback)
|
||||||
self.config_path = project_path
|
self.config_path = project_path
|
||||||
|
|
||||||
logger.info(f"ConfigManager initialized with path: {self.config_path}")
|
print(f"🔧 ConfigManager initialized with path: {self.config_path}")
|
||||||
|
|
||||||
self.config_data: Dict[str, Any] = {}
|
self.config_data: Dict[str, Any] = {}
|
||||||
self._fernet: Optional[Fernet] = None
|
self._fernet: Optional[Fernet] = None
|
||||||
|
|
@ -52,7 +45,7 @@ class ConfigManager:
|
||||||
else:
|
else:
|
||||||
self.database_path = self.base_dir / "database" / "music_library.db"
|
self.database_path = self.base_dir / "database" / "music_library.db"
|
||||||
|
|
||||||
logger.info(f"Database path set to: {self.database_path}")
|
print(f"💾 Database path set to: {self.database_path}")
|
||||||
|
|
||||||
self.load_config(str(self.config_path))
|
self.load_config(str(self.config_path))
|
||||||
|
|
||||||
|
|
@ -66,12 +59,6 @@ class ConfigManager:
|
||||||
|
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
# Placeholder shipped to the browser in place of a configured secret
|
|
||||||
# (#832 follow-up). The settings UI shows it as masked dots; if it's
|
|
||||||
# round-tripped back on save, ``set()`` treats it as "keep existing" so the
|
|
||||||
# real value is never overwritten by the mask.
|
|
||||||
REDACTED_SENTINEL = '__redacted_unchanged__'
|
|
||||||
|
|
||||||
# Dot-notation paths to sensitive config values that must be encrypted at rest.
|
# Dot-notation paths to sensitive config values that must be encrypted at rest.
|
||||||
# Paths pointing to dicts encrypt the entire dict as a JSON blob.
|
# Paths pointing to dicts encrypt the entire dict as a JSON blob.
|
||||||
_SENSITIVE_PATHS = frozenset({
|
_SENSITIVE_PATHS = frozenset({
|
||||||
|
|
@ -92,25 +79,13 @@ class ConfigManager:
|
||||||
# Download sources
|
# Download sources
|
||||||
'soulseek.api_key',
|
'soulseek.api_key',
|
||||||
'deezer_download.arl',
|
'deezer_download.arl',
|
||||||
'lidarr_download.api_key',
|
|
||||||
'prowlarr.api_key',
|
|
||||||
'torrent_client.password',
|
|
||||||
'usenet_client.api_key',
|
|
||||||
'usenet_client.password',
|
|
||||||
# Enrichment services
|
# Enrichment services
|
||||||
'listenbrainz.token',
|
'listenbrainz.token',
|
||||||
'acoustid.api_key',
|
'acoustid.api_key',
|
||||||
'lastfm.api_key',
|
'lastfm.api_key',
|
||||||
'lastfm.api_secret',
|
|
||||||
'lastfm.session_key',
|
|
||||||
'genius.access_token',
|
'genius.access_token',
|
||||||
# Deezer OAuth
|
|
||||||
'deezer.app_id',
|
|
||||||
'deezer.app_secret',
|
|
||||||
'deezer.access_token',
|
|
||||||
# Other
|
# Other
|
||||||
'hydrabase.api_key',
|
'hydrabase.api_key',
|
||||||
'discogs.token',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_fernet(self) -> Fernet:
|
def _get_fernet(self) -> Fernet:
|
||||||
|
|
@ -124,7 +99,7 @@ class ConfigManager:
|
||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
shutil.move(str(old_key_file), str(key_file))
|
shutil.move(str(old_key_file), str(key_file))
|
||||||
logger.info(f"Moved encryption key to {key_file}")
|
print(f"[MIGRATE] 🔑 Moved encryption key to {key_file}")
|
||||||
except Exception:
|
except Exception:
|
||||||
key_file = old_key_file # Fall back to old location
|
key_file = old_key_file # Fall back to old location
|
||||||
if key_file.exists():
|
if key_file.exists():
|
||||||
|
|
@ -172,10 +147,8 @@ class ConfigManager:
|
||||||
return decrypted
|
return decrypted
|
||||||
except InvalidToken:
|
except InvalidToken:
|
||||||
# Key mismatch — encrypted with a different key (key file deleted/replaced)
|
# Key mismatch — encrypted with a different key (key file deleted/replaced)
|
||||||
logger.error(
|
print(f"[ERROR] ⚠️ Failed to decrypt a config value — encryption key may have changed. "
|
||||||
"Failed to decrypt a config value — encryption key may have changed. "
|
f"Re-enter credentials in Settings or restore the original .encryption_key file.")
|
||||||
"Re-enter credentials in Settings or restore the original .encryption_key file."
|
|
||||||
)
|
|
||||||
return value
|
return value
|
||||||
except Exception:
|
except Exception:
|
||||||
return value
|
return value
|
||||||
|
|
@ -231,7 +204,7 @@ class ConfigManager:
|
||||||
"""Re-save config to encrypt any plaintext sensitive values still in the DB."""
|
"""Re-save config to encrypt any plaintext sensitive values still in the DB."""
|
||||||
try:
|
try:
|
||||||
# Read raw DB content to check if any sensitive value is still plaintext
|
# Read raw DB content to check if any sensitive value is still plaintext
|
||||||
conn = self._connect_db()
|
conn = sqlite3.connect(str(self.database_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
@ -262,24 +235,11 @@ class ConfigManager:
|
||||||
needs_migration = True
|
needs_migration = True
|
||||||
break
|
break
|
||||||
if needs_migration:
|
if needs_migration:
|
||||||
logger.info("Encrypting sensitive config values at rest...")
|
print("[MIGRATE] 🔐 Encrypting sensitive config values at rest...")
|
||||||
self._save_to_database(self.config_data)
|
self._save_to_database(self.config_data)
|
||||||
logger.info("Sensitive config values encrypted successfully")
|
print("[OK] ✅ Sensitive config values encrypted successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not migrate encryption: {e}")
|
print(f"[WARN] Could not migrate encryption: {e}")
|
||||||
|
|
||||||
def _connect_db(self) -> sqlite3.Connection:
|
|
||||||
"""Open a configured SQLite connection for the config DB.
|
|
||||||
|
|
||||||
Centralizes pragma setup so every connection gets WAL mode,
|
|
||||||
a 30s busy timeout, and synchronous=NORMAL (the safe pairing
|
|
||||||
with WAL that avoids unnecessary fsyncs on slow disks).
|
|
||||||
"""
|
|
||||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
|
||||||
conn.execute("PRAGMA busy_timeout=30000")
|
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _ensure_database_exists(self):
|
def _ensure_database_exists(self):
|
||||||
"""Ensure database file and metadata table exist"""
|
"""Ensure database file and metadata table exist"""
|
||||||
|
|
@ -288,7 +248,8 @@ class ConfigManager:
|
||||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Connect to database (creates file if it doesn't exist)
|
# Connect to database (creates file if it doesn't exist)
|
||||||
conn = self._connect_db()
|
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Create metadata table if it doesn't exist
|
# Create metadata table if it doesn't exist
|
||||||
|
|
@ -302,7 +263,7 @@ class ConfigManager:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not ensure database exists: {e}")
|
print(f"Warning: Could not ensure database exists: {e}")
|
||||||
|
|
||||||
def _load_from_database(self) -> Optional[Dict[str, Any]]:
|
def _load_from_database(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Load configuration from database, decrypting sensitive values."""
|
"""Load configuration from database, decrypting sensitive values."""
|
||||||
|
|
@ -310,7 +271,8 @@ class ConfigManager:
|
||||||
try:
|
try:
|
||||||
self._ensure_database_exists()
|
self._ensure_database_exists()
|
||||||
|
|
||||||
conn = self._connect_db()
|
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
@ -319,77 +281,20 @@ class ConfigManager:
|
||||||
config_data = json.loads(row[0])
|
config_data = json.loads(row[0])
|
||||||
# Decrypt sensitive values (gracefully handles plaintext migration)
|
# Decrypt sensitive values (gracefully handles plaintext migration)
|
||||||
config_data = self._decrypt_sensitive(config_data)
|
config_data = self._decrypt_sensitive(config_data)
|
||||||
logger.info("Configuration loaded from database")
|
print("[OK] Configuration loaded from database")
|
||||||
return config_data
|
return config_data
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not load config from database: {e}")
|
print(f"Warning: Could not load config from database: {e}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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 = self._connect_db()
|
|
||||||
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:
|
def _save_to_database(self, config_data: Dict[str, Any]) -> bool:
|
||||||
"""Save configuration to database, encrypting sensitive values.
|
"""Save configuration to database, encrypting sensitive values."""
|
||||||
|
|
||||||
Returns ``True`` on success. Transient ``database is locked``
|
|
||||||
failures are logged at DEBUG so the caller's retry loop owns the
|
|
||||||
user-visible error message — otherwise every retry would spam
|
|
||||||
ERROR-level logs even when the next attempt succeeds.
|
|
||||||
"""
|
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
self._ensure_database_exists()
|
self._ensure_database_exists()
|
||||||
|
|
@ -397,7 +302,9 @@ class ConfigManager:
|
||||||
# Encrypt sensitive values before writing (original dict is untouched)
|
# Encrypt sensitive values before writing (original dict is untouched)
|
||||||
encrypted_data = self._encrypt_sensitive(config_data)
|
encrypted_data = self._encrypt_sensitive(config_data)
|
||||||
|
|
||||||
conn = self._connect_db()
|
# Use longer timeout (30s) to handle contention from enrichment workers
|
||||||
|
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
config_json = json.dumps(encrypted_data, indent=2)
|
config_json = json.dumps(encrypted_data, indent=2)
|
||||||
|
|
@ -409,18 +316,8 @@ class ConfigManager:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
# SQLite raises OperationalError("database is locked") when the
|
|
||||||
# busy_timeout expires while another writer holds the lock.
|
|
||||||
# Log at DEBUG so the caller can decide whether the final
|
|
||||||
# outcome warrants an ERROR-level message.
|
|
||||||
if "locked" in str(e).lower():
|
|
||||||
logger.debug(f"Config DB locked, will retry: {e}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Could not save config to database: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Could not save config to database: {e}")
|
print(f"Error: Could not save config to database: {e}")
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
|
|
@ -432,12 +329,12 @@ class ConfigManager:
|
||||||
if self.config_path.exists():
|
if self.config_path.exists():
|
||||||
with open(self.config_path, 'r') as f:
|
with open(self.config_path, 'r') as f:
|
||||||
config_data = json.load(f)
|
config_data = json.load(f)
|
||||||
logger.info(f"Configuration loaded from {self.config_path}")
|
print(f"[OK] Configuration loaded from {self.config_path}")
|
||||||
return config_data
|
return config_data
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not load config from file: {e}")
|
print(f"Warning: Could not load config from file: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_default_config(self) -> Dict[str, Any]:
|
def _get_default_config(self) -> Dict[str, Any]:
|
||||||
|
|
@ -476,71 +373,14 @@ class ConfigManager:
|
||||||
"download_path": "./downloads",
|
"download_path": "./downloads",
|
||||||
"transfer_path": "./Transfer",
|
"transfer_path": "./Transfer",
|
||||||
"max_peer_queue": 0,
|
"max_peer_queue": 0,
|
||||||
"download_timeout": 600,
|
"download_timeout": 600
|
||||||
# Reddit report (YeloMelo95, Bell Canada): the existing
|
|
||||||
# 35-per-220s sliding-window cap allows all 35 searches in
|
|
||||||
# rapid succession before throttling — that burst trips ISP
|
|
||||||
# anti-abuse. This knob forces a min gap between consecutive
|
|
||||||
# searches even when the window cap isn't hit. 0 = disabled
|
|
||||||
# (preserves prior behavior).
|
|
||||||
"search_min_delay_seconds": 0,
|
|
||||||
},
|
},
|
||||||
"download_source": {
|
"download_source": {
|
||||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid", "torrent", "usenet"
|
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
|
||||||
"hybrid_primary": "soulseek", # Legacy: primary source for hybrid mode
|
"hybrid_primary": "soulseek", # Legacy: primary source for hybrid mode
|
||||||
"hybrid_secondary": "youtube", # Legacy: fallback source for hybrid mode
|
"hybrid_secondary": "youtube", # Legacy: fallback source for hybrid mode
|
||||||
"hybrid_order": [], # Ordered list of sources for hybrid mode (overrides primary/secondary)
|
"hybrid_order": [], # Ordered list of sources for hybrid mode (overrides primary/secondary)
|
||||||
"stream_source": "youtube", # Options: "youtube" (instant, default), "active" (use download source; falls back to youtube if soulseek)
|
"stream_source": "youtube", # Options: "youtube" (instant, default), "active" (use download source; falls back to youtube if soulseek)
|
||||||
# Album-bundle (torrent / usenet single-source) poll tuning.
|
|
||||||
# Downloader is polled every N seconds until the release
|
|
||||||
# lands; whole job aborts at the timeout. Defaults match
|
|
||||||
# the previous hard-coded constants. Users on slow private
|
|
||||||
# trackers / large box sets can extend the timeout without
|
|
||||||
# editing source.
|
|
||||||
"album_bundle_poll_interval_seconds": 2.0,
|
|
||||||
"album_bundle_timeout_seconds": 6 * 60 * 60, # 6 hours
|
|
||||||
# Stalled-torrent handling (noldevin): abandon a torrent that
|
|
||||||
# makes zero download progress for this long (dead magnet
|
|
||||||
# stuck on "downloading metadata", no seeders) instead of
|
|
||||||
# holding the worker for the full album timeout. 0 disables.
|
|
||||||
"torrent_stall_timeout_seconds": 10 * 60, # 10 minutes
|
|
||||||
# What to do when a torrent stalls: "abandon" (remove it +
|
|
||||||
# its partial data, fail the download so the next source can
|
|
||||||
# try) or "pause" (pause in the client, leave for the user).
|
|
||||||
"torrent_stall_action": "abandon",
|
|
||||||
# Where THIS container can read completed torrent/usenet
|
|
||||||
# downloads (#857). The downloader (qBit/SAB) reports a save
|
|
||||||
# path from inside ITS OWN container — often a category folder
|
|
||||||
# like /data/downloads/music — which may be mounted at a
|
|
||||||
# different point here. Set these to the in-container path(s)
|
|
||||||
# where SoulSync sees those finished downloads; the resolver
|
|
||||||
# then finds the release by name under them. Empty = fall back
|
|
||||||
# to the soulseek download/transfer dirs (the shared-volume
|
|
||||||
# default). See core.download_plugins.album_bundle.resolve_reported_save_path.
|
|
||||||
"torrent_download_path": "",
|
|
||||||
"usenet_download_path": "",
|
|
||||||
# Explicit remote→local prefix mappings for non-shared / oddly
|
|
||||||
# mounted layouts (Sonarr/Radarr "Remote Path Mapping" style):
|
|
||||||
# a list of {"from": "<client path>", "to": "<soulsync path>"}.
|
|
||||||
# Tried before the basename fallback above.
|
|
||||||
"usenet_path_mappings": [],
|
|
||||||
},
|
|
||||||
"post_processing": {
|
|
||||||
# When a download is quarantined (AcoustID mismatch, integrity /
|
|
||||||
# duration failure), retry the next-best candidate instead of
|
|
||||||
# failing outright. Default ON (PR #801's documented default —
|
|
||||||
# the monitor reads this with inline default True; this template
|
|
||||||
# said False, so fresh installs silently shipped with the retry
|
|
||||||
# engine off while existing configs got it on. CI caught the
|
|
||||||
# split: its fresh default config failed all 7 requeue tests).
|
|
||||||
"retry_next_candidate_on_mismatch": True,
|
|
||||||
# Opt-in exhaustive retry: budget retries PER SOURCE so every
|
|
||||||
# source (Soulseek, then HiFi/Tidal/…) gets its own attempts
|
|
||||||
# before the track gives up. Default off (single global cap).
|
|
||||||
"retry_exhaustive": False,
|
|
||||||
# Retries per search query per source in exhaustive mode. The
|
|
||||||
# per-source budget is query_count × this value.
|
|
||||||
"retries_per_query": 5,
|
|
||||||
},
|
},
|
||||||
"tidal_download": {
|
"tidal_download": {
|
||||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||||
|
|
@ -562,61 +402,6 @@ class ConfigManager:
|
||||||
"hifi_download": {
|
"hifi_download": {
|
||||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||||
},
|
},
|
||||||
"hifi": {
|
|
||||||
"embed_tags": True,
|
|
||||||
"tags": {
|
|
||||||
"track_id": True,
|
|
||||||
"artist_id": True,
|
|
||||||
"isrc": True,
|
|
||||||
"bpm": True,
|
|
||||||
"copyright": True,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lidarr_download": {
|
|
||||||
"url": "",
|
|
||||||
"api_key": "",
|
|
||||||
"root_folder": "",
|
|
||||||
"quality_profile": "Any",
|
|
||||||
"cleanup_after_import": True,
|
|
||||||
},
|
|
||||||
# Prowlarr — indexer aggregator. Feeds the torrent / usenet
|
|
||||||
# download plugins. Not a standalone source.
|
|
||||||
"prowlarr": {
|
|
||||||
"url": "",
|
|
||||||
"api_key": "",
|
|
||||||
# Comma-separated list of indexer IDs to limit searches to.
|
|
||||||
# Empty = search all enabled indexers.
|
|
||||||
"indexer_ids": "",
|
|
||||||
},
|
|
||||||
# Torrent client — receives .torrent / magnet URIs from the
|
|
||||||
# torrent download plugin. ``type`` picks which adapter to
|
|
||||||
# instantiate (qbittorrent | transmission | deluge).
|
|
||||||
"torrent_client": {
|
|
||||||
"type": "qbittorrent",
|
|
||||||
"url": "",
|
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"category": "soulsync",
|
|
||||||
"save_path": "",
|
|
||||||
},
|
|
||||||
# Usenet client — receives .nzb URLs / payloads. ``type``
|
|
||||||
# picks the adapter (sabnzbd | nzbget). SABnzbd uses an
|
|
||||||
# API key; NZBGet uses username + password.
|
|
||||||
"usenet_client": {
|
|
||||||
"type": "sabnzbd",
|
|
||||||
"url": "",
|
|
||||||
"api_key": "",
|
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"category": "soulsync",
|
|
||||||
},
|
|
||||||
"soundcloud_download": {
|
|
||||||
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
|
|
||||||
# added later, with credentials living under a "session" subkey
|
|
||||||
# alongside Tidal/Qobuz. No quality knob: anonymous SoundCloud
|
|
||||||
# caps at the upload's transcoding (typically 128 kbps MP3 or
|
|
||||||
# AAC). yt-dlp resolves bestaudio at download time.
|
|
||||||
},
|
|
||||||
"listenbrainz": {
|
"listenbrainz": {
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"token": "",
|
"token": "",
|
||||||
|
|
@ -643,43 +428,16 @@ class ConfigManager:
|
||||||
"path": os.environ.get('DATABASE_PATH', 'database/music_library.db'),
|
"path": os.environ.get('DATABASE_PATH', 'database/music_library.db'),
|
||||||
"max_workers": 5
|
"max_workers": 5
|
||||||
},
|
},
|
||||||
"image_cache": {
|
|
||||||
"enabled": True,
|
|
||||||
"path": "storage/image_cache",
|
|
||||||
"ttl_seconds": 2592000,
|
|
||||||
"failed_ttl_seconds": 21600,
|
|
||||||
"max_download_mb": 15
|
|
||||||
},
|
|
||||||
"metadata_enhancement": {
|
"metadata_enhancement": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"embed_album_art": True,
|
"embed_album_art": True,
|
||||||
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"],
|
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||||
# Ordered preferred cover-art sources (empty = use the
|
|
||||||
# download's own art, i.e. today's behavior). Resolved + walked
|
|
||||||
# with fallback by core/metadata/art_sources.py.
|
|
||||||
"album_art_order": [],
|
|
||||||
# Minimum cover-art resolution (shortest side, px). A preferred
|
|
||||||
# source whose art is smaller is skipped so the next source is
|
|
||||||
# tried — stops a low-res Cover Art Archive upload from winning.
|
|
||||||
# 0 disables the size gate.
|
|
||||||
"min_art_size": 1000,
|
|
||||||
# When a track matches a SINGLE release, look up the parent ALBUM
|
|
||||||
# that contains it and tag it as that album, so it groups with its
|
|
||||||
# album-mates and gets the album cover (not the single's). Off by
|
|
||||||
# default — it's an extra per-import metadata lookup.
|
|
||||||
"single_to_album": False
|
|
||||||
},
|
},
|
||||||
"musicbrainz": {
|
"musicbrainz": {
|
||||||
"embed_tags": True
|
"embed_tags": True
|
||||||
},
|
},
|
||||||
"playlist_sync": {
|
"playlist_sync": {
|
||||||
"create_backup": True,
|
"create_backup": True
|
||||||
# How a re-sync writes to the server playlist:
|
|
||||||
# replace — delete + recreate (default; today's behavior)
|
|
||||||
# reconcile — edit in place (add/remove delta), preserving the
|
|
||||||
# playlist's custom image, description, and identity (#792)
|
|
||||||
# append — only add new tracks, never remove
|
|
||||||
"mode": "replace"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio_quality": "flac"
|
"audio_quality": "flac"
|
||||||
|
|
@ -695,44 +453,11 @@ class ConfigManager:
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"poll_interval": 30
|
"poll_interval": 30
|
||||||
},
|
},
|
||||||
"library": {
|
|
||||||
"music_paths": [],
|
|
||||||
"music_videos_path": ""
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"path": "./scripts",
|
|
||||||
"timeout": 60
|
|
||||||
},
|
|
||||||
"import": {
|
"import": {
|
||||||
"staging_path": "./Staging",
|
"staging_path": "./Staging"
|
||||||
# Master toggle for quality-filtering on import. On by default:
|
|
||||||
# downloaded files that don't meet the quality profile are
|
|
||||||
# quarantined instead of imported (same gate the download
|
|
||||||
# pipeline uses). Off → import everything regardless of quality;
|
|
||||||
# the library Quality Upgrade Scanner still flags them.
|
|
||||||
"quality_filter_enabled": True,
|
|
||||||
"replace_lower_quality": False,
|
|
||||||
# Use the top Staging folder as the artist (Artist/Album layouts,
|
|
||||||
# mixtapes). On by default to preserve the long-standing import
|
|
||||||
# behaviour for existing users. Turn OFF if you stage a mixed pile
|
|
||||||
# of songs under one container folder, otherwise that folder's name
|
|
||||||
# overrides every metadata-identified artist (the "soulsync" case).
|
|
||||||
"folder_artist_override": True
|
|
||||||
},
|
},
|
||||||
"m3u_export": {
|
"m3u_export": {
|
||||||
"enabled": False,
|
"enabled": False
|
||||||
"entry_base_path": ""
|
|
||||||
},
|
|
||||||
"playlists": {
|
|
||||||
# Where "Organize by playlist" materializes playlist folders.
|
|
||||||
# MUST be a separate root from the music library so the media
|
|
||||||
# server (and the maintenance jobs) never scan it — otherwise the
|
|
||||||
# same track would show up twice. Mapped separately for Docker.
|
|
||||||
"materialize_path": "./Playlists",
|
|
||||||
# "symlink" (relative links, ~zero disk) or "copy" (real
|
|
||||||
# duplicates for FAT/USB/DAPs that can't follow links). Symlink
|
|
||||||
# auto-falls back to copy when the filesystem can't link.
|
|
||||||
"materialize_mode": "symlink"
|
|
||||||
},
|
},
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
|
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
|
||||||
|
|
@ -756,79 +481,66 @@ class ConfigManager:
|
||||||
2. config.json (migration from file-based config)
|
2. config.json (migration from file-based config)
|
||||||
3. Defaults (fresh install)
|
3. Defaults (fresh install)
|
||||||
"""
|
"""
|
||||||
logger.info("Loading configuration...")
|
print(f"📥 Loading configuration...")
|
||||||
|
|
||||||
# Try loading from database first
|
# Try loading from database first
|
||||||
config_data = self._load_from_database()
|
config_data = self._load_from_database()
|
||||||
|
|
||||||
if config_data:
|
if config_data:
|
||||||
# Configuration exists in database
|
# Configuration exists in database
|
||||||
self.config_data = self._apply_log_level_overrides(config_data)
|
self.config_data = config_data
|
||||||
# Ensure sensitive values are encrypted at rest (one-time migration)
|
# Ensure sensitive values are encrypted at rest (one-time migration)
|
||||||
self._migrate_encrypt_if_needed()
|
self._migrate_encrypt_if_needed()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Database is empty - try migration from config.json
|
# Database is empty - try migration from config.json
|
||||||
logger.info(f"Configuration not found in database. Attempting migration from: {self.config_path}")
|
print(f"⚠️ Configuration not found in database. Attempting migration from: {self.config_path}")
|
||||||
config_data = self._load_from_config_file()
|
config_data = self._load_from_config_file()
|
||||||
|
|
||||||
if config_data:
|
if config_data:
|
||||||
# Migrate from config.json to database
|
# Migrate from config.json to database
|
||||||
logger.info("Migrating configuration from config.json to database...")
|
print("[MIGRATE] 🚀 Migrating configuration from config.json to database...")
|
||||||
if self._save_to_database(config_data):
|
if self._save_to_database(config_data):
|
||||||
logger.info("Configuration migrated successfully to database.")
|
print("[OK] ✅ Configuration migrated successfully to database.")
|
||||||
self.config_data = self._apply_log_level_overrides(config_data)
|
self.config_data = config_data
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
logger.warning("Migration failed - using file-based config temporarily.")
|
print("[WARN] ⚠️ Migration failed - using file-based config temporarily.")
|
||||||
self.config_data = self._apply_log_level_overrides(config_data)
|
self.config_data = config_data
|
||||||
return
|
return
|
||||||
|
|
||||||
# No config.json either - use defaults
|
# No config.json either - use defaults
|
||||||
logger.info("No existing configuration found (DB or File) - using defaults")
|
print("[INFO] ℹ️ No existing configuration found (DB or File) - using defaults")
|
||||||
config_data = self._get_default_config()
|
config_data = self._get_default_config()
|
||||||
|
|
||||||
# Try to save defaults to database
|
# Try to save defaults to database
|
||||||
if self._save_to_database(config_data):
|
if self._save_to_database(config_data):
|
||||||
logger.info("Default configuration saved to database")
|
print("[OK] ✅ Default configuration saved to database")
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not save defaults to database - using in-memory config")
|
print("[WARN] ⚠️ Could not save defaults to database - using in-memory config")
|
||||||
|
|
||||||
self.config_data = self._apply_log_level_overrides(config_data)
|
self.config_data = config_data
|
||||||
|
|
||||||
def _save_config(self):
|
def _save_config(self):
|
||||||
"""Save configuration to database with exponential-backoff retry on lock.
|
"""Save configuration to database with retry on lock."""
|
||||||
|
success = self._save_to_database(self.config_data)
|
||||||
|
|
||||||
Spread retries over ~7 seconds so a long-held writer (enrichment
|
if not success:
|
||||||
worker batch insert, library scan commit, etc.) on a slow disk
|
# Retry once after a brief wait (handles transient lock contention)
|
||||||
has time to release the lock before we fall back to the JSON
|
import time
|
||||||
file. The single 1s retry that used to live here gave up too
|
time.sleep(1)
|
||||||
early on HDD-backed Docker volumes.
|
success = self._save_to_database(self.config_data)
|
||||||
"""
|
|
||||||
# Cumulative delay across attempts: 0.2 + 0.5 + 1.0 + 2.0 + 4.0 = 7.7s
|
|
||||||
# plus the 30s busy_timeout that already runs inside each attempt.
|
|
||||||
retry_delays = [0.2, 0.5, 1.0, 2.0, 4.0]
|
|
||||||
if self._save_to_database(self.config_data):
|
|
||||||
return
|
|
||||||
|
|
||||||
for delay in retry_delays:
|
if not success:
|
||||||
time.sleep(delay)
|
# Fallback: Try to save to config.json if database fails
|
||||||
if self._save_to_database(self.config_data):
|
print("[WARN] Database save failed - attempting file fallback")
|
||||||
return
|
try:
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# All retries exhausted — fall back to config.json so the user
|
with open(self.config_path, 'w') as f:
|
||||||
# doesn't lose their settings, then log a single error.
|
json.dump(self.config_data, f, indent=2)
|
||||||
logger.error(
|
print("[OK] Configuration saved to config.json as fallback")
|
||||||
f"Config DB save failed after {len(retry_delays) + 1} attempts (database is locked) — "
|
except Exception as e:
|
||||||
"falling back to config.json"
|
print(f"[ERROR] Failed to save configuration: {e}")
|
||||||
)
|
|
||||||
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)
|
|
||||||
logger.warning("Configuration saved to config.json as fallback")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save configuration: {e}")
|
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
keys = key.split('.')
|
keys = key.split('.')
|
||||||
|
|
@ -842,40 +554,7 @@ class ConfigManager:
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def redacted_config(self) -> Dict[str, Any]:
|
|
||||||
"""Deep copy of the live config with every sensitive value masked.
|
|
||||||
|
|
||||||
Used for ``GET /api/settings`` so decrypted secrets never reach the
|
|
||||||
browser (#832 follow-up). A *set* secret becomes ``REDACTED_SENTINEL``
|
|
||||||
(the UI renders it as masked dots); an unset one stays empty so the UI
|
|
||||||
can show "not configured". Dict-valued secrets (OAuth sessions) collapse
|
|
||||||
to the sentinel too — the UI has no field for them anyway. The matching
|
|
||||||
guard in ``set()`` turns a round-tripped sentinel back into a no-op.
|
|
||||||
"""
|
|
||||||
import copy
|
|
||||||
data = copy.deepcopy(self.config_data)
|
|
||||||
for path in self._SENSITIVE_PATHS:
|
|
||||||
keys = path.split('.')
|
|
||||||
parent = data
|
|
||||||
for k in keys[:-1]:
|
|
||||||
if isinstance(parent, dict) and k in parent:
|
|
||||||
parent = parent[k]
|
|
||||||
else:
|
|
||||||
parent = None
|
|
||||||
break
|
|
||||||
if not isinstance(parent, dict):
|
|
||||||
continue
|
|
||||||
leaf = keys[-1]
|
|
||||||
if leaf in parent and parent[leaf] not in (None, '', {}, [], 0, False):
|
|
||||||
parent[leaf] = self.REDACTED_SENTINEL
|
|
||||||
return data
|
|
||||||
|
|
||||||
def set(self, key: str, value: Any):
|
def set(self, key: str, value: Any):
|
||||||
# The UI round-trips REDACTED_SENTINEL for any secret the user didn't
|
|
||||||
# touch — never let the mask overwrite the real value (#832 follow-up).
|
|
||||||
if value == self.REDACTED_SENTINEL and key in self._SENSITIVE_PATHS:
|
|
||||||
return
|
|
||||||
|
|
||||||
keys = key.split('.')
|
keys = key.split('.')
|
||||||
config = self.config_data
|
config = self.config_data
|
||||||
|
|
||||||
|
|
@ -887,20 +566,6 @@ class ConfigManager:
|
||||||
config[keys[-1]] = value
|
config[keys[-1]] = value
|
||||||
self._save_config()
|
self._save_config()
|
||||||
|
|
||||||
def resolve_secret(self, key: str, posted: Any) -> str:
|
|
||||||
"""Resolve a secret value coming back from the settings UI.
|
|
||||||
|
|
||||||
The UI renders a saved-but-untouched secret as the REDACTED_SENTINEL (shown
|
|
||||||
masked); empty or that sentinel means "use the stored value", while a real
|
|
||||||
string is a genuine new secret. A connection-test endpoint should test the
|
|
||||||
EFFECTIVE secret, not the mask — otherwise testing a saved-but-untouched
|
|
||||||
token sends the sentinel and the source rejects it (#870)."""
|
|
||||||
if isinstance(posted, str):
|
|
||||||
posted = posted.strip()
|
|
||||||
if not posted or posted == self.REDACTED_SENTINEL:
|
|
||||||
return self.get(key, '') or ''
|
|
||||||
return posted
|
|
||||||
|
|
||||||
def get_spotify_config(self) -> Dict[str, str]:
|
def get_spotify_config(self) -> Dict[str, str]:
|
||||||
return self.get('spotify', {})
|
return self.get('spotify', {})
|
||||||
|
|
||||||
|
|
@ -932,8 +597,8 @@ class ConfigManager:
|
||||||
return self.get('active_media_server', 'plex')
|
return self.get('active_media_server', 'plex')
|
||||||
|
|
||||||
def set_active_media_server(self, server: str):
|
def set_active_media_server(self, server: str):
|
||||||
"""Set the active media server (plex, jellyfin, navidrome, or soulsync)"""
|
"""Set the active media server (plex, jellyfin, or navidrome)"""
|
||||||
if server not in ['plex', 'jellyfin', 'navidrome', 'soulsync']:
|
if server not in ['plex', 'jellyfin', 'navidrome']:
|
||||||
raise ValueError(f"Invalid media server: {server}")
|
raise ValueError(f"Invalid media server: {server}")
|
||||||
self.set('active_media_server', server)
|
self.set('active_media_server', server)
|
||||||
|
|
||||||
|
|
@ -946,8 +611,6 @@ class ConfigManager:
|
||||||
return self.get_jellyfin_config()
|
return self.get_jellyfin_config()
|
||||||
elif active_server == 'navidrome':
|
elif active_server == 'navidrome':
|
||||||
return self.get_navidrome_config()
|
return self.get_navidrome_config()
|
||||||
elif active_server == 'soulsync':
|
|
||||||
return {'transfer_path': self.get('soulseek.transfer_path', './Transfer')}
|
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
@ -967,8 +630,6 @@ class ConfigManager:
|
||||||
elif active_server == 'navidrome':
|
elif active_server == 'navidrome':
|
||||||
navidrome = self.get_navidrome_config()
|
navidrome = self.get_navidrome_config()
|
||||||
media_server_configured = bool(navidrome.get('base_url')) and bool(navidrome.get('username')) and bool(navidrome.get('password'))
|
media_server_configured = bool(navidrome.get('base_url')) and bool(navidrome.get('username')) and bool(navidrome.get('password'))
|
||||||
elif active_server == 'soulsync':
|
|
||||||
media_server_configured = True # SoulSync standalone is always configured
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
bool(spotify.get('client_id')) and
|
bool(spotify.get('client_id')) and
|
||||||
|
|
@ -989,7 +650,6 @@ class ConfigManager:
|
||||||
validation['plex'] = bool(self.get('plex.base_url')) and bool(self.get('plex.token'))
|
validation['plex'] = bool(self.get('plex.base_url')) and bool(self.get('plex.token'))
|
||||||
validation['jellyfin'] = bool(self.get('jellyfin.base_url')) and bool(self.get('jellyfin.api_key'))
|
validation['jellyfin'] = bool(self.get('jellyfin.base_url')) and bool(self.get('jellyfin.api_key'))
|
||||||
validation['navidrome'] = bool(self.get('navidrome.base_url')) and bool(self.get('navidrome.username')) and bool(self.get('navidrome.password'))
|
validation['navidrome'] = bool(self.get('navidrome.base_url')) and bool(self.get('navidrome.username')) and bool(self.get('navidrome.password'))
|
||||||
validation['soulsync'] = True # Standalone mode is always valid
|
|
||||||
validation['active_media_server'] = active_server
|
validation['active_media_server'] = active_server
|
||||||
|
|
||||||
return validation
|
return validation
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ from typing import Dict, List, Optional, Any, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import logging
|
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
@ -30,23 +29,22 @@ from config.settings import config_manager
|
||||||
FPCALC_BIN_DIR = Path(__file__).parent.parent / "bin"
|
FPCALC_BIN_DIR = Path(__file__).parent.parent / "bin"
|
||||||
CHROMAPRINT_VERSION = "1.5.1"
|
CHROMAPRINT_VERSION = "1.5.1"
|
||||||
|
|
||||||
_acoustid_logger = logging.getLogger("soulsync.acoustid")
|
# Set up dedicated AcoustID logger with its own file
|
||||||
_acoustid_logger.setLevel(logging.DEBUG)
|
logger = get_logger("acoustid_client")
|
||||||
_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)
|
|
||||||
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")
|
# Add dedicated file handler for AcoustID logs
|
||||||
|
_acoustid_log_path = Path(__file__).parent.parent / "logs" / "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)
|
||||||
|
|
||||||
# Check if pyacoustid is available
|
# Check if pyacoustid is available
|
||||||
try:
|
try:
|
||||||
|
|
@ -196,7 +194,7 @@ class AcoustIDClient:
|
||||||
result = client.fingerprint_and_lookup("/path/to/audio.mp3")
|
result = client.fingerprint_and_lookup("/path/to/audio.mp3")
|
||||||
if result:
|
if result:
|
||||||
for mbid in result['recording_mbids']:
|
for mbid in result['recording_mbids']:
|
||||||
logger.info(f"Match: {mbid}")
|
print(f"Match: {mbid}")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -274,7 +272,7 @@ class AcoustIDClient:
|
||||||
if not search_dir.exists():
|
if not search_dir.exists():
|
||||||
continue
|
continue
|
||||||
# Walk up to 2 levels deep to find an audio file quickly
|
# Walk up to 2 levels deep to find an audio file quickly
|
||||||
for _depth, pattern in enumerate(['*', '*/*']):
|
for depth, pattern in enumerate(['*', '*/*']):
|
||||||
for f in search_dir.glob(pattern):
|
for f in search_dir.glob(pattern):
|
||||||
if f.is_file() and f.suffix.lower() in audio_extensions:
|
if f.is_file() and f.suffix.lower() in audio_extensions:
|
||||||
return str(f)
|
return str(f)
|
||||||
|
|
@ -282,9 +280,8 @@ class AcoustIDClient:
|
||||||
|
|
||||||
def test_api_key(self) -> Tuple[bool, str]:
|
def test_api_key(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Validate the API key with a direct AcoustID lookup call. An invalid key
|
Validate the API key by fingerprinting a real audio file and looking it up.
|
||||||
is reported as invalid (error code 4); any other error means the key was
|
Falls back to a direct API call if no audio files are available.
|
||||||
accepted.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
|
|
@ -295,12 +292,24 @@ class AcoustIDClient:
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Authoritative key check: a direct API lookup with a dummy
|
# Try to find a real audio file to fingerprint for an end-to-end test
|
||||||
# fingerprint. AcoustID validates the client key first, so an
|
test_file = self._find_test_audio_file()
|
||||||
# invalid key returns error code 4 regardless of the fingerprint.
|
|
||||||
# (The previous real-file path trusted "no exception = valid", but
|
if test_file and CHROMAPRINT_AVAILABLE:
|
||||||
# fingerprint_and_lookup swallows the invalid-key error and returns
|
logger.info(f"Testing API key with real audio file: {test_file}")
|
||||||
# None — so it reported broken keys as valid. #756-adjacent.)
|
try:
|
||||||
|
result = self.fingerprint_and_lookup(test_file)
|
||||||
|
# If we get here without exception, the API key is valid
|
||||||
|
# (invalid keys raise or return error before results)
|
||||||
|
return True, "AcoustID API key is valid"
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if 'invalid' in error_str and 'api' in error_str:
|
||||||
|
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
|
||||||
|
# Fingerprint/lookup failed for non-key reasons, fall through to direct test
|
||||||
|
logger.warning(f"Real file test failed ({e}), trying direct API call")
|
||||||
|
|
||||||
|
# Fallback: direct API call with minimal fingerprint
|
||||||
url = 'https://api.acoustid.org/v2/lookup'
|
url = 'https://api.acoustid.org/v2/lookup'
|
||||||
params = {
|
params = {
|
||||||
'client': self.api_key,
|
'client': self.api_key,
|
||||||
|
|
@ -315,13 +324,12 @@ class AcoustIDClient:
|
||||||
if data.get('status') == 'error':
|
if data.get('status') == 'error':
|
||||||
error = data.get('error', {})
|
error = data.get('error', {})
|
||||||
error_code = error.get('code', 0)
|
error_code = error.get('code', 0)
|
||||||
|
error_msg = error.get('message', 'Unknown error')
|
||||||
|
|
||||||
# Error code 4 is specifically "invalid API key"
|
# Error code 4 is specifically "invalid API key"
|
||||||
if error_code == 4:
|
if error_code == 4:
|
||||||
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
|
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
|
||||||
# Any other error (e.g. "invalid fingerprint") means the API key
|
return False, f"AcoustID API error: {error_msg}"
|
||||||
# was accepted — the dummy test fingerprint is just rejected as expected
|
|
||||||
return True, "AcoustID API key is valid"
|
|
||||||
|
|
||||||
# Status is 'ok' - key is valid
|
# Status is 'ok' - key is valid
|
||||||
return True, "AcoustID API key is valid"
|
return True, "AcoustID API key is valid"
|
||||||
|
|
@ -334,33 +342,33 @@ class AcoustIDClient:
|
||||||
logger.error(f"Error testing AcoustID API key: {e}")
|
logger.error(f"Error testing AcoustID API key: {e}")
|
||||||
return False, f"Error: {str(e)}"
|
return False, f"Error: {str(e)}"
|
||||||
|
|
||||||
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
|
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
|
"""
|
||||||
|
Generate fingerprint and look up recording in AcoustID.
|
||||||
|
|
||||||
Unlike fingerprint_and_lookup() (which collapses every outcome into
|
This is the main method - combines fingerprinting and lookup in one call.
|
||||||
dict-or-None), this distinguishes a genuine no-match from an actual
|
|
||||||
error — an invalid API key, rate limit, missing chromaprint, or a
|
|
||||||
fingerprint failure. That distinction is what lets the UI show "AcoustID
|
|
||||||
Error" (something is broken — fix it) instead of a benign-looking
|
|
||||||
"Skipped" that silently hides a dead key.
|
|
||||||
|
|
||||||
Returns dict with:
|
Args:
|
||||||
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
|
audio_file: Path to the audio file
|
||||||
| 'fingerprint_error' | 'unsupported' | 'unavailable'
|
|
||||||
| 'not_found'
|
Returns:
|
||||||
'recordings': list (meaningful only for 'ok')
|
Dict with:
|
||||||
'best_score': float
|
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
|
||||||
'recording_mbids': list
|
'best_score': float (highest score across all results)
|
||||||
'error': human-readable detail for any non-'ok' status
|
'recording_mbids': list of unique MBIDs (for backward compat)
|
||||||
'invalid_key': bool (True when the API specifically rejected the key)
|
Or None on error.
|
||||||
"""
|
"""
|
||||||
if not ACOUSTID_AVAILABLE:
|
if not ACOUSTID_AVAILABLE:
|
||||||
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
|
logger.debug("Cannot lookup: pyacoustid not available")
|
||||||
|
return None
|
||||||
|
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
|
logger.debug("Cannot lookup: no API key")
|
||||||
|
return None
|
||||||
|
|
||||||
if not os.path.isfile(audio_file):
|
if not os.path.isfile(audio_file):
|
||||||
logger.warning(f"Cannot lookup: file not found: {audio_file}")
|
logger.warning(f"Cannot lookup: file not found: {audio_file}")
|
||||||
return {'status': 'not_found', 'recordings': [], 'error': f'File not found: {audio_file}'}
|
return None
|
||||||
|
|
||||||
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
|
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
|
||||||
try:
|
try:
|
||||||
|
|
@ -370,8 +378,7 @@ class AcoustIDClient:
|
||||||
channels = getattr(mf.info, 'channels', 2)
|
channels = getattr(mf.info, 'channels', 2)
|
||||||
if channels and channels > 2:
|
if channels and channels > 2:
|
||||||
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
|
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
|
||||||
return {'status': 'unsupported', 'recordings': [],
|
return None
|
||||||
'error': f'{channels}-channel (surround) audio not supported by chromaprint'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
|
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
|
||||||
|
|
||||||
|
|
@ -381,12 +388,17 @@ class AcoustIDClient:
|
||||||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
|
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
|
||||||
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
|
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
|
||||||
|
|
||||||
|
# Use match() which handles fingerprinting + lookup + parsing
|
||||||
logger.debug("Running acoustid.match()...")
|
logger.debug("Running acoustid.match()...")
|
||||||
recordings = []
|
recordings = []
|
||||||
seen_mbids = set()
|
seen_mbids = set()
|
||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
|
|
||||||
for result in acoustid.match(self.api_key, audio_file, parse=True):
|
for result in acoustid.match(
|
||||||
|
self.api_key,
|
||||||
|
audio_file,
|
||||||
|
parse=True
|
||||||
|
):
|
||||||
# match() with parse=True returns (score, recording_id, title, artist)
|
# match() with parse=True returns (score, recording_id, title, artist)
|
||||||
if not isinstance(result, tuple) or len(result) < 2:
|
if not isinstance(result, tuple) or len(result) < 2:
|
||||||
logger.warning(f"Unexpected result format: {result}")
|
logger.warning(f"Unexpected result format: {result}")
|
||||||
|
|
@ -404,57 +416,45 @@ class AcoustIDClient:
|
||||||
|
|
||||||
if recording_id and recording_id not in seen_mbids:
|
if recording_id and recording_id not in seen_mbids:
|
||||||
seen_mbids.add(recording_id)
|
seen_mbids.add(recording_id)
|
||||||
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
|
recordings.append({
|
||||||
|
'mbid': recording_id,
|
||||||
|
'title': title,
|
||||||
|
'artist': artist,
|
||||||
|
'score': score,
|
||||||
|
})
|
||||||
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
||||||
|
|
||||||
if not recordings:
|
if not recordings:
|
||||||
logger.info(f"No AcoustID matches found for: {audio_file}")
|
logger.info(f"No AcoustID matches found for: {audio_file}")
|
||||||
return {'status': 'no_match', 'recordings': [], 'best_score': best_score,
|
return None
|
||||||
'recording_mbids': [], 'error': 'Track not found in AcoustID database'}
|
|
||||||
|
|
||||||
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
|
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
|
||||||
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
|
return {
|
||||||
'recording_mbids': list(seen_mbids)}
|
'recordings': recordings,
|
||||||
|
'best_score': best_score,
|
||||||
|
'recording_mbids': list(seen_mbids),
|
||||||
|
}
|
||||||
|
|
||||||
except acoustid.NoBackendError:
|
except acoustid.NoBackendError:
|
||||||
logger.error("Chromaprint library not found and fpcalc not available")
|
logger.error("Chromaprint library not found and fpcalc not available")
|
||||||
return {'status': 'no_backend', 'recordings': [],
|
return None
|
||||||
'error': 'Chromaprint/fpcalc not installed (install libchromaprint1)'}
|
|
||||||
except acoustid.FingerprintGenerationError as e:
|
except acoustid.FingerprintGenerationError as e:
|
||||||
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
|
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
|
||||||
return {'status': 'fingerprint_error', 'recordings': [], 'error': f'Could not fingerprint file: {e}'}
|
return None
|
||||||
except acoustid.WebServiceError as e:
|
except acoustid.WebServiceError as e:
|
||||||
|
# Log more details about the API error
|
||||||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
|
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
|
||||||
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
|
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
|
||||||
|
# Check for common errors
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# Old pyacoustid reports an invalid key as the bare "status: error"
|
if 'invalid' in error_str or 'unknown' in error_str:
|
||||||
# (it drops the detail), so treat that as an invalid-key signal too.
|
logger.error("API key appears to be invalid - check your AcoustID settings")
|
||||||
invalid = ('invalid' in error_str or 'unknown' in error_str or 'status: error' in error_str)
|
|
||||||
if invalid:
|
|
||||||
logger.error("AcoustID API key appears to be invalid — check your AcoustID settings")
|
|
||||||
elif 'rate' in error_str or 'limit' in error_str:
|
elif 'rate' in error_str or 'limit' in error_str:
|
||||||
logger.warning("Rate limited by AcoustID — will retry later")
|
logger.warning("Rate limited by AcoustID - will retry later")
|
||||||
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
|
return None
|
||||||
'error': f'AcoustID API error: {e}'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
|
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
|
||||||
return {'status': 'error', 'recordings': [], 'error': f'Unexpected error: {e}'}
|
return None
|
||||||
|
|
||||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Legacy dict-or-None lookup. Returns the recordings dict on a confirmed
|
|
||||||
match, else None. Kept for callers that only need "did we identify it"
|
|
||||||
(library scanner, auto-import). Callers that must report WHY a lookup
|
|
||||||
didn't match (verification badge, key test) should use
|
|
||||||
``lookup_with_status`` so an error isn't mistaken for a no-match.
|
|
||||||
"""
|
|
||||||
res = self.lookup_with_status(audio_file)
|
|
||||||
if res.get('status') == 'ok':
|
|
||||||
return {
|
|
||||||
'recordings': res['recordings'],
|
|
||||||
'best_score': res.get('best_score', 0.0),
|
|
||||||
'recording_mbids': res.get('recording_mbids', []),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def refresh_config(self):
|
def refresh_config(self):
|
||||||
"""Refresh cached config values (call after settings change)."""
|
"""Refresh cached config values (call after settings change)."""
|
||||||
|
|
|
||||||
|
|
@ -15,82 +15,96 @@ from typing import Optional, Dict, Any, Tuple, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from core.acoustid_client import AcoustIDClient
|
from core.acoustid_client import AcoustIDClient
|
||||||
from core.matching_engine import MusicMatchingEngine
|
|
||||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
|
||||||
from core.matching.script_compat import is_cross_script_mismatch
|
|
||||||
from core.musicbrainz_client import MusicBrainzClient
|
from core.musicbrainz_client import MusicBrainzClient
|
||||||
|
|
||||||
logger = get_logger("acoustid.verification")
|
logger = get_logger("acoustid_verification")
|
||||||
|
|
||||||
# Thresholds — single definition lives in the shared core; re-exported here so
|
# Thresholds
|
||||||
# existing importers keep working and the values can't drift between paths.
|
MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
|
||||||
from core.matching.audio_verification import ( # noqa: E402
|
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity needed to consider a match
|
||||||
MIN_ACOUSTID_SCORE, TITLE_MATCH_THRESHOLD, ARTIST_MATCH_THRESHOLD,
|
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity needed to consider a match
|
||||||
)
|
|
||||||
|
|
||||||
# Single matching-engine instance so version detection reuses the same patterns
|
|
||||||
# used by the pre-download Soulseek matcher (remix / live / acoustic /
|
|
||||||
# instrumental / etc). detect_version_type doesn't use self state, so one
|
|
||||||
# shared instance is fine.
|
|
||||||
_match_engine_for_version = MusicMatchingEngine()
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_title_version(title: str) -> str:
|
|
||||||
"""Return version label for a track title.
|
|
||||||
|
|
||||||
Returns ``'original'`` when no version marker is detected, otherwise one
|
|
||||||
of the labels produced by ``MusicMatchingEngine.detect_version_type``
|
|
||||||
(``'instrumental'``, ``'live'``, ``'acoustic'``, ``'remix'``, etc).
|
|
||||||
"""
|
|
||||||
if not title:
|
|
||||||
return 'original'
|
|
||||||
version_type, _ = _match_engine_for_version.detect_version_type(title)
|
|
||||||
return version_type
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationResult(Enum):
|
class VerificationResult(Enum):
|
||||||
"""Possible outcomes of audio verification."""
|
"""Possible outcomes of audio verification."""
|
||||||
PASS = "pass" # Title/artist match - file is correct
|
PASS = "pass" # Title/artist match - file is correct
|
||||||
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
|
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
|
||||||
SKIP = "skip" # Genuinely couldn't verify (no match in DB) - continue normally
|
SKIP = "skip" # Could not verify (error or unavailable) - continue normally
|
||||||
DISABLED = "disabled" # Verification not enabled
|
DISABLED = "disabled" # Verification not enabled
|
||||||
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
|
|
||||||
|
|
||||||
|
|
||||||
# normalize() + similarity() + the alias-aware comparison now live in the shared
|
def _normalize(text: str) -> str:
|
||||||
# decision core (core/matching/audio_verification.py) so import-time verification
|
"""Normalize a string for comparison: lowercase, strip parentheticals, punctuation."""
|
||||||
# and the library scan share ONE definition — the <>-strip fix, CJK handling and
|
if not text:
|
||||||
# thresholds can't drift apart again. Names kept (`_normalize` etc.) for existing
|
return ""
|
||||||
# importers/tests.
|
s = text.lower().strip()
|
||||||
from core.matching.audio_verification import ( # noqa: E402
|
# Remove ALL parenthetical suffixes — these are metadata annotations, not core title
|
||||||
normalize as _normalize,
|
# Covers: (Live), (Remastered), (Parody of ...), (from "..." Soundtrack), (feat. ...), etc.
|
||||||
similarity as _similarity,
|
s = re.sub(r'\s*\([^)]*\)', '', s)
|
||||||
_alias_aware_artist_sim,
|
# Remove ALL square bracket suffixes: [Live], [Remastered], [Deluxe], etc.
|
||||||
_find_best_title_artist_match as _core_find_best_title_artist_match,
|
s = re.sub(r'\s*\[[^\]]*\]', '', s)
|
||||||
evaluate as _core_evaluate,
|
# Remove trailing featuring info not in parentheses: "feat. ...", "ft. ...", "featuring ..."
|
||||||
Decision as _CoreDecision,
|
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
|
||||||
)
|
# Remove dash-separated version tags: "- Vocal", "- Instrumental", "- Acoustic", etc.
|
||||||
|
s = re.sub(r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$', '', s, flags=re.IGNORECASE)
|
||||||
|
# Remove soundtrack/source subtitles: ' - From "..." Soundtrack', ' - from the film ...'
|
||||||
|
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
|
||||||
|
# Remove non-alphanumeric except spaces
|
||||||
|
s = re.sub(r'[^\w\s]', '', s)
|
||||||
|
# Collapse whitespace
|
||||||
|
s = re.sub(r'\s+', ' ', s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
|
def _similarity(a: str, b: str) -> float:
|
||||||
expected_artist_aliases=None):
|
"""Calculate similarity between two strings (0.0-1.0) after normalization."""
|
||||||
"""Back-compat wrapper around the shared core matcher (keeps the
|
na = _normalize(a)
|
||||||
``expected_artist_aliases`` kwarg name for existing callers/tests)."""
|
nb = _normalize(b)
|
||||||
return _core_find_best_title_artist_match(
|
if not na or not nb:
|
||||||
recordings, expected_title, expected_artist, expected_artist_aliases,
|
return 0.0
|
||||||
)
|
if na == nb:
|
||||||
|
return 1.0
|
||||||
|
return SequenceMatcher(None, na, nb).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_best_title_artist_match(
|
||||||
|
recordings: List[Dict[str, Any]],
|
||||||
|
expected_title: str,
|
||||||
|
expected_artist: str,
|
||||||
|
) -> Tuple[Optional[Dict], float, float]:
|
||||||
|
"""
|
||||||
|
Find the AcoustID recording that best matches expected title/artist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(best_recording, title_similarity, artist_similarity)
|
||||||
|
"""
|
||||||
|
best_rec = None
|
||||||
|
best_title_sim = 0.0
|
||||||
|
best_artist_sim = 0.0
|
||||||
|
best_combined = 0.0
|
||||||
|
|
||||||
|
for rec in recordings:
|
||||||
|
title = rec.get('title') or ''
|
||||||
|
artist = rec.get('artist') or ''
|
||||||
|
|
||||||
|
title_sim = _similarity(expected_title, title)
|
||||||
|
artist_sim = _similarity(expected_artist, artist)
|
||||||
|
# Weight title higher since that's the primary identifier
|
||||||
|
combined = (title_sim * 0.6) + (artist_sim * 0.4)
|
||||||
|
|
||||||
|
if combined > best_combined:
|
||||||
|
best_combined = combined
|
||||||
|
best_rec = rec
|
||||||
|
best_title_sim = title_sim
|
||||||
|
best_artist_sim = artist_sim
|
||||||
|
|
||||||
|
return best_rec, best_title_sim, best_artist_sim
|
||||||
|
|
||||||
|
|
||||||
# Shared MusicBrainz client for enrichment lookups
|
# Shared MusicBrainz client for enrichment lookups
|
||||||
_mb_client = None
|
_mb_client = None
|
||||||
_mb_client_lock = threading.Lock()
|
_mb_client_lock = threading.Lock()
|
||||||
|
|
||||||
# Shared MusicBrainzService for alias lookups (issue #442). Service
|
|
||||||
# layer wraps the raw client + adds caching + DB access — all of which
|
|
||||||
# the alias resolution chain (library DB → cache → live MB) needs.
|
|
||||||
_mb_service = None
|
|
||||||
_mb_service_lock = threading.Lock()
|
|
||||||
|
|
||||||
MAX_MB_ENRICHMENT_LOOKUPS = 3
|
MAX_MB_ENRICHMENT_LOOKUPS = 3
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,42 +118,6 @@ def _get_mb_client() -> MusicBrainzClient:
|
||||||
return _mb_client
|
return _mb_client
|
||||||
|
|
||||||
|
|
||||||
def _get_mb_service():
|
|
||||||
"""Get or create a shared MusicBrainzService instance.
|
|
||||||
|
|
||||||
Used by the alias-resolution chain in `verify_audio_file`. Lazy
|
|
||||||
init so importing this module doesn't trigger a DB connection on
|
|
||||||
paths that never run AcoustID verification (test runs, dry runs).
|
|
||||||
"""
|
|
||||||
global _mb_service
|
|
||||||
if _mb_service is None:
|
|
||||||
with _mb_service_lock:
|
|
||||||
if _mb_service is None:
|
|
||||||
from core.musicbrainz_service import MusicBrainzService
|
|
||||||
from database.music_database import get_database
|
|
||||||
_mb_service = MusicBrainzService(get_database())
|
|
||||||
return _mb_service
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_expected_artist_aliases(expected_artist_name: str) -> List[str]:
|
|
||||||
"""Look up alternate-spelling aliases for the expected artist.
|
|
||||||
|
|
||||||
Issue #442 — bridges cross-script artist comparisons (Japanese
|
|
||||||
kanji ↔ romanized, Cyrillic ↔ Latin, etc.) without forcing the
|
|
||||||
verifier to know about the resolution chain. Best-effort: any
|
|
||||||
failure (no MB service, network down, no library DB) returns
|
|
||||||
empty list so verification falls back to the prior direct
|
|
||||||
similarity check.
|
|
||||||
"""
|
|
||||||
if not expected_artist_name:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return _get_mb_service().lookup_artist_aliases(expected_artist_name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("alias lookup failed for %r: %s", expected_artist_name, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich_recordings_from_musicbrainz(
|
def _enrich_recordings_from_musicbrainz(
|
||||||
recordings: List[Dict[str, Any]],
|
recordings: List[Dict[str, Any]],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -265,33 +243,18 @@ class AcoustIDVerification:
|
||||||
logger.debug(f"AcoustID verification skipped: {reason}")
|
logger.debug(f"AcoustID verification skipped: {reason}")
|
||||||
return VerificationResult.SKIP, reason
|
return VerificationResult.SKIP, reason
|
||||||
|
|
||||||
# Step 2: Fingerprint and lookup in AcoustID (structured so an
|
# Step 2: Fingerprint and lookup in AcoustID
|
||||||
# actual error — invalid key / rate limit / no chromaprint — is
|
|
||||||
# reported distinctly from a genuine no-match, instead of both
|
|
||||||
# silently surfacing as "Skipped").
|
|
||||||
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
|
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
|
||||||
lookup = self.acoustid_client.lookup_with_status(audio_file_path) or {}
|
acoustid_result = self.acoustid_client.fingerprint_and_lookup(audio_file_path)
|
||||||
status = lookup.get('status')
|
|
||||||
# Infer status by content when absent (a caller/stub that returned
|
|
||||||
# just recordings): recordings => matched, none => no match.
|
|
||||||
if status is None:
|
|
||||||
status = 'ok' if lookup.get('recordings') else 'no_match'
|
|
||||||
|
|
||||||
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
|
if not acoustid_result:
|
||||||
# Something is broken (not the track's fault) — never quarantine
|
return VerificationResult.SKIP, "Track not found in AcoustID database"
|
||||||
# on this; surface it so the user can fix it.
|
|
||||||
return VerificationResult.ERROR, lookup.get('error', 'AcoustID lookup failed')
|
|
||||||
|
|
||||||
if status != 'ok':
|
|
||||||
# no_match / unsupported / not_found — genuinely could not verify.
|
|
||||||
return VerificationResult.SKIP, lookup.get('error', 'No match in AcoustID database')
|
|
||||||
|
|
||||||
acoustid_result = lookup
|
|
||||||
recordings = acoustid_result.get('recordings', [])
|
recordings = acoustid_result.get('recordings', [])
|
||||||
best_score = acoustid_result.get('best_score', 0)
|
best_score = acoustid_result.get('best_score', 0)
|
||||||
|
|
||||||
if not recordings:
|
if not recordings:
|
||||||
return VerificationResult.SKIP, "No match in AcoustID database"
|
return VerificationResult.SKIP, "AcoustID returned no recordings"
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"AcoustID returned {len(recordings)} recording(s) "
|
f"AcoustID returned {len(recordings)} recording(s) "
|
||||||
|
|
@ -307,55 +270,93 @@ class AcoustIDVerification:
|
||||||
# Enrich recordings that are missing title/artist via MusicBrainz lookup
|
# Enrich recordings that are missing title/artist via MusicBrainz lookup
|
||||||
recordings = _enrich_recordings_from_musicbrainz(recordings)
|
recordings = _enrich_recordings_from_musicbrainz(recordings)
|
||||||
|
|
||||||
# Issue #442 — alias resolution is LAZY. We pass a memoising
|
# Step 4: Find best title/artist match among AcoustID results
|
||||||
# thunk to the artist-comparison sites; it only fires the
|
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
|
||||||
# multi-tier lookup (library DB → cache → live MB) when
|
recordings, expected_track_name, expected_artist_name
|
||||||
# direct artist similarity falls below threshold. Verifications
|
|
||||||
# where the direct match already passes (the common case for
|
|
||||||
# same-script artist names) never trigger any lookup work,
|
|
||||||
# so the fix doesn't add a per-verification DB query for the
|
|
||||||
# happy path. When the thunk DOES fire, the result is cached
|
|
||||||
# in the closure so the 3 comparison sites within one
|
|
||||||
# verification share a single resolution pass.
|
|
||||||
_alias_cache: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
def _aliases_provider() -> List[str]:
|
|
||||||
if 'value' not in _alias_cache:
|
|
||||||
resolved = _resolve_expected_artist_aliases(expected_artist_name)
|
|
||||||
_alias_cache['value'] = resolved
|
|
||||||
if resolved:
|
|
||||||
logger.debug(
|
|
||||||
"Resolved %d aliases for expected artist '%s'",
|
|
||||||
len(resolved), expected_artist_name,
|
|
||||||
)
|
|
||||||
return _alias_cache['value']
|
|
||||||
|
|
||||||
# Steps 4-5: delegate the PASS/SKIP/FAIL decision to the shared core
|
|
||||||
# (core/matching/audio_verification.evaluate) so import verification
|
|
||||||
# and the library scan apply identical logic.
|
|
||||||
outcome = _core_evaluate(
|
|
||||||
expected_track_name, expected_artist_name, recordings,
|
|
||||||
fingerprint_score=best_score,
|
|
||||||
aliases_provider=_aliases_provider,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not best_rec:
|
||||||
|
return VerificationResult.SKIP, "No recordings with title/artist info"
|
||||||
|
|
||||||
|
matched_title = best_rec.get('title', '?')
|
||||||
|
matched_artist = best_rec.get('artist', '?')
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Best match: '%s' by '%s' (title_sim=%.2f, artist_sim=%.2f) -> %s",
|
f"Best match: '{matched_title}' by '{matched_artist}' "
|
||||||
outcome.matched_title, outcome.matched_artist,
|
f"(title_sim={title_sim:.2f}, artist_sim={artist_sim:.2f})"
|
||||||
outcome.title_sim, outcome.artist_sim, outcome.decision.value,
|
|
||||||
)
|
)
|
||||||
_decision_map = {
|
|
||||||
_CoreDecision.PASS: VerificationResult.PASS,
|
# Step 5: Decide pass/fail based on similarity
|
||||||
_CoreDecision.SKIP: VerificationResult.SKIP,
|
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
|
||||||
_CoreDecision.FAIL: VerificationResult.FAIL,
|
msg = (
|
||||||
}
|
f"Audio verified: '{matched_title}' by '{matched_artist}' "
|
||||||
result = _decision_map[outcome.decision]
|
f"matches expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||||
if result == VerificationResult.PASS:
|
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
|
||||||
logger.info("AcoustID verification PASSED - %s", outcome.reason)
|
)
|
||||||
elif result == VerificationResult.FAIL:
|
logger.info(f"AcoustID verification PASSED - {msg}")
|
||||||
logger.warning("AcoustID verification FAILED - %s", outcome.reason)
|
return VerificationResult.PASS, msg
|
||||||
else:
|
|
||||||
logger.info("AcoustID verification SKIPPED - %s", outcome.reason)
|
# Title matches but artist doesn't — could be a cover or collab, skip
|
||||||
return result, outcome.reason
|
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
|
||||||
|
# Check if the expected artist appears anywhere in the AcoustID results
|
||||||
|
for rec in recordings:
|
||||||
|
if _similarity(expected_artist_name, rec.get('artist', '')) >= ARTIST_MATCH_THRESHOLD:
|
||||||
|
msg = (
|
||||||
|
f"Audio verified: found '{expected_track_name}' by '{expected_artist_name}' "
|
||||||
|
f"in AcoustID results"
|
||||||
|
)
|
||||||
|
logger.info(f"AcoustID verification PASSED (secondary match) - {msg}")
|
||||||
|
return VerificationResult.PASS, msg
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"Title matches but artist unclear: "
|
||||||
|
f"AcoustID='{matched_title}' by '{matched_artist}', "
|
||||||
|
f"expected '{expected_track_name}' by '{expected_artist_name}'"
|
||||||
|
)
|
||||||
|
logger.info(f"AcoustID verification SKIPPED - {msg}")
|
||||||
|
return VerificationResult.SKIP, msg
|
||||||
|
|
||||||
|
# Title doesn't match — check ALL recordings for any title/artist match
|
||||||
|
# (the best combined match might not be the right one if there are many results)
|
||||||
|
for rec in recordings:
|
||||||
|
t = rec.get('title') or ''
|
||||||
|
a = rec.get('artist') or ''
|
||||||
|
if (_similarity(expected_track_name, t) >= TITLE_MATCH_THRESHOLD and
|
||||||
|
_similarity(expected_artist_name, a) >= ARTIST_MATCH_THRESHOLD):
|
||||||
|
msg = (
|
||||||
|
f"Audio verified: found '{t}' by '{a}' in AcoustID results "
|
||||||
|
f"matching expected '{expected_track_name}' by '{expected_artist_name}'"
|
||||||
|
)
|
||||||
|
logger.info(f"AcoustID verification PASSED (scan match) - {msg}")
|
||||||
|
return VerificationResult.PASS, msg
|
||||||
|
|
||||||
|
# No match found — but if fingerprint score is very high (≥0.95),
|
||||||
|
# the audio IS correct and the title mismatch is likely a language
|
||||||
|
# difference (e.g. Japanese kanji vs English title for the same song).
|
||||||
|
# Skip rather than quarantine a correct file.
|
||||||
|
if best_score >= 0.95:
|
||||||
|
top = recordings[0]
|
||||||
|
msg = (
|
||||||
|
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
|
||||||
|
f"AcoustID='{top.get('title', '?')}' by '{top.get('artist', '?')}', "
|
||||||
|
f"expected '{expected_track_name}' by '{expected_artist_name}' — "
|
||||||
|
f"likely same song in different language/script"
|
||||||
|
)
|
||||||
|
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
|
||||||
|
return VerificationResult.SKIP, msg
|
||||||
|
|
||||||
|
# Low fingerprint score + no metadata match — file is likely wrong
|
||||||
|
top = recordings[0]
|
||||||
|
top_title = top.get('title', '?')
|
||||||
|
top_artist = top.get('artist', '?')
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"Audio mismatch: file identified as '{top_title}' by '{top_artist}', "
|
||||||
|
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||||
|
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
|
||||||
|
)
|
||||||
|
logger.warning(f"AcoustID verification FAILED - {msg}")
|
||||||
|
return VerificationResult.FAIL, msg
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Any unexpected error -> SKIP (fail open)
|
# Any unexpected error -> SKIP (fail open)
|
||||||
|
|
|
||||||
|
|
@ -57,86 +57,6 @@ _ID3_TXXX_MAP = {
|
||||||
# MP4 freeform keys
|
# MP4 freeform keys
|
||||||
_MP4_KEY_PREFIX = '----:com.apple.iTunes:'
|
_MP4_KEY_PREFIX = '----:com.apple.iTunes:'
|
||||||
|
|
||||||
# ── Picard-style release preference scoring ──
|
|
||||||
# Preferred countries (higher = better). US/GB/XW(worldwide) are most common
|
|
||||||
# for English-language music. XE = Europe-wide.
|
|
||||||
_COUNTRY_SCORES = {
|
|
||||||
'US': 10, 'XW': 10, 'GB': 8, 'XE': 7, 'CA': 6, 'AU': 5, 'DE': 4,
|
|
||||||
'FR': 4, 'JP': 3, 'NL': 3, 'SE': 3, 'IT': 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Preferred formats (higher = better). Digital/CD are the standard;
|
|
||||||
# vinyl and cassette are niche reissues that often differ from the
|
|
||||||
# canonical tracklist.
|
|
||||||
_FORMAT_SCORES = {
|
|
||||||
'Digital Media': 10, 'CD': 9, 'Enhanced CD': 8,
|
|
||||||
'SACD': 7, 'Hybrid SACD': 7, 'Blu-spec CD': 7,
|
|
||||||
'Vinyl': 3, '12" Vinyl': 3, '7" Vinyl': 2,
|
|
||||||
'Cassette': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Release status preference
|
|
||||||
_STATUS_SCORES = {
|
|
||||||
'Official': 10, 'Promotion': 5, 'Bootleg': 1, 'Pseudo-Release': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _score_release(release: dict, expected_track_count: int) -> float:
|
|
||||||
"""Score a MusicBrainz release for preference ranking.
|
|
||||||
|
|
||||||
Higher score = better candidate. Factors:
|
|
||||||
- Track count match (most important — wrong count is wrong release)
|
|
||||||
- Release status (Official > Promo > Bootleg)
|
|
||||||
- Country preference (US/worldwide > regional)
|
|
||||||
- Format preference (Digital/CD > Vinyl > Cassette)
|
|
||||||
- Has barcode (sign of a real commercial release)
|
|
||||||
- Penalize releases with no media info (incomplete data)
|
|
||||||
"""
|
|
||||||
score = 0.0
|
|
||||||
|
|
||||||
# Track count match (0-40 points, biggest factor)
|
|
||||||
media = release.get('media', [])
|
|
||||||
mb_track_count = sum(len(m.get('tracks') or m.get('track-list', []))
|
|
||||||
for m in media)
|
|
||||||
track_diff = abs(mb_track_count - expected_track_count)
|
|
||||||
if track_diff == 0:
|
|
||||||
score += 40
|
|
||||||
elif track_diff <= 1:
|
|
||||||
score += 30
|
|
||||||
elif track_diff <= 2:
|
|
||||||
score += 20
|
|
||||||
elif track_diff <= 5:
|
|
||||||
score += 10
|
|
||||||
# else: 0 points
|
|
||||||
|
|
||||||
# Status (0-10 points)
|
|
||||||
status = release.get('status', '')
|
|
||||||
score += _STATUS_SCORES.get(status, 2)
|
|
||||||
|
|
||||||
# Country (0-10 points)
|
|
||||||
country = release.get('country', '')
|
|
||||||
score += _COUNTRY_SCORES.get(country, 1)
|
|
||||||
|
|
||||||
# Format from first medium (0-10 points)
|
|
||||||
if media:
|
|
||||||
fmt = media[0].get('format', '')
|
|
||||||
score += _FORMAT_SCORES.get(fmt, 4)
|
|
||||||
else:
|
|
||||||
score -= 5 # No media info = suspect
|
|
||||||
|
|
||||||
# Barcode (0-3 points) — real commercial releases have barcodes
|
|
||||||
if release.get('barcode'):
|
|
||||||
score += 3
|
|
||||||
|
|
||||||
# Date completeness (0-2 points) — prefer releases with full dates
|
|
||||||
date = release.get('date', '')
|
|
||||||
if len(date) >= 10:
|
|
||||||
score += 2 # Full YYYY-MM-DD
|
|
||||||
elif len(date) >= 4:
|
|
||||||
score += 1 # Year only
|
|
||||||
|
|
||||||
return score
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_title(s):
|
def _normalize_title(s):
|
||||||
"""Normalize a title for comparison."""
|
"""Normalize a title for comparison."""
|
||||||
|
|
@ -151,91 +71,106 @@ def _normalize_title(s):
|
||||||
|
|
||||||
def _find_best_release(album_name, artist_name, track_count, mb_service):
|
def _find_best_release(album_name, artist_name, track_count, mb_service):
|
||||||
"""Search MusicBrainz for the best release matching this album.
|
"""Search MusicBrainz for the best release matching this album.
|
||||||
|
Prefers releases where track count matches the download."""
|
||||||
Uses Picard-style preference scoring: track count match, release status,
|
|
||||||
country (US/worldwide preferred), format (Digital/CD preferred), barcode
|
|
||||||
presence, and date completeness. Deterministic — same inputs always
|
|
||||||
produce the same release.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
import re
|
# First try our existing match_release (uses version qualifier scoring)
|
||||||
|
match = mb_service.match_release(album_name, artist_name)
|
||||||
|
if not match or not match.get('mbid'):
|
||||||
|
# Try stripping edition qualifiers — Spotify uses "Album (Super Deluxe)"
|
||||||
|
# but MusicBrainz just calls it "Album"
|
||||||
|
import re
|
||||||
|
stripped = re.sub(
|
||||||
|
r'\s*[\(\[]'
|
||||||
|
r'[^)\]]*'
|
||||||
|
r'(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||||
|
r'limited|bonus|platinum|gold|super\s*deluxe|standard|edition)'
|
||||||
|
r'[^)\]]*'
|
||||||
|
r'[\)\]]',
|
||||||
|
'', album_name, flags=re.IGNORECASE
|
||||||
|
).strip()
|
||||||
|
# Also strip trailing bare editions
|
||||||
|
stripped = re.sub(
|
||||||
|
r'\s+(?:-\s+)?(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||||
|
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
||||||
|
r'(?:\s+(?:edition|version))?\s*$',
|
||||||
|
'', stripped, flags=re.IGNORECASE
|
||||||
|
).strip()
|
||||||
|
|
||||||
# Build search name variants
|
if stripped and stripped.lower() != album_name.lower():
|
||||||
search_names = [album_name]
|
logger.info(f"Retrying MB search with stripped name: '{stripped}' (was '{album_name}')")
|
||||||
stripped = re.sub(
|
match = mb_service.match_release(stripped, artist_name)
|
||||||
r'\s*[\(\[]'
|
|
||||||
r'[^)\]]*'
|
|
||||||
r'(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
|
||||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard|edition)'
|
|
||||||
r'[^)\]]*'
|
|
||||||
r'[\)\]]',
|
|
||||||
'', album_name, flags=re.IGNORECASE
|
|
||||||
).strip()
|
|
||||||
stripped = re.sub(
|
|
||||||
r'\s+(?:-\s+)?(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
|
||||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
|
||||||
r'(?:\s+(?:edition|version))?\s*$',
|
|
||||||
'', stripped, flags=re.IGNORECASE
|
|
||||||
).strip()
|
|
||||||
if stripped and stripped.lower() != album_name.lower():
|
|
||||||
search_names.append(stripped)
|
|
||||||
|
|
||||||
# Collect candidate release MBIDs from all search variants
|
if not match or not match.get('mbid'):
|
||||||
candidate_mbids = []
|
# Final fallback: direct API search with stripped name
|
||||||
for name in search_names:
|
search_name = stripped or album_name
|
||||||
# Try cached match first
|
logger.info(f"No cached MB release — trying direct search for '{search_name}'")
|
||||||
match = mb_service.match_release(name, artist_name)
|
search_results = mb_service.mb_client.search_release(search_name, artist_name, limit=5)
|
||||||
if match and match.get('mbid'):
|
if not search_results:
|
||||||
candidate_mbids.append(match['mbid'])
|
logger.info(f"No MB release found for '{album_name}' by '{artist_name}'")
|
||||||
|
return None
|
||||||
|
mbid = search_results[0].get('id', '')
|
||||||
|
if not mbid:
|
||||||
|
return None
|
||||||
|
logger.info(f"Direct search found: {search_results[0].get('title', '')} ({mbid[:8]}...)")
|
||||||
|
else:
|
||||||
|
mbid = match['mbid']
|
||||||
|
else:
|
||||||
|
mbid = match['mbid']
|
||||||
|
|
||||||
# Also try direct search for more candidates
|
# Fetch full release with tracklist
|
||||||
try:
|
release = mb_service.mb_client.get_release(
|
||||||
search_results = mb_service.mb_client.search_release(name, artist_name, limit=5)
|
mbid, includes=['recordings', 'release-groups', 'labels', 'media', 'artist-credits']
|
||||||
for sr in (search_results or []):
|
)
|
||||||
sr_id = sr.get('id', '')
|
if not release:
|
||||||
if sr_id and sr_id not in candidate_mbids:
|
|
||||||
candidate_mbids.append(sr_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("search_release fallback failed: %s", e)
|
|
||||||
|
|
||||||
if not candidate_mbids:
|
|
||||||
logger.info(f"No MB release found for '{album_name}' by '{artist_name}'")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Fetch full release data for each candidate and score them
|
# Check track count match
|
||||||
best_release = None
|
mb_track_count = sum(len(m.get('tracks') or m.get('track-list', [])) for m in release.get('media', []))
|
||||||
best_score = -1
|
if abs(mb_track_count - track_count) <= 2:
|
||||||
|
logger.info(f"Accepted release '{release.get('title')}' ({mbid[:8]}...) — "
|
||||||
|
f"{mb_track_count} tracks (downloaded {track_count})")
|
||||||
|
return release
|
||||||
|
|
||||||
for mbid in candidate_mbids[:8]: # Cap at 8 to limit API calls
|
# Track count mismatch — try searching for a better release
|
||||||
|
# Use stripped name for search (MB often doesn't include edition suffixes)
|
||||||
|
import re
|
||||||
|
_search_name = re.sub(
|
||||||
|
r'\s*[\(\[][^)\]]*(?:deluxe|expanded|remaster|anniversary|special|collector|'
|
||||||
|
r'limited|bonus|platinum|gold|super\s*deluxe|standard|edition)[^)\]]*[\)\]]',
|
||||||
|
'', album_name, flags=re.IGNORECASE
|
||||||
|
).strip() or album_name
|
||||||
|
logger.info(f"Release '{release.get('title')}' has {mb_track_count} tracks but we have {track_count} — "
|
||||||
|
f"searching for better match with '{_search_name}'")
|
||||||
|
search_results = mb_service.mb_client.search_release(_search_name, artist_name, limit=5)
|
||||||
|
if not search_results:
|
||||||
|
# Fall back to the first match even if count doesn't match perfectly
|
||||||
|
return release
|
||||||
|
|
||||||
|
best_release = release
|
||||||
|
best_diff = abs(mb_track_count - track_count)
|
||||||
|
|
||||||
|
for sr in search_results:
|
||||||
|
sr_mbid = sr.get('id', '')
|
||||||
|
if not sr_mbid or sr_mbid == mbid:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
release = mb_service.mb_client.get_release(
|
candidate = mb_service.mb_client.get_release(
|
||||||
mbid, includes=['recordings', 'release-groups', 'labels',
|
sr_mbid, includes=['recordings', 'release-groups', 'labels', 'media', 'artist-credits']
|
||||||
'media', 'artist-credits']
|
|
||||||
)
|
)
|
||||||
if not release:
|
if not candidate:
|
||||||
continue
|
continue
|
||||||
|
cand_count = sum(len(m.get('tracks') or m.get('track-list', [])) for m in candidate.get('media', []))
|
||||||
score = _score_release(release, track_count)
|
cand_diff = abs(cand_count - track_count)
|
||||||
|
if cand_diff < best_diff:
|
||||||
if score > best_score:
|
best_diff = cand_diff
|
||||||
best_score = score
|
best_release = candidate
|
||||||
best_release = release
|
if cand_diff == 0:
|
||||||
|
break # Perfect match
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if best_release:
|
logger.info(f"Best release: '{best_release.get('title')}' ({best_release.get('id', '')[:8]}...) — "
|
||||||
mb_count = sum(len(m.get('tracks') or m.get('track-list', []))
|
f"track count diff: {best_diff}")
|
||||||
for m in best_release.get('media', []))
|
|
||||||
logger.info(
|
|
||||||
f"Selected release '{best_release.get('title')}' "
|
|
||||||
f"({best_release.get('id', '')[:8]}...) — "
|
|
||||||
f"score={best_score:.0f}, tracks={mb_count}, "
|
|
||||||
f"country={best_release.get('country', '?')}, "
|
|
||||||
f"format={best_release.get('media', [{}])[0].get('format', '?')}, "
|
|
||||||
f"status={best_release.get('status', '?')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return best_release
|
return best_release
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,796 +0,0 @@
|
||||||
"""Amazon Music metadata client backed by a T2Tunes proxy instance.
|
|
||||||
|
|
||||||
T2Tunes exposes the Amazon Music catalog (search, album metadata, stream info)
|
|
||||||
through a simple REST API. This module wraps those endpoints and normalises the
|
|
||||||
responses into the same Track / Artist / Album dataclass shape used by
|
|
||||||
DeezerClient and iTunesClient.
|
|
||||||
|
|
||||||
Endpoints used:
|
|
||||||
GET /api/status
|
|
||||||
GET /api/amazon-music/search
|
|
||||||
GET /api/amazon-music/metadata
|
|
||||||
GET /api/amazon-music/media-from-asin
|
|
||||||
|
|
||||||
Config keys (all optional — fall back to public defaults):
|
|
||||||
amazon.base_url T2Tunes instance URL (default: https://t2tunes.site)
|
|
||||||
amazon.country ISO-3166 country code (default: US)
|
|
||||||
amazon.preferred_codec Preferred audio codec (default: flac)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, Iterator, List, Optional
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from config.settings import config_manager
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("amazon_client")
|
|
||||||
|
|
||||||
# Strips featuring credits like "Artist feat. X", "Artist ft. Y" so artist
|
|
||||||
# deduplication works on the primary artist name only.
|
|
||||||
_FEAT_RE = re.compile(r'\s+(?:feat(?:uring)?\.?|ft\.?)\s+.*', re.IGNORECASE)
|
|
||||||
|
|
||||||
# Strips the Explicit marker — explicit is treated as the default version.
|
|
||||||
# Clean/Edited/Censored stay in the name so users can distinguish them.
|
|
||||||
_EDITION_RE = re.compile(r'\s*[\[\(]explicit[\]\)]', re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _primary_artist(name: str) -> str:
|
|
||||||
return _FEAT_RE.sub('', name).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_edition(name: str) -> str:
|
|
||||||
return _EDITION_RE.sub('', name).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _unslugify(name: str) -> str:
|
|
||||||
"""Convert a slug-form artist ID (e.g. 'kendrick_lamar') to a search name."""
|
|
||||||
return name.replace('_', ' ')
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://t2tunes.site"
|
|
||||||
DEFAULT_COUNTRY = "US"
|
|
||||||
DEFAULT_CODEC = "flac"
|
|
||||||
MIN_API_INTERVAL = 0.5 # seconds — T2Tunes has no published rate limit
|
|
||||||
|
|
||||||
_last_api_call: float = 0.0
|
|
||||||
_api_call_lock = threading.Lock()
|
|
||||||
|
|
||||||
_META_CACHE_TTL = 300 # seconds
|
|
||||||
_meta_cache: Dict[str, tuple] = {} # asin -> (fetched_at, meta_dict)
|
|
||||||
_meta_cache_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonClientError(RuntimeError):
|
|
||||||
"""Raised on unrecoverable T2Tunes API errors.
|
|
||||||
|
|
||||||
Carries the HTTP ``status_code`` when the failure was an HTTP error, so
|
|
||||||
callers (the worker's outage detection) can tell a source outage (5xx) from
|
|
||||||
a per-item miss without parsing the message.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, status_code=None):
|
|
||||||
super().__init__(*args)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dataclasses — field layout matches DeezerClient / iTunesClient exactly
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Track:
|
|
||||||
id: str # Amazon track ASIN
|
|
||||||
name: str
|
|
||||||
artists: List[str]
|
|
||||||
album: str
|
|
||||||
duration_ms: int
|
|
||||||
popularity: int
|
|
||||||
preview_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
release_date: Optional[str] = None
|
|
||||||
track_number: Optional[int] = None
|
|
||||||
disc_number: Optional[int] = None
|
|
||||||
album_type: Optional[str] = None
|
|
||||||
total_tracks: Optional[int] = None
|
|
||||||
isrc: Optional[str] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_search_hit(cls, doc: Dict[str, Any]) -> "Track":
|
|
||||||
return cls(
|
|
||||||
id=str(doc.get("asin") or ""),
|
|
||||||
name=str(doc.get("title") or ""),
|
|
||||||
artists=[str(doc.get("artistName") or "Unknown Artist")],
|
|
||||||
album=str(doc.get("albumName") or ""),
|
|
||||||
duration_ms=int(doc.get("duration") or 0) * 1000,
|
|
||||||
popularity=0,
|
|
||||||
isrc=str(doc.get("isrc") or "") or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_stream_info(
|
|
||||||
cls,
|
|
||||||
stream: "T2TunesStreamInfo",
|
|
||||||
album_meta: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> "Track":
|
|
||||||
album = album_meta or {}
|
|
||||||
return cls(
|
|
||||||
id=stream.asin,
|
|
||||||
name=stream.title,
|
|
||||||
artists=[stream.artist] if stream.artist else ["Unknown Artist"],
|
|
||||||
album=stream.album,
|
|
||||||
duration_ms=0,
|
|
||||||
popularity=0,
|
|
||||||
image_url=album.get("image"),
|
|
||||||
release_date=album.get("release_date"),
|
|
||||||
total_tracks=album.get("trackCount"),
|
|
||||||
isrc=stream.isrc or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Artist:
|
|
||||||
id: str # Slugified artist name — T2Tunes exposes no artist IDs
|
|
||||||
name: str
|
|
||||||
popularity: int
|
|
||||||
genres: List[str]
|
|
||||||
followers: int
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_name(cls, name: str) -> "Artist":
|
|
||||||
slug = name.lower().replace(" ", "_")
|
|
||||||
return cls(id=slug, name=name, popularity=0, genres=[], followers=0)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Album:
|
|
||||||
id: str # Amazon album ASIN
|
|
||||||
name: str
|
|
||||||
artists: List[str]
|
|
||||||
release_date: str
|
|
||||||
total_tracks: int
|
|
||||||
album_type: str
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
explicit: Optional[bool] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_search_hit(cls, doc: Dict[str, Any]) -> "Album":
|
|
||||||
return cls(
|
|
||||||
id=str(doc.get("albumAsin") or doc.get("asin") or ""),
|
|
||||||
name=str(doc.get("albumName") or doc.get("title") or ""),
|
|
||||||
artists=[str(doc.get("artistName") or "Unknown Artist")],
|
|
||||||
release_date="",
|
|
||||||
total_tracks=0,
|
|
||||||
album_type="album",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_metadata(cls, album_meta: Dict[str, Any], asin: str = "") -> "Album":
|
|
||||||
return cls(
|
|
||||||
id=str(album_meta.get("asin") or asin or ""),
|
|
||||||
name=str(album_meta.get("title") or ""),
|
|
||||||
artists=[str(album_meta.get("artistName") or "Unknown Artist")],
|
|
||||||
release_date=str(album_meta.get("release_date") or album_meta.get("releaseDate") or ""),
|
|
||||||
total_tracks=int(album_meta.get("trackCount") or 0),
|
|
||||||
album_type="album",
|
|
||||||
image_url=album_meta.get("image"),
|
|
||||||
explicit=album_meta.get("explicit"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal dataclasses for raw T2Tunes response parsing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class T2TunesSearchItem:
|
|
||||||
asin: str
|
|
||||||
title: str
|
|
||||||
artist_name: str
|
|
||||||
item_type: str
|
|
||||||
album_name: str = ""
|
|
||||||
album_asin: str = ""
|
|
||||||
duration_seconds: int = 0
|
|
||||||
isrc: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_album(self) -> bool:
|
|
||||||
return "album" in self.item_type.lower()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_track(self) -> bool:
|
|
||||||
return "track" in self.item_type.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class T2TunesStreamInfo:
|
|
||||||
asin: str
|
|
||||||
streamable: bool
|
|
||||||
codec: str
|
|
||||||
format: str
|
|
||||||
sample_rate: Optional[int]
|
|
||||||
stream_url: str
|
|
||||||
decryption_key: Optional[str] # hex-encoded AES key; None when stream is clear
|
|
||||||
title: str = ""
|
|
||||||
artist: str = ""
|
|
||||||
album: str = ""
|
|
||||||
isrc: str = ""
|
|
||||||
cover_url: str = ""
|
|
||||||
track_number: Optional[int] = None
|
|
||||||
disc_number: Optional[int] = None
|
|
||||||
genre: str = ""
|
|
||||||
label: str = ""
|
|
||||||
date: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_decryption_key(self) -> bool:
|
|
||||||
return bool(self.decryption_key)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rate-limit enforcement
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _rate_limit() -> None:
|
|
||||||
global _last_api_call
|
|
||||||
with _api_call_lock:
|
|
||||||
elapsed = time.monotonic() - _last_api_call
|
|
||||||
if elapsed < MIN_API_INTERVAL:
|
|
||||||
time.sleep(MIN_API_INTERVAL - elapsed)
|
|
||||||
_last_api_call = time.monotonic()
|
|
||||||
api_call_tracker.record_call("amazon")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Main client
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class AmazonClient:
|
|
||||||
"""T2Tunes-backed Amazon Music metadata and stream-info client."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
base_url: Optional[str] = None,
|
|
||||||
country: Optional[str] = None,
|
|
||||||
preferred_codec: Optional[str] = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
session: Optional[Any] = None,
|
|
||||||
) -> None:
|
|
||||||
self.base_url = (base_url or config_manager.get("amazon.base_url", DEFAULT_BASE_URL)).rstrip("/")
|
|
||||||
self.country = (country or config_manager.get("amazon.country", DEFAULT_COUNTRY)).upper()
|
|
||||||
self.preferred_codec = (
|
|
||||||
preferred_codec or config_manager.get("amazon.preferred_codec", DEFAULT_CODEC)
|
|
||||||
).lower()
|
|
||||||
self.timeout = timeout
|
|
||||||
self.session: Any = session or requests.Session()
|
|
||||||
if isinstance(self.session, requests.Session):
|
|
||||||
self.session.headers.update({
|
|
||||||
"Accept": "application/json",
|
|
||||||
"User-Agent": "SoulSync/1.0",
|
|
||||||
"Referer": self.base_url,
|
|
||||||
})
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def reload_config(self) -> None:
|
|
||||||
self.base_url = config_manager.get("amazon.base_url", DEFAULT_BASE_URL).rstrip("/")
|
|
||||||
self.country = config_manager.get("amazon.country", DEFAULT_COUNTRY).upper()
|
|
||||||
self.preferred_codec = config_manager.get("amazon.preferred_codec", DEFAULT_CODEC).lower()
|
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
"""Return True when the T2Tunes instance reports Amazon Music as up."""
|
|
||||||
try:
|
|
||||||
return str(self.status().get("amazonMusic", "")).lower() == "up"
|
|
||||||
except AmazonClientError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Low-level API wrappers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def status(self) -> Dict[str, Any]:
|
|
||||||
return self._get_json("/api/status")
|
|
||||||
|
|
||||||
def search_raw(self, query: str, *, types: str = "track,album") -> List[T2TunesSearchItem]:
|
|
||||||
data = self._get_json(
|
|
||||||
"/api/amazon-music/search",
|
|
||||||
params={"query": query, "types": types, "country": self.country},
|
|
||||||
)
|
|
||||||
return list(self._iter_search_items(data))
|
|
||||||
|
|
||||||
def album_metadata(self, asin: str) -> Dict[str, Any]:
|
|
||||||
return self._get_json(
|
|
||||||
"/api/amazon-music/metadata",
|
|
||||||
params={"asin": asin, "country": self.country},
|
|
||||||
)
|
|
||||||
|
|
||||||
def media_from_asin(self, asin: str, codec: Optional[str] = None) -> List[T2TunesStreamInfo]:
|
|
||||||
effective_codec = (codec or self.preferred_codec).lower()
|
|
||||||
data = self._get_json(
|
|
||||||
"/api/amazon-music/media-from-asin",
|
|
||||||
params={"asin": asin, "country": self.country, "codec": effective_codec},
|
|
||||||
)
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [self._parse_stream_info(item) for item in data if isinstance(item, dict)]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return [self._parse_stream_info(data)]
|
|
||||||
raise AmazonClientError(f"Unexpected media-from-asin response type: {type(data).__name__}")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Metadata interface — mirrors DeezerClient / iTunesClient signatures
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
|
||||||
_rate_limit()
|
|
||||||
items = self.search_raw(query, types="track")
|
|
||||||
track_pairs: List[tuple] = [] # (Track, album_asin)
|
|
||||||
seen_album_asins: List[str] = []
|
|
||||||
for item in items:
|
|
||||||
if not item.is_track:
|
|
||||||
continue
|
|
||||||
track = Track.from_search_hit({
|
|
||||||
"asin": item.asin,
|
|
||||||
"title": _strip_edition(item.title),
|
|
||||||
"artistName": _primary_artist(item.artist_name),
|
|
||||||
"albumName": _strip_edition(item.album_name),
|
|
||||||
"albumAsin": item.album_asin,
|
|
||||||
"duration": item.duration_seconds,
|
|
||||||
"isrc": item.isrc,
|
|
||||||
})
|
|
||||||
track_pairs.append((track, item.album_asin))
|
|
||||||
if item.album_asin and item.album_asin not in seen_album_asins:
|
|
||||||
seen_album_asins.append(item.album_asin)
|
|
||||||
if len(track_pairs) >= limit:
|
|
||||||
break
|
|
||||||
album_metas = self._fetch_album_metas(seen_album_asins[:5])
|
|
||||||
tracks: List[Track] = []
|
|
||||||
for track, album_asin in track_pairs:
|
|
||||||
if album_asin and album_asin in album_metas:
|
|
||||||
meta = album_metas[album_asin]
|
|
||||||
track.image_url = meta.get("image")
|
|
||||||
track.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
|
||||||
track.total_tracks = meta.get("trackCount")
|
|
||||||
tracks.append(track)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
|
||||||
_rate_limit()
|
|
||||||
items = self.search_raw(query, types="track")
|
|
||||||
seen: Dict[str, Artist] = {}
|
|
||||||
artist_album_asin: Dict[str, str] = {} # artist name → first album ASIN seen
|
|
||||||
for item in items:
|
|
||||||
name = _primary_artist(item.artist_name)
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if name not in seen:
|
|
||||||
seen[name] = Artist.from_name(name)
|
|
||||||
if name not in artist_album_asin and item.album_asin:
|
|
||||||
artist_album_asin[name] = item.album_asin
|
|
||||||
if len(seen) >= limit:
|
|
||||||
break
|
|
||||||
# T2Tunes has no artist images — use an album cover as stand-in.
|
|
||||||
unique_asins = list({v for v in artist_album_asin.values()})[:5]
|
|
||||||
album_metas = self._fetch_album_metas(unique_asins)
|
|
||||||
for name, artist in seen.items():
|
|
||||||
asin = artist_album_asin.get(name)
|
|
||||||
if asin and asin in album_metas:
|
|
||||||
artist.image_url = album_metas[asin].get("image")
|
|
||||||
return list(seen.values())
|
|
||||||
|
|
||||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
|
||||||
_rate_limit()
|
|
||||||
items = self.search_raw(query, types="track")
|
|
||||||
album_candidates: List[tuple] = [] # (Album, asin)
|
|
||||||
seen_keys: set = set()
|
|
||||||
for item in items:
|
|
||||||
album_asin = item.album_asin
|
|
||||||
raw_name = item.album_name
|
|
||||||
if not raw_name:
|
|
||||||
continue
|
|
||||||
display_name = _strip_edition(raw_name)
|
|
||||||
artist = _primary_artist(item.artist_name)
|
|
||||||
# Collapse Explicit/Clean variants: same normalised name + artist = same album
|
|
||||||
dedup_key = (display_name.lower(), artist.lower())
|
|
||||||
if dedup_key in seen_keys:
|
|
||||||
continue
|
|
||||||
seen_keys.add(dedup_key)
|
|
||||||
album = Album.from_search_hit({
|
|
||||||
"albumAsin": album_asin,
|
|
||||||
"albumName": display_name,
|
|
||||||
"artistName": artist,
|
|
||||||
})
|
|
||||||
album_candidates.append((album, album_asin))
|
|
||||||
if len(album_candidates) >= limit:
|
|
||||||
break
|
|
||||||
album_metas = self._fetch_album_metas([a for _, a in album_candidates[:10]])
|
|
||||||
albums: List[Album] = []
|
|
||||||
for album, asin in album_candidates:
|
|
||||||
if asin in album_metas:
|
|
||||||
meta = album_metas[asin]
|
|
||||||
album.image_url = meta.get("image")
|
|
||||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
|
||||||
album.total_tracks = int(meta.get("trackCount") or 0)
|
|
||||||
albums.append(album)
|
|
||||||
return albums
|
|
||||||
|
|
||||||
def get_track_details(self, asin: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return a Spotify-compatible dict for a single track ASIN."""
|
|
||||||
_rate_limit()
|
|
||||||
try:
|
|
||||||
streams = self.media_from_asin(asin)
|
|
||||||
except AmazonClientError:
|
|
||||||
return None
|
|
||||||
if not streams:
|
|
||||||
return None
|
|
||||||
s = streams[0]
|
|
||||||
|
|
||||||
album_data: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
meta = self.album_metadata(asin)
|
|
||||||
albums = meta.get("albumList")
|
|
||||||
if isinstance(albums, list) and albums and isinstance(albums[0], dict):
|
|
||||||
album_data = albums[0]
|
|
||||||
except AmazonClientError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": s.asin,
|
|
||||||
"name": _strip_edition(s.title),
|
|
||||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
|
||||||
"album": {
|
|
||||||
"id": album_data.get("asin", ""),
|
|
||||||
"name": _strip_edition(s.album),
|
|
||||||
"images": [{"url": album_data["image"]}] if album_data.get("image") else [],
|
|
||||||
"release_date": album_data.get("release_date") or album_data.get("releaseDate") or s.date or "",
|
|
||||||
"total_tracks": album_data.get("trackCount", 0),
|
|
||||||
},
|
|
||||||
"duration_ms": 0,
|
|
||||||
"popularity": 0,
|
|
||||||
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
|
|
||||||
"track_number": s.track_number,
|
|
||||||
"disc_number": s.disc_number,
|
|
||||||
"isrc": s.isrc,
|
|
||||||
"is_album_track": True,
|
|
||||||
"raw_data": {
|
|
||||||
"codec": s.codec,
|
|
||||||
"format": s.format,
|
|
||||||
"sample_rate": s.sample_rate,
|
|
||||||
"streamable": s.streamable,
|
|
||||||
"has_decryption_key": s.has_decryption_key,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_album(self, asin: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return a Spotify-compatible album dict."""
|
|
||||||
_rate_limit()
|
|
||||||
try:
|
|
||||||
meta = self.album_metadata(asin)
|
|
||||||
except AmazonClientError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
albums = meta.get("albumList")
|
|
||||||
if not isinstance(albums, list) or not albums:
|
|
||||||
return None
|
|
||||||
album = albums[0] if isinstance(albums[0], dict) else {}
|
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
|
||||||
"id": asin,
|
|
||||||
"name": _strip_edition(album.get("title", "")),
|
|
||||||
"artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}],
|
|
||||||
"release_date": album.get("release_date") or album.get("releaseDate") or "",
|
|
||||||
"total_tracks": album.get("trackCount", 0),
|
|
||||||
"album_type": "album",
|
|
||||||
"images": [{"url": album["image"]}] if album.get("image") else [],
|
|
||||||
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
|
|
||||||
"label": album.get("label", ""),
|
|
||||||
}
|
|
||||||
if include_tracks:
|
|
||||||
tracks_data = self.get_album_tracks(asin) or {
|
|
||||||
"items": [], "total": 0, "limit": 50, "next": None,
|
|
||||||
}
|
|
||||||
result["tracks"] = tracks_data
|
|
||||||
# Backfill release_date from stream tags when album metadata lacks it.
|
|
||||||
if not result["release_date"]:
|
|
||||||
items = tracks_data.get("items") or []
|
|
||||||
for item in items:
|
|
||||||
rd = item.get("release_date") or ""
|
|
||||||
if rd and len(rd) >= 4:
|
|
||||||
result["release_date"] = rd
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_album_tracks(self, asin: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return album tracks in Spotify pagination format."""
|
|
||||||
_rate_limit()
|
|
||||||
try:
|
|
||||||
streams = self.media_from_asin(asin)
|
|
||||||
except AmazonClientError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# media_from_asin has no duration — enrich from search results which do.
|
|
||||||
duration_map: Dict[str, int] = {} # track asin → duration_ms
|
|
||||||
if streams:
|
|
||||||
album_name = _strip_edition(streams[0].album)
|
|
||||||
artist_name = _primary_artist(streams[0].artist)
|
|
||||||
try:
|
|
||||||
search_items = self.search_raw(
|
|
||||||
f"{album_name} {artist_name}", types="track"
|
|
||||||
)
|
|
||||||
for item in search_items:
|
|
||||||
if item.album_asin == asin and item.duration_seconds:
|
|
||||||
duration_map[item.asin] = item.duration_seconds * 1000
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Duration backfill failed for ASIN %s: %s", asin, exc)
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"id": s.asin,
|
|
||||||
"name": _strip_edition(s.title),
|
|
||||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
|
||||||
"duration_ms": duration_map.get(s.asin, 0),
|
|
||||||
"track_number": s.track_number if s.track_number is not None else idx + 1,
|
|
||||||
"disc_number": s.disc_number,
|
|
||||||
"release_date": s.date or "",
|
|
||||||
"isrc": s.isrc,
|
|
||||||
}
|
|
||||||
for idx, s in enumerate(streams)
|
|
||||||
]
|
|
||||||
return {"items": items, "total": len(items), "limit": 50, "next": None}
|
|
||||||
|
|
||||||
def get_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return a Spotify-compatible artist dict inferred from search results."""
|
|
||||||
_rate_limit()
|
|
||||||
search_name = _unslugify(artist_name)
|
|
||||||
try:
|
|
||||||
items = self.search_raw(search_name, types="track")
|
|
||||||
except AmazonClientError:
|
|
||||||
return None
|
|
||||||
name_lower = search_name.lower()
|
|
||||||
match = next(
|
|
||||||
(i for i in items if _primary_artist(i.artist_name).lower() == name_lower),
|
|
||||||
next((i for i in items if name_lower in _primary_artist(i.artist_name).lower()), None),
|
|
||||||
)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"id": match.artist_name.lower().replace(" ", "_"),
|
|
||||||
"name": match.artist_name,
|
|
||||||
"genres": [],
|
|
||||||
"popularity": 0,
|
|
||||||
"followers": {"total": 0},
|
|
||||||
"images": [],
|
|
||||||
"external_urls": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_artist_albums(
|
|
||||||
self,
|
|
||||||
artist_name: str,
|
|
||||||
album_type: str = "album,single",
|
|
||||||
limit: int = 200,
|
|
||||||
) -> List[Album]:
|
|
||||||
"""Return albums for an artist inferred from search results."""
|
|
||||||
_rate_limit()
|
|
||||||
search_name = _unslugify(artist_name)
|
|
||||||
try:
|
|
||||||
items = self.search_raw(f"{search_name} album", types="track")
|
|
||||||
except AmazonClientError:
|
|
||||||
return []
|
|
||||||
album_candidates: List[tuple] = [] # (Album, asin)
|
|
||||||
seen_asins: set = set()
|
|
||||||
name_lower = search_name.lower()
|
|
||||||
for item in items:
|
|
||||||
if not item.is_album:
|
|
||||||
continue
|
|
||||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
|
||||||
continue
|
|
||||||
album_asin = item.album_asin or item.asin
|
|
||||||
if album_asin in seen_asins:
|
|
||||||
continue
|
|
||||||
seen_asins.add(album_asin)
|
|
||||||
album = Album.from_search_hit({
|
|
||||||
"albumAsin": album_asin,
|
|
||||||
"albumName": _strip_edition(item.album_name or item.title),
|
|
||||||
"artistName": _primary_artist(item.artist_name),
|
|
||||||
})
|
|
||||||
album_candidates.append((album, album_asin))
|
|
||||||
if len(album_candidates) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fetch metadata for art, release_date, track_count, and type inference.
|
|
||||||
# No explicit cap here — search_raw naturally bounds results to ~20 items,
|
|
||||||
# so album_candidates is already small.
|
|
||||||
asins_to_fetch = [a for _, a in album_candidates]
|
|
||||||
metas = self._fetch_album_metas(asins_to_fetch)
|
|
||||||
|
|
||||||
albums: List[Album] = []
|
|
||||||
for album, asin in album_candidates:
|
|
||||||
meta = metas.get(asin, {})
|
|
||||||
if meta:
|
|
||||||
album.image_url = meta.get("image")
|
|
||||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
|
||||||
total = int(meta.get("trackCount") or 0)
|
|
||||||
album.total_tracks = total
|
|
||||||
# T2Tunes doesn't expose release type — infer from track count.
|
|
||||||
# 1-track releases are singles; keep default "album" otherwise.
|
|
||||||
if total == 1:
|
|
||||||
album.album_type = "single"
|
|
||||||
albums.append(album)
|
|
||||||
return albums
|
|
||||||
|
|
||||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Not available from Amazon Music — returns None for compatibility."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
|
||||||
"""Return an album cover as artist image stand-in (T2Tunes has no artist images)."""
|
|
||||||
search_name = _unslugify(artist_id)
|
|
||||||
try:
|
|
||||||
items = self.search_raw(search_name, types="track")
|
|
||||||
except AmazonClientError:
|
|
||||||
return None
|
|
||||||
name_lower = search_name.lower()
|
|
||||||
for item in items:
|
|
||||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
|
||||||
continue
|
|
||||||
asin = item.album_asin or item.asin
|
|
||||||
if not asin:
|
|
||||||
continue
|
|
||||||
metas = self._fetch_album_metas([asin])
|
|
||||||
if asin in metas and metas[asin].get("image"):
|
|
||||||
return metas[asin]["image"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ==================== Interface Aliases (match DeezerClient method names) ====================
|
|
||||||
get_album_metadata = get_album
|
|
||||||
get_artist_info = get_artist
|
|
||||||
get_artist_albums_list = get_artist_albums
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fetch_album_metas(self, asins: List[str]) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Parallel-fetch album metadata for up to N ASINs. Returns {asin: albumList[0]}."""
|
|
||||||
if not asins:
|
|
||||||
return {}
|
|
||||||
metas: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
def _fetch(asin: str) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
with _meta_cache_lock:
|
|
||||||
entry = _meta_cache.get(asin)
|
|
||||||
if entry and (now - entry[0]) < _META_CACHE_TTL:
|
|
||||||
metas[asin] = entry[1]
|
|
||||||
return
|
|
||||||
_rate_limit()
|
|
||||||
try:
|
|
||||||
raw = self.album_metadata(asin)
|
|
||||||
lst = raw.get("albumList")
|
|
||||||
if isinstance(lst, list) and lst and isinstance(lst[0], dict):
|
|
||||||
meta = lst[0]
|
|
||||||
with _meta_cache_lock:
|
|
||||||
_meta_cache[asin] = (time.monotonic(), meta)
|
|
||||||
metas[asin] = meta
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Album metadata fetch failed for ASIN %s: %s", asin, exc)
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
with ThreadPoolExecutor(max_workers=min(len(asins), 5)) as pool:
|
|
||||||
list(pool.map(_fetch, asins))
|
|
||||||
return metas
|
|
||||||
|
|
||||||
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
||||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
||||||
last_exc: Optional[Exception] = None
|
|
||||||
for attempt in range(3):
|
|
||||||
if attempt:
|
|
||||||
time.sleep(1.0 * attempt)
|
|
||||||
try:
|
|
||||||
resp = self.session.get(url, params=params, timeout=self.timeout)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except requests.HTTPError as exc:
|
|
||||||
body = exc.response.text[:500].replace("\n", " ")
|
|
||||||
# T2Tunes returns 400 for transient Amazon-side failures — retry those.
|
|
||||||
if exc.response.status_code == 400 and "Failed to search" in exc.response.text:
|
|
||||||
logger.debug("T2Tunes transient 400 on attempt %d, retrying: %s", attempt + 1, url)
|
|
||||||
last_exc = AmazonClientError(
|
|
||||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
raise AmazonClientError(
|
|
||||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}",
|
|
||||||
status_code=exc.response.status_code,
|
|
||||||
) from exc
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except ValueError as exc:
|
|
||||||
preview = resp.text[:200].replace("\n", " ")
|
|
||||||
raise AmazonClientError(f"Response not JSON for {url}: {preview!r}") from exc
|
|
||||||
raise last_exc # type: ignore[misc]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _iter_search_items(response: Any) -> Iterator[T2TunesSearchItem]:
|
|
||||||
if not isinstance(response, dict):
|
|
||||||
raise AmazonClientError(
|
|
||||||
f"Unexpected search response type: {type(response).__name__}"
|
|
||||||
)
|
|
||||||
for result in response.get("results") or []:
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
continue
|
|
||||||
for hit in result.get("hits") or []:
|
|
||||||
if not isinstance(hit, dict):
|
|
||||||
continue
|
|
||||||
doc = hit.get("document")
|
|
||||||
if not isinstance(doc, dict):
|
|
||||||
continue
|
|
||||||
asin = str(doc.get("asin") or "")
|
|
||||||
if not asin:
|
|
||||||
continue
|
|
||||||
yield T2TunesSearchItem(
|
|
||||||
asin=asin,
|
|
||||||
title=str(doc.get("title") or ""),
|
|
||||||
artist_name=str(doc.get("artistName") or ""),
|
|
||||||
item_type=str(doc.get("__type") or ""),
|
|
||||||
album_name=str(doc.get("albumName") or ""),
|
|
||||||
album_asin=str(doc.get("albumAsin") or ""),
|
|
||||||
duration_seconds=int(doc.get("duration") or 0),
|
|
||||||
isrc=str(doc.get("isrc") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_stream_info(item: Dict[str, Any]) -> T2TunesStreamInfo:
|
|
||||||
stream_info = item.get("streamInfo") if isinstance(item.get("streamInfo"), dict) else {}
|
|
||||||
tags = item.get("tags") if isinstance(item.get("tags"), dict) else {}
|
|
||||||
# T2Tunes API has a typo: "stremeable" in some responses
|
|
||||||
streamable = item.get("streamable")
|
|
||||||
if streamable is None:
|
|
||||||
streamable = item.get("stremeable")
|
|
||||||
raw_key = item.get("decryptionKey")
|
|
||||||
decryption_key = str(raw_key) if raw_key else None
|
|
||||||
|
|
||||||
def _int_tag(key: str) -> Optional[int]:
|
|
||||||
v = tags.get(key)
|
|
||||||
try:
|
|
||||||
return int(v) if v is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return T2TunesStreamInfo(
|
|
||||||
asin=str(item.get("asin") or ""),
|
|
||||||
streamable=bool(streamable),
|
|
||||||
codec=str(stream_info.get("codec") or ""),
|
|
||||||
format=str(stream_info.get("format") or ""),
|
|
||||||
sample_rate=(
|
|
||||||
stream_info.get("sampleRate")
|
|
||||||
if isinstance(stream_info.get("sampleRate"), int)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
stream_url=str(stream_info.get("streamUrl") or ""),
|
|
||||||
decryption_key=decryption_key,
|
|
||||||
title=str(tags.get("title") or ""),
|
|
||||||
artist=str(tags.get("artist") or ""),
|
|
||||||
album=str(tags.get("album") or ""),
|
|
||||||
isrc=str(tags.get("isrc") or ""),
|
|
||||||
cover_url=str(item.get("coverUrl") or ""),
|
|
||||||
track_number=_int_tag("trackNumber"),
|
|
||||||
disc_number=_int_tag("discNumber"),
|
|
||||||
genre=str(tags.get("genre") or ""),
|
|
||||||
label=str(tags.get("label") or ""),
|
|
||||||
date=str(tags.get("date") or ""),
|
|
||||||
)
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
"""Amazon Music download source plugin backed by a T2Tunes proxy.
|
|
||||||
|
|
||||||
NOT yet wired into the app registry — validated in isolation only.
|
|
||||||
See tests/tools/test_amazon_download_client.py.
|
|
||||||
|
|
||||||
Filename encoding (the DownloadSourcePlugin dispatch contract):
|
|
||||||
"{asin}||{display_name}"
|
|
||||||
e.g. "B09XYZ1234||Kendrick Lamar - Not Like Us"
|
|
||||||
|
|
||||||
Codec preference order: FLAC → Opus → EAC3.
|
|
||||||
|
|
||||||
Download flow (from Tubifarry reference implementation):
|
|
||||||
1. GET stream_url → encrypted bytes on disk
|
|
||||||
2. FFmpeg -decryption_key <hex> to decrypt in place
|
|
||||||
3. Embed metadata tags (handled by the app's standard post-processing)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import requests as http_requests
|
|
||||||
|
|
||||||
from config.settings import config_manager
|
|
||||||
from core.amazon_client import AmazonClient, AmazonClientError
|
|
||||||
from core.download_plugins.base import DownloadSourcePlugin
|
|
||||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
|
||||||
from core.quality.model import AudioQuality
|
|
||||||
from core.quality.source_map import quality_tier_for_source
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("amazon_download_client")
|
|
||||||
|
|
||||||
# Quality / codec helpers
|
|
||||||
CODEC_PREFERENCE = ["flac", "opus", "eac3"]
|
|
||||||
|
|
||||||
_CODEC_EXTENSIONS: Dict[str, str] = {
|
|
||||||
"flac": "flac",
|
|
||||||
"ogg_vorbis": "ogg",
|
|
||||||
"opus": "opus",
|
|
||||||
"eac3": "eac3",
|
|
||||||
"mp4": "m4a",
|
|
||||||
"aac": "m4a",
|
|
||||||
"mp3": "mp3",
|
|
||||||
}
|
|
||||||
|
|
||||||
MIN_AUDIO_BYTES = 512 * 1024 # 512 KB — anything smaller is a broken stream
|
|
||||||
|
|
||||||
|
|
||||||
def _codec_key(codec: str) -> str:
|
|
||||||
return codec.lower().replace("-", "_").replace(" ", "_")
|
|
||||||
|
|
||||||
|
|
||||||
def _file_extension(codec: str) -> str:
|
|
||||||
return _CODEC_EXTENSIONS.get(_codec_key(codec), "bin")
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_label(codec: str, sample_rate: Optional[int] = None) -> str:
|
|
||||||
ck = _codec_key(codec)
|
|
||||||
if ck == "flac":
|
|
||||||
if sample_rate and sample_rate > 48000:
|
|
||||||
return "Hi-Res"
|
|
||||||
return "Lossless"
|
|
||||||
return "Lossy"
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonDownloadClient(DownloadSourcePlugin):
|
|
||||||
"""DownloadSourcePlugin — Amazon Music via T2Tunes proxy."""
|
|
||||||
|
|
||||||
def __init__(self, download_path: Optional[str] = None) -> None:
|
|
||||||
if download_path is None:
|
|
||||||
download_path = config_manager.get("soulseek.download_path", "./downloads")
|
|
||||||
self.download_path = Path(download_path)
|
|
||||||
try:
|
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not verify download path {self.download_path}: {e}")
|
|
||||||
|
|
||||||
self._quality = quality_tier_for_source("amazon", default="flac")
|
|
||||||
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
|
|
||||||
|
|
||||||
self._client = AmazonClient(preferred_codec=self._quality)
|
|
||||||
self.session = http_requests.Session()
|
|
||||||
self.session.headers.update({
|
|
||||||
"User-Agent": "SoulSync/1.0",
|
|
||||||
"Accept": "*/*",
|
|
||||||
})
|
|
||||||
|
|
||||||
self._engine: Any = None
|
|
||||||
self.shutdown_check: Any = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DownloadSourcePlugin — lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def set_engine(self, engine) -> None:
|
|
||||||
"""Engine callback — wires the central thread worker + state store."""
|
|
||||||
self._engine = engine
|
|
||||||
|
|
||||||
def set_shutdown_check(self, check_callable) -> None:
|
|
||||||
self.shutdown_check = check_callable
|
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
|
||||||
# T2Tunes has a public default instance; no credentials required.
|
|
||||||
# Return True unconditionally so the source shows as available.
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
|
||||||
try:
|
|
||||||
return self._client.is_authenticated()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DownloadSourcePlugin — search
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
timeout: Optional[int] = None,
|
|
||||||
progress_callback: Any = None,
|
|
||||||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
|
||||||
try:
|
|
||||||
items = self._client.search_raw(query, types="track,album")
|
|
||||||
except AmazonClientError as exc:
|
|
||||||
logger.warning(f"Amazon search failed for {query!r}: {exc}")
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
track_results: List[TrackResult] = []
|
|
||||||
album_map: Dict[str, AlbumResult] = {}
|
|
||||||
album_order: List[str] = []
|
|
||||||
preferred = self._client.preferred_codec
|
|
||||||
# Search results only carry the codec (real sample_rate arrives at
|
|
||||||
# stream time). Claim the format honestly — FLAC for the lossless
|
|
||||||
# codec, lossy otherwise — so audio_quality derives a real format
|
|
||||||
# instead of the display label ("Lossless"), and the post-download
|
|
||||||
# probe pins the actual sample_rate/bit_depth.
|
|
||||||
amazon_q = AudioQuality(format='flac' if _codec_key(preferred) == 'flac' else 'aac')
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
quality = _quality_label(preferred)
|
|
||||||
if item.is_track:
|
|
||||||
tr = TrackResult(
|
|
||||||
username="amazon",
|
|
||||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
|
||||||
size=0,
|
|
||||||
bitrate=None,
|
|
||||||
duration=item.duration_seconds * 1000 if item.duration_seconds else None,
|
|
||||||
quality=quality,
|
|
||||||
free_upload_slots=999,
|
|
||||||
upload_speed=999_999,
|
|
||||||
queue_length=0,
|
|
||||||
artist=item.artist_name,
|
|
||||||
title=item.title,
|
|
||||||
album=item.album_name,
|
|
||||||
_source_metadata={
|
|
||||||
"asin": item.asin,
|
|
||||||
"album_asin": item.album_asin,
|
|
||||||
"isrc": item.isrc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tr.set_quality(amazon_q)
|
|
||||||
track_results.append(tr)
|
|
||||||
elif item.is_album:
|
|
||||||
album_asin = item.album_asin or item.asin
|
|
||||||
if album_asin not in album_map:
|
|
||||||
placeholder = TrackResult(
|
|
||||||
username="amazon",
|
|
||||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
|
||||||
size=0,
|
|
||||||
bitrate=None,
|
|
||||||
duration=None,
|
|
||||||
quality=quality,
|
|
||||||
free_upload_slots=999,
|
|
||||||
upload_speed=999_999,
|
|
||||||
queue_length=0,
|
|
||||||
artist=item.artist_name,
|
|
||||||
title=item.title,
|
|
||||||
album=item.album_name,
|
|
||||||
)
|
|
||||||
placeholder.set_quality(amazon_q)
|
|
||||||
album_map[album_asin] = AlbumResult(
|
|
||||||
username="amazon",
|
|
||||||
album_path=album_asin,
|
|
||||||
album_title=item.album_name or item.title,
|
|
||||||
artist=item.artist_name,
|
|
||||||
track_count=0,
|
|
||||||
total_size=0,
|
|
||||||
tracks=[placeholder],
|
|
||||||
dominant_quality=quality,
|
|
||||||
)
|
|
||||||
album_order.append(album_asin)
|
|
||||||
|
|
||||||
return track_results, [album_map[k] for k in album_order]
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DownloadSourcePlugin — download dispatch
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def download(
|
|
||||||
self,
|
|
||||||
username: str,
|
|
||||||
filename: str,
|
|
||||||
file_size: int = 0,
|
|
||||||
) -> Optional[str]:
|
|
||||||
if "||" not in filename:
|
|
||||||
logger.error(f"Invalid Amazon filename format (no '||'): {filename!r}")
|
|
||||||
return None
|
|
||||||
if self._engine is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"AmazonDownloadClient._engine is not set — "
|
|
||||||
"client not connected to download infrastructure"
|
|
||||||
)
|
|
||||||
asin, display_name = filename.split("||", 1)
|
|
||||||
asin = asin.strip()
|
|
||||||
display_name = display_name.strip()
|
|
||||||
return self._engine.worker.dispatch(
|
|
||||||
source_name="amazon",
|
|
||||||
target_id=asin,
|
|
||||||
display_name=display_name,
|
|
||||||
original_filename=filename,
|
|
||||||
impl_callable=self._download_sync,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _download_sync(
|
|
||||||
self,
|
|
||||||
download_id: str,
|
|
||||||
target_id: str,
|
|
||||||
display_name: str,
|
|
||||||
) -> Optional[str]:
|
|
||||||
asin = str(target_id)
|
|
||||||
codecs = CODEC_PREFERENCE if self._allow_fallback else [self._quality]
|
|
||||||
for codec in codecs:
|
|
||||||
try:
|
|
||||||
streams = self._client.media_from_asin(asin, codec=codec)
|
|
||||||
except AmazonClientError as exc:
|
|
||||||
logger.warning(f"media_from_asin({asin!r}, {codec}) failed: {exc}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
stream = next(
|
|
||||||
(s for s in streams if s.streamable and s.stream_url),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not stream:
|
|
||||||
logger.debug(f"No streamable result for {asin} at codec={codec}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
ext = _file_extension(stream.codec or codec)
|
|
||||||
safe = "".join(
|
|
||||||
ch if ch.isalnum() or ch in " -_." else "_"
|
|
||||||
for ch in display_name
|
|
||||||
)[:80]
|
|
||||||
# T2Tunes always serves audio in an encrypted MP4 container.
|
|
||||||
# The codec extension (.flac, .opus, .eac3) is only for the
|
|
||||||
# final decrypted output.
|
|
||||||
enc_ext = "mp4" if stream.decryption_key else ext
|
|
||||||
enc_path = self._unique_path(self.download_path / f"{safe}.enc.{enc_ext}")
|
|
||||||
out_path = self._unique_path(self.download_path / f"{safe}.{ext}")
|
|
||||||
|
|
||||||
if self._engine is not None:
|
|
||||||
self._engine.update_record(
|
|
||||||
"amazon", download_id, {"state": "downloading", "progress": 0.0}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
downloaded = self._stream_to_file(stream.stream_url, enc_path, download_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Stream download failed for {asin} ({codec}): {exc}")
|
|
||||||
enc_path.unlink(missing_ok=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if downloaded < MIN_AUDIO_BYTES:
|
|
||||||
logger.warning(
|
|
||||||
f"File too small ({downloaded} B) for {asin} at {codec} — trying next"
|
|
||||||
)
|
|
||||||
enc_path.unlink(missing_ok=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if stream.decryption_key:
|
|
||||||
if self._engine is not None:
|
|
||||||
self._engine.update_record(
|
|
||||||
"amazon", download_id, {"state": "decrypting", "progress": 1.0}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
self._decrypt_with_ffmpeg(enc_path, out_path, stream.decryption_key)
|
|
||||||
enc_path.unlink(missing_ok=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"Decryption failed for {asin} ({codec}): {exc}")
|
|
||||||
enc_path.unlink(missing_ok=True)
|
|
||||||
out_path.unlink(missing_ok=True)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
enc_path.rename(out_path)
|
|
||||||
|
|
||||||
final_size = out_path.stat().st_size
|
|
||||||
logger.info(
|
|
||||||
f"Amazon download complete ({codec}): {out_path} "
|
|
||||||
f"({final_size / (1024 * 1024):.1f} MB)"
|
|
||||||
)
|
|
||||||
# Sync size == transferred so the download monitor's bytes-incomplete
|
|
||||||
# guard doesn't block post-processing. The throttled updates in
|
|
||||||
# _stream_to_file leave transferred < size after the last 0.5s tick;
|
|
||||||
# other streaming clients avoid this by not tracking bytes at all
|
|
||||||
# (size stays 0, the guard is skipped). Writing the final output size
|
|
||||||
# here restores parity.
|
|
||||||
if self._engine is not None:
|
|
||||||
self._engine.update_record(
|
|
||||||
"amazon", download_id, {'size': final_size, 'transferred': final_size}
|
|
||||||
)
|
|
||||||
return str(out_path)
|
|
||||||
|
|
||||||
logger.error(f"All codec tiers exhausted for '{display_name}' ({asin})")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _decrypt_with_ffmpeg(
|
|
||||||
self, enc_path: Path, out_path: Path, hex_key: str
|
|
||||||
) -> None:
|
|
||||||
"""Decrypt a T2Tunes encrypted audio file using FFmpeg -decryption_key.
|
|
||||||
|
|
||||||
T2Tunes uses CENC (Common Encryption) for DRM-protected tracks.
|
|
||||||
FFmpeg supports decryption via the -decryption_key flag when the
|
|
||||||
key is provided in hex.
|
|
||||||
"""
|
|
||||||
ffmpeg = shutil.which("ffmpeg")
|
|
||||||
if not ffmpeg:
|
|
||||||
tools_dir = Path(__file__).parent.parent / "tools"
|
|
||||||
candidate = tools_dir / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
|
|
||||||
if candidate.exists():
|
|
||||||
ffmpeg = str(candidate)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
"ffmpeg is required for Amazon Music decryption. "
|
|
||||||
"Install ffmpeg and ensure it is on your PATH."
|
|
||||||
)
|
|
||||||
cmd = [
|
|
||||||
ffmpeg,
|
|
||||||
"-y",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel", "error",
|
|
||||||
"-decryption_key", hex_key,
|
|
||||||
"-i", str(enc_path),
|
|
||||||
"-map", "0:a:0", # extract first audio stream (FLAC/Opus/EAC3 inside MP4)
|
|
||||||
"-c", "copy",
|
|
||||||
str(out_path),
|
|
||||||
]
|
|
||||||
logger.debug(f"Decrypting {enc_path.name} → {out_path.name}")
|
|
||||||
result = subprocess.run(cmd, capture_output=True)
|
|
||||||
if result.returncode != 0:
|
|
||||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
|
||||||
raise RuntimeError(f"FFmpeg decryption failed (exit {result.returncode}): {stderr}")
|
|
||||||
|
|
||||||
def _stream_to_file(self, url: str, out_path: Path, download_id: str) -> int:
|
|
||||||
resp = self.session.get(url, stream=True, timeout=60)
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
total = int(resp.headers.get("content-length", 0))
|
|
||||||
downloaded = 0
|
|
||||||
last_report = time.monotonic()
|
|
||||||
shutdown_triggered = False
|
|
||||||
|
|
||||||
with out_path.open("wb") as fh:
|
|
||||||
for chunk in resp.iter_content(chunk_size=64 * 1024):
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
if self.shutdown_check and self.shutdown_check():
|
|
||||||
shutdown_triggered = True
|
|
||||||
break
|
|
||||||
fh.write(chunk)
|
|
||||||
downloaded += len(chunk)
|
|
||||||
now = time.monotonic()
|
|
||||||
if self._engine and now - last_report >= 0.5:
|
|
||||||
self._engine.update_record(
|
|
||||||
"amazon",
|
|
||||||
download_id,
|
|
||||||
{
|
|
||||||
"transferred": downloaded,
|
|
||||||
"size": total,
|
|
||||||
"progress": downloaded / total if total else 0.0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
last_report = now
|
|
||||||
|
|
||||||
if shutdown_triggered:
|
|
||||||
out_path.unlink(missing_ok=True)
|
|
||||||
raise RuntimeError("Shutdown requested mid-download")
|
|
||||||
|
|
||||||
return downloaded
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DownloadSourcePlugin — status interface
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
|
||||||
if self._engine is None:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
self._record_to_status(record)
|
|
||||||
for record in self._engine.iter_records_for_source('amazon')
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
|
||||||
if self._engine is None:
|
|
||||||
return None
|
|
||||||
record = self._engine.get_record('amazon', download_id)
|
|
||||||
return self._record_to_status(record) if record is not None else None
|
|
||||||
|
|
||||||
async def cancel_download(
|
|
||||||
self,
|
|
||||||
download_id: str,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
remove: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
if self._engine is None:
|
|
||||||
return False
|
|
||||||
if self._engine.get_record('amazon', download_id) is None:
|
|
||||||
return False
|
|
||||||
self._engine.update_record('amazon', download_id, {'state': 'Cancelled'})
|
|
||||||
if remove:
|
|
||||||
self._engine.remove_record('amazon', download_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def clear_all_completed_downloads(self) -> bool:
|
|
||||||
if self._engine is None:
|
|
||||||
return True
|
|
||||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
|
||||||
for record in list(self._engine.iter_records_for_source('amazon')):
|
|
||||||
if record.get('state') in terminal:
|
|
||||||
self._engine.remove_record('amazon', record['id'])
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _unique_path(path: Path) -> Path:
|
|
||||||
if not path.exists():
|
|
||||||
return path
|
|
||||||
stem, suffix = path.stem, path.suffix
|
|
||||||
for i in range(1, 100):
|
|
||||||
candidate = path.with_name(f"{stem} ({i}){suffix}")
|
|
||||||
if not candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return path.with_name(f"{stem}_{uuid.uuid4().hex[:8]}{suffix}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _record_to_status(rec: Dict[str, Any]) -> DownloadStatus:
|
|
||||||
return DownloadStatus(
|
|
||||||
id=str(rec.get('id', '')),
|
|
||||||
filename=str(rec.get('filename', '')),
|
|
||||||
username='amazon',
|
|
||||||
state=str(rec.get('state', 'queued')),
|
|
||||||
progress=float(rec.get('progress', 0.0)),
|
|
||||||
size=int(rec.get('size', 0)),
|
|
||||||
transferred=int(rec.get('transferred', 0)),
|
|
||||||
speed=int(rec.get('speed', 0)),
|
|
||||||
time_remaining=rec.get('time_remaining'),
|
|
||||||
file_path=rec.get('file_path'),
|
|
||||||
)
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"""Amazon enrichment outage detection + back-off — pure, importable, testable.
|
|
||||||
|
|
||||||
The Amazon worker enriches via a public T2Tunes proxy instance. When that
|
|
||||||
instance is down (HTTP 5xx, "Amazon Music API is not initialized", or an
|
|
||||||
unreachable host), the worker must NOT treat every album as an individual
|
|
||||||
failure: doing so floods the logs with an error per item, churns network + DB
|
|
||||||
continuously, and permanently marks the whole library ``error`` (which the
|
|
||||||
retry tiers never re-attempt) for what is really a transient outage.
|
|
||||||
|
|
||||||
Instead it recognizes "the whole source is down", leaves the item untouched so
|
|
||||||
it's retried once the instance recovers, and backs off hard. These two pure
|
|
||||||
helpers carry that logic so it can be unit-tested without the worker, the DB,
|
|
||||||
or the network.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# HTTP statuses that mean "the source/proxy is unhealthy", not "no match".
|
|
||||||
_OUTAGE_STATUS = {500, 502, 503, 504}
|
|
||||||
|
|
||||||
# Substrings (lower-cased) in an error message that indicate a source outage
|
|
||||||
# rather than a per-item miss: proxy not ready, gateway errors, the host being
|
|
||||||
# unreachable, or an error page returned instead of JSON.
|
|
||||||
_OUTAGE_PHRASES = (
|
|
||||||
"not initialized", "not configured", "service unavailable",
|
|
||||||
"bad gateway", "gateway time", "request failed", "response not json",
|
|
||||||
"max retries", "connection", "timed out", "temporarily unavailable",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Back-off schedule while the source is down.
|
|
||||||
_NORMAL_DELAY = 2 # seconds between items when healthy
|
|
||||||
_OUTAGE_BASE = 30 # first back-off step
|
|
||||||
_OUTAGE_CAP = 1800 # 30 minutes max
|
|
||||||
|
|
||||||
|
|
||||||
def is_source_outage(exc: Exception) -> bool:
|
|
||||||
"""True when ``exc`` indicates the Amazon source/proxy is down (transient,
|
|
||||||
whole-source), as opposed to a normal per-item error.
|
|
||||||
|
|
||||||
Robust to how the error is surfaced: an explicit ``status_code`` attribute,
|
|
||||||
an ``HTTP <code>`` prefix in the message, or an outage phrase (covers
|
|
||||||
connection failures and non-JSON error pages that carry no status code)."""
|
|
||||||
code = getattr(exc, "status_code", None)
|
|
||||||
if isinstance(code, int) and code in _OUTAGE_STATUS:
|
|
||||||
return True
|
|
||||||
msg = str(exc).lower()
|
|
||||||
m = re.search(r"http\s+(\d{3})", msg)
|
|
||||||
if m and int(m.group(1)) in _OUTAGE_STATUS:
|
|
||||||
return True
|
|
||||||
return any(p in msg for p in _OUTAGE_PHRASES)
|
|
||||||
|
|
||||||
|
|
||||||
def next_poll_delay_seconds(outage_streak: int) -> int:
|
|
||||||
"""Seconds to wait before the next item. Normal cadence when healthy;
|
|
||||||
escalating back-off (30s, 60s, 120s, … capped at 30 min) the longer the
|
|
||||||
source has been down, so a dead instance can't flood logs/CPU/DB."""
|
|
||||||
if outage_streak <= 0:
|
|
||||||
return _NORMAL_DELAY
|
|
||||||
return min(_OUTAGE_BASE * (2 ** min(outage_streak - 1, 6)), _OUTAGE_CAP)
|
|
||||||
|
|
@ -1,645 +0,0 @@
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
from database.music_database import MusicDatabase
|
|
||||||
from core.amazon_client import AmazonClient
|
|
||||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
|
||||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
|
||||||
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
|
|
||||||
|
|
||||||
logger = get_logger("amazon_worker")
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonWorker:
|
|
||||||
"""Background worker for enriching library artists, albums, and tracks with Amazon Music metadata."""
|
|
||||||
|
|
||||||
def __init__(self, database: MusicDatabase):
|
|
||||||
self.db = database
|
|
||||||
self.client = AmazonClient()
|
|
||||||
self._amazon_schema_checked = False
|
|
||||||
|
|
||||||
self.running = False
|
|
||||||
self.paused = False
|
|
||||||
self.should_stop = False
|
|
||||||
self.thread = None
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
self.current_item = None
|
|
||||||
|
|
||||||
self.stats = {
|
|
||||||
'matched': 0,
|
|
||||||
'not_found': 0,
|
|
||||||
'pending': 0,
|
|
||||||
'errors': 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.retry_days = 30
|
|
||||||
self.name_similarity_threshold = 0.80
|
|
||||||
|
|
||||||
# Source-outage circuit breaker: counts consecutive whole-source
|
|
||||||
# failures (proxy down / "not initialized" / unreachable) so the loop
|
|
||||||
# backs off instead of grinding the whole library item-by-item.
|
|
||||||
self._outage_streak = 0
|
|
||||||
|
|
||||||
logger.info("Amazon background worker initialized")
|
|
||||||
|
|
||||||
def _ensure_amazon_schema(self, cursor) -> None:
|
|
||||||
"""Ensure upgraded installs have the Amazon enrichment columns.
|
|
||||||
|
|
||||||
MusicDatabase normally runs this migration during startup, but the
|
|
||||||
worker should still be defensive because it is the code path that
|
|
||||||
repeatedly queries these columns in the background.
|
|
||||||
"""
|
|
||||||
if self._amazon_schema_checked:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_columns = {
|
|
||||||
'artists': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
|
|
||||||
'albums': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
|
|
||||||
'tracks': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
|
|
||||||
}
|
|
||||||
for table, columns in table_columns.items():
|
|
||||||
cursor.execute(f"PRAGMA table_info({table})")
|
|
||||||
existing = {row[1] for row in cursor.fetchall()}
|
|
||||||
for column in columns:
|
|
||||||
if column not in existing:
|
|
||||||
column_type = 'TIMESTAMP' if column == 'amazon_last_attempted' else 'TEXT'
|
|
||||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
|
|
||||||
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")
|
|
||||||
cursor.connection.commit()
|
|
||||||
self._amazon_schema_checked = True
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if self.running:
|
|
||||||
logger.warning("Worker already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
self.should_stop = False
|
|
||||||
self._stop_event.clear()
|
|
||||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
logger.info("Amazon background worker started")
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
if not self.running:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Stopping Amazon worker...")
|
|
||||||
self.should_stop = True
|
|
||||||
self.running = False
|
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
if self.thread:
|
|
||||||
self.thread.join(timeout=1)
|
|
||||||
|
|
||||||
logger.info("Amazon worker stopped")
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
if not self.running:
|
|
||||||
logger.warning("Worker not running, cannot pause")
|
|
||||||
return
|
|
||||||
self.paused = True
|
|
||||||
logger.info("Amazon worker paused")
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
if not self.running:
|
|
||||||
logger.warning("Worker not running, start it first")
|
|
||||||
return
|
|
||||||
self.paused = False
|
|
||||||
logger.info("Amazon worker resumed")
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
|
||||||
self.stats['pending'] = self._count_pending_items()
|
|
||||||
progress = self._get_progress_breakdown()
|
|
||||||
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
|
||||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
|
||||||
return {
|
|
||||||
'enabled': True,
|
|
||||||
'running': is_actually_running and not self.paused,
|
|
||||||
'paused': self.paused,
|
|
||||||
'idle': is_idle,
|
|
||||||
'current_item': self.current_item,
|
|
||||||
'stats': self.stats.copy(),
|
|
||||||
'progress': progress,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
logger.info("Amazon worker thread started")
|
|
||||||
while not self.should_stop:
|
|
||||||
try:
|
|
||||||
if self.paused:
|
|
||||||
interruptible_sleep(self._stop_event, 1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.current_item = None
|
|
||||||
item = self._get_next_item()
|
|
||||||
|
|
||||||
if not item:
|
|
||||||
logger.debug("No pending items, sleeping...")
|
|
||||||
interruptible_sleep(self._stop_event, 10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.current_item = item
|
|
||||||
item_id = item.get('id') or item.get('artist_id') or item.get('album_id')
|
|
||||||
if item_id is None:
|
|
||||||
logger.warning(f"Skipping {item.get('type', 'unknown')} with NULL id: {item.get('name', '?')}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._process_item(item)
|
|
||||||
# Normal 2s cadence when healthy; escalating back-off (up to
|
|
||||||
# 30 min) while the source is in an outage streak.
|
|
||||||
interruptible_sleep(self._stop_event, next_poll_delay_seconds(self._outage_streak))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in worker loop: {e}")
|
|
||||||
interruptible_sleep(self._stop_event, 5)
|
|
||||||
|
|
||||||
logger.info("Amazon worker thread finished")
|
|
||||||
|
|
||||||
def _get_next_item(self) -> Optional[Dict[str, Any]]:
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
self._ensure_amazon_schema(cursor)
|
|
||||||
|
|
||||||
# Pinned-group override (Manage Enrichment Workers): process one
|
|
||||||
# entity type first, then fall through to the normal chain. Unset or
|
|
||||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
|
||||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
|
||||||
_prio = read_enrichment_priority('amazon')
|
|
||||||
if _prio:
|
|
||||||
_pi = priority_pending_item(cursor, 'amazon', _prio)
|
|
||||||
if _pi:
|
|
||||||
return _pi
|
|
||||||
|
|
||||||
# Priority 1: Unattempted artists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, name FROM artists
|
|
||||||
WHERE amazon_match_status IS NULL AND id IS NOT NULL
|
|
||||||
ORDER BY id ASC LIMIT 1
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
|
||||||
|
|
||||||
# Priority 2: Unattempted albums
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
|
||||||
FROM albums a
|
|
||||||
JOIN artists ar ON a.artist_id = ar.id
|
|
||||||
WHERE a.amazon_match_status IS NULL AND a.id IS NOT NULL
|
|
||||||
ORDER BY a.id ASC LIMIT 1
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
|
||||||
|
|
||||||
# Priority 3: Unattempted tracks
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
|
||||||
FROM tracks t
|
|
||||||
JOIN artists ar ON t.artist_id = ar.id
|
|
||||||
WHERE t.amazon_match_status IS NULL AND t.id IS NOT NULL
|
|
||||||
ORDER BY t.id ASC LIMIT 1
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
|
||||||
|
|
||||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
|
||||||
|
|
||||||
# Priority 4: Retry not_found artists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, name FROM artists
|
|
||||||
WHERE amazon_match_status = 'not_found' AND amazon_last_attempted < ?
|
|
||||||
ORDER BY amazon_last_attempted ASC LIMIT 1
|
|
||||||
""", (not_found_cutoff,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
|
||||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
|
||||||
|
|
||||||
# Priority 5: Retry not_found albums
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
|
||||||
FROM albums a
|
|
||||||
JOIN artists ar ON a.artist_id = ar.id
|
|
||||||
WHERE a.amazon_match_status = 'not_found' AND a.amazon_last_attempted < ?
|
|
||||||
ORDER BY a.amazon_last_attempted ASC LIMIT 1
|
|
||||||
""", (not_found_cutoff,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
|
||||||
|
|
||||||
# Priority 6: Retry not_found tracks
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
|
||||||
FROM tracks t
|
|
||||||
JOIN artists ar ON t.artist_id = ar.id
|
|
||||||
WHERE t.amazon_match_status = 'not_found' AND t.amazon_last_attempted < ?
|
|
||||||
ORDER BY t.amazon_last_attempted ASC LIMIT 1
|
|
||||||
""", (not_found_cutoff,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting next item: {e}")
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _normalize_name(self, name: str) -> str:
|
|
||||||
name = name.lower().strip()
|
|
||||||
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
|
|
||||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
|
||||||
name = re.sub(r'[^\w\s]', '', name)
|
|
||||||
name = re.sub(r'\s+', ' ', name).strip()
|
|
||||||
return name
|
|
||||||
|
|
||||||
def _name_matches(self, query_name: str, result_name: str) -> bool:
|
|
||||||
norm_query = self._normalize_name(query_name)
|
|
||||||
norm_result = self._normalize_name(result_name)
|
|
||||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
|
||||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
|
||||||
return similarity >= self.name_similarity_threshold
|
|
||||||
|
|
||||||
def _process_item(self, item: Dict[str, Any]):
|
|
||||||
try:
|
|
||||||
item_type = item['type']
|
|
||||||
item_id = item['id']
|
|
||||||
item_name = item['name']
|
|
||||||
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
|
|
||||||
|
|
||||||
if item_type == 'artist':
|
|
||||||
self._process_artist(item_id, item_name)
|
|
||||||
elif item_type == 'album':
|
|
||||||
self._process_album(item_id, item_name, item.get('artist', ''), item)
|
|
||||||
elif item_type == 'track':
|
|
||||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
|
||||||
|
|
||||||
# The source answered (match or not_found) — clear any outage streak.
|
|
||||||
if self._outage_streak:
|
|
||||||
logger.info("Amazon source recovered after %d outage(s), resuming",
|
|
||||||
self._outage_streak)
|
|
||||||
self._outage_streak = 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if is_source_outage(e):
|
|
||||||
# The whole source is down (proxy 5xx / "not initialized" /
|
|
||||||
# unreachable). Do NOT mark the item 'error' — that would burn
|
|
||||||
# the entire library to a state the retry tiers never re-attempt
|
|
||||||
# for a transient outage. Leave it untouched so it's retried once
|
|
||||||
# the instance recovers, and let the loop back off. Log once per
|
|
||||||
# streak to avoid flooding.
|
|
||||||
self._outage_streak += 1
|
|
||||||
if self._outage_streak == 1:
|
|
||||||
logger.warning("Amazon source unavailable — pausing enrichment "
|
|
||||||
"until it recovers: %s", e)
|
|
||||||
else:
|
|
||||||
logger.debug("Amazon source still unavailable (streak=%d): %s",
|
|
||||||
self._outage_streak, e)
|
|
||||||
return
|
|
||||||
# A non-outage error means the source actually answered (e.g. a
|
|
||||||
# 404/parse error on a real response), so the outage is over —
|
|
||||||
# clear the streak and handle this as a normal per-item error.
|
|
||||||
self._outage_streak = 0
|
|
||||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
|
||||||
self.stats['errors'] += 1
|
|
||||||
try:
|
|
||||||
self._mark_status(item['type'], item['id'], 'error')
|
|
||||||
except Exception as e2:
|
|
||||||
logger.error(f"Error updating item status: {e2}")
|
|
||||||
|
|
||||||
def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]:
|
|
||||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
|
||||||
table = table_map.get(entity_type)
|
|
||||||
if not table:
|
|
||||||
return None
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(f"SELECT amazon_id FROM {table} WHERE id = ?", (entity_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return row[0] if row and row[0] else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _process_artist(self, artist_id: int, artist_name: str):
|
|
||||||
existing_id = self._get_existing_id('artist', artist_id)
|
|
||||||
if existing_id:
|
|
||||||
logger.debug(f"Preserving existing Amazon ID for artist '{artist_name}': {existing_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
results = self.client.search_artists(artist_name, limit=5)
|
|
||||||
if results:
|
|
||||||
result = results[0]
|
|
||||||
if self._name_matches(artist_name, result.name):
|
|
||||||
self._update_artist(artist_id, result)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Amazon ID: {result.id}")
|
|
||||||
else:
|
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result.name}')")
|
|
||||||
else:
|
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"No match for artist '{artist_name}'")
|
|
||||||
|
|
||||||
def _refresh_album_via_stored_id(self, album_id, stored_id, api_data):
|
|
||||||
self._update_album(album_id, api_data, stored_id)
|
|
||||||
|
|
||||||
def _refresh_track_via_stored_id(self, track_id, stored_id, api_data):
|
|
||||||
self._update_track(track_id, api_data, stored_id)
|
|
||||||
|
|
||||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
|
||||||
if honor_stored_match(
|
|
||||||
db=self.db, entity_table='albums', entity_id=album_id,
|
|
||||||
id_column='amazon_id',
|
|
||||||
client_fetch_fn=lambda asin: self.client.get_album(asin, include_tracks=False),
|
|
||||||
on_match_fn=self._refresh_album_via_stored_id,
|
|
||||||
log_prefix='Amazon',
|
|
||||||
):
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
query = f"{artist_name} {album_name}"
|
|
||||||
results = self.client.search_albums(query, limit=10)
|
|
||||||
if results:
|
|
||||||
result = results[0]
|
|
||||||
if self._name_matches(album_name, result.name):
|
|
||||||
full_album = None
|
|
||||||
if result.id:
|
|
||||||
try:
|
|
||||||
full_album = self.client.get_album(result.id, include_tracks=False)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch full album '{album_name}' (ASIN: {result.id}): {e}")
|
|
||||||
|
|
||||||
if full_album is None:
|
|
||||||
self._mark_status('album', album_id, 'error')
|
|
||||||
self.stats['errors'] += 1
|
|
||||||
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._update_album(album_id, full_album, result.id)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Matched album '{album_name}' -> Amazon ASIN: {result.id}")
|
|
||||||
else:
|
|
||||||
self._mark_status('album', album_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"Name mismatch for album '{album_name}' (got '{result.name}')")
|
|
||||||
else:
|
|
||||||
self._mark_status('album', album_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"No match for album '{album_name}'")
|
|
||||||
|
|
||||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
|
||||||
if honor_stored_match(
|
|
||||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
|
||||||
id_column='amazon_id',
|
|
||||||
client_fetch_fn=self.client.get_track_details,
|
|
||||||
on_match_fn=self._refresh_track_via_stored_id,
|
|
||||||
log_prefix='Amazon',
|
|
||||||
):
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
query = f"{artist_name} {track_name}"
|
|
||||||
results = self.client.search_tracks(query, limit=10)
|
|
||||||
if results:
|
|
||||||
result = results[0]
|
|
||||||
if self._name_matches(track_name, result.name):
|
|
||||||
full_track = None
|
|
||||||
if result.id:
|
|
||||||
try:
|
|
||||||
full_track = self.client.get_track_details(result.id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch full track '{track_name}' (ASIN: {result.id}): {e}")
|
|
||||||
|
|
||||||
if full_track is None:
|
|
||||||
self._mark_status('track', track_id, 'error')
|
|
||||||
self.stats['errors'] += 1
|
|
||||||
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._update_track(track_id, full_track, result.id)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Matched track '{track_name}' -> Amazon ASIN: {result.id}")
|
|
||||||
else:
|
|
||||||
self._mark_status('track', track_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"Name mismatch for track '{track_name}' (got '{result.name}')")
|
|
||||||
else:
|
|
||||||
self._mark_status('track', track_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"No match for track '{track_name}'")
|
|
||||||
|
|
||||||
def _update_artist(self, artist_id: int, result):
|
|
||||||
"""Store Amazon metadata for an artist. ``result`` is an Artist dataclass."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET
|
|
||||||
amazon_id = ?,
|
|
||||||
amazon_match_status = 'matched',
|
|
||||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (str(result.id), artist_id))
|
|
||||||
|
|
||||||
# Backfill thumb_url from album cover stand-in when artist has no image
|
|
||||||
image_url = result.image_url
|
|
||||||
if not image_url:
|
|
||||||
try:
|
|
||||||
image_url = self.client._get_artist_image_from_albums(result.id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Artist image from albums failed for %s: %s", result.id, exc)
|
|
||||||
if image_url:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET thumb_url = ?
|
|
||||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
|
||||||
""", (image_url, artist_id))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating artist #{artist_id} with Amazon data: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _update_album(self, album_id: int, full_data: Dict[str, Any], asin: str):
|
|
||||||
"""Store Amazon metadata for an album. ``full_data`` is a get_album() dict."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET
|
|
||||||
amazon_id = ?,
|
|
||||||
amazon_match_status = 'matched',
|
|
||||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (asin, album_id))
|
|
||||||
|
|
||||||
# Backfill label when missing
|
|
||||||
label = full_data.get('label')
|
|
||||||
if label:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET label = ?
|
|
||||||
WHERE id = ? AND (label IS NULL OR label = '')
|
|
||||||
""", (label, album_id))
|
|
||||||
|
|
||||||
# Backfill thumb_url
|
|
||||||
images = full_data.get('images') or []
|
|
||||||
thumb_url = images[0].get('url') if images else None
|
|
||||||
if thumb_url:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET thumb_url = ?
|
|
||||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
|
||||||
""", (thumb_url, album_id))
|
|
||||||
|
|
||||||
# Cache authoritative track count for completeness repair
|
|
||||||
total_tracks = full_data.get('total_tracks') or (
|
|
||||||
full_data.get('tracks', {}).get('total') if isinstance(full_data.get('tracks'), dict) else None
|
|
||||||
)
|
|
||||||
set_album_api_track_count(cursor, album_id, total_tracks)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating album #{album_id} with Amazon data: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _update_track(self, track_id: int, full_data: Dict[str, Any], asin: str):
|
|
||||||
"""Store Amazon metadata for a track. ``full_data`` is a get_track_details() dict."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE tracks SET
|
|
||||||
amazon_id = ?,
|
|
||||||
amazon_match_status = 'matched',
|
|
||||||
amazon_last_attempted = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (asin, track_id))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating track #{track_id} with Amazon data: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _mark_status(self, entity_type: str, entity_id: int, status: str):
|
|
||||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
|
||||||
table = table_map.get(entity_type)
|
|
||||||
if not table:
|
|
||||||
logger.error(f"Unknown entity type: {entity_type}")
|
|
||||||
return
|
|
||||||
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(f"""
|
|
||||||
UPDATE {table} SET
|
|
||||||
amazon_match_status = ?,
|
|
||||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (status, entity_id))
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _count_pending_items(self) -> int:
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
self._ensure_amazon_schema(cursor)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM artists WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
|
||||||
(SELECT COUNT(*) FROM albums WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
|
||||||
(SELECT COUNT(*) FROM tracks WHERE amazon_match_status IS NULL AND id IS NOT NULL)
|
|
||||||
AS pending
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return row[0] if row else 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error counting pending items: {e}")
|
|
||||||
return 0
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]:
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
self._ensure_amazon_schema(cursor)
|
|
||||||
progress = {}
|
|
||||||
|
|
||||||
for table in ('artists', 'albums', 'tracks'):
|
|
||||||
cursor.execute(f"""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS total,
|
|
||||||
SUM(CASE WHEN amazon_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
|
|
||||||
FROM {table}
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
total, processed = row[0], row[1] or 0
|
|
||||||
progress[table] = {
|
|
||||||
'matched': processed,
|
|
||||||
'total': total,
|
|
||||||
'percent': int((processed / total * 100) if total > 0 else 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
return progress
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting progress breakdown: {e}")
|
|
||||||
return {}
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
"""
|
|
||||||
Centralized API call tracker for all enrichment services.
|
|
||||||
|
|
||||||
Tracks actual API calls (not items processed) with rolling timestamps
|
|
||||||
for real-time rate monitoring and minute-bucketed history for 24-hour graphs.
|
|
||||||
Thread-safe, persists 24h history to disk on shutdown and restores on startup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
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 = {
|
|
||||||
'spotify': 171, # MIN_API_INTERVAL=0.35s → ~171/min
|
|
||||||
'itunes': 20, # MIN_API_INTERVAL=3.0s → ~20/min
|
|
||||||
'deezer': 60, # MIN_API_INTERVAL=1.0s → ~60/min
|
|
||||||
'lastfm': 300, # MIN_API_INTERVAL=0.2s → ~300/min
|
|
||||||
'genius': 30, # MIN_API_INTERVAL=2.0s → ~30/min
|
|
||||||
'musicbrainz': 60, # MIN_API_INTERVAL=1.0s → ~60/min
|
|
||||||
'audiodb': 30, # MIN_API_INTERVAL=2.0s → ~30/min
|
|
||||||
'tidal': 120, # MIN_API_INTERVAL=0.5s → ~120/min
|
|
||||||
'qobuz': 60, # Variable throttle, ~60/min estimate
|
|
||||||
'discogs': 60, # MIN_API_INTERVAL=1.0s with auth → ~60/min
|
|
||||||
'amazon': 120, # MIN_API_INTERVAL=0.5s → ~120/min (T2Tunes proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Display names for UI
|
|
||||||
SERVICE_LABELS = {
|
|
||||||
'spotify': 'Spotify',
|
|
||||||
'itunes': 'Apple Music',
|
|
||||||
'deezer': 'Deezer',
|
|
||||||
'lastfm': 'Last.fm',
|
|
||||||
'genius': 'Genius',
|
|
||||||
'musicbrainz': 'MusicBrainz',
|
|
||||||
'audiodb': 'AudioDB',
|
|
||||||
'tidal': 'Tidal',
|
|
||||||
'qobuz': 'Qobuz',
|
|
||||||
'discogs': 'Discogs',
|
|
||||||
'amazon': 'Amazon Music',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Display order
|
|
||||||
SERVICE_ORDER = [
|
|
||||||
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
|
|
||||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_PERSIST_PATH = os.path.join('database', 'api_call_history.json')
|
|
||||||
|
|
||||||
|
|
||||||
class ApiCallTracker:
|
|
||||||
"""Centralized tracker for actual API calls across all enrichment services."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
# Recent call timestamps per service (last 60s window for speedometer)
|
|
||||||
# maxlen=600 covers 10 calls/sec for 60s — more than any service does
|
|
||||||
self._recent_calls = defaultdict(lambda: deque(maxlen=600))
|
|
||||||
|
|
||||||
# 24-hour minute-bucketed history per service
|
|
||||||
# Each entry: (minute_floor_timestamp, call_count)
|
|
||||||
self._minute_history = defaultdict(lambda: deque(maxlen=1440))
|
|
||||||
self._current_minute_counts = defaultdict(int)
|
|
||||||
self._current_minute_ts = {}
|
|
||||||
|
|
||||||
# Rate limit event log — records bans, peaks, escalations
|
|
||||||
# Each entry: {ts, event, service, endpoint, duration, detail}
|
|
||||||
self._events = deque(maxlen=200)
|
|
||||||
|
|
||||||
# Restore persisted history from disk
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def record_call(self, service_key, endpoint=None):
|
|
||||||
"""Record an API call. Called from rate_limited decorators.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
service_key: Service identifier ('spotify', 'itunes', etc.)
|
|
||||||
endpoint: Optional endpoint name for per-endpoint tracking (Spotify only)
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
minute_floor = int(now // 60) * 60
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
# Record in recent timestamps
|
|
||||||
self._recent_calls[service_key].append(now)
|
|
||||||
# Roll minute bucket
|
|
||||||
self._roll_minute(service_key, minute_floor)
|
|
||||||
|
|
||||||
# Spotify per-endpoint tracking
|
|
||||||
if endpoint and service_key == 'spotify':
|
|
||||||
ep_key = f"spotify:{endpoint}"
|
|
||||||
self._recent_calls[ep_key].append(now)
|
|
||||||
self._roll_minute(ep_key, minute_floor)
|
|
||||||
|
|
||||||
def _roll_minute(self, key, minute_floor):
|
|
||||||
"""Roll the minute bucket forward if we've moved to a new minute.
|
|
||||||
Must be called while holding self._lock."""
|
|
||||||
prev_ts = self._current_minute_ts.get(key)
|
|
||||||
|
|
||||||
if prev_ts is None or minute_floor > prev_ts:
|
|
||||||
# Flush previous minute's count to history
|
|
||||||
if prev_ts is not None and self._current_minute_counts[key] > 0:
|
|
||||||
self._minute_history[key].append((prev_ts, self._current_minute_counts[key]))
|
|
||||||
# Fill gaps with zeros (if minutes were skipped)
|
|
||||||
if prev_ts is not None:
|
|
||||||
gap_start = prev_ts + 60
|
|
||||||
while gap_start < minute_floor:
|
|
||||||
self._minute_history[key].append((gap_start, 0))
|
|
||||||
gap_start += 60
|
|
||||||
# Start new minute
|
|
||||||
self._current_minute_ts[key] = minute_floor
|
|
||||||
self._current_minute_counts[key] = 1
|
|
||||||
else:
|
|
||||||
self._current_minute_counts[key] += 1
|
|
||||||
|
|
||||||
def record_event(self, service_key, event_type, detail='', endpoint='', duration=0):
|
|
||||||
"""Record a rate limit event (ban, escalation, cooldown, etc.).
|
|
||||||
Called from spotify_client.py when rate limits are detected."""
|
|
||||||
with self._lock:
|
|
||||||
self._events.append({
|
|
||||||
'ts': time.time(),
|
|
||||||
'event': event_type,
|
|
||||||
'service': service_key,
|
|
||||||
'endpoint': endpoint,
|
|
||||||
'duration': duration,
|
|
||||||
'detail': detail,
|
|
||||||
})
|
|
||||||
|
|
||||||
def get_events(self, since=None):
|
|
||||||
"""Get rate limit events, optionally filtered by timestamp."""
|
|
||||||
cutoff = since or (time.time() - 86400)
|
|
||||||
with self._lock:
|
|
||||||
return [e for e in self._events if e['ts'] >= cutoff]
|
|
||||||
|
|
||||||
def get_debug_summary(self):
|
|
||||||
"""Build a comprehensive debug summary for Copy Debug Info.
|
|
||||||
Includes 24h totals, peaks, rate limit events, and per-endpoint breakdown."""
|
|
||||||
now = time.time()
|
|
||||||
cutoff_24h = now - 86400
|
|
||||||
summary = {}
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
for svc in SERVICE_ORDER:
|
|
||||||
# 24h total calls
|
|
||||||
total = 0
|
|
||||||
peak_cpm = 0
|
|
||||||
peak_ts = 0
|
|
||||||
for ts, count in self._minute_history.get(svc, []):
|
|
||||||
if ts >= cutoff_24h:
|
|
||||||
total += count
|
|
||||||
if count > peak_cpm:
|
|
||||||
peak_cpm = count
|
|
||||||
peak_ts = ts
|
|
||||||
# Include current minute
|
|
||||||
cur_ts = self._current_minute_ts.get(svc)
|
|
||||||
cur_count = self._current_minute_counts.get(svc, 0)
|
|
||||||
if cur_ts and cur_ts >= cutoff_24h:
|
|
||||||
total += cur_count
|
|
||||||
if cur_count > peak_cpm:
|
|
||||||
peak_cpm = cur_count
|
|
||||||
peak_ts = cur_ts
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
'total_24h': total,
|
|
||||||
'peak_cpm': peak_cpm,
|
|
||||||
'limit_cpm': RATE_LIMITS.get(svc, 60),
|
|
||||||
}
|
|
||||||
if peak_ts:
|
|
||||||
entry['peak_at'] = time.strftime('%Y-%m-%d %H:%M', time.localtime(peak_ts))
|
|
||||||
summary[svc] = entry
|
|
||||||
|
|
||||||
# Spotify per-endpoint breakdown
|
|
||||||
if svc == 'spotify':
|
|
||||||
ep_totals = {}
|
|
||||||
for key in list(self._minute_history.keys()):
|
|
||||||
if key.startswith('spotify:'):
|
|
||||||
ep_name = key[8:]
|
|
||||||
ep_total = sum(c for ts, c in self._minute_history[key] if ts >= cutoff_24h)
|
|
||||||
cur = self._current_minute_counts.get(key, 0)
|
|
||||||
ep_total += cur
|
|
||||||
if ep_total > 0:
|
|
||||||
ep_totals[ep_name] = ep_total
|
|
||||||
if ep_totals:
|
|
||||||
summary[svc]['endpoints'] = ep_totals
|
|
||||||
|
|
||||||
# Rate limit events
|
|
||||||
events = [e for e in self._events if e['ts'] >= cutoff_24h]
|
|
||||||
|
|
||||||
if events:
|
|
||||||
summary['_rate_limit_events'] = [{
|
|
||||||
'time': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['ts'])),
|
|
||||||
'event': e['event'],
|
|
||||||
'service': e['service'],
|
|
||||||
'endpoint': e.get('endpoint', ''),
|
|
||||||
'duration': e.get('duration', 0),
|
|
||||||
'detail': e.get('detail', ''),
|
|
||||||
} for e in events[-20:]] # Last 20 events
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def get_calls_per_minute(self, service_key):
|
|
||||||
"""Get current calls/minute rate from last 60 seconds."""
|
|
||||||
now = time.time()
|
|
||||||
cutoff = now - 60.0
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
dq = self._recent_calls.get(service_key)
|
|
||||||
if not dq:
|
|
||||||
return 0.0
|
|
||||||
count = sum(1 for ts in dq if ts > cutoff)
|
|
||||||
return float(count)
|
|
||||||
|
|
||||||
def get_24h_history(self, service_key):
|
|
||||||
"""Return list of [minute_timestamp, count] for last 24 hours.
|
|
||||||
Includes the current in-progress minute."""
|
|
||||||
now = time.time()
|
|
||||||
cutoff = now - 86400
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
history = []
|
|
||||||
for ts, count in self._minute_history.get(service_key, []):
|
|
||||||
if ts >= cutoff:
|
|
||||||
history.append([ts, count])
|
|
||||||
|
|
||||||
# Include current minute in progress
|
|
||||||
cur_ts = self._current_minute_ts.get(service_key)
|
|
||||||
cur_count = self._current_minute_counts.get(service_key, 0)
|
|
||||||
if cur_ts is not None and cur_count > 0 and cur_ts >= cutoff:
|
|
||||||
history.append([cur_ts, cur_count])
|
|
||||||
|
|
||||||
return history
|
|
||||||
|
|
||||||
def get_all_rates(self):
|
|
||||||
"""Get current rates for all services. Used by WebSocket emission."""
|
|
||||||
result = {}
|
|
||||||
for svc in SERVICE_ORDER:
|
|
||||||
cpm = self.get_calls_per_minute(svc)
|
|
||||||
entry = {
|
|
||||||
'cpm': round(cpm, 1),
|
|
||||||
'limit': RATE_LIMITS.get(svc, 60),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Spotify per-endpoint breakdown
|
|
||||||
if svc == 'spotify':
|
|
||||||
endpoints = {}
|
|
||||||
for key in list(self._recent_calls.keys()):
|
|
||||||
if key.startswith('spotify:'):
|
|
||||||
ep_name = key[8:] # strip 'spotify:'
|
|
||||||
ep_cpm = self.get_calls_per_minute(key)
|
|
||||||
if ep_cpm > 0:
|
|
||||||
endpoints[ep_name] = round(ep_cpm, 1)
|
|
||||||
entry['endpoints'] = endpoints
|
|
||||||
|
|
||||||
result[svc] = entry
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
"""Persist 24h minute history to disk. Call on shutdown.
|
|
||||||
|
|
||||||
Uses an atomic write (write to a sibling .tmp file, fsync, rename) so
|
|
||||||
a SIGINT/SIGTERM/crash mid-write can't leave the JSON file truncated.
|
|
||||||
Without this, an interrupted shutdown corrupts the history file and
|
|
||||||
the next startup loses 24h of metrics."""
|
|
||||||
try:
|
|
||||||
now = time.time()
|
|
||||||
cutoff = now - 86400
|
|
||||||
data = {}
|
|
||||||
with self._lock:
|
|
||||||
for key, hist in self._minute_history.items():
|
|
||||||
entries = [[ts, count] for ts, count in hist if ts >= cutoff]
|
|
||||||
# Include current in-progress minute
|
|
||||||
cur_ts = self._current_minute_ts.get(key)
|
|
||||||
cur_count = self._current_minute_counts.get(key, 0)
|
|
||||||
if cur_ts is not None and cur_count > 0 and cur_ts >= cutoff:
|
|
||||||
entries.append([cur_ts, cur_count])
|
|
||||||
if entries:
|
|
||||||
data[key] = entries
|
|
||||||
events = [dict(e) for e in self._events if e['ts'] >= cutoff]
|
|
||||||
|
|
||||||
payload = {'ts': now, 'history': data, 'events': events}
|
|
||||||
tmp_path = _PERSIST_PATH + '.tmp'
|
|
||||||
with open(tmp_path, 'w') as f:
|
|
||||||
json.dump(payload, f)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp_path, _PERSIST_PATH)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[ApiCallTracker] Failed to save history: {e}")
|
|
||||||
# Best-effort cleanup of stale tmp file from a failed write.
|
|
||||||
try:
|
|
||||||
if os.path.exists(_PERSIST_PATH + '.tmp'):
|
|
||||||
os.remove(_PERSIST_PATH + '.tmp')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("remove stale tmp file failed: %s", 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)
|
|
||||||
# Only restore if saved within last 24h
|
|
||||||
if time.time() - saved_ts > 86400:
|
|
||||||
return
|
|
||||||
history = raw.get('history', {})
|
|
||||||
events = raw.get('events', [])
|
|
||||||
cutoff = time.time() - 86400
|
|
||||||
with self._lock:
|
|
||||||
for key, entries in history.items():
|
|
||||||
for ts, count in entries:
|
|
||||||
if ts >= cutoff:
|
|
||||||
self._minute_history[key].append((ts, count))
|
|
||||||
for e in events:
|
|
||||||
if e.get('ts', 0) >= cutoff:
|
|
||||||
self._events.append(e)
|
|
||||||
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:
|
|
||||||
logger.error(f"[ApiCallTracker] Failed to load history: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
api_call_tracker = ApiCallTracker()
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
"""Archive extraction + audio-file discovery for torrent / usenet downloads.
|
|
||||||
|
|
||||||
The torrent and usenet download plugins need a uniform way to:
|
|
||||||
|
|
||||||
1. Walk the downloader's save directory and find every audio file in it.
|
|
||||||
2. If the directory contains an archive (``.zip`` / ``.rar`` / ``.tar`` /
|
|
||||||
``.7z``), extract it first so the audio files inside become walkable.
|
|
||||||
|
|
||||||
This module is intentionally narrow — no matching, no tagging, no
|
|
||||||
import. The download plugin layer composes this with the existing
|
|
||||||
post-processing / matching pipeline. Lidarr does NOT use this module:
|
|
||||||
Lidarr extracts archives in its own import step before SoulSync sees
|
|
||||||
the files at all. Usenet downloaders (SABnzbd, NZBGet) also auto-
|
|
||||||
extract by default. Torrents are the main case where SoulSync may
|
|
||||||
need to do the extract step itself — most music torrents ship loose,
|
|
||||||
but some bundle the album in a ``.rar`` archive.
|
|
||||||
|
|
||||||
``rarfile`` is an optional dependency. If it isn't installed, archives
|
|
||||||
with ``.rar`` content are skipped with a single warning rather than
|
|
||||||
crashing the download.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tarfile
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("archive_pipeline")
|
|
||||||
|
|
||||||
|
|
||||||
# Same audio-extension set as ``core/imports/file_ops.py`` ``quality_tiers``.
|
|
||||||
# Keep them in sync — if a new format is added to file_ops, add it here too
|
|
||||||
# or the walker will skip it and the download plugin will mark the download
|
|
||||||
# failed even when files arrived.
|
|
||||||
AUDIO_EXTENSIONS = frozenset([
|
|
||||||
# lossless
|
|
||||||
'.flac', '.ape', '.wav', '.alac', '.dsf', '.dff', '.aiff', '.aif',
|
|
||||||
# high lossy
|
|
||||||
'.opus', '.ogg',
|
|
||||||
# standard lossy
|
|
||||||
'.m4a', '.aac',
|
|
||||||
# low lossy
|
|
||||||
'.mp3', '.wma',
|
|
||||||
])
|
|
||||||
|
|
||||||
ARCHIVE_EXTENSIONS = frozenset(['.zip', '.rar', '.tar', '.tar.gz', '.tgz', '.7z'])
|
|
||||||
|
|
||||||
|
|
||||||
def is_archive(path: Path) -> bool:
|
|
||||||
"""True if the file extension looks like a supported archive.
|
|
||||||
|
|
||||||
Compound extensions (``.tar.gz``, ``.tar.bz2``) are detected by
|
|
||||||
checking the last two suffixes joined together — Path.suffix
|
|
||||||
only returns the final suffix.
|
|
||||||
"""
|
|
||||||
if not path.is_file():
|
|
||||||
return False
|
|
||||||
name = path.name.lower()
|
|
||||||
if name.endswith(('.tar.gz', '.tar.bz2', '.tar.xz')):
|
|
||||||
return True
|
|
||||||
return path.suffix.lower() in ARCHIVE_EXTENSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def walk_audio_files(directory: Path) -> List[Path]:
|
|
||||||
"""Recursively scan ``directory`` for audio files. Returns
|
|
||||||
a sorted list of absolute paths. Empty list if the directory
|
|
||||||
doesn't exist or contains no audio.
|
|
||||||
"""
|
|
||||||
if not directory or not directory.exists() or not directory.is_dir():
|
|
||||||
return []
|
|
||||||
out: List[Path] = []
|
|
||||||
for child in directory.rglob('*'):
|
|
||||||
if not child.is_file():
|
|
||||||
continue
|
|
||||||
if child.suffix.lower() in AUDIO_EXTENSIONS:
|
|
||||||
out.append(child.resolve())
|
|
||||||
out.sort()
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def find_archives_in_dir(directory: Path) -> List[Path]:
|
|
||||||
"""Find every archive file directly inside ``directory`` (one
|
|
||||||
level deep — torrents normally put the archive at the root of
|
|
||||||
their folder; we don't search nested dirs to avoid extracting
|
|
||||||
something we shouldn't).
|
|
||||||
"""
|
|
||||||
if not directory or not directory.exists() or not directory.is_dir():
|
|
||||||
return []
|
|
||||||
return sorted(p for p in directory.iterdir() if is_archive(p))
|
|
||||||
|
|
||||||
|
|
||||||
def extract_archive(archive_path: Path, extract_to: Optional[Path] = None) -> Optional[Path]:
|
|
||||||
"""Extract a single archive in-place (or to ``extract_to`` if
|
|
||||||
given). Returns the directory the archive was extracted into,
|
|
||||||
or ``None`` on failure.
|
|
||||||
|
|
||||||
Supports ``.zip``, ``.tar``/``.tar.gz``/``.tar.bz2``/``.tar.xz``,
|
|
||||||
and ``.rar`` (only when the optional ``rarfile`` library is
|
|
||||||
installed). ``.7z`` is recognised but extraction requires
|
|
||||||
``py7zr``; without it, the call logs and returns None.
|
|
||||||
"""
|
|
||||||
if not archive_path or not archive_path.exists():
|
|
||||||
logger.warning("archive_pipeline: %s does not exist", archive_path)
|
|
||||||
return None
|
|
||||||
dest = extract_to or archive_path.parent
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
name = archive_path.name.lower()
|
|
||||||
try:
|
|
||||||
if name.endswith('.zip'):
|
|
||||||
with zipfile.ZipFile(archive_path) as zf:
|
|
||||||
_safe_extract_zip(zf, dest)
|
|
||||||
return dest
|
|
||||||
if name.endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz')):
|
|
||||||
with tarfile.open(archive_path) as tf:
|
|
||||||
_safe_extract_tar(tf, dest)
|
|
||||||
return dest
|
|
||||||
if name.endswith('.rar'):
|
|
||||||
return _extract_rar(archive_path, dest)
|
|
||||||
if name.endswith('.7z'):
|
|
||||||
return _extract_7z(archive_path, dest)
|
|
||||||
except (zipfile.BadZipFile, tarfile.TarError, OSError) as e:
|
|
||||||
logger.error("archive_pipeline: failed to extract %s: %s", archive_path, e)
|
|
||||||
return None
|
|
||||||
logger.warning("archive_pipeline: unknown archive type for %s", archive_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_all_in_dir(directory: Path) -> List[Path]:
|
|
||||||
"""Find every archive in ``directory`` and extract each in place.
|
|
||||||
Returns the list of directories archives were extracted into
|
|
||||||
(usually all the same — ``directory`` itself). Archives that
|
|
||||||
failed to extract are skipped silently after a warning.
|
|
||||||
"""
|
|
||||||
out: List[Path] = []
|
|
||||||
for archive in find_archives_in_dir(directory):
|
|
||||||
result = extract_archive(archive)
|
|
||||||
if result is not None:
|
|
||||||
out.append(result)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def collect_audio_after_extraction(directory: Path) -> List[Path]:
|
|
||||||
"""One-shot helper for the download plugins: extract any archives
|
|
||||||
in the directory, then return the walked audio file list. This is
|
|
||||||
the common pattern — torrent / usenet plugin gets a save_path,
|
|
||||||
calls this, hands the resulting files to the matching pipeline.
|
|
||||||
"""
|
|
||||||
extract_all_in_dir(directory)
|
|
||||||
return walk_audio_files(directory)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Safety helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
|
||||||
"""Extract a zipfile after rejecting any member whose resolved
|
|
||||||
path escapes ``dest`` (path traversal protection).
|
|
||||||
"""
|
|
||||||
dest = dest.resolve()
|
|
||||||
for member in zf.namelist():
|
|
||||||
target = (dest / member).resolve()
|
|
||||||
if dest not in target.parents and target != dest:
|
|
||||||
logger.error("archive_pipeline: refusing path-traversal member %r", member)
|
|
||||||
return
|
|
||||||
zf.extractall(dest)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None:
|
|
||||||
"""Same path-traversal protection for tarfiles."""
|
|
||||||
dest = dest.resolve()
|
|
||||||
for member in tf.getmembers():
|
|
||||||
target = (dest / member.name).resolve()
|
|
||||||
if dest not in target.parents and target != dest:
|
|
||||||
logger.error("archive_pipeline: refusing path-traversal member %r", member.name)
|
|
||||||
return
|
|
||||||
# ``filter='data'`` is the Python 3.12+ safe extractor; fall back
|
|
||||||
# to the legacy call on older runtimes.
|
|
||||||
try:
|
|
||||||
tf.extractall(dest, filter='data') # type: ignore[call-arg]
|
|
||||||
except TypeError:
|
|
||||||
tf.extractall(dest)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_rar(archive_path: Path, dest: Path) -> Optional[Path]:
|
|
||||||
try:
|
|
||||||
import rarfile # type: ignore[import-untyped]
|
|
||||||
except ImportError:
|
|
||||||
logger.warning(
|
|
||||||
"archive_pipeline: cannot extract %s — rarfile library not installed. "
|
|
||||||
"Install with: pip install rarfile (and ensure unrar is on PATH).",
|
|
||||||
archive_path,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with rarfile.RarFile(archive_path) as rf:
|
|
||||||
dest_resolved = dest.resolve()
|
|
||||||
for name in rf.namelist():
|
|
||||||
target = (dest_resolved / name).resolve()
|
|
||||||
if dest_resolved not in target.parents and target != dest_resolved:
|
|
||||||
logger.error("archive_pipeline: refusing path-traversal rar member %r", name)
|
|
||||||
return None
|
|
||||||
rf.extractall(dest)
|
|
||||||
return dest
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("archive_pipeline: rar extract failed for %s: %s", archive_path, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_7z(archive_path: Path, dest: Path) -> Optional[Path]:
|
|
||||||
try:
|
|
||||||
import py7zr # type: ignore[import-untyped]
|
|
||||||
except ImportError:
|
|
||||||
logger.warning(
|
|
||||||
"archive_pipeline: cannot extract %s — py7zr library not installed. "
|
|
||||||
"Install with: pip install py7zr.",
|
|
||||||
archive_path,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with py7zr.SevenZipFile(archive_path, 'r') as sz:
|
|
||||||
dest_resolved = dest.resolve()
|
|
||||||
for name in sz.getnames():
|
|
||||||
target = (dest_resolved / name).resolve()
|
|
||||||
if dest_resolved not in target.parents and target != dest_resolved:
|
|
||||||
logger.error("archive_pipeline: refusing path-traversal 7z member %r", name)
|
|
||||||
return None
|
|
||||||
sz.extractall(path=dest)
|
|
||||||
return dest
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("archive_pipeline: 7z extract failed for %s: %s", archive_path, e)
|
|
||||||
return None
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
"""Synthesize an artist-detail response for an artist that isn't in the library.
|
|
||||||
|
|
||||||
Extracted from ``web_server.py`` so the logic is importable at test time.
|
|
||||||
The route handler in ``web_server.py`` is now a thin wrapper that builds the
|
|
||||||
per-source clients (which live as module globals there), calls this function,
|
|
||||||
and wraps the return value in ``jsonify``.
|
|
||||||
|
|
||||||
Used by ``/api/artist-detail/<id>`` when the URL is called with a ``source``
|
|
||||||
query parameter and the library DB lookup misses. Enriches the response with
|
|
||||||
whatever metadata we can pull on demand:
|
|
||||||
|
|
||||||
* Image URL (via ``core.metadata.artist_image.get_artist_image_url``)
|
|
||||||
* Source-specific artist info — genres + follower count from the named
|
|
||||||
source's ``get_artist`` / ``get_artist_info`` helper
|
|
||||||
* Last.fm bio + listeners + playcount + URL (by artist name)
|
|
||||||
* Discography from the named source, with variant dedup disabled so every
|
|
||||||
release surfaces
|
|
||||||
|
|
||||||
All per-source clients are passed in explicitly. Callers that can't or don't
|
|
||||||
want to provide a given client pass ``None`` — the corresponding enrichment
|
|
||||||
branch becomes a no-op.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
from core.artist_source_lookup import SOURCE_ID_FIELD
|
|
||||||
from core.metadata import artist_image as metadata_artist_image
|
|
||||||
from core.metadata import discography as metadata_discography
|
|
||||||
from core.metadata.lookup import MetadataLookupOptions
|
|
||||||
|
|
||||||
logger = logging.getLogger("artist_source_detail")
|
|
||||||
|
|
||||||
|
|
||||||
def build_source_only_artist_detail(
|
|
||||||
artist_id: str,
|
|
||||||
artist_name: str,
|
|
||||||
source: str,
|
|
||||||
*,
|
|
||||||
spotify_client: Optional[Any] = None,
|
|
||||||
deezer_client: Optional[Any] = None,
|
|
||||||
itunes_client: Optional[Any] = None,
|
|
||||||
discogs_client: Optional[Any] = None,
|
|
||||||
amazon_client: Optional[Any] = None,
|
|
||||||
lastfm_api_key: Optional[str] = None,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Build the artist-detail payload for a source-only artist.
|
|
||||||
|
|
||||||
Returns ``(payload_dict, http_status)``. Callers wrap the dict in
|
|
||||||
``jsonify`` or equivalent. Status is 200 on success, 404 when the
|
|
||||||
source's discography lookup returned no releases.
|
|
||||||
"""
|
|
||||||
resolved_name = (artist_name or "").strip()
|
|
||||||
|
|
||||||
# 1. Image URL via the same helper /api/artist/<id>/image uses.
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
try:
|
|
||||||
image_url = metadata_artist_image.get_artist_image_url(artist_id, source_override=source)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}")
|
|
||||||
|
|
||||||
# 2. Source-side artist info (image, genres, followers depending on source).
|
|
||||||
source_genres: list = []
|
|
||||||
source_followers: Optional[int] = None
|
|
||||||
try:
|
|
||||||
if source == "spotify" and spotify_client is not None:
|
|
||||||
sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False)
|
|
||||||
if sp_artist:
|
|
||||||
if not artist_name and sp_artist.get("name"):
|
|
||||||
resolved_name = sp_artist["name"]
|
|
||||||
source_genres = sp_artist.get("genres") or []
|
|
||||||
source_followers = (sp_artist.get("followers") or {}).get("total")
|
|
||||||
if not image_url and sp_artist.get("images"):
|
|
||||||
image_url = sp_artist["images"][0].get("url")
|
|
||||||
elif source == "deezer" and deezer_client is not None:
|
|
||||||
dz_artist = deezer_client.get_artist_info(artist_id)
|
|
||||||
if dz_artist:
|
|
||||||
if not artist_name and dz_artist.get("name"):
|
|
||||||
resolved_name = dz_artist["name"]
|
|
||||||
source_genres = dz_artist.get("genres") or []
|
|
||||||
source_followers = (dz_artist.get("followers") or {}).get("total")
|
|
||||||
elif source == "itunes" and itunes_client is not None:
|
|
||||||
it_artist = itunes_client.get_artist(artist_id)
|
|
||||||
if it_artist:
|
|
||||||
if not artist_name and it_artist.get("name"):
|
|
||||||
resolved_name = it_artist["name"]
|
|
||||||
source_genres = it_artist.get("genres") or []
|
|
||||||
elif source == "discogs" and discogs_client is not None:
|
|
||||||
dc_artist = discogs_client.get_artist(artist_id)
|
|
||||||
if dc_artist:
|
|
||||||
if not artist_name and dc_artist.get("name"):
|
|
||||||
resolved_name = dc_artist["name"]
|
|
||||||
source_genres = dc_artist.get("genres") or []
|
|
||||||
elif source == "amazon" and amazon_client is not None:
|
|
||||||
az_artist = amazon_client.get_artist(resolved_name or artist_id)
|
|
||||||
if az_artist:
|
|
||||||
if not artist_name and az_artist.get("name"):
|
|
||||||
resolved_name = az_artist["name"]
|
|
||||||
source_genres = az_artist.get("genres") or []
|
|
||||||
if not image_url and az_artist.get("images"):
|
|
||||||
image_url = az_artist["images"][0].get("url")
|
|
||||||
elif source == "musicbrainz":
|
|
||||||
try:
|
|
||||||
from core.musicbrainz_search import MusicBrainzSearchClient
|
|
||||||
mb = MusicBrainzSearchClient()
|
|
||||||
mb_artist = mb.get_artist(artist_id)
|
|
||||||
if mb_artist:
|
|
||||||
if not artist_name and mb_artist.get("name"):
|
|
||||||
resolved_name = mb_artist["name"]
|
|
||||||
source_genres = mb_artist.get("genres") or []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"MusicBrainz artist info lookup failed for {artist_id}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")
|
|
||||||
|
|
||||||
# 3. Last.fm enrichment by artist name.
|
|
||||||
lastfm_bio: Optional[str] = None
|
|
||||||
lastfm_listeners: Optional[int] = None
|
|
||||||
lastfm_playcount: Optional[int] = None
|
|
||||||
lastfm_url: Optional[str] = None
|
|
||||||
if resolved_name and lastfm_api_key:
|
|
||||||
try:
|
|
||||||
from core.lastfm_client import LastFMClient
|
|
||||||
lastfm = LastFMClient(api_key=lastfm_api_key)
|
|
||||||
lf_info = lastfm.get_artist_info(resolved_name)
|
|
||||||
if lf_info:
|
|
||||||
bio_obj = lf_info.get("bio") or {}
|
|
||||||
lastfm_bio = bio_obj.get("content") or bio_obj.get("summary")
|
|
||||||
stats_obj = lf_info.get("stats") or {}
|
|
||||||
if stats_obj.get("listeners"):
|
|
||||||
try:
|
|
||||||
lastfm_listeners = int(stats_obj["listeners"])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
if stats_obj.get("playcount"):
|
|
||||||
try:
|
|
||||||
lastfm_playcount = int(stats_obj["playcount"])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
lastfm_url = lf_info.get("url")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Last.fm enrichment failed for {resolved_name}: {e}")
|
|
||||||
|
|
||||||
# 4. Discography from the specified source. Skip variant dedup so the
|
|
||||||
# page shows every release the source returns — matches the inline
|
|
||||||
# Artists-page behaviour that this view was modelled after.
|
|
||||||
discography_result = metadata_discography.get_artist_detail_discography(
|
|
||||||
artist_id,
|
|
||||||
artist_name=resolved_name or artist_id,
|
|
||||||
options=MetadataLookupOptions(
|
|
||||||
source_override=source,
|
|
||||||
allow_fallback=True,
|
|
||||||
skip_cache=False,
|
|
||||||
max_pages=0,
|
|
||||||
# Match the Download Discography endpoint cap (200).
|
|
||||||
# Spotify already paginates all; Deezer / iTunes / Discogs /
|
|
||||||
# Hydrabase clamp at the outer limit. 200 covers prolific
|
|
||||||
# catalogues without exceeding iTunes/Discogs internal caps.
|
|
||||||
limit=200,
|
|
||||||
artist_source_ids={source: artist_id},
|
|
||||||
dedup_variants=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not discography_result.get("success"):
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": discography_result.get("error", "Could not load discography"),
|
|
||||||
"source": source,
|
|
||||||
}, 404
|
|
||||||
|
|
||||||
artist_info: Dict[str, Any] = {
|
|
||||||
"id": artist_id,
|
|
||||||
"name": resolved_name or artist_id,
|
|
||||||
"image_url": image_url,
|
|
||||||
"server_source": None, # not in library
|
|
||||||
"genres": source_genres,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Stamp the source-specific ID so the correct service badge renders on the
|
|
||||||
# hero (e.g. source=deezer -> deezer_id; source=spotify -> spotify_artist_id).
|
|
||||||
source_id_field = SOURCE_ID_FIELD.get(source)
|
|
||||||
if source_id_field:
|
|
||||||
artist_info[source_id_field] = artist_id
|
|
||||||
|
|
||||||
if source_followers is not None:
|
|
||||||
artist_info["followers"] = source_followers
|
|
||||||
if lastfm_bio:
|
|
||||||
artist_info["lastfm_bio"] = lastfm_bio
|
|
||||||
if lastfm_listeners is not None:
|
|
||||||
artist_info["lastfm_listeners"] = lastfm_listeners
|
|
||||||
if lastfm_playcount is not None:
|
|
||||||
artist_info["lastfm_playcount"] = lastfm_playcount
|
|
||||||
if lastfm_url:
|
|
||||||
artist_info["lastfm_url"] = lastfm_url
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Source-only artist-detail: {artist_info['name']} from {source} — "
|
|
||||||
f"albums={len(discography_result.get('albums', []))}, "
|
|
||||||
f"eps={len(discography_result.get('eps', []))}, "
|
|
||||||
f"singles={len(discography_result.get('singles', []))}, "
|
|
||||||
f"genres={len(source_genres)}, lastfm_bio={'yes' if lastfm_bio else 'no'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"artist": artist_info,
|
|
||||||
"discography": discography_result,
|
|
||||||
"enrichment_coverage": {},
|
|
||||||
}, 200
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"""Source-artist → library lookup helpers.
|
|
||||||
|
|
||||||
Extracted from `web_server.py` so the logic can be imported and unit-tested
|
|
||||||
without booting the Flask app, Spotify client, Soulseek connection, etc.
|
|
||||||
|
|
||||||
Two concepts live here:
|
|
||||||
|
|
||||||
* ``SOURCE_ID_FIELD`` — the per-source column on the ``artists`` table that
|
|
||||||
stores the external service ID (Spotify track ID, Deezer artist ID, …).
|
|
||||||
This map is what ties a result clicked in the source-aware Search results
|
|
||||||
back to a library record so we can serve the richer library view.
|
|
||||||
|
|
||||||
* ``find_library_artist_for_source`` — given a source-aware click (e.g.
|
|
||||||
``deezer:525046``), try to locate a matching library artist. First by
|
|
||||||
direct column match against the source's ID column, then by case-
|
|
||||||
insensitive name match scoped to the active media server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from core.source_ids import id_column as _artist_id_column
|
|
||||||
|
|
||||||
logger = logging.getLogger("artist_source_lookup")
|
|
||||||
|
|
||||||
|
|
||||||
SOURCE_ONLY_ARTIST_SOURCES = frozenset({
|
|
||||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# The per-source column on the ``artists`` table, derived from the canonical
|
|
||||||
# source-ID registry (the single source of truth). Values are unchanged from the
|
|
||||||
# previous hardcoded map — this just stops duplicating that knowledge here.
|
|
||||||
SOURCE_ID_FIELD = {
|
|
||||||
source: _artist_id_column(source, "artist")
|
|
||||||
for source in (
|
|
||||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def find_library_artist_for_source(
|
|
||||||
database,
|
|
||||||
source: str,
|
|
||||||
source_artist_id: str,
|
|
||||||
artist_name: Optional[str] = None,
|
|
||||||
active_server: Optional[str] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Return the library PK of an artist matching the source-aware click.
|
|
||||||
|
|
||||||
Lookup order:
|
|
||||||
1. Direct match on the source-specific ID column (server-agnostic — any
|
|
||||||
library record with the right external ID is a hit). If that id is
|
|
||||||
stamped on MORE than one library artist, the mapping is corrupt /
|
|
||||||
ambiguous (e.g. an enrichment bug wrote one Deezer id onto several
|
|
||||||
artists) — we refuse to guess and fall through, so the caller can
|
|
||||||
show the source artist directly instead of an arbitrary wrong one.
|
|
||||||
2. Case-insensitive name match within ``active_server`` (defaults to the
|
|
||||||
active media server when not provided), so we don't jump the user
|
|
||||||
across server contexts on a name collision.
|
|
||||||
|
|
||||||
Returns ``None`` on miss or on any database error.
|
|
||||||
"""
|
|
||||||
column = SOURCE_ID_FIELD.get(source)
|
|
||||||
if not column:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with database._get_connection() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# LIMIT 2 so we can tell a unique match from an ambiguous one.
|
|
||||||
cursor.execute(
|
|
||||||
f"SELECT id FROM artists WHERE {column} = ? LIMIT 2",
|
|
||||||
(str(source_artist_id),),
|
|
||||||
)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
if len(rows) == 1:
|
|
||||||
return rows[0][0]
|
|
||||||
if len(rows) > 1:
|
|
||||||
# Same source id on multiple artists — corrupt mapping. Don't
|
|
||||||
# upgrade on the id; fall through to the name match (and, if
|
|
||||||
# that misses, let the caller render the source artist).
|
|
||||||
logger.warning(
|
|
||||||
f"Source id {source}:{source_artist_id} maps to "
|
|
||||||
f"{len(rows)}+ library artists — ambiguous, skipping "
|
|
||||||
f"id-based library upgrade"
|
|
||||||
)
|
|
||||||
|
|
||||||
if artist_name and active_server:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id FROM artists "
|
|
||||||
"WHERE LOWER(name) = LOWER(?) AND server_source = ? LIMIT 1",
|
|
||||||
(artist_name, active_server),
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(
|
|
||||||
f"Library upgrade lookup failed for {source}:{source_artist_id}: {e}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
"""Liked-artist multi-source matching — lifted from web_server.py.
|
|
||||||
|
|
||||||
Both function bodies are byte-identical to the originals. The
|
|
||||||
``spotify_client`` proxy + ``_get_*_client`` shims let the bodies resolve
|
|
||||||
their original names without any modification.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from config.settings import config_manager
|
|
||||||
from core.metadata.registry import (
|
|
||||||
get_deezer_client,
|
|
||||||
get_discogs_client,
|
|
||||||
get_itunes_client,
|
|
||||||
get_musicbrainz_client,
|
|
||||||
get_spotify_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_itunes_client():
|
|
||||||
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
|
||||||
return get_itunes_client()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_deezer_client():
|
|
||||||
"""Mirror of web_server._get_deezer_client — delegates to registry."""
|
|
||||||
return get_deezer_client()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_discogs_client(token=None):
|
|
||||||
"""Mirror of web_server._get_discogs_client — delegates to registry."""
|
|
||||||
return get_discogs_client(token)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_musicbrainz_client():
|
|
||||||
"""Mirror of web_server._get_musicbrainz_client — delegates to registry."""
|
|
||||||
return get_musicbrainz_client()
|
|
||||||
|
|
||||||
|
|
||||||
class _SpotifyClientProxy:
|
|
||||||
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
|
||||||
rebinds the cached client in core.metadata.registry is visible to the
|
|
||||||
lifted bodies."""
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
client = get_spotify_client()
|
|
||||||
if client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
return getattr(client, name)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return get_spotify_client() is not None
|
|
||||||
|
|
||||||
|
|
||||||
spotify_client = _SpotifyClientProxy()
|
|
||||||
|
|
||||||
|
|
||||||
def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|
||||||
"""Match pending liked artists to ALL metadata sources (Spotify, iTunes, Deezer, Discogs).
|
|
||||||
Uses the same matching pattern as the watchlist scanner: DB-first, then API search
|
|
||||||
with fuzzy name matching. Stores all resolved IDs so source switching works instantly."""
|
|
||||||
pending = database.get_liked_artists_pending_match(profile_id, limit=200)
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Source → column mapping
|
|
||||||
source_cols = {
|
|
||||||
'spotify': 'spotify_artist_id',
|
|
||||||
'itunes': 'itunes_artist_id',
|
|
||||||
'deezer': 'deezer_artist_id',
|
|
||||||
'discogs': 'discogs_artist_id',
|
|
||||||
'musicbrainz': 'musicbrainz_artist_id',
|
|
||||||
}
|
|
||||||
id_cols = list(source_cols.values())
|
|
||||||
|
|
||||||
# Reject known placeholder images and local server paths
|
|
||||||
_placeholder_hashes = {'2a96cbd8b46e442fc41c2b86b821562f'}
|
|
||||||
def _valid_image(url):
|
|
||||||
if not url or not url.strip():
|
|
||||||
return None
|
|
||||||
if any(ph in url for ph in _placeholder_hashes):
|
|
||||||
return None
|
|
||||||
# Reject local media server paths (Plex/Jellyfin) — not loadable in browser
|
|
||||||
if url.startswith('/') or url.startswith('\\'):
|
|
||||||
return None
|
|
||||||
if not url.startswith('http'):
|
|
||||||
return None
|
|
||||||
return url
|
|
||||||
|
|
||||||
# Build search clients for each source
|
|
||||||
from core.deezer_client import DeezerClient
|
|
||||||
search_clients = {}
|
|
||||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
||||||
search_clients['spotify'] = spotify_client
|
|
||||||
try:
|
|
||||||
search_clients['itunes'] = _get_itunes_client()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("itunes client init failed: %s", e)
|
|
||||||
try:
|
|
||||||
search_clients['deezer'] = _get_deezer_client()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("deezer client init failed: %s", e)
|
|
||||||
try:
|
|
||||||
dc = _get_discogs_client()
|
|
||||||
# Only use Discogs if token is configured
|
|
||||||
from config.settings import config_manager as _cm
|
|
||||||
if _cm.get('discogs.token', ''):
|
|
||||||
search_clients['discogs'] = dc
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("discogs client init failed: %s", e)
|
|
||||||
try:
|
|
||||||
search_clients['musicbrainz'] = _get_musicbrainz_client()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("musicbrainz client init failed: %s", e)
|
|
||||||
|
|
||||||
# Reuse watchlist scanner's fuzzy matching logic
|
|
||||||
from core.watchlist_scanner import WatchlistScanner
|
|
||||||
_normalize = WatchlistScanner._normalize_artist_name
|
|
||||||
|
|
||||||
def _best_match(results, artist_name):
|
|
||||||
"""Pick best match from search results using name similarity (same as watchlist scanner)."""
|
|
||||||
if not results:
|
|
||||||
return None
|
|
||||||
# Exact normalized match
|
|
||||||
for r in results:
|
|
||||||
if _normalize(r.name) == _normalize(artist_name):
|
|
||||||
return r
|
|
||||||
# Fuzzy scoring
|
|
||||||
best = None
|
|
||||||
best_sim = 0
|
|
||||||
for r in results:
|
|
||||||
# Simple normalized comparison
|
|
||||||
n1 = _normalize(artist_name)
|
|
||||||
n2 = _normalize(r.name)
|
|
||||||
if n1 == n2:
|
|
||||||
return r
|
|
||||||
# Levenshtein-style similarity
|
|
||||||
max_len = max(len(n1), len(n2))
|
|
||||||
if max_len == 0:
|
|
||||||
continue
|
|
||||||
distance = sum(1 for a, b in zip(n1, n2, strict=False) if a != b) + abs(len(n1) - len(n2))
|
|
||||||
sim = (max_len - distance) / max_len
|
|
||||||
if sim > best_sim:
|
|
||||||
best_sim = sim
|
|
||||||
best = r
|
|
||||||
if best and best_sim >= 0.85:
|
|
||||||
return best
|
|
||||||
return None
|
|
||||||
|
|
||||||
api_calls = 0
|
|
||||||
matched = 0
|
|
||||||
|
|
||||||
for entry in pending:
|
|
||||||
name = entry['artist_name']
|
|
||||||
pool_id = entry['id']
|
|
||||||
harvested_ids = {}
|
|
||||||
best_image = None
|
|
||||||
|
|
||||||
# Pre-load existing IDs from the entry itself
|
|
||||||
for col in id_cols:
|
|
||||||
if entry.get(col):
|
|
||||||
harvested_ids[col] = entry[col]
|
|
||||||
|
|
||||||
# --- DB STRATEGIES (free, no API calls) ---
|
|
||||||
|
|
||||||
# 1. Library artists table
|
|
||||||
try:
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT * FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (name,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
r = dict(row)
|
|
||||||
for col in id_cols:
|
|
||||||
if r.get(col) and col not in harvested_ids:
|
|
||||||
harvested_ids[col] = str(r[col])
|
|
||||||
if _valid_image(r.get('thumb_url')):
|
|
||||||
best_image = r['thumb_url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("library artist lookup failed: %s", e)
|
|
||||||
|
|
||||||
# 2. Watchlist artists
|
|
||||||
try:
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT * FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE AND profile_id = ? LIMIT 1",
|
|
||||||
(name, profile_id)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
wl = dict(row)
|
|
||||||
for col in id_cols:
|
|
||||||
if wl.get(col) and col not in harvested_ids:
|
|
||||||
harvested_ids[col] = str(wl[col])
|
|
||||||
if _valid_image(wl.get('image_url')) and not best_image:
|
|
||||||
best_image = wl['image_url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("watchlist artist lookup failed: %s", e)
|
|
||||||
|
|
||||||
# 3. Metadata cache (all sources)
|
|
||||||
try:
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT entity_id, source, image_url FROM metadata_cache_entities WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE",
|
|
||||||
(name,)
|
|
||||||
)
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
col = source_cols.get(row['source'])
|
|
||||||
if col and col not in harvested_ids:
|
|
||||||
harvested_ids[col] = row['entity_id']
|
|
||||||
if _valid_image(row['image_url']) and not best_image:
|
|
||||||
best_image = row['image_url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("metadata cache lookup failed: %s", e)
|
|
||||||
|
|
||||||
# --- API STRATEGIES (search each missing source) ---
|
|
||||||
# Same pattern as watchlist scanner's _backfill_missing_ids
|
|
||||||
for source, col in source_cols.items():
|
|
||||||
if col in harvested_ids:
|
|
||||||
continue # Already have this source's ID
|
|
||||||
client = search_clients.get(source)
|
|
||||||
if not client:
|
|
||||||
continue
|
|
||||||
if api_calls >= 200: # Hard cap per refresh cycle
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
results = client.search_artists(name, limit=5)
|
|
||||||
best = _best_match(results, name)
|
|
||||||
if best:
|
|
||||||
harvested_ids[col] = best.id
|
|
||||||
if hasattr(best, 'image_url') and _valid_image(best.image_url) and not best_image:
|
|
||||||
best_image = best.image_url
|
|
||||||
api_calls += 1
|
|
||||||
time.sleep(0.4) # Rate limit breathing room
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[Your Artists] {source} search failed for '{name}': {e}")
|
|
||||||
api_calls += 1
|
|
||||||
|
|
||||||
# Save all harvested IDs
|
|
||||||
if harvested_ids:
|
|
||||||
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
|
|
||||||
resolved_source = None
|
|
||||||
resolved_id = None
|
|
||||||
for src in ('spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'):
|
|
||||||
col = source_cols[src]
|
|
||||||
if col in harvested_ids:
|
|
||||||
resolved_source = src
|
|
||||||
resolved_id = harvested_ids[col]
|
|
||||||
break
|
|
||||||
|
|
||||||
database.update_liked_artist_match(
|
|
||||||
pool_id, active_source=resolved_source, active_source_id=resolved_id,
|
|
||||||
image_url=best_image, all_ids=harvested_ids
|
|
||||||
)
|
|
||||||
matched += 1
|
|
||||||
|
|
||||||
database.sync_liked_artists_watchlist_flags(profile_id)
|
|
||||||
logger.info(f"[Your Artists] Matched {matched}/{len(pending)} artists to {len(search_clients)} sources ({api_calls} API calls)")
|
|
||||||
|
|
||||||
# Image backfill: fetch images for matched artists that have IDs but no image
|
|
||||||
_backfill_liked_artist_images(database, profile_id, search_clients)
|
|
||||||
|
|
||||||
|
|
||||||
def _backfill_liked_artist_images(database, profile_id: int, search_clients: dict):
|
|
||||||
"""Fetch images for matched artists missing artwork using their stored source IDs."""
|
|
||||||
try:
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id
|
|
||||||
FROM liked_artists_pool
|
|
||||||
WHERE profile_id = ? AND match_status = 'matched'
|
|
||||||
AND (image_url IS NULL OR image_url = ''
|
|
||||||
OR image_url LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'
|
|
||||||
OR image_url NOT LIKE 'http%')
|
|
||||||
LIMIT 100
|
|
||||||
""", (profile_id,))
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"[Your Artists] Backfilling images for {len(rows)} artists...")
|
|
||||||
filled = 0
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
r = dict(row)
|
|
||||||
image_url = None
|
|
||||||
|
|
||||||
# Try Spotify artist lookup (has best images)
|
|
||||||
if r.get('spotify_artist_id') and 'spotify' in search_clients:
|
|
||||||
try:
|
|
||||||
sp = search_clients['spotify']
|
|
||||||
if hasattr(sp, 'sp') and sp.sp:
|
|
||||||
artist_data = sp.sp.artist(r['spotify_artist_id'])
|
|
||||||
if artist_data and artist_data.get('images'):
|
|
||||||
image_url = artist_data['images'][0]['url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("spotify artist image fetch failed: %s", e)
|
|
||||||
|
|
||||||
# Try Deezer (direct image URL from ID)
|
|
||||||
if not image_url and r.get('deezer_artist_id'):
|
|
||||||
image_url = f"https://api.deezer.com/artist/{r['deezer_artist_id']}/image?size=big"
|
|
||||||
|
|
||||||
if image_url:
|
|
||||||
try:
|
|
||||||
cursor2 = conn.cursor()
|
|
||||||
cursor2.execute(
|
|
||||||
"UPDATE liked_artists_pool SET image_url = ? WHERE id = ?",
|
|
||||||
(image_url, r['id'])
|
|
||||||
)
|
|
||||||
filled += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("liked artist image update failed: %s", e)
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
if filled:
|
|
||||||
logger.info(f"[Your Artists] Backfilled {filled}/{len(rows)} artist images")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[Your Artists] Image backfill error: {e}")
|
|
||||||
|
|
@ -1,994 +0,0 @@
|
||||||
"""Artist Map endpoints — lifted from web_server.py.
|
|
||||||
|
|
||||||
The four route bodies (``get_artist_map_data``, ``get_artist_map_genre_list``,
|
|
||||||
``get_artist_map_genres``, ``get_artist_map_explore``) plus their cache helpers
|
|
||||||
and the artist-map cache are byte-identical to the originals. Module-level
|
|
||||||
shims for ``get_current_profile_id``, ``_get_itunes_client``, and the
|
|
||||||
``spotify_client`` proxy let the bodies resolve their original names without
|
|
||||||
modification.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from flask import g, jsonify, request
|
|
||||||
|
|
||||||
from database.music_database import get_database
|
|
||||||
from core.metadata.registry import get_itunes_client, get_spotify_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_profile_id() -> int:
|
|
||||||
"""Mirror of web_server.get_current_profile_id — uses Flask g.
|
|
||||||
|
|
||||||
Catches RuntimeError too because reading `g` outside a request
|
|
||||||
context raises that (not AttributeError) — happens when this is
|
|
||||||
called from background threads (sync, automation, scanners)."""
|
|
||||||
try:
|
|
||||||
return g.profile_id
|
|
||||||
except (AttributeError, RuntimeError):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _get_itunes_client():
|
|
||||||
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
|
||||||
return get_itunes_client()
|
|
||||||
|
|
||||||
|
|
||||||
class _SpotifyClientProxy:
|
|
||||||
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
|
||||||
rebinds the cached client in core.metadata.registry is visible to the
|
|
||||||
lifted route bodies."""
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
client = get_spotify_client()
|
|
||||||
if client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
return getattr(client, name)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return get_spotify_client() is not None
|
|
||||||
|
|
||||||
|
|
||||||
spotify_client = _SpotifyClientProxy()
|
|
||||||
|
|
||||||
|
|
||||||
# Artist Map data cache — avoids re-querying 4+ tables on every request
|
|
||||||
# Keys: 'watchlist_{profile}', 'genres_{profile}', 'genre_list'
|
|
||||||
# Values: {'data': <json-ready dict>, 'ts': <timestamp>}
|
|
||||||
_artist_map_cache = {}
|
|
||||||
_ARTIST_MAP_CACHE_TTL = 300 # 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
def _artmap_cache_get(key):
|
|
||||||
"""Get cached artist map data if still fresh."""
|
|
||||||
entry = _artist_map_cache.get(key)
|
|
||||||
if entry and (time.time() - entry['ts']) < _ARTIST_MAP_CACHE_TTL:
|
|
||||||
return entry['data']
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _artmap_cache_set(key, data):
|
|
||||||
"""Store artist map data in cache."""
|
|
||||||
_artist_map_cache[key] = {'data': data, 'ts': time.time()}
|
|
||||||
|
|
||||||
|
|
||||||
def _artmap_cache_invalidate(profile_id=None):
|
|
||||||
"""Invalidate artist map cache (call after watchlist changes, scans, etc.)."""
|
|
||||||
if profile_id:
|
|
||||||
_artist_map_cache.pop(f'watchlist_{profile_id}', None)
|
|
||||||
_artist_map_cache.pop(f'genres_{profile_id}', None)
|
|
||||||
_artist_map_cache.pop('genre_list', None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_artist_map_data():
|
|
||||||
"""Get watchlist artists + their similar artists for the force-directed artist map."""
|
|
||||||
try:
|
|
||||||
database = get_database()
|
|
||||||
profile_id = get_current_profile_id()
|
|
||||||
|
|
||||||
cached = _artmap_cache_get(f'watchlist_{profile_id}')
|
|
||||||
if cached:
|
|
||||||
return jsonify(cached)
|
|
||||||
|
|
||||||
# Get all watchlist artists
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
|
||||||
discogs_artist_id, image_url
|
|
||||||
FROM watchlist_artists WHERE profile_id = ?
|
|
||||||
""", (profile_id,))
|
|
||||||
watchlist_rows = cursor.fetchall()
|
|
||||||
|
|
||||||
nodes = [] # {id, name, image_url, type: 'watchlist'|'similar', genres, size}
|
|
||||||
edges = [] # {source, target, weight}
|
|
||||||
seen_names = {} # normalized_name → node index
|
|
||||||
|
|
||||||
def _norm(name):
|
|
||||||
return (name or '').lower().strip()
|
|
||||||
|
|
||||||
# Add watchlist artists as anchor nodes
|
|
||||||
for wa in watchlist_rows:
|
|
||||||
w = dict(wa)
|
|
||||||
norm = _norm(w['artist_name'])
|
|
||||||
if norm in seen_names:
|
|
||||||
continue
|
|
||||||
idx = len(nodes)
|
|
||||||
seen_names[norm] = idx
|
|
||||||
# Get image — prefer HTTP URLs
|
|
||||||
img = w.get('image_url', '') or ''
|
|
||||||
if img and not img.startswith('http'):
|
|
||||||
img = ''
|
|
||||||
nodes.append({
|
|
||||||
'id': idx,
|
|
||||||
'name': w['artist_name'],
|
|
||||||
'image_url': img,
|
|
||||||
'type': 'watchlist',
|
|
||||||
'genres': [],
|
|
||||||
'spotify_id': w.get('spotify_artist_id') or '',
|
|
||||||
'itunes_id': w.get('itunes_artist_id') or '',
|
|
||||||
'deezer_id': w.get('deezer_artist_id') or '',
|
|
||||||
'discogs_id': w.get('discogs_artist_id') or '',
|
|
||||||
'source_db_id': str(w['id']),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get all similar artists for all watchlist artists
|
|
||||||
watchlist_ids = [dict(wa)['spotify_artist_id'] or dict(wa)['itunes_artist_id'] or str(dict(wa)['id']) for wa in watchlist_rows]
|
|
||||||
if watchlist_ids:
|
|
||||||
placeholders = ','.join(['?'] * len(watchlist_ids))
|
|
||||||
cursor.execute(f"""
|
|
||||||
SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id,
|
|
||||||
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
||||||
similarity_rank, occurrence_count, image_url, genres, popularity
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE profile_id = ? AND source_artist_id IN ({placeholders})
|
|
||||||
ORDER BY similarity_rank ASC
|
|
||||||
""", [profile_id] + watchlist_ids)
|
|
||||||
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
r = dict(row)
|
|
||||||
sim_norm = _norm(r['similar_artist_name'])
|
|
||||||
|
|
||||||
# Find or create similar artist node
|
|
||||||
if sim_norm not in seen_names:
|
|
||||||
idx = len(nodes)
|
|
||||||
seen_names[sim_norm] = idx
|
|
||||||
img = r.get('image_url', '') or ''
|
|
||||||
if img and not img.startswith('http'):
|
|
||||||
img = ''
|
|
||||||
genres = []
|
|
||||||
if r.get('genres'):
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("similar node genres parse failed: %s", e)
|
|
||||||
nodes.append({
|
|
||||||
'id': idx,
|
|
||||||
'name': r['similar_artist_name'],
|
|
||||||
'image_url': img,
|
|
||||||
'type': 'similar',
|
|
||||||
'genres': genres,
|
|
||||||
'spotify_id': r.get('similar_artist_spotify_id') or '',
|
|
||||||
'itunes_id': r.get('similar_artist_itunes_id') or '',
|
|
||||||
'deezer_id': r.get('similar_artist_deezer_id') or '',
|
|
||||||
'musicbrainz_id': r.get('similar_artist_musicbrainz_id') or '',
|
|
||||||
'rank': r.get('similarity_rank', 5),
|
|
||||||
'occurrence': r.get('occurrence_count', 1),
|
|
||||||
'popularity': r.get('popularity', 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
sim_idx = seen_names[sim_norm]
|
|
||||||
|
|
||||||
# Find the watchlist node that sourced this similar artist
|
|
||||||
source_norm = None
|
|
||||||
for wa in watchlist_rows:
|
|
||||||
w = dict(wa)
|
|
||||||
sid = w.get('spotify_artist_id') or w.get('itunes_artist_id') or str(w['id'])
|
|
||||||
if sid == r['source_artist_id']:
|
|
||||||
source_norm = _norm(w['artist_name'])
|
|
||||||
break
|
|
||||||
|
|
||||||
if source_norm and source_norm in seen_names:
|
|
||||||
source_idx = seen_names[source_norm]
|
|
||||||
# Weight: inverse of rank (rank 1 = strongest connection)
|
|
||||||
weight = max(1, 11 - (r.get('similarity_rank', 5)))
|
|
||||||
edges.append({
|
|
||||||
'source': source_idx,
|
|
||||||
'target': sim_idx,
|
|
||||||
'weight': weight,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Also check if any similar artists ARE watchlist artists (cross-links)
|
|
||||||
# These create extra connections between watchlist nodes
|
|
||||||
for i, node in enumerate(nodes):
|
|
||||||
if node['type'] == 'similar':
|
|
||||||
# Check if this similar artist is also a watchlist artist
|
|
||||||
for j, wnode in enumerate(nodes):
|
|
||||||
if wnode['type'] == 'watchlist' and i != j:
|
|
||||||
if _norm(node['name']) == _norm(wnode['name']):
|
|
||||||
# Merge: upgrade the similar node to watchlist
|
|
||||||
node['type'] = 'watchlist'
|
|
||||||
break
|
|
||||||
|
|
||||||
# ── Backfill from metadata cache: batch-lookup all node names across all sources ──
|
|
||||||
# Single query to get ALL cached artist entries matching ANY node name
|
|
||||||
try:
|
|
||||||
all_names = list(set(_norm(n['name']) for n in nodes if n.get('name')))
|
|
||||||
if all_names:
|
|
||||||
# Build case-insensitive IN clause via temp matching
|
|
||||||
# Lightweight query — no raw_json (can be huge)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT entity_id, source, name, image_url, genres, popularity
|
|
||||||
FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist'
|
|
||||||
""")
|
|
||||||
cache_rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Index cache by normalized name → {source: {id, image_url, genres}}
|
|
||||||
cache_by_name = {}
|
|
||||||
for cr in cache_rows:
|
|
||||||
cn = _norm(cr['name'] or '')
|
|
||||||
if cn not in cache_by_name:
|
|
||||||
cache_by_name[cn] = {}
|
|
||||||
source = cr['source']
|
|
||||||
genres = []
|
|
||||||
if cr['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(cr['genres']) if isinstance(cr['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("backfill cache genres parse failed: %s", e)
|
|
||||||
cache_by_name[cn][source] = {
|
|
||||||
'id': cr['entity_id'],
|
|
||||||
'image_url': cr['image_url'] or '',
|
|
||||||
'genres': genres,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apply cache data to nodes
|
|
||||||
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
||||||
for n in nodes:
|
|
||||||
nn = _norm(n['name'])
|
|
||||||
cached = cache_by_name.get(nn)
|
|
||||||
if not cached:
|
|
||||||
continue
|
|
||||||
for source, field in source_id_map.items():
|
|
||||||
if not n.get(field) and source in cached:
|
|
||||||
n[field] = cached[source]['id']
|
|
||||||
# Backfill image if missing or local path
|
|
||||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
||||||
for source in ('spotify', 'deezer', 'itunes'):
|
|
||||||
if source in cached and cached[source].get('image_url', '').startswith('http'):
|
|
||||||
n['image_url'] = cached[source]['image_url']
|
|
||||||
break
|
|
||||||
# Backfill genres if missing
|
|
||||||
if not n.get('genres') or len(n.get('genres', [])) == 0:
|
|
||||||
for source in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
|
|
||||||
if source in cached and cached[source].get('genres'):
|
|
||||||
n['genres'] = cached[source]['genres'][:5]
|
|
||||||
break
|
|
||||||
# Deezer direct URL fallback
|
|
||||||
for n in nodes:
|
|
||||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
||||||
if n.get('deezer_id'):
|
|
||||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
||||||
|
|
||||||
# Album art fallback (iTunes artists have no artist images)
|
|
||||||
_album_art = {}
|
|
||||||
try:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT artist_name, image_url FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
|
||||||
AND artist_name IS NOT NULL AND artist_name != ''
|
|
||||||
""")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
an = _norm(r['artist_name'])
|
|
||||||
if an and an not in _album_art:
|
|
||||||
_album_art[an] = r['image_url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("artist map album-art cache build failed: %s", e)
|
|
||||||
for n in nodes:
|
|
||||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
|
||||||
nn = _norm(n['name'])
|
|
||||||
if nn in _album_art:
|
|
||||||
n['image_url'] = _album_art[nn]
|
|
||||||
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.debug(f"Artist map cache backfill error: {cache_err}")
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'success': True,
|
|
||||||
'nodes': nodes,
|
|
||||||
'edges': edges,
|
|
||||||
'watchlist_count': sum(1 for n in nodes if n['type'] == 'watchlist'),
|
|
||||||
'similar_count': sum(1 for n in nodes if n['type'] == 'similar'),
|
|
||||||
}
|
|
||||||
_artmap_cache_set(f'watchlist_{profile_id}', result)
|
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting artist map data: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
def get_artist_map_genre_list():
|
|
||||||
"""Lightweight endpoint — just genre names + counts for the picker. No node data."""
|
|
||||||
try:
|
|
||||||
cached = _artmap_cache_get('genre_list')
|
|
||||||
if cached:
|
|
||||||
return jsonify(cached)
|
|
||||||
|
|
||||||
database = get_database()
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Fast query: just count artists per genre from cache
|
|
||||||
genre_counts = {}
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT genres FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist' AND genres IS NOT NULL AND genres != '' AND genres != '[]'
|
|
||||||
""")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
try:
|
|
||||||
for g in json.loads(r['genres']):
|
|
||||||
if g and isinstance(g, str):
|
|
||||||
gl = g.lower().strip()
|
|
||||||
genre_counts[gl] = genre_counts.get(gl, 0) + 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("genre count row parse failed: %s", e)
|
|
||||||
|
|
||||||
# Sort by count descending
|
|
||||||
sorted_genres = sorted(genre_counts.items(), key=lambda x: -x[1])
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'success': True,
|
|
||||||
'genres': [{'name': g, 'count': c} for g, c in sorted_genres],
|
|
||||||
'total': len(sorted_genres)
|
|
||||||
}
|
|
||||||
_artmap_cache_set('genre_list', result)
|
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
def get_artist_map_genres():
|
|
||||||
"""Get ALL artists from every data source, grouped by genre for the genre map."""
|
|
||||||
try:
|
|
||||||
database = get_database()
|
|
||||||
profile_id = get_current_profile_id()
|
|
||||||
|
|
||||||
cached = _artmap_cache_get(f'genres_{profile_id}')
|
|
||||||
if cached:
|
|
||||||
return jsonify(cached)
|
|
||||||
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
artists_by_name = {} # normalized_name → {name, image, genres[], sources, ids}
|
|
||||||
|
|
||||||
def _norm(n):
|
|
||||||
return (n or '').lower().strip()
|
|
||||||
|
|
||||||
def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, musicbrainz_id=None, source='unknown', popularity=0):
|
|
||||||
n = _norm(name)
|
|
||||||
if not n or len(n) < 2:
|
|
||||||
return
|
|
||||||
if n not in artists_by_name:
|
|
||||||
artists_by_name[n] = {
|
|
||||||
'name': name, 'image_url': '', 'genres': set(),
|
|
||||||
'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': '',
|
|
||||||
'sources': set(), 'popularity': 0
|
|
||||||
}
|
|
||||||
a = artists_by_name[n]
|
|
||||||
if image_url and image_url.startswith('http') and not a['image_url']:
|
|
||||||
a['image_url'] = image_url
|
|
||||||
if genres:
|
|
||||||
for g in (genres if isinstance(genres, list) else []):
|
|
||||||
if g and isinstance(g, str):
|
|
||||||
a['genres'].add(g.lower().strip())
|
|
||||||
if spotify_id and not a['spotify_id']:
|
|
||||||
a['spotify_id'] = str(spotify_id)
|
|
||||||
if itunes_id and not a['itunes_id']:
|
|
||||||
a['itunes_id'] = str(itunes_id)
|
|
||||||
if deezer_id and not a['deezer_id']:
|
|
||||||
a['deezer_id'] = str(deezer_id)
|
|
||||||
if discogs_id and not a['discogs_id']:
|
|
||||||
a['discogs_id'] = str(discogs_id)
|
|
||||||
if musicbrainz_id and not a['musicbrainz_id']:
|
|
||||||
a['musicbrainz_id'] = str(musicbrainz_id)
|
|
||||||
if popularity > a['popularity']:
|
|
||||||
a['popularity'] = popularity
|
|
||||||
a['sources'].add(source)
|
|
||||||
|
|
||||||
# 1. Metadata cache — biggest source
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT name, entity_id, source, image_url, genres, popularity
|
|
||||||
FROM metadata_cache_entities WHERE entity_type = 'artist'
|
|
||||||
""")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
genres = []
|
|
||||||
if r['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("cache artist genres parse failed: %s", e)
|
|
||||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
||||||
kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']}
|
|
||||||
_add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs)
|
|
||||||
|
|
||||||
# 2. Similar artists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT similar_artist_name, similar_artist_spotify_id, similar_artist_itunes_id,
|
|
||||||
similar_artist_deezer_id, similar_artist_musicbrainz_id, image_url, genres, popularity
|
|
||||||
FROM similar_artists WHERE profile_id = ?
|
|
||||||
""", (profile_id,))
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
genres = []
|
|
||||||
if r['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("similar artist genres parse failed: %s", e)
|
|
||||||
_add(r['similar_artist_name'], image_url=r['image_url'], genres=genres,
|
|
||||||
spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'],
|
|
||||||
deezer_id=r['similar_artist_deezer_id'],
|
|
||||||
musicbrainz_id=r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else None,
|
|
||||||
source='similar', popularity=r['popularity'] or 0)
|
|
||||||
|
|
||||||
# 3. Watchlist artists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
|
||||||
discogs_artist_id, image_url
|
|
||||||
FROM watchlist_artists WHERE profile_id = ?
|
|
||||||
""", (profile_id,))
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
_add(r['artist_name'], image_url=r['image_url'],
|
|
||||||
spotify_id=r['spotify_artist_id'], itunes_id=r['itunes_artist_id'],
|
|
||||||
deezer_id=r['deezer_artist_id'], discogs_id=r['discogs_artist_id'], source='watchlist')
|
|
||||||
|
|
||||||
# 4. Library artists
|
|
||||||
cursor.execute("SELECT name, thumb_url, genres FROM artists")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
genres = []
|
|
||||||
if r['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("library artist genres parse failed: %s", e)
|
|
||||||
img = r['thumb_url'] if r['thumb_url'] and r['thumb_url'].startswith('http') else None
|
|
||||||
_add(r['name'], image_url=img, genres=genres, source='library')
|
|
||||||
|
|
||||||
# Filter: only include artists that have at least one genre
|
|
||||||
genre_artists = {k: v for k, v in artists_by_name.items() if v['genres']}
|
|
||||||
|
|
||||||
# Build genre → artists map
|
|
||||||
genre_map = {} # genre_name → [artist_keys]
|
|
||||||
for key, a in genre_artists.items():
|
|
||||||
for g in a['genres']:
|
|
||||||
if g not in genre_map:
|
|
||||||
genre_map[g] = []
|
|
||||||
genre_map[g].append(key)
|
|
||||||
|
|
||||||
# Sort genres by artist count, take top genres
|
|
||||||
sorted_genres = sorted(genre_map.items(), key=lambda x: -len(x[1]))
|
|
||||||
|
|
||||||
# Build nodes
|
|
||||||
nodes = []
|
|
||||||
node_idx = {}
|
|
||||||
for key, a in genre_artists.items():
|
|
||||||
idx = len(nodes)
|
|
||||||
node_idx[key] = idx
|
|
||||||
nodes.append({
|
|
||||||
'id': idx,
|
|
||||||
'name': a['name'],
|
|
||||||
'image_url': a['image_url'],
|
|
||||||
'genres': list(a['genres'])[:5],
|
|
||||||
'spotify_id': a['spotify_id'],
|
|
||||||
'itunes_id': a['itunes_id'],
|
|
||||||
'deezer_id': a['deezer_id'],
|
|
||||||
'discogs_id': a['discogs_id'],
|
|
||||||
'musicbrainz_id': a['musicbrainz_id'],
|
|
||||||
'popularity': a['popularity'],
|
|
||||||
'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Build genre clusters — allow artists in multiple genres
|
|
||||||
top_genres = sorted_genres[:40]
|
|
||||||
|
|
||||||
# Sort genres by co-occurrence so related genres are adjacent in the list.
|
|
||||||
# This makes the spiral layout place related genres near each other.
|
|
||||||
if len(top_genres) > 2:
|
|
||||||
genre_sets = {g: set(keys) for g, keys in top_genres}
|
|
||||||
ordered = [top_genres[0][0]] # Start with biggest genre
|
|
||||||
remaining = {g for g, _ in top_genres[1:]}
|
|
||||||
while remaining:
|
|
||||||
last = ordered[-1]
|
|
||||||
last_set = genre_sets.get(last, set())
|
|
||||||
# Find most similar remaining genre (highest artist overlap)
|
|
||||||
best = None
|
|
||||||
best_overlap = -1
|
|
||||||
for g in remaining:
|
|
||||||
overlap = len(last_set & genre_sets.get(g, set()))
|
|
||||||
if overlap > best_overlap:
|
|
||||||
best_overlap = overlap
|
|
||||||
best = g
|
|
||||||
ordered.append(best)
|
|
||||||
remaining.remove(best)
|
|
||||||
# Rebuild top_genres in the ordered sequence
|
|
||||||
genre_dict = dict(top_genres)
|
|
||||||
top_genres = [(g, genre_dict[g]) for g in ordered if g in genre_dict]
|
|
||||||
|
|
||||||
genres_out = []
|
|
||||||
for genre, artist_keys in top_genres:
|
|
||||||
genres_out.append({
|
|
||||||
'name': genre,
|
|
||||||
'count': len(artist_keys),
|
|
||||||
'artist_ids': [node_idx[k] for k in artist_keys if k in node_idx],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Image cleanup + multi-source fallback
|
|
||||||
# Build two lookups: name→image_url AND name→deezer_entity_id
|
|
||||||
_img_cache = {}
|
|
||||||
_deezer_id_cache = {}
|
|
||||||
_album_art_cache = {} # artist_name → album image (iTunes fallback)
|
|
||||||
try:
|
|
||||||
# Artist images + Deezer IDs
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT name, entity_id, source, image_url FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist'
|
|
||||||
AND ((image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%')
|
|
||||||
OR source = 'deezer')
|
|
||||||
""")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
nn = (r['name'] or '').lower().strip()
|
|
||||||
if not nn:
|
|
||||||
continue
|
|
||||||
if r['image_url'] and r['image_url'].startswith('http') and nn not in _img_cache:
|
|
||||||
_img_cache[nn] = r['image_url']
|
|
||||||
if r['source'] == 'deezer' and r['entity_id'] and nn not in _deezer_id_cache:
|
|
||||||
_deezer_id_cache[nn] = r['entity_id']
|
|
||||||
|
|
||||||
# Album art by artist name (for iTunes artists with no artist image)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT artist_name, image_url FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'album'
|
|
||||||
AND image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%'
|
|
||||||
AND artist_name IS NOT NULL AND artist_name != ''
|
|
||||||
""")
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
nn = (r['artist_name'] or '').lower().strip()
|
|
||||||
if nn and nn not in _album_art_cache:
|
|
||||||
_album_art_cache[nn] = r['image_url']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("genre map cache build failed: %s", e)
|
|
||||||
|
|
||||||
for n in nodes:
|
|
||||||
img = n.get('image_url', '')
|
|
||||||
if img in ('None', 'null', '') or (img and not img.startswith('http')):
|
|
||||||
n['image_url'] = ''
|
|
||||||
nn = n['name'].lower().strip()
|
|
||||||
if not n['image_url']:
|
|
||||||
# Try cache image by name
|
|
||||||
n['image_url'] = _img_cache.get(nn, '')
|
|
||||||
if not n['image_url'] and n.get('deezer_id'):
|
|
||||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
||||||
if not n['image_url']:
|
|
||||||
# Try Deezer ID from cache by name
|
|
||||||
did = _deezer_id_cache.get(nn)
|
|
||||||
if did:
|
|
||||||
n['deezer_id'] = did
|
|
||||||
n['image_url'] = f"https://api.deezer.com/artist/{did}/image?size=big"
|
|
||||||
if not n['image_url']:
|
|
||||||
# Try album art by artist name (iTunes artists have no artist images)
|
|
||||||
n['image_url'] = _album_art_cache.get(nn, '')
|
|
||||||
|
|
||||||
_img_count = sum(1 for n in nodes if n.get('image_url'))
|
|
||||||
_deezer_count = sum(1 for n in nodes if n.get('image_url', '').startswith('https://api.deezer'))
|
|
||||||
_none_count = sum(1 for n in nodes if not n.get('image_url'))
|
|
||||||
logger.info(f"[Genre Map] {len(nodes)} artists, {len(sorted_genres)} genres")
|
|
||||||
logger.warning(f"[Genre Map] Images: {_img_count} have URLs, {_deezer_count} Deezer fallback, {_none_count} missing")
|
|
||||||
if _none_count > 0:
|
|
||||||
samples = [n['name'] for n in nodes if not n.get('image_url')][:5]
|
|
||||||
logger.warning(f"[Genre Map] Missing image samples: {samples}")
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'success': True,
|
|
||||||
'nodes': nodes,
|
|
||||||
'genres': genres_out,
|
|
||||||
'total_artists': len(nodes),
|
|
||||||
'total_genres': len(sorted_genres),
|
|
||||||
}
|
|
||||||
_artmap_cache_set(f'genres_{profile_id}', result)
|
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting genre map data: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
def get_artist_map_explore():
|
|
||||||
"""Build an exploration map outward from a single artist."""
|
|
||||||
try:
|
|
||||||
artist_name = request.args.get('name', '').strip()
|
|
||||||
artist_id = request.args.get('id', '').strip()
|
|
||||||
|
|
||||||
if not artist_name and not artist_id:
|
|
||||||
return jsonify({"success": False, "error": "Provide artist name or id"}), 400
|
|
||||||
|
|
||||||
database = get_database()
|
|
||||||
profile_id = get_current_profile_id()
|
|
||||||
conn = database._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
def _norm(n):
|
|
||||||
return (n or '').lower().strip()
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
edges = []
|
|
||||||
seen = {} # norm_name → node index
|
|
||||||
|
|
||||||
# Find the center artist
|
|
||||||
center_name = artist_name
|
|
||||||
center_image = ''
|
|
||||||
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': ''}
|
|
||||||
center_genres = []
|
|
||||||
|
|
||||||
# Search metadata cache for the center artist
|
|
||||||
if artist_id:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist' AND entity_id = ? LIMIT 1
|
|
||||||
""", (artist_id,))
|
|
||||||
else:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE LIMIT 1
|
|
||||||
""", (artist_name,))
|
|
||||||
|
|
||||||
row = cursor.fetchone()
|
|
||||||
artist_found = False
|
|
||||||
if row:
|
|
||||||
artist_found = True
|
|
||||||
center_name = row['name']
|
|
||||||
if row['image_url'] and row['image_url'].startswith('http'):
|
|
||||||
center_image = row['image_url']
|
|
||||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
||||||
k = src_map.get(row['source'], 'spotify_id')
|
|
||||||
center_ids[k] = row['entity_id']
|
|
||||||
if row['genres']:
|
|
||||||
try:
|
|
||||||
center_genres = json.loads(row['genres']) if isinstance(row['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("initial center genres parse failed: %s", e)
|
|
||||||
|
|
||||||
# Check watchlist + library if not in cache
|
|
||||||
if not artist_found and not artist_id:
|
|
||||||
cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
|
||||||
wr = cursor.fetchone()
|
|
||||||
if wr:
|
|
||||||
artist_found = True
|
|
||||||
center_name = wr['artist_name']
|
|
||||||
if wr['image_url'] and str(wr['image_url']).startswith('http'):
|
|
||||||
center_image = wr['image_url']
|
|
||||||
for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id'), ('musicbrainz_id', 'musicbrainz_artist_id')]:
|
|
||||||
if wr[col]:
|
|
||||||
center_ids[k] = str(wr[col])
|
|
||||||
else:
|
|
||||||
cursor.execute("SELECT name, thumb_url FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
|
||||||
lr = cursor.fetchone()
|
|
||||||
if lr:
|
|
||||||
artist_found = True
|
|
||||||
center_name = lr['name']
|
|
||||||
if lr['thumb_url'] and str(lr['thumb_url']).startswith('http'):
|
|
||||||
center_image = lr['thumb_url']
|
|
||||||
|
|
||||||
# If not found locally, validate via metadata API search
|
|
||||||
if not artist_found and not artist_id:
|
|
||||||
try:
|
|
||||||
api_match = None
|
|
||||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
||||||
results = spotify_client.search_artists(artist_name, limit=1)
|
|
||||||
if results and len(results) > 0:
|
|
||||||
sa = results[0]
|
|
||||||
if sa.name.lower().strip() == artist_name.lower().strip() or \
|
|
||||||
artist_name.lower().strip() in sa.name.lower().strip():
|
|
||||||
api_match = sa
|
|
||||||
center_name = sa.name
|
|
||||||
center_ids['spotify_id'] = sa.id
|
|
||||||
center_image = sa.image_url if hasattr(sa, 'image_url') else ''
|
|
||||||
center_genres = sa.genres if hasattr(sa, 'genres') else []
|
|
||||||
artist_found = True
|
|
||||||
if not artist_found:
|
|
||||||
ic = _get_itunes_client()
|
|
||||||
results = ic.search_artists(artist_name, limit=1)
|
|
||||||
if results and len(results) > 0:
|
|
||||||
ia = results[0]
|
|
||||||
if ia.name.lower().strip() == artist_name.lower().strip() or \
|
|
||||||
artist_name.lower().strip() in ia.name.lower().strip():
|
|
||||||
center_name = ia.name
|
|
||||||
center_ids['itunes_id'] = str(ia.id)
|
|
||||||
center_image = ia.image_url if hasattr(ia, 'image_url') else ''
|
|
||||||
artist_found = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[Artist Explorer] API validation failed for '{artist_name}': {e}")
|
|
||||||
|
|
||||||
if not artist_found:
|
|
||||||
return jsonify({"success": False, "error": f"Artist '{artist_name}' not found"}), 404
|
|
||||||
|
|
||||||
# Also check cache for other source IDs
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
|
||||||
""", (center_name,))
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
||||||
k = src_map.get(r['source'], 'spotify_id')
|
|
||||||
if not center_ids.get(k):
|
|
||||||
center_ids[k] = r['entity_id']
|
|
||||||
if r['image_url'] and r['image_url'].startswith('http') and not center_image:
|
|
||||||
center_image = r['image_url']
|
|
||||||
if r['genres'] and not center_genres:
|
|
||||||
try:
|
|
||||||
center_genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("center genres parse failed: %s", e)
|
|
||||||
|
|
||||||
# Add center node
|
|
||||||
center_idx = 0
|
|
||||||
seen[_norm(center_name)] = center_idx
|
|
||||||
nodes.append({
|
|
||||||
'id': 0, 'name': center_name, 'image_url': center_image,
|
|
||||||
'type': 'center', 'genres': center_genres[:5],
|
|
||||||
**center_ids, 'ring': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Ring 1: Direct similar artists from similar_artists table
|
|
||||||
# Search by all known IDs
|
|
||||||
id_values = [v for v in center_ids.values() if v]
|
|
||||||
ring1_artists = []
|
|
||||||
if id_values:
|
|
||||||
placeholders = ','.join(['?'] * len(id_values))
|
|
||||||
cursor.execute(f"""
|
|
||||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
||||||
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
||||||
image_url, genres, popularity, similarity_rank
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
||||||
ORDER BY similarity_rank ASC
|
|
||||||
""", id_values + [profile_id])
|
|
||||||
ring1_artists = cursor.fetchall()
|
|
||||||
|
|
||||||
# Also search by name (the center artist might be a watchlist source)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT DISTINCT sa.similar_artist_name, sa.similar_artist_spotify_id,
|
|
||||||
sa.similar_artist_itunes_id, sa.similar_artist_deezer_id, sa.similar_artist_musicbrainz_id,
|
|
||||||
sa.image_url, sa.genres, sa.popularity, sa.similarity_rank
|
|
||||||
FROM similar_artists sa
|
|
||||||
JOIN watchlist_artists wa ON sa.source_artist_id = COALESCE(wa.spotify_artist_id, wa.itunes_artist_id, CAST(wa.id AS TEXT))
|
|
||||||
WHERE wa.artist_name = ? COLLATE NOCASE AND sa.profile_id = ?
|
|
||||||
ORDER BY sa.similarity_rank ASC
|
|
||||||
""", (center_name, profile_id))
|
|
||||||
ring1_artists.extend(cursor.fetchall())
|
|
||||||
|
|
||||||
# If no similar artists in DB, fetch from MusicMap on-the-fly
|
|
||||||
if not ring1_artists:
|
|
||||||
try:
|
|
||||||
logger.debug(f"[Artist Explorer] No stored similar artists for '{center_name}', fetching from MusicMap...")
|
|
||||||
from core.watchlist_scanner import WatchlistScanner
|
|
||||||
scanner = WatchlistScanner(spotify_client=spotify_client) if spotify_client else None
|
|
||||||
if scanner:
|
|
||||||
similar = scanner._fetch_similar_artists_from_musicmap(center_name, limit=15)
|
|
||||||
if similar:
|
|
||||||
source_artist_id = center_ids.get('spotify_id') or center_ids.get('itunes_id') or center_name
|
|
||||||
# Store in DB for future use
|
|
||||||
for rank, sa in enumerate(similar, 1):
|
|
||||||
try:
|
|
||||||
database.add_or_update_similar_artist(
|
|
||||||
source_artist_id=source_artist_id,
|
|
||||||
similar_artist_name=sa['name'],
|
|
||||||
similar_artist_spotify_id=sa.get('spotify_id'),
|
|
||||||
similar_artist_itunes_id=sa.get('itunes_id'),
|
|
||||||
similarity_rank=rank,
|
|
||||||
profile_id=profile_id,
|
|
||||||
image_url=sa.get('image_url'),
|
|
||||||
genres=sa.get('genres'),
|
|
||||||
popularity=sa.get('popularity', 0),
|
|
||||||
similar_artist_deezer_id=sa.get('deezer_id'),
|
|
||||||
similar_artist_musicbrainz_id=sa.get('musicbrainz_id'),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("similar artist insert failed: %s", e)
|
|
||||||
# Re-query from DB to get consistent format
|
|
||||||
if id_values:
|
|
||||||
placeholders = ','.join(['?'] * len(id_values))
|
|
||||||
cursor.execute(f"""
|
|
||||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
||||||
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
||||||
image_url, genres, popularity, similarity_rank
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
||||||
ORDER BY similarity_rank ASC
|
|
||||||
""", id_values + [profile_id])
|
|
||||||
ring1_artists = cursor.fetchall()
|
|
||||||
if not ring1_artists:
|
|
||||||
# Fallback: query by name-based source ID
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
|
||||||
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
||||||
image_url, genres, popularity, similarity_rank
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE source_artist_id = ? AND profile_id = ?
|
|
||||||
ORDER BY similarity_rank ASC
|
|
||||||
""", (source_artist_id, profile_id))
|
|
||||||
ring1_artists = cursor.fetchall()
|
|
||||||
logger.debug(f"[Artist Explorer] Fetched {len(ring1_artists)} similar artists from MusicMap for '{center_name}'")
|
|
||||||
_artmap_cache_invalidate(profile_id) # New similar artists added
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[Artist Explorer] MusicMap fetch failed for '{center_name}': {e}")
|
|
||||||
|
|
||||||
# Deduplicate ring 1
|
|
||||||
for r in ring1_artists:
|
|
||||||
nn = _norm(r['similar_artist_name'])
|
|
||||||
if nn in seen:
|
|
||||||
continue
|
|
||||||
idx = len(nodes)
|
|
||||||
seen[nn] = idx
|
|
||||||
genres = []
|
|
||||||
if r['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("ring1 genres parse failed: %s", e)
|
|
||||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
|
||||||
nodes.append({
|
|
||||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
|
||||||
'type': 'ring1', 'genres': genres[:5],
|
|
||||||
'spotify_id': r['similar_artist_spotify_id'] or '',
|
|
||||||
'itunes_id': r['similar_artist_itunes_id'] or '',
|
|
||||||
'deezer_id': r['similar_artist_deezer_id'] or '',
|
|
||||||
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
|
|
||||||
'discogs_id': '',
|
|
||||||
'popularity': r['popularity'] or 0,
|
|
||||||
'rank': r['similarity_rank'] or 5,
|
|
||||||
'ring': 1,
|
|
||||||
})
|
|
||||||
weight = max(1, 11 - (r['similarity_rank'] or 5))
|
|
||||||
edges.append({'source': center_idx, 'target': idx, 'weight': weight})
|
|
||||||
|
|
||||||
# Ring 2: Similar artists of ring 1 artists (from similar_artists table)
|
|
||||||
ring1_ids = []
|
|
||||||
for n in nodes[1:]: # skip center
|
|
||||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
||||||
if sid:
|
|
||||||
ring1_ids.append(sid)
|
|
||||||
|
|
||||||
if ring1_ids:
|
|
||||||
placeholders = ','.join(['?'] * len(ring1_ids))
|
|
||||||
cursor.execute(f"""
|
|
||||||
SELECT DISTINCT source_artist_id, similar_artist_name,
|
|
||||||
similar_artist_spotify_id, similar_artist_itunes_id,
|
|
||||||
similar_artist_deezer_id, similar_artist_musicbrainz_id,
|
|
||||||
image_url, genres, popularity, similarity_rank
|
|
||||||
FROM similar_artists
|
|
||||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
|
||||||
ORDER BY similarity_rank ASC
|
|
||||||
""", ring1_ids + [profile_id])
|
|
||||||
|
|
||||||
for r in cursor.fetchall():
|
|
||||||
nn = _norm(r['similar_artist_name'])
|
|
||||||
if nn in seen:
|
|
||||||
# Create edge to existing node if not center
|
|
||||||
existing_idx = seen[nn]
|
|
||||||
# Find the ring1 node that sourced this
|
|
||||||
source_norm = None
|
|
||||||
for n in nodes[1:]:
|
|
||||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
||||||
if sid == r['source_artist_id']:
|
|
||||||
source_norm = _norm(n['name'])
|
|
||||||
break
|
|
||||||
if source_norm:
|
|
||||||
break
|
|
||||||
if source_norm and source_norm in seen and existing_idx != seen[source_norm]:
|
|
||||||
edges.append({'source': seen[source_norm], 'target': existing_idx, 'weight': 3})
|
|
||||||
continue
|
|
||||||
|
|
||||||
idx = len(nodes)
|
|
||||||
if idx >= 500: # Cap at 500 nodes for performance
|
|
||||||
break
|
|
||||||
seen[nn] = idx
|
|
||||||
genres = []
|
|
||||||
if r['genres']:
|
|
||||||
try:
|
|
||||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("ring2 genres parse failed: %s", e)
|
|
||||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
|
||||||
nodes.append({
|
|
||||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
|
||||||
'type': 'ring2', 'genres': genres[:5],
|
|
||||||
'spotify_id': r['similar_artist_spotify_id'] or '',
|
|
||||||
'itunes_id': r['similar_artist_itunes_id'] or '',
|
|
||||||
'deezer_id': r['similar_artist_deezer_id'] or '',
|
|
||||||
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
|
|
||||||
'discogs_id': '',
|
|
||||||
'popularity': r['popularity'] or 0,
|
|
||||||
'rank': r['similarity_rank'] or 5,
|
|
||||||
'ring': 2,
|
|
||||||
})
|
|
||||||
# Find the ring1 source
|
|
||||||
for n in nodes[1:]:
|
|
||||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
|
||||||
if sid == r['source_artist_id']:
|
|
||||||
edges.append({'source': n['id'], 'target': idx, 'weight': max(1, 11 - (r['similarity_rank'] or 5))})
|
|
||||||
break
|
|
||||||
|
|
||||||
# Backfill images/genres from ALL cache sources + Deezer fallback
|
|
||||||
for n in nodes:
|
|
||||||
# Clean up string "None" stored as image URL
|
|
||||||
if n['image_url'] in ('None', 'null', ''):
|
|
||||||
n['image_url'] = ''
|
|
||||||
if n['image_url'] and n['genres']:
|
|
||||||
continue
|
|
||||||
# Check all cache entries for this artist (multiple sources)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
|
||||||
""", (n['name'],))
|
|
||||||
for cr in cursor.fetchall():
|
|
||||||
if not n['image_url'] and cr['image_url'] and cr['image_url'].startswith('http'):
|
|
||||||
n['image_url'] = cr['image_url']
|
|
||||||
if not n['genres'] and cr['genres']:
|
|
||||||
try:
|
|
||||||
n['genres'] = json.loads(cr['genres'])[:5] if isinstance(cr['genres'], str) else []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("explorer node genres parse failed: %s", e)
|
|
||||||
# Harvest missing IDs from cache
|
|
||||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
|
|
||||||
k = src_map.get(cr['source'])
|
|
||||||
if k and not n.get(k):
|
|
||||||
n[k] = cr['entity_id']
|
|
||||||
|
|
||||||
# Deezer image fallback — construct URL directly from ID
|
|
||||||
if not n['image_url'] and n.get('deezer_id'):
|
|
||||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
|
||||||
|
|
||||||
# Spotify image fallback — try API if authenticated
|
|
||||||
if not n['image_url'] and n.get('spotify_id'):
|
|
||||||
try:
|
|
||||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
api_call_tracker.record_call('spotify', endpoint='artist')
|
|
||||||
artist_data = spotify_client.sp.artist(n['spotify_id'])
|
|
||||||
if artist_data and artist_data.get('images'):
|
|
||||||
n['image_url'] = artist_data['images'][0]['url']
|
|
||||||
if not n['genres'] and artist_data.get('genres'):
|
|
||||||
n['genres'] = artist_data['genres'][:5]
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("spotify artist image fallback failed: %s", e)
|
|
||||||
|
|
||||||
# Album art fallback (iTunes artists have no artist images)
|
|
||||||
if not n['image_url']:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT image_url FROM metadata_cache_entities
|
|
||||||
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
|
||||||
AND artist_name = ? COLLATE NOCASE LIMIT 1
|
|
||||||
""", (n['name'],))
|
|
||||||
alb = cursor.fetchone()
|
|
||||||
if alb:
|
|
||||||
n['image_url'] = alb['image_url']
|
|
||||||
|
|
||||||
logger.info(f"[Artist Explorer] Center: {center_name}, Ring 1: {sum(1 for n in nodes if n.get('ring')==1)}, Ring 2: {sum(1 for n in nodes if n.get('ring')==2)}, Edges: {len(edges)}")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'nodes': nodes,
|
|
||||||
'edges': edges,
|
|
||||||
'center': center_name,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting artist explorer data: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
@ -1,496 +0,0 @@
|
||||||
"""Artist quality enhancement helper.
|
|
||||||
|
|
||||||
`enhance_artist_quality(artist_id, track_ids, deps)` is the route-handler
|
|
||||||
body for the `/api/library/artist/<artist_id>/enhance` endpoint. It walks
|
|
||||||
the user's selected tracks, finds the best metadata match against the
|
|
||||||
configured primary source, and queues high-quality re-downloads on the
|
|
||||||
wishlist with `source_type='enhance'`.
|
|
||||||
|
|
||||||
Per-track flow:
|
|
||||||
|
|
||||||
1. Resolve the existing track via the artist's full detail map (built up
|
|
||||||
front from `database.get_artist_full_detail`).
|
|
||||||
2. Read current quality tier from the file extension.
|
|
||||||
3. Build `matched_track_data` for the wishlist entry, in priority order:
|
|
||||||
- **Direct lookup using stored source IDs** — for every source the
|
|
||||||
user has configured, if the library track has the corresponding
|
|
||||||
stored ID (`spotify_track_id` / `deezer_id` / `itunes_track_id` /
|
|
||||||
`soul_id`), call `client.get_track_details(stored_id)` and convert
|
|
||||||
the result to the wishlist payload. First success wins; the user's
|
|
||||||
configured primary source is tried first. Mirrors what Download
|
|
||||||
Discography does — stable IDs straight to the source's API, no
|
|
||||||
fuzzy text matching.
|
|
||||||
- **Multi-source parallel text search fallback** — if no stored ID
|
|
||||||
resolved, run the shared `core.metadata.multi_source_search`
|
|
||||||
against every configured source in parallel and pick the best
|
|
||||||
cross-source match (auto-accept threshold 0.7).
|
|
||||||
4. Validate the match has non-empty title, album, and artists. Reject
|
|
||||||
matches with empty fields — those propagated as
|
|
||||||
"unknown artist - unknown album - unknown track" wishlist entries
|
|
||||||
pre-fix because the wishlist payload normalizer's truthy-check
|
|
||||||
passthrough accepted dicts with empty string fields.
|
|
||||||
5. Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
|
|
||||||
with `source_type='enhance'` and a `source_context` carrying the
|
|
||||||
original file path, format tier, bitrate, and artist name.
|
|
||||||
6. Tally `enhanced_count` / `failed_count` / per-track failure reasons.
|
|
||||||
|
|
||||||
The flow originally had Spotify-only logic with an iTunes search-only
|
|
||||||
fallback. Two failure modes drove the rewrite:
|
|
||||||
|
|
||||||
- Users with neither Spotify nor Deezer connected got silent failures
|
|
||||||
("unknown artist - unknown album - unknown track" wishlist entries)
|
|
||||||
because iTunes's text search returned junk matches with empty fields
|
|
||||||
that cleared the 0.7 confidence threshold.
|
|
||||||
- Library tracks with messy tags ("Title (Live)", featured artists in
|
|
||||||
the artist field, etc.) failed fuzzy text search even when a perfect
|
|
||||||
stored ID was available — Download Discography had no such problem
|
|
||||||
because it resolves albums by stable ID.
|
|
||||||
|
|
||||||
Direct-lookup-via-stored-ID matches the Download Discography contract
|
|
||||||
for every source where we have an ID column. Text search is only the
|
|
||||||
fallback now.
|
|
||||||
|
|
||||||
Returns `(payload_dict, http_status_code)` so the route wrapper can
|
|
||||||
`jsonify()` and return.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger('artists.quality')
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ArtistQualityDeps:
|
|
||||||
"""Bundle of cross-cutting deps the artist quality enhancement needs."""
|
|
||||||
matching_engine: Any
|
|
||||||
get_database: Callable[[], Any]
|
|
||||||
get_wishlist_service: Callable[[], Any]
|
|
||||||
get_current_profile_id: Callable[[], int]
|
|
||||||
get_quality_tier_from_extension: Callable
|
|
||||||
# Returns ``[(source_name, client), ...]`` for every metadata source
|
|
||||||
# the user has configured. Powers both the direct-lookup fast path
|
|
||||||
# (resolves stored source IDs straight from each source's API,
|
|
||||||
# like Download Discography) and the multi-source parallel text
|
|
||||||
# search fallback (shared with Track Redownload via
|
|
||||||
# ``core.metadata.multi_source_search``).
|
|
||||||
get_metadata_search_sources: Callable[[], list]
|
|
||||||
|
|
||||||
|
|
||||||
def _has_complete_metadata(payload: Optional[dict]) -> bool:
|
|
||||||
"""Reject matches with empty / missing core fields. Pre-fix, iTunes
|
|
||||||
returned matches that cleared the 0.7 confidence threshold while
|
|
||||||
having empty artist / album / title — those propagated as junk
|
|
||||||
wishlist entries displayed as 'unknown artist - unknown album -
|
|
||||||
unknown track'."""
|
|
||||||
if not payload:
|
|
||||||
return False
|
|
||||||
if not (payload.get('name') or '').strip():
|
|
||||||
return False
|
|
||||||
artists = payload.get('artists') or []
|
|
||||||
has_artist = any(
|
|
||||||
(a.get('name') or '').strip() if isinstance(a, dict) else (a or '').strip()
|
|
||||||
for a in artists
|
|
||||||
)
|
|
||||||
if not has_artist:
|
|
||||||
return False
|
|
||||||
album = payload.get('album') or {}
|
|
||||||
if isinstance(album, dict):
|
|
||||||
if not (album.get('name') or '').strip():
|
|
||||||
return False
|
|
||||||
elif not (album or '').strip():
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _build_payload_from_track(track_obj) -> dict:
|
|
||||||
"""Build a Spotify-shaped wishlist payload from any metadata source's
|
|
||||||
Track-shaped object (Spotify Track, iTunes Track, Deezer Track,
|
|
||||||
Discogs Track — they all have the same .id / .name / .artists /
|
|
||||||
.album / .duration_ms / etc shape because each client mimics
|
|
||||||
Spotify's surface).
|
|
||||||
|
|
||||||
The wishlist's downstream pipeline expects Spotify shape; this helper
|
|
||||||
is the single place that knows how to produce it. Replaces the
|
|
||||||
duplicated payload construction that used to live in the Spotify
|
|
||||||
search path AND the iTunes fallback path.
|
|
||||||
|
|
||||||
Does NOT substitute defaults for missing artists / album / title —
|
|
||||||
``_has_complete_metadata`` rejects empty matches downstream so the
|
|
||||||
user sees a clear failure instead of a junk wishlist entry with
|
|
||||||
fabricated values.
|
|
||||||
"""
|
|
||||||
image_url = getattr(track_obj, 'image_url', '') or ''
|
|
||||||
album_images = (
|
|
||||||
[{'url': image_url, 'height': 600, 'width': 600}]
|
|
||||||
if image_url else []
|
|
||||||
)
|
|
||||||
artist_names = list(getattr(track_obj, 'artists', None) or [])
|
|
||||||
return {
|
|
||||||
'id': getattr(track_obj, 'id', ''),
|
|
||||||
'name': getattr(track_obj, 'name', '') or '',
|
|
||||||
'artists': [{'name': a} for a in artist_names],
|
|
||||||
'album': {
|
|
||||||
'name': getattr(track_obj, 'album', '') or '',
|
|
||||||
'artists': [{'name': a} for a in artist_names],
|
|
||||||
'album_type': getattr(track_obj, 'album_type', None) or 'album',
|
|
||||||
'images': album_images,
|
|
||||||
'release_date': getattr(track_obj, 'release_date', '') or '',
|
|
||||||
'total_tracks': 1,
|
|
||||||
},
|
|
||||||
'duration_ms': getattr(track_obj, 'duration_ms', 0) or 0,
|
|
||||||
'track_number': getattr(track_obj, 'track_number', None) or 1,
|
|
||||||
'disc_number': getattr(track_obj, 'disc_number', None) or 1,
|
|
||||||
'popularity': getattr(track_obj, 'popularity', None) or 0,
|
|
||||||
'preview_url': getattr(track_obj, 'preview_url', None),
|
|
||||||
'external_urls': getattr(track_obj, 'external_urls', None) or {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Map metadata source name → DB column on the ``tracks`` table that
|
|
||||||
# stores that source's native track ID. Used to drive the direct-lookup
|
|
||||||
# fast path: when a library track has a stored ID for source X and the
|
|
||||||
# user has source X configured, skip fuzzy text search and resolve
|
|
||||||
# straight from X's API. Mirrors what Download Discography does — stable
|
|
||||||
# IDs all the way, no fuzzy text matching.
|
|
||||||
#
|
|
||||||
# Discogs is release-based and has no per-track ID column; not listed
|
|
||||||
# here, so direct lookup never tries Discogs (search-fallback still
|
|
||||||
# runs for Discogs as one of the parallel sources).
|
|
||||||
_STORED_ID_COLUMNS = {
|
|
||||||
'spotify': 'spotify_track_id',
|
|
||||||
'deezer': 'deezer_id',
|
|
||||||
'itunes': 'itunes_track_id',
|
|
||||||
'hydrabase': 'soul_id',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _enhanced_to_wishlist_payload(enhanced: dict,
|
|
||||||
fallback_title: str,
|
|
||||||
fallback_artist: str,
|
|
||||||
fallback_album: str) -> Optional[dict]:
|
|
||||||
"""Convert a ``get_track_details`` enhanced-shape dict to the
|
|
||||||
Spotify-shape wishlist payload.
|
|
||||||
|
|
||||||
Every metadata source's ``get_track_details`` returns the same
|
|
||||||
"enhanced" intermediate shape (top-level ``id``, ``name``,
|
|
||||||
``artists`` as a list of strings, ``album.artists`` as strings),
|
|
||||||
documented and pinned across spotify_client / itunes_client /
|
|
||||||
deezer_client / hydrabase_client. The wishlist downstream expects
|
|
||||||
Spotify's native shape (``artists`` as ``[{'name': ...}]``), so
|
|
||||||
this helper does the conversion in one place.
|
|
||||||
|
|
||||||
Spotify's ``raw_data`` field is already in wishlist shape (the
|
|
||||||
raw Spotify API response), so we return it as-is when detected,
|
|
||||||
preserving full ``album.images`` and ``external_urls`` that the
|
|
||||||
enhanced top-level fields drop. Other sources' ``raw_data`` is
|
|
||||||
in source-native shape and gets ignored.
|
|
||||||
"""
|
|
||||||
if not enhanced:
|
|
||||||
return None
|
|
||||||
raw = enhanced.get('raw_data')
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
raw_artists = raw.get('artists')
|
|
||||||
if (isinstance(raw_artists, list) and raw_artists
|
|
||||||
and isinstance(raw_artists[0], dict)):
|
|
||||||
return raw
|
|
||||||
|
|
||||||
artists = enhanced.get('artists') or [fallback_artist]
|
|
||||||
album_data = enhanced.get('album') or {}
|
|
||||||
album_artists = album_data.get('artists') or artists
|
|
||||||
|
|
||||||
def _to_dict_artists(seq):
|
|
||||||
return [a if isinstance(a, dict) else {'name': a} for a in seq]
|
|
||||||
|
|
||||||
image_url = enhanced.get('image_url') or ''
|
|
||||||
album_images_field = album_data.get('images')
|
|
||||||
if isinstance(album_images_field, list) and album_images_field:
|
|
||||||
album_images = album_images_field
|
|
||||||
elif image_url:
|
|
||||||
album_images = [{'url': image_url, 'height': 600, 'width': 600}]
|
|
||||||
else:
|
|
||||||
album_images = []
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': str(enhanced.get('id', '')),
|
|
||||||
'name': enhanced.get('name') or fallback_title,
|
|
||||||
'artists': _to_dict_artists(artists),
|
|
||||||
'album': {
|
|
||||||
'id': str(album_data.get('id', '')),
|
|
||||||
'name': album_data.get('name') or fallback_album,
|
|
||||||
'album_type': album_data.get('album_type', 'album'),
|
|
||||||
'release_date': album_data.get('release_date', ''),
|
|
||||||
'total_tracks': album_data.get('total_tracks', 1),
|
|
||||||
'artists': _to_dict_artists(album_artists),
|
|
||||||
'images': album_images,
|
|
||||||
},
|
|
||||||
'duration_ms': enhanced.get('duration_ms', 0),
|
|
||||||
'track_number': enhanced.get('track_number', 1),
|
|
||||||
'disc_number': enhanced.get('disc_number', 1),
|
|
||||||
'popularity': enhanced.get('popularity', 0),
|
|
||||||
'preview_url': enhanced.get('preview_url'),
|
|
||||||
'external_urls': enhanced.get('external_urls', {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _try_direct_lookup_all_sources(track: dict,
|
|
||||||
sources: list,
|
|
||||||
preferred_source: Optional[str],
|
|
||||||
title: str,
|
|
||||||
artist_name: str,
|
|
||||||
album_title: str
|
|
||||||
) -> tuple:
|
|
||||||
"""Try direct ID-based lookup on every source where the library
|
|
||||||
track has a stored ID. Returns ``(payload, source_name)`` on first
|
|
||||||
success, or ``(None, None)`` if no source has a stored ID with a
|
|
||||||
successful lookup.
|
|
||||||
|
|
||||||
Mirrors what Download Discography does — stable IDs straight to the
|
|
||||||
source's API, no fuzzy text matching. Avoids the failure mode where
|
|
||||||
library text tags don't match the source's canonical title (the
|
|
||||||
Discord report case: track tagged "Title (Live)" and source has
|
|
||||||
"Title" → fuzzy search misses, but stored ID resolves directly).
|
|
||||||
|
|
||||||
Preferred source attempted first when present in ``sources``,
|
|
||||||
typically the user's configured primary metadata source — so a
|
|
||||||
Deezer-primary user gets Deezer art / album shape on the wishlist
|
|
||||||
entry instead of whichever source happened to have a stored ID
|
|
||||||
first in iteration order.
|
|
||||||
"""
|
|
||||||
def _priority(entry):
|
|
||||||
name = entry[0]
|
|
||||||
return 0 if name == preferred_source else 1
|
|
||||||
ordered = sorted(sources, key=_priority)
|
|
||||||
|
|
||||||
for source_name, client in ordered:
|
|
||||||
column = _STORED_ID_COLUMNS.get(source_name)
|
|
||||||
if not column:
|
|
||||||
continue
|
|
||||||
stored_id = track.get(column)
|
|
||||||
if not stored_id:
|
|
||||||
continue
|
|
||||||
if not hasattr(client, 'get_track_details'):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
enhanced = client.get_track_details(str(stored_id))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
f"[Enhance] {source_name} direct lookup failed for "
|
|
||||||
f"ID {stored_id}: {exc}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not enhanced:
|
|
||||||
continue
|
|
||||||
payload = _enhanced_to_wishlist_payload(
|
|
||||||
enhanced, title, artist_name, album_title,
|
|
||||||
)
|
|
||||||
if _has_complete_metadata(payload):
|
|
||||||
logger.info(
|
|
||||||
f"[Enhance] Direct lookup matched: {source_name} "
|
|
||||||
f"ID {stored_id} → '{payload.get('name')}'"
|
|
||||||
)
|
|
||||||
return payload, source_name
|
|
||||||
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
# Minimum match-score threshold for accepting a search-fallback match
|
|
||||||
# without user confirmation. Mirrors the legacy threshold the enhance
|
|
||||||
# flow has always used.
|
|
||||||
_AUTO_ACCEPT_SCORE_THRESHOLD = 0.7
|
|
||||||
|
|
||||||
|
|
||||||
def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|
||||||
"""Add selected tracks to wishlist for quality enhancement re-download.
|
|
||||||
|
|
||||||
Per-track flow:
|
|
||||||
|
|
||||||
1. **Direct lookup using stored source IDs** (mirrors what Download
|
|
||||||
Discography does — stable IDs straight to the source's API, no
|
|
||||||
fuzzy text matching). For each source the user has configured,
|
|
||||||
if the library track has the corresponding stored ID
|
|
||||||
(``spotify_track_id`` / ``deezer_id`` / ``itunes_track_id`` /
|
|
||||||
``soul_id``), call ``client.get_track_details(stored_id)`` and
|
|
||||||
convert to wishlist payload. First success wins; preferred
|
|
||||||
source (user's configured primary) tried first.
|
|
||||||
|
|
||||||
2. **Multi-source parallel text search fallback** (via the shared
|
|
||||||
``core.metadata.multi_source_search`` module — same code path
|
|
||||||
Track Redownload uses) for tracks with no stored IDs / lookup
|
|
||||||
misses.
|
|
||||||
|
|
||||||
3. **Validation**: reject matches with empty title / album / artists
|
|
||||||
so the user sees a clear failure instead of an "unknown artist"
|
|
||||||
wishlist entry.
|
|
||||||
|
|
||||||
Pre-refactor: only Spotify had a direct-lookup fast path; everything
|
|
||||||
else went through fuzzy text search. Discogs / Hydrabase / Deezer-
|
|
||||||
primary users got far worse coverage than Download Discography
|
|
||||||
despite both flows asking the same question.
|
|
||||||
"""
|
|
||||||
from core.metadata.multi_source_search import TrackQuery, search_all_sources
|
|
||||||
from core.metadata.registry import get_primary_source
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not track_ids:
|
|
||||||
return {"success": False, "error": "No track IDs provided"}, 400
|
|
||||||
|
|
||||||
database = deps.get_database()
|
|
||||||
wishlist_service = deps.get_wishlist_service()
|
|
||||||
profile_id = deps.get_current_profile_id()
|
|
||||||
|
|
||||||
# Get artist info
|
|
||||||
artist_result = database.get_artist_full_detail(artist_id)
|
|
||||||
if not artist_result.get('success'):
|
|
||||||
return {"success": False, "error": "Artist not found"}, 404
|
|
||||||
|
|
||||||
artist_name = artist_result.get('artist', {}).get('name', 'Unknown Artist')
|
|
||||||
|
|
||||||
# Build lookup of all tracks for this artist
|
|
||||||
track_lookup = {}
|
|
||||||
for album in artist_result.get('albums', []):
|
|
||||||
album_title = album.get('title', '')
|
|
||||||
for track in album.get('tracks', []):
|
|
||||||
tid = str(track.get('id', ''))
|
|
||||||
track['_album_title'] = album_title
|
|
||||||
track['_album_id'] = album.get('id')
|
|
||||||
track_lookup[tid] = track
|
|
||||||
|
|
||||||
# Resolve every configured metadata source up front.
|
|
||||||
search_sources = deps.get_metadata_search_sources()
|
|
||||||
|
|
||||||
# User's configured primary source — direct-lookup tries this
|
|
||||||
# first so Deezer-primary users get Deezer payloads on the
|
|
||||||
# wishlist entry (correct cover art / album shape) even when
|
|
||||||
# other sources also have stored IDs for the same track.
|
|
||||||
try:
|
|
||||||
preferred_source = get_primary_source()
|
|
||||||
except Exception:
|
|
||||||
preferred_source = None
|
|
||||||
|
|
||||||
enhanced_count = 0
|
|
||||||
failed_count = 0
|
|
||||||
failed_tracks = []
|
|
||||||
|
|
||||||
for track_id in track_ids:
|
|
||||||
track_id_str = str(track_id)
|
|
||||||
track = track_lookup.get(track_id_str)
|
|
||||||
if not track:
|
|
||||||
failed_count += 1
|
|
||||||
failed_tracks.append({'track_id': track_id, 'reason': 'Track not found'})
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_path = track.get('file_path')
|
|
||||||
if not file_path:
|
|
||||||
failed_count += 1
|
|
||||||
failed_tracks.append({'track_id': track_id, 'reason': 'No file path'})
|
|
||||||
continue
|
|
||||||
|
|
||||||
tier_name, tier_num = deps.get_quality_tier_from_extension(file_path)
|
|
||||||
title = track.get('title', '') or ''
|
|
||||||
if not title.strip():
|
|
||||||
title = os.path.splitext(os.path.basename(file_path))[0]
|
|
||||||
album_title = track.get('_album_title', '')
|
|
||||||
|
|
||||||
matched_track_data = None
|
|
||||||
chosen_source = None
|
|
||||||
|
|
||||||
# 1. Direct lookup via every stored source ID — like Download
|
|
||||||
# Discography. Stable IDs, no fuzzy text matching.
|
|
||||||
if search_sources:
|
|
||||||
matched_track_data, chosen_source = _try_direct_lookup_all_sources(
|
|
||||||
track, search_sources, preferred_source,
|
|
||||||
title, artist_name, album_title,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Multi-source parallel text search fallback — for tracks
|
|
||||||
# with no stored IDs / lookup misses.
|
|
||||||
if not matched_track_data and search_sources:
|
|
||||||
try:
|
|
||||||
track_query = TrackQuery(
|
|
||||||
title=title,
|
|
||||||
artist=artist_name,
|
|
||||||
album=album_title,
|
|
||||||
duration_ms=track.get('duration', 0) or 0,
|
|
||||||
spotify_track_id=track.get('spotify_track_id'),
|
|
||||||
deezer_id=track.get('deezer_id'),
|
|
||||||
)
|
|
||||||
multi_result = search_all_sources(track_query, search_sources)
|
|
||||||
if multi_result.best_match and multi_result.best_match['score'] >= _AUTO_ACCEPT_SCORE_THRESHOLD:
|
|
||||||
chosen_source = multi_result.best_match['source']
|
|
||||||
best_track_obj = multi_result.best_track()
|
|
||||||
if best_track_obj:
|
|
||||||
matched_track_data = _build_payload_from_track(best_track_obj)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"[Enhance] Multi-source search failed for {title}: {exc}")
|
|
||||||
|
|
||||||
# 3. Reject matches with empty / missing core fields.
|
|
||||||
if not _has_complete_metadata(matched_track_data):
|
|
||||||
if matched_track_data:
|
|
||||||
logger.warning(
|
|
||||||
f"[Enhance] {chosen_source} match for '{title}' rejected — "
|
|
||||||
f"empty title / album / artists (would render as 'unknown')"
|
|
||||||
)
|
|
||||||
matched_track_data = None
|
|
||||||
|
|
||||||
if not matched_track_data:
|
|
||||||
failed_count += 1
|
|
||||||
source_list = ', '.join(name for name, _ in (search_sources or []))
|
|
||||||
if not source_list:
|
|
||||||
reason = (
|
|
||||||
'No metadata source configured — connect Spotify / '
|
|
||||||
'iTunes / Deezer / Discogs / Hydrabase to enable enhance'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reason = (
|
|
||||||
f'No usable match across {source_list} — '
|
|
||||||
f'try connecting an additional metadata source'
|
|
||||||
)
|
|
||||||
failed_tracks.append({
|
|
||||||
'track_id': track_id,
|
|
||||||
'title': title,
|
|
||||||
'reason': reason,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add to wishlist with enhance source
|
|
||||||
source_context = {
|
|
||||||
'enhance': True,
|
|
||||||
'original_file_path': file_path,
|
|
||||||
'original_format': tier_name,
|
|
||||||
'original_bitrate': track.get('bitrate'),
|
|
||||||
'original_tier': tier_num,
|
|
||||||
'artist_name': artist_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
success = wishlist_service.add_spotify_track_to_wishlist(
|
|
||||||
spotify_track_data=matched_track_data,
|
|
||||||
failure_reason=f"Quality enhance - upgrading from {tier_name.replace('_', ' ').title()}",
|
|
||||||
source_type='enhance',
|
|
||||||
source_context=source_context,
|
|
||||||
profile_id=profile_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
enhanced_count += 1
|
|
||||||
logger.info(f"[Enhance] Queued for upgrade: {artist_name} - {title} ({tier_name})")
|
|
||||||
else:
|
|
||||||
failed_count += 1
|
|
||||||
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'Wishlist add failed'})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'enhanced_count': enhanced_count,
|
|
||||||
'failed_count': failed_count,
|
|
||||||
'failed_tracks': failed_tracks
|
|
||||||
}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Enhance] {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return {"success": False, "error": str(e)}, 500
|
|
||||||
|
|
@ -28,9 +28,6 @@ def rate_limited(func):
|
||||||
|
|
||||||
_last_api_call_time = time.time()
|
_last_api_call_time = time.time()
|
||||||
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
api_call_tracker.record_call('audiodb')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.audiodb_client import AudioDBClient
|
from core.audiodb_client import AudioDBClient
|
||||||
from core.worker_utils import accept_artist_match, interruptible_sleep
|
|
||||||
|
|
||||||
logger = get_logger("audiodb_worker")
|
logger = get_logger("audiodb_worker")
|
||||||
|
|
||||||
|
|
@ -25,7 +24,6 @@ class AudioDBWorker:
|
||||||
self.paused = False
|
self.paused = False
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
# Current item being processed (for UI tooltip)
|
# Current item being processed (for UI tooltip)
|
||||||
self.current_item = None
|
self.current_item = None
|
||||||
|
|
@ -54,7 +52,6 @@ class AudioDBWorker:
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
self._stop_event.clear()
|
|
||||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
logger.info("AudioDB background worker started")
|
logger.info("AudioDB background worker started")
|
||||||
|
|
@ -67,10 +64,9 @@ class AudioDBWorker:
|
||||||
logger.info("Stopping AudioDB worker...")
|
logger.info("Stopping AudioDB worker...")
|
||||||
self.should_stop = True
|
self.should_stop = True
|
||||||
self.running = False
|
self.running = False
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
if self.thread:
|
if self.thread:
|
||||||
self.thread.join(timeout=1)
|
self.thread.join(timeout=5)
|
||||||
|
|
||||||
logger.info("AudioDB worker stopped")
|
logger.info("AudioDB worker stopped")
|
||||||
|
|
||||||
|
|
@ -119,7 +115,7 @@ class AudioDBWorker:
|
||||||
while not self.should_stop:
|
while not self.should_stop:
|
||||||
try:
|
try:
|
||||||
if self.paused:
|
if self.paused:
|
||||||
interruptible_sleep(self._stop_event, 1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.current_item = None
|
self.current_item = None
|
||||||
|
|
@ -128,7 +124,7 @@ class AudioDBWorker:
|
||||||
|
|
||||||
if not item:
|
if not item:
|
||||||
logger.debug("No pending items, sleeping...")
|
logger.debug("No pending items, sleeping...")
|
||||||
interruptible_sleep(self._stop_event, 10)
|
time.sleep(10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.current_item = item
|
self.current_item = item
|
||||||
|
|
@ -140,18 +136,18 @@ class AudioDBWorker:
|
||||||
itype = item.get('type', '')
|
itype = item.get('type', '')
|
||||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||||
# Can't mark status without an ID — just skip
|
# Can't mark status without an ID — just skip
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("null id table resolve failed: %s", e)
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
self._process_item(item)
|
self._process_item(item)
|
||||||
|
|
||||||
interruptible_sleep(self._stop_event, 2)
|
time.sleep(2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in worker loop: {e}")
|
logger.error(f"Error in worker loop: {e}")
|
||||||
interruptible_sleep(self._stop_event, 5)
|
time.sleep(5)
|
||||||
|
|
||||||
logger.info("AudioDB worker thread finished")
|
logger.info("AudioDB worker thread finished")
|
||||||
|
|
||||||
|
|
@ -162,16 +158,6 @@ class AudioDBWorker:
|
||||||
conn = self.db._get_connection()
|
conn = self.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Pinned-group override (Manage Enrichment Workers): process one
|
|
||||||
# entity type first, then fall through to the normal chain. Unset or
|
|
||||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
|
||||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
|
||||||
_prio = read_enrichment_priority('audiodb')
|
|
||||||
if _prio:
|
|
||||||
_pi = priority_pending_item(cursor, 'audiodb', _prio)
|
|
||||||
if _pi:
|
|
||||||
return _pi
|
|
||||||
|
|
||||||
# Priority 1: Unattempted artists
|
# Priority 1: Unattempted artists
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
|
|
@ -210,45 +196,42 @@ class AudioDBWorker:
|
||||||
if row:
|
if row:
|
||||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
||||||
|
|
||||||
# Priority 4: Retry 'not_found' OR 'error' artists after retry_days.
|
# Priority 4: Retry 'not_found' artists after retry_days
|
||||||
# 'error' status covers transient AudioDB outages (timeouts, 500s)
|
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
||||||
# that the issue-#553 fix marks rather than leaving NULL — without
|
|
||||||
# this retry path those rows would stay errored forever.
|
|
||||||
retry_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM artists
|
FROM artists
|
||||||
WHERE audiodb_match_status IN ('not_found', 'error') AND audiodb_last_attempted < ?
|
WHERE audiodb_match_status = 'not_found' AND audiodb_last_attempted < ?
|
||||||
ORDER BY audiodb_last_attempted ASC
|
ORDER BY audiodb_last_attempted ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", (retry_cutoff,))
|
""", (not_found_cutoff,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
||||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||||
|
|
||||||
# Priority 5: Retry 'not_found' OR 'error' albums
|
# Priority 5: Retry 'not_found' albums
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT a.id, a.title, ar.name AS artist_name, ar.audiodb_id AS artist_audiodb_id
|
SELECT a.id, a.title, ar.name AS artist_name, ar.audiodb_id AS artist_audiodb_id
|
||||||
FROM albums a
|
FROM albums a
|
||||||
JOIN artists ar ON a.artist_id = ar.id
|
JOIN artists ar ON a.artist_id = ar.id
|
||||||
WHERE a.audiodb_match_status IN ('not_found', 'error') AND a.audiodb_last_attempted < ?
|
WHERE a.audiodb_match_status = 'not_found' AND a.audiodb_last_attempted < ?
|
||||||
ORDER BY a.audiodb_last_attempted ASC
|
ORDER BY a.audiodb_last_attempted ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", (retry_cutoff,))
|
""", (not_found_cutoff,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
||||||
|
|
||||||
# Priority 6: Retry 'not_found' OR 'error' tracks
|
# Priority 6: Retry 'not_found' tracks
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT t.id, t.title, ar.name AS artist_name, ar.audiodb_id AS artist_audiodb_id
|
SELECT t.id, t.title, ar.name AS artist_name, ar.audiodb_id AS artist_audiodb_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists ar ON t.artist_id = ar.id
|
JOIN artists ar ON t.artist_id = ar.id
|
||||||
WHERE t.audiodb_match_status IN ('not_found', 'error') AND t.audiodb_last_attempted < ?
|
WHERE t.audiodb_match_status = 'not_found' AND t.audiodb_last_attempted < ?
|
||||||
ORDER BY t.audiodb_last_attempted ASC
|
ORDER BY t.audiodb_last_attempted ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", (retry_cutoff,))
|
""", (not_found_cutoff,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_audiodb_id': row[3]}
|
||||||
|
|
@ -273,13 +256,8 @@ class AudioDBWorker:
|
||||||
|
|
||||||
def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
||||||
"""Verify that the result's artist ID matches the parent artist's stored AudioDB ID.
|
"""Verify that the result's artist ID matches the parent artist's stored AudioDB ID.
|
||||||
|
|
||||||
If mismatched, the album/track search is more specific (uses artist+title),
|
If mismatched, the album/track search is more specific (uses artist+title),
|
||||||
so we trust it and correct the parent artist's audiodb_id — BUT only when
|
so we trust it and correct the parent artist's audiodb_id."""
|
||||||
the result's artist *name* matches our parent artist. Without that guard,
|
|
||||||
a collaboration/compilation (a track our library credits to one artist
|
|
||||||
that lives on another artist's album) would stamp the wrong AudioDB id
|
|
||||||
onto our artist. See the Deezer fix for the full write-up."""
|
|
||||||
parent_audiodb_id = item.get('artist_audiodb_id')
|
parent_audiodb_id = item.get('artist_audiodb_id')
|
||||||
if not parent_audiodb_id:
|
if not parent_audiodb_id:
|
||||||
return True
|
return True
|
||||||
|
|
@ -289,18 +267,6 @@ class AudioDBWorker:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_audiodb_id):
|
if str(result_artist_id) != str(parent_audiodb_id):
|
||||||
parent_name = item.get('artist') or ''
|
|
||||||
result_artist_name = result.get('strArtist') or ''
|
|
||||||
if (result_artist_name and parent_name
|
|
||||||
and not self._name_matches(parent_name, result_artist_name)):
|
|
||||||
logger.info(
|
|
||||||
f"Skipping artist-ID correction from {item['type']} "
|
|
||||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
|
||||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
|
||||||
f"correction)"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}"
|
f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}"
|
||||||
|
|
@ -404,40 +370,22 @@ class AudioDBWorker:
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Direct lookup failed for existing AudioDB ID {existing_id}: {e}")
|
logger.warning(f"Direct lookup failed for existing AudioDB ID {existing_id}: {e}")
|
||||||
# Direct lookup returned no metadata (None) or raised — don't
|
# Direct lookup failed — don't overwrite manual match
|
||||||
# fall through to the name-search path below, which could
|
logger.debug(f"Preserving manual match for {item_type} '{item_name}' (AudioDB ID: {existing_id})")
|
||||||
# overwrite a manually-matched audiodb_id with a wrong guess.
|
|
||||||
# Mark status='error' so the queue's NULL-status filter stops
|
|
||||||
# re-picking this row on every tick (issue #553: AudioDB
|
|
||||||
# `track.php` timeouts caused infinite enrichment loops as
|
|
||||||
# the row was repeatedly picked + re-attempted because it
|
|
||||||
# never left the NULL state). The error-retry priority block
|
|
||||||
# in `_get_next_item` re-attempts after `retry_days` so
|
|
||||||
# transient AudioDB outages still recover automatically.
|
|
||||||
self._mark_status(item_type, item_id, 'error')
|
|
||||||
self.stats['errors'] += 1
|
|
||||||
logger.debug(
|
|
||||||
f"Preserving manual match for {item_type} '{item_name}' "
|
|
||||||
f"(AudioDB ID: {existing_id}); marked error pending retry"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if item_type == 'artist':
|
if item_type == 'artist':
|
||||||
result = self.client.search_artist(item_name)
|
result = self.client.search_artist(item_name)
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('strArtist', '')
|
result_name = result.get('strArtist', '')
|
||||||
ok, reason = accept_artist_match(
|
if self._name_matches(item_name, result_name):
|
||||||
self.db, 'audiodb_id', result.get('idArtist'), item_id,
|
|
||||||
item_name, result_name,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
self._update_artist(item_id, result)
|
self._update_artist(item_id, result)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', item_id, 'not_found')
|
self._mark_status('artist', item_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"Artist '{item_name}' not matched: {reason}")
|
logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', item_id, 'not_found')
|
self._mark_status('artist', item_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
|
|
@ -528,14 +476,10 @@ class AudioDBWorker:
|
||||||
# Backfill genres if artist has none
|
# Backfill genres if artist has none
|
||||||
genre = data.get('strGenre')
|
genre = data.get('strGenre')
|
||||||
if genre:
|
if genre:
|
||||||
from core.genre_filter import filter_genres
|
cursor.execute("""
|
||||||
from config.settings import config_manager as _cfg
|
UPDATE artists SET genres = ?
|
||||||
_filtered = filter_genres([genre], _cfg)
|
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||||
if _filtered:
|
""", (json.dumps([genre]), artist_id))
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET genres = ?
|
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
|
||||||
""", (json.dumps(_filtered), artist_id))
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
@ -580,14 +524,10 @@ class AudioDBWorker:
|
||||||
# Backfill genres if album has none
|
# Backfill genres if album has none
|
||||||
genre = data.get('strGenre')
|
genre = data.get('strGenre')
|
||||||
if genre:
|
if genre:
|
||||||
from core.genre_filter import filter_genres
|
cursor.execute("""
|
||||||
from config.settings import config_manager as _cfg
|
UPDATE albums SET genres = ?
|
||||||
_filtered = filter_genres([genre], _cfg)
|
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||||
if _filtered:
|
""", (json.dumps([genre]), album_id))
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET genres = ?
|
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
|
||||||
""", (json.dumps(_filtered), album_id))
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
"""Automation API + progress + handlers package.
|
|
||||||
|
|
||||||
Lifted from web_server.py:
|
|
||||||
- `/api/automations/*` route helpers → `api.py`
|
|
||||||
- block library used by the trigger/action UI → `blocks.py`
|
|
||||||
- progress tracker (init / update / finish) → `progress.py`
|
|
||||||
- cross-handler signal bus → `signals.py`
|
|
||||||
- per-action handler functions → `handlers/` subpackage (with
|
|
||||||
`deps.py` defining the dependency-injection surface so handlers
|
|
||||||
stay testable in isolation)
|
|
||||||
"""
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
"""Automation REST API helpers.
|
|
||||||
|
|
||||||
CRUD + run + progress + history logic for /api/automations/* routes.
|
|
||||||
Each function takes the deps it needs (database, automation_engine,
|
|
||||||
profile_id) so the route layer is left as pure HTTP shuffling.
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- /api/automations/blocks — static JSON + one call into signals.py;
|
|
||||||
stays inline in web_server.py for now.
|
|
||||||
- /api/test/automation — touches scan manager + media clients +
|
|
||||||
config_manager; stays inline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Hydration helpers — convert raw DB rows to API-friendly dicts
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_JSON_FIELDS = ('trigger_config', 'action_config', 'notify_config', 'last_result')
|
|
||||||
_JSON_DEFAULT_DICT = {'trigger_config', 'action_config', 'notify_config'}
|
|
||||||
|
|
||||||
|
|
||||||
def _hydrate_automation(auto: dict) -> dict:
|
|
||||||
"""Parse JSON columns and backfill `then_actions` from legacy notify_*."""
|
|
||||||
for field in _JSON_FIELDS:
|
|
||||||
try:
|
|
||||||
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
auto[field] = {} if field in _JSON_DEFAULT_DICT else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = auto.get('then_actions')
|
|
||||||
auto['then_actions'] = json.loads(raw or '[]') if isinstance(raw, str) else (raw or [])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
auto['then_actions'] = []
|
|
||||||
|
|
||||||
if not auto['then_actions'] and auto.get('notify_type'):
|
|
||||||
auto['then_actions'] = [{
|
|
||||||
'type': auto['notify_type'],
|
|
||||||
'config': auto.get('notify_config', {}),
|
|
||||||
}]
|
|
||||||
return auto
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Signal cycle detection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _has_signal_concern(trigger_type: str, then_actions: list[dict]) -> bool:
|
|
||||||
return trigger_type == 'signal_received' or any(
|
|
||||||
t.get('type') == 'fire_signal' for t in then_actions
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_create_cycle(
|
|
||||||
automation_engine,
|
|
||||||
database,
|
|
||||||
profile_id: int,
|
|
||||||
trigger_type: str,
|
|
||||||
trigger_config_json: str,
|
|
||||||
then_actions_json: str,
|
|
||||||
then_actions: list[dict],
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Return cycle path string if creating this automation would loop, else None."""
|
|
||||||
if not automation_engine or not _has_signal_concern(trigger_type, then_actions):
|
|
||||||
return None
|
|
||||||
all_autos = database.get_automations(profile_id)
|
|
||||||
test_auto = {
|
|
||||||
'trigger_type': trigger_type,
|
|
||||||
'trigger_config': trigger_config_json,
|
|
||||||
'then_actions': then_actions_json,
|
|
||||||
'enabled': True,
|
|
||||||
}
|
|
||||||
all_autos.append(test_auto)
|
|
||||||
cycle = automation_engine.detect_signal_cycles(all_autos)
|
|
||||||
if cycle:
|
|
||||||
return ' → '.join(cycle)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_update_cycle(
|
|
||||||
automation_engine,
|
|
||||||
database,
|
|
||||||
automation_id: int,
|
|
||||||
data: dict,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Return cycle path string if updating this automation would loop, else None."""
|
|
||||||
if not automation_engine:
|
|
||||||
return None
|
|
||||||
trigger_type = data.get('trigger_type', '')
|
|
||||||
then_actions = data.get('then_actions', [])
|
|
||||||
if not _has_signal_concern(trigger_type, then_actions):
|
|
||||||
return None
|
|
||||||
|
|
||||||
all_autos = database.get_automations()
|
|
||||||
test_autos = []
|
|
||||||
for a in all_autos:
|
|
||||||
if a['id'] == automation_id:
|
|
||||||
merged = dict(a)
|
|
||||||
if 'trigger_type' in data:
|
|
||||||
merged['trigger_type'] = data['trigger_type']
|
|
||||||
if 'trigger_config' in data:
|
|
||||||
merged['trigger_config'] = json.dumps(data['trigger_config'])
|
|
||||||
if 'then_actions' in data:
|
|
||||||
merged['then_actions'] = json.dumps(data['then_actions'])
|
|
||||||
merged['enabled'] = True
|
|
||||||
test_autos.append(merged)
|
|
||||||
else:
|
|
||||||
test_autos.append(a)
|
|
||||||
cycle = automation_engine.detect_signal_cycles(test_autos)
|
|
||||||
if cycle:
|
|
||||||
return ' → '.join(cycle)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CRUD helpers — return (response_dict, http_status)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_automations(database, profile_id: int) -> list[dict]:
|
|
||||||
"""All automations for the profile, with JSON columns parsed."""
|
|
||||||
automations = database.get_automations(profile_id)
|
|
||||||
return [_hydrate_automation(a) for a in automations]
|
|
||||||
|
|
||||||
|
|
||||||
def get_automation(database, automation_id: int) -> Optional[dict]:
|
|
||||||
"""One automation, hydrated. Returns None if not found."""
|
|
||||||
auto = database.get_automation(automation_id)
|
|
||||||
if not auto:
|
|
||||||
return None
|
|
||||||
return _hydrate_automation(auto)
|
|
||||||
|
|
||||||
|
|
||||||
def create_automation(
|
|
||||||
database,
|
|
||||||
automation_engine,
|
|
||||||
profile_id: int,
|
|
||||||
data: dict,
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
"""Create + schedule an automation. Returns (response_body, http_status)."""
|
|
||||||
name = (data.get('name') or '').strip()
|
|
||||||
if not name:
|
|
||||||
return {'error': 'Name is required'}, 400
|
|
||||||
|
|
||||||
trigger_type = data.get('trigger_type', 'schedule')
|
|
||||||
trigger_config = json.dumps(data.get('trigger_config', {}))
|
|
||||||
action_type = data.get('action_type', 'process_wishlist')
|
|
||||||
action_config = json.dumps(data.get('action_config', {}))
|
|
||||||
then_actions = data.get('then_actions', [])
|
|
||||||
then_actions_json = json.dumps(then_actions)
|
|
||||||
|
|
||||||
if then_actions:
|
|
||||||
notify_type = then_actions[0].get('type')
|
|
||||||
notify_config = json.dumps(then_actions[0].get('config', {}))
|
|
||||||
else:
|
|
||||||
notify_type = data.get('notify_type') or None
|
|
||||||
notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}'
|
|
||||||
|
|
||||||
cycle_path = _check_create_cycle(
|
|
||||||
automation_engine, database, profile_id,
|
|
||||||
trigger_type, trigger_config, then_actions_json, then_actions,
|
|
||||||
)
|
|
||||||
if cycle_path:
|
|
||||||
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
|
|
||||||
|
|
||||||
group_name = data.get('group_name') or None
|
|
||||||
owned_by = data.get('owned_by') or None
|
|
||||||
auto_id = database.create_automation(
|
|
||||||
name, trigger_type, trigger_config, action_type, action_config,
|
|
||||||
profile_id, notify_type, notify_config, then_actions_json, group_name,
|
|
||||||
owned_by=owned_by,
|
|
||||||
)
|
|
||||||
if auto_id is None:
|
|
||||||
return {'error': 'Failed to create automation'}, 500
|
|
||||||
|
|
||||||
if automation_engine:
|
|
||||||
automation_engine.schedule_automation(auto_id)
|
|
||||||
return {'success': True, 'id': auto_id}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def update_automation(
|
|
||||||
database,
|
|
||||||
automation_engine,
|
|
||||||
automation_id: int,
|
|
||||||
data: dict,
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
"""Update + reschedule an automation. Returns (response_body, http_status)."""
|
|
||||||
update_fields: dict[str, Any] = {}
|
|
||||||
if 'name' in data:
|
|
||||||
update_fields['name'] = data['name'].strip()
|
|
||||||
if 'trigger_type' in data:
|
|
||||||
update_fields['trigger_type'] = data['trigger_type']
|
|
||||||
if 'trigger_config' in data:
|
|
||||||
update_fields['trigger_config'] = json.dumps(data['trigger_config'])
|
|
||||||
if 'action_type' in data:
|
|
||||||
update_fields['action_type'] = data['action_type']
|
|
||||||
if 'action_config' in data:
|
|
||||||
update_fields['action_config'] = json.dumps(data['action_config'])
|
|
||||||
if 'then_actions' in data:
|
|
||||||
then_actions = data['then_actions']
|
|
||||||
update_fields['then_actions'] = json.dumps(then_actions)
|
|
||||||
if then_actions:
|
|
||||||
update_fields['notify_type'] = then_actions[0].get('type')
|
|
||||||
update_fields['notify_config'] = json.dumps(then_actions[0].get('config', {}))
|
|
||||||
else:
|
|
||||||
update_fields['notify_type'] = None
|
|
||||||
update_fields['notify_config'] = '{}'
|
|
||||||
elif 'notify_type' in data:
|
|
||||||
update_fields['notify_type'] = data['notify_type'] or None
|
|
||||||
if 'notify_config' in data and 'then_actions' not in data:
|
|
||||||
update_fields['notify_config'] = json.dumps(data['notify_config'])
|
|
||||||
if 'group_name' in data:
|
|
||||||
update_fields['group_name'] = data['group_name'] or None
|
|
||||||
if 'owned_by' in data:
|
|
||||||
update_fields['owned_by'] = data['owned_by'] or None
|
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
return {'error': 'No fields to update'}, 400
|
|
||||||
|
|
||||||
cycle_path = _check_update_cycle(automation_engine, database, automation_id, data)
|
|
||||||
if cycle_path:
|
|
||||||
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
|
|
||||||
|
|
||||||
# Schedule-shape changes must invalidate the stored next_run so the
|
|
||||||
# scheduler recomputes it; otherwise restart-survival logic keeps the
|
|
||||||
# leftover timestamp from the previous interval.
|
|
||||||
if {'trigger_type', 'trigger_config'} & update_fields.keys():
|
|
||||||
update_fields['next_run'] = None
|
|
||||||
|
|
||||||
success = database.update_automation(automation_id, **update_fields)
|
|
||||||
if not success:
|
|
||||||
return {'error': 'Automation not found'}, 404
|
|
||||||
|
|
||||||
if automation_engine:
|
|
||||||
auto = database.get_automation(automation_id)
|
|
||||||
if auto and auto.get('enabled'):
|
|
||||||
automation_engine.schedule_automation(automation_id)
|
|
||||||
else:
|
|
||||||
automation_engine.cancel_automation(automation_id)
|
|
||||||
return {'success': True}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def batch_update_group(database, automation_ids: list, group_name: Optional[str]) -> tuple[dict, int]:
|
|
||||||
"""Move/rename a set of automations into a single group (or ungroup)."""
|
|
||||||
if not automation_ids or not isinstance(automation_ids, list):
|
|
||||||
return {'error': 'automation_ids must be a non-empty list'}, 400
|
|
||||||
try:
|
|
||||||
automation_ids = [int(aid) for aid in automation_ids]
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return {'error': 'automation_ids must contain integers'}, 400
|
|
||||||
|
|
||||||
updated = database.batch_update_group(automation_ids, group_name)
|
|
||||||
return {'success': True, 'updated': updated}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def bulk_toggle(
|
|
||||||
database,
|
|
||||||
automation_engine,
|
|
||||||
automation_ids: list,
|
|
||||||
enabled: bool,
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
"""Bulk enable/disable a set of automations + reschedule each affected."""
|
|
||||||
if not automation_ids or not isinstance(automation_ids, list):
|
|
||||||
return {'error': 'automation_ids must be a non-empty list'}, 400
|
|
||||||
try:
|
|
||||||
automation_ids = [int(aid) for aid in automation_ids]
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return {'error': 'automation_ids must contain integers'}, 400
|
|
||||||
|
|
||||||
updated = database.bulk_set_enabled(automation_ids, bool(enabled))
|
|
||||||
|
|
||||||
if automation_engine and updated > 0:
|
|
||||||
for aid in automation_ids:
|
|
||||||
auto = database.get_automation(aid)
|
|
||||||
if auto:
|
|
||||||
if auto.get('enabled'):
|
|
||||||
automation_engine.schedule_automation(auto)
|
|
||||||
else:
|
|
||||||
automation_engine.cancel_automation(aid)
|
|
||||||
return {'success': True, 'updated': updated}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def delete_automation(database, automation_engine, automation_id: int) -> tuple[dict, int]:
|
|
||||||
"""Delete an automation. System automations are protected."""
|
|
||||||
auto = database.get_automation(automation_id)
|
|
||||||
if auto and auto.get('is_system'):
|
|
||||||
return {'error': 'System automations cannot be deleted'}, 403
|
|
||||||
if automation_engine:
|
|
||||||
automation_engine.cancel_automation(automation_id)
|
|
||||||
success = database.delete_automation(automation_id)
|
|
||||||
if not success:
|
|
||||||
return {'error': 'Automation not found'}, 404
|
|
||||||
return {'success': True}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def duplicate_automation(
|
|
||||||
database,
|
|
||||||
automation_engine,
|
|
||||||
profile_id: int,
|
|
||||||
automation_id: int,
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
"""Duplicate an automation. System automations are protected."""
|
|
||||||
auto = database.get_automation(automation_id)
|
|
||||||
if not auto:
|
|
||||||
return {'error': 'Automation not found'}, 404
|
|
||||||
if auto.get('is_system'):
|
|
||||||
return {'error': 'System automations cannot be duplicated'}, 403
|
|
||||||
new_id = database.create_automation(
|
|
||||||
name=f"{auto['name']} (Copy)",
|
|
||||||
trigger_type=auto['trigger_type'],
|
|
||||||
trigger_config=auto.get('trigger_config', '{}'),
|
|
||||||
action_type=auto['action_type'],
|
|
||||||
action_config=auto.get('action_config', '{}'),
|
|
||||||
profile_id=profile_id,
|
|
||||||
notify_type=auto.get('notify_type'),
|
|
||||||
notify_config=auto.get('notify_config', '{}'),
|
|
||||||
then_actions=auto.get('then_actions', '[]'),
|
|
||||||
group_name=auto.get('group_name'),
|
|
||||||
)
|
|
||||||
if new_id is None:
|
|
||||||
return {'error': 'Failed to duplicate automation'}, 500
|
|
||||||
if automation_engine:
|
|
||||||
automation_engine.schedule_automation(new_id)
|
|
||||||
return {'success': True, 'id': new_id}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def toggle_automation(database, automation_engine, automation_id: int) -> tuple[dict, int]:
|
|
||||||
"""Toggle an automation's enabled state + reschedule/cancel."""
|
|
||||||
success = database.toggle_automation(automation_id)
|
|
||||||
if not success:
|
|
||||||
return {'error': 'Automation not found'}, 404
|
|
||||||
|
|
||||||
if automation_engine:
|
|
||||||
auto = database.get_automation(automation_id)
|
|
||||||
if auto and auto.get('enabled'):
|
|
||||||
automation_engine.schedule_automation(automation_id)
|
|
||||||
else:
|
|
||||||
automation_engine.cancel_automation(automation_id)
|
|
||||||
return {'success': True}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def run_automation(automation_engine, automation_id: int, profile_id: int) -> tuple[dict, int]:
|
|
||||||
"""Manually trigger an automation."""
|
|
||||||
if not automation_engine:
|
|
||||||
return {'error': 'Automation engine not available'}, 500
|
|
||||||
success = automation_engine.run_now(automation_id, profile_id=profile_id)
|
|
||||||
if not success:
|
|
||||||
return {'error': 'Automation not found'}, 404
|
|
||||||
return {'success': True}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def get_history(database, automation_id: int, *, limit: int, offset: int) -> dict:
|
|
||||||
"""Run-history page for an automation, with log_lines/result_json parsed."""
|
|
||||||
data = database.get_automation_run_history(automation_id, limit=limit, offset=offset)
|
|
||||||
for entry in data.get('history', []):
|
|
||||||
if entry.get('log_lines'):
|
|
||||||
try:
|
|
||||||
entry['log_lines'] = json.loads(entry['log_lines'])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
entry['log_lines'] = []
|
|
||||||
else:
|
|
||||||
entry['log_lines'] = []
|
|
||||||
if entry.get('result_json'):
|
|
||||||
try:
|
|
||||||
entry['result_json'] = json.loads(entry['result_json'])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
data['automation_id'] = automation_id
|
|
||||||
return data
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"""Static block definitions for the automation builder UI.
|
|
||||||
|
|
||||||
Returned verbatim by `/api/automations/blocks` (with `known_signals`
|
|
||||||
injected by the route from `signals.collect_known_signals`).
|
|
||||||
|
|
||||||
Three top-level lists:
|
|
||||||
- `TRIGGERS` — WHEN blocks: schedule, daily/weekly time, app started,
|
|
||||||
event triggers (track_downloaded, batch_complete, etc.), signal_received,
|
|
||||||
webhook_received.
|
|
||||||
- `ACTIONS` — DO blocks: process_wishlist, scan_library, etc.
|
|
||||||
- `NOTIFICATIONS` — THEN blocks: discord/pushbullet/telegram/webhook,
|
|
||||||
plus fire_signal and run_script then-actions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
TRIGGERS: list[dict] = [
|
|
||||||
{"type": "schedule", "label": "Schedule", "icon": "clock", "description": "Run on a timer interval", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "interval", "type": "number", "label": "Every", "default": 6, "min": 1},
|
|
||||||
{"key": "unit", "type": "select", "label": "Unit",
|
|
||||||
"options": [{"value": "minutes", "label": "Minutes"}, {"value": "hours", "label": "Hours"}, {"value": "days", "label": "Days"}],
|
|
||||||
"default": "hours"}
|
|
||||||
]},
|
|
||||||
{"type": "daily_time", "label": "Daily Time", "icon": "clock", "description": "Run every day at a specific time", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"}
|
|
||||||
]},
|
|
||||||
{"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "description": "Run on specific days of the week at a set time", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"},
|
|
||||||
{"key": "days", "type": "multi_select", "label": "Days",
|
|
||||||
"options": [{"value": "mon", "label": "Mon"}, {"value": "tue", "label": "Tue"}, {"value": "wed", "label": "Wed"},
|
|
||||||
{"value": "thu", "label": "Thu"}, {"value": "fri", "label": "Fri"}, {"value": "sat", "label": "Sat"}, {"value": "sun", "label": "Sun"}]}
|
|
||||||
]},
|
|
||||||
{"type": "app_started", "label": "App Started", "icon": "power", "description": "When SoulSync starts up", "available": True},
|
|
||||||
{"type": "track_downloaded", "label": "Track Downloaded", "icon": "download", "description": "When a track finishes downloading", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["artist", "title", "album", "quality"],
|
|
||||||
"variables": ["artist", "title", "album", "quality"]},
|
|
||||||
{"type": "batch_complete", "label": "Batch Complete", "icon": "check-circle", "description": "When an album/playlist download finishes", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["playlist_name"],
|
|
||||||
"variables": ["playlist_name", "total_tracks", "completed_tracks", "failed_tracks"]},
|
|
||||||
{"type": "watchlist_new_release", "label": "New Release Found", "icon": "bell", "description": "When watchlist detects new music", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["artist"],
|
|
||||||
"variables": ["artist", "new_tracks", "added_to_wishlist"]},
|
|
||||||
{"type": "playlist_synced", "label": "Playlist Synced", "icon": "refresh", "description": "When a playlist sync completes", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["playlist_name"],
|
|
||||||
"variables": ["playlist_name", "total_tracks", "matched_tracks", "synced_tracks", "failed_tracks"]},
|
|
||||||
{"type": "playlist_changed", "label": "Playlist Changed", "icon": "edit", "description": "When a mirrored playlist detects track changes from source", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["playlist_name"],
|
|
||||||
"variables": ["playlist_name", "old_count", "new_count", "added", "removed"]},
|
|
||||||
{"type": "discovery_completed", "label": "Discovery Complete", "icon": "search", "description": "When playlist track discovery finishes", "available": True,
|
|
||||||
"has_conditions": True,
|
|
||||||
"condition_fields": ["playlist_name"],
|
|
||||||
"variables": ["playlist_name", "total_tracks", "discovered_count", "failed_count", "skipped_count"]},
|
|
||||||
# Phase 3 triggers
|
|
||||||
{"type": "wishlist_processing_completed", "label": "Wishlist Processed", "icon": "check-circle",
|
|
||||||
"description": "When auto-wishlist processing finishes", "available": True,
|
|
||||||
"variables": ["tracks_processed", "tracks_found", "tracks_failed"]},
|
|
||||||
{"type": "watchlist_scan_completed", "label": "Watchlist Scan Done", "icon": "check-circle",
|
|
||||||
"description": "When watchlist scan finishes", "available": True,
|
|
||||||
"variables": ["artists_scanned", "new_tracks_found", "tracks_added"]},
|
|
||||||
{"type": "database_update_completed", "label": "Database Updated", "icon": "database",
|
|
||||||
"description": "When library database refresh finishes", "available": True,
|
|
||||||
"variables": ["total_artists", "total_albums", "total_tracks"]},
|
|
||||||
{"type": "library_scan_completed", "label": "Library Scan Done", "icon": "hard-drive",
|
|
||||||
"description": "When media library scan finishes", "available": True,
|
|
||||||
"variables": ["server_type"]},
|
|
||||||
{"type": "download_failed", "label": "Download Failed", "icon": "x-circle",
|
|
||||||
"description": "When a track permanently fails to download", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist", "title", "reason"],
|
|
||||||
"variables": ["artist", "title", "reason"]},
|
|
||||||
{"type": "download_quarantined", "label": "File Quarantined", "icon": "alert-triangle",
|
|
||||||
"description": "When AcoustID verification fails", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
|
||||||
"variables": ["artist", "title", "reason"]},
|
|
||||||
{"type": "wishlist_item_added", "label": "Wishlist Item Added", "icon": "plus-circle",
|
|
||||||
"description": "When a track is added to wishlist", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
|
||||||
"variables": ["artist", "title", "reason"]},
|
|
||||||
{"type": "watchlist_artist_added", "label": "Artist Watched", "icon": "user-plus",
|
|
||||||
"description": "When an artist is added to watchlist", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist"],
|
|
||||||
"variables": ["artist", "artist_id"]},
|
|
||||||
{"type": "watchlist_artist_removed", "label": "Artist Unwatched", "icon": "user-minus",
|
|
||||||
"description": "When an artist is removed from watchlist", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist"],
|
|
||||||
"variables": ["artist", "artist_id"]},
|
|
||||||
{"type": "import_completed", "label": "Import Complete", "icon": "upload",
|
|
||||||
"description": "When album/track import finishes", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["artist", "album_name"],
|
|
||||||
"variables": ["track_count", "album_name", "artist"]},
|
|
||||||
{"type": "mirrored_playlist_created", "label": "Playlist Mirrored", "icon": "copy",
|
|
||||||
"description": "When a new playlist is mirrored", "available": True,
|
|
||||||
"has_conditions": True, "condition_fields": ["playlist_name", "source"],
|
|
||||||
"variables": ["playlist_name", "source", "track_count"]},
|
|
||||||
{"type": "quality_scan_completed", "label": "Quality Scan Done", "icon": "bar-chart",
|
|
||||||
"description": "When quality scan finishes", "available": True,
|
|
||||||
"variables": ["quality_met", "low_quality", "total_scanned"]},
|
|
||||||
{"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers",
|
|
||||||
"description": "When duplicate cleaner finishes", "available": True,
|
|
||||||
"variables": ["files_scanned", "duplicates_found", "space_freed"]},
|
|
||||||
# Signal trigger
|
|
||||||
{"type": "signal_received", "label": "Signal Received", "icon": "zap",
|
|
||||||
"description": "When another automation fires a named signal", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
|
||||||
],
|
|
||||||
"variables": ["signal_name"]},
|
|
||||||
# Webhook trigger
|
|
||||||
{"type": "webhook_received", "label": "Webhook Received", "icon": "globe",
|
|
||||||
"description": "When an external API request is received (POST /api/v1/request)", "available": True,
|
|
||||||
"variables": ["query", "request_id", "source"]},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
ACTIONS: list[dict] = [
|
|
||||||
{"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True,
|
|
||||||
"config_fields": [{"key": "category", "type": "select", "label": "Category", "options": [{"value": "all", "label": "All"}, {"value": "albums", "label": "Albums"}, {"value": "singles", "label": "Singles"}], "default": "all"}]},
|
|
||||||
{"type": "scan_watchlist", "label": "Scan Watchlist", "icon": "eye", "description": "Check watched artists for new releases", "available": True},
|
|
||||||
{"type": "scan_library", "label": "Scan Library", "icon": "refresh", "description": "Trigger media server library scan", "available": True},
|
|
||||||
{"type": "refresh_mirrored", "label": "Refresh Mirrored Playlist", "icon": "copy", "description": "Re-fetch playlist from source and update mirror", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
|
||||||
{"key": "all", "type": "checkbox", "label": "Refresh all mirrored playlists", "default": False}
|
|
||||||
]},
|
|
||||||
{"type": "sync_playlist", "label": "Sync Playlist", "icon": "sync", "description": "Sync mirrored playlist to media server", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"}
|
|
||||||
]},
|
|
||||||
{"type": "discover_playlist", "label": "Discover Playlist", "icon": "search", "description": "Find official Spotify/iTunes metadata for mirrored playlist tracks", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
|
||||||
{"key": "all", "type": "checkbox", "label": "Discover all mirrored playlists", "default": False}
|
|
||||||
]},
|
|
||||||
{"type": "playlist_pipeline", "label": "Playlist Pipeline", "icon": "rocket",
|
|
||||||
"description": "Full lifecycle: refresh → discover → sync → download missing. One automation for the entire flow.",
|
|
||||||
"available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
|
||||||
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "default": False},
|
|
||||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
|
||||||
]},
|
|
||||||
{"type": "personalized_pipeline", "label": "Personalized Playlist Pipeline", "icon": "sparkles",
|
|
||||||
"description": "Sync personalized / discover-page playlists (Hidden Gems, Time Machine, Fresh Tape, etc.) to your media server + queue missing tracks for download.",
|
|
||||||
"available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "kinds", "type": "personalized_playlist_select", "label": "Playlists to sync",
|
|
||||||
"description": "Multi-select: Hidden Gems, Discovery Shuffle, Time Machine (per decade), Genre playlists, Fresh Tape, The Archives, Seasonal Mix (per season)"},
|
|
||||||
{"key": "refresh_first", "type": "checkbox", "label": "Refresh playlists before sync (regenerate snapshots)", "default": False},
|
|
||||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
|
||||||
]},
|
|
||||||
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
|
|
||||||
# Phase 3 actions
|
|
||||||
{"type": "start_database_update", "label": "Update Database", "icon": "database",
|
|
||||||
"description": "Trigger library database refresh", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "full_refresh", "type": "checkbox", "label": "Full refresh (slower)", "default": False}
|
|
||||||
]},
|
|
||||||
{"type": "run_duplicate_cleaner", "label": "Run Duplicate Cleaner", "icon": "layers",
|
|
||||||
"description": "Scan for and remove duplicate files", "available": True},
|
|
||||||
{"type": "clear_quarantine", "label": "Clear Quarantine", "icon": "trash",
|
|
||||||
"description": "Delete all quarantined files", "available": True},
|
|
||||||
{"type": "cleanup_wishlist", "label": "Clean Up Wishlist", "icon": "filter",
|
|
||||||
"description": "Remove duplicate/owned tracks from wishlist", "available": True},
|
|
||||||
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
|
|
||||||
"description": "Refresh discovery pool with new tracks", "available": True},
|
|
||||||
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
|
|
||||||
"description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True},
|
|
||||||
{"type": "backup_database", "label": "Backup Database", "icon": "save",
|
|
||||||
"description": "Create timestamped database backup", "available": True},
|
|
||||||
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",
|
|
||||||
"description": "Scrape Beatport homepage and warm the cache", "available": True},
|
|
||||||
{"type": "clean_search_history", "label": "Clean Search History", "icon": "trash-2",
|
|
||||||
"description": "Remove old searches from Soulseek", "available": True},
|
|
||||||
{"type": "clean_completed_downloads", "label": "Clean Completed Downloads", "icon": "check-square",
|
|
||||||
"description": "Clear completed downloads and empty directories", "available": True},
|
|
||||||
{"type": "full_cleanup", "label": "Full Cleanup", "icon": "trash",
|
|
||||||
"description": "Clear quarantine, download queue, import folder, and search history in one sweep", "available": True},
|
|
||||||
{"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search",
|
|
||||||
"description": "Full library comparison without losing enrichment data", "available": True},
|
|
||||||
{"type": "run_script", "label": "Run Script", "icon": "terminal",
|
|
||||||
"description": "Execute a script from the scripts folder", "available": True},
|
|
||||||
{"type": "search_and_download", "label": "Search & Download", "icon": "download",
|
|
||||||
"description": "Search for a track and download the best match", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "query", "type": "text", "label": "Search Query",
|
|
||||||
"placeholder": "Artist - Track (leave empty to use trigger's query)"}
|
|
||||||
]},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
NOTIFICATIONS: list[dict] = [
|
|
||||||
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
|
||||||
"variables": ["time", "name", "run_count", "status"]},
|
|
||||||
{"type": "pushbullet", "label": "Pushbullet", "icon": "push", "description": "Push notification to phone/desktop", "available": True,
|
|
||||||
"variables": ["time", "name", "run_count", "status"]},
|
|
||||||
{"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True,
|
|
||||||
"variables": ["time", "name", "run_count", "status"]},
|
|
||||||
{"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "description": "Send a POST request to any URL", "available": True,
|
|
||||||
"variables": ["time", "name", "run_count", "status"]},
|
|
||||||
# Signal fire action
|
|
||||||
{"type": "fire_signal", "label": "Fire Signal", "icon": "zap",
|
|
||||||
"description": "Fire a signal that other automations can listen for", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
|
||||||
]},
|
|
||||||
# Run script then-action
|
|
||||||
{"type": "run_script", "label": "Run Script", "icon": "terminal",
|
|
||||||
"description": "Execute a script after the action completes", "available": True,
|
|
||||||
"config_fields": [
|
|
||||||
{"key": "script_name", "type": "script_select", "label": "Script"}
|
|
||||||
]},
|
|
||||||
]
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
"""Dependency-injection surface for automation handlers.
|
|
||||||
|
|
||||||
Each handler in ``core.automation.handlers`` is a top-level pure
|
|
||||||
function that accepts ``(config: dict, deps: AutomationDeps)`` instead
|
|
||||||
of reaching for module-level globals in ``web_server``. The deps
|
|
||||||
namespace bundles every callable, client, and mutable-state container
|
|
||||||
the handlers need.
|
|
||||||
|
|
||||||
Construction happens once at app startup in ``web_server.py``:
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps, AutomationState
|
|
||||||
state = AutomationState()
|
|
||||||
deps = AutomationDeps(
|
|
||||||
engine=automation_engine,
|
|
||||||
state=state,
|
|
||||||
get_database=get_database,
|
|
||||||
spotify_client=spotify_client,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
register_all(deps)
|
|
||||||
|
|
||||||
Tests construct a fake ``AutomationDeps`` with stub callables — every
|
|
||||||
handler is then exercisable without spinning up Flask, the DB, or
|
|
||||||
the real media-server clients.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AutomationState:
|
|
||||||
"""Mutable flags shared across handler invocations.
|
|
||||||
|
|
||||||
Pre-refactor each was a ``global`` or ``nonlocal`` variable inside
|
|
||||||
the registration closure. Lifted here so handlers + their guards
|
|
||||||
can read/write a single object instead of importing globals.
|
|
||||||
|
|
||||||
All mutations should hold ``lock``; the helper methods below do
|
|
||||||
so for the common get/set patterns.
|
|
||||||
"""
|
|
||||||
|
|
||||||
scan_library_automation_id: Optional[str] = None
|
|
||||||
db_update_automation_id: Optional[str] = None
|
|
||||||
pipeline_running: bool = False
|
|
||||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
||||||
|
|
||||||
def is_scan_library_active(self) -> bool:
|
|
||||||
with self.lock:
|
|
||||||
return self.scan_library_automation_id is not None
|
|
||||||
|
|
||||||
def is_pipeline_running(self) -> bool:
|
|
||||||
with self.lock:
|
|
||||||
return self.pipeline_running
|
|
||||||
|
|
||||||
def try_start_pipeline(self) -> bool:
|
|
||||||
"""Atomically mark the shared playlist pipeline as running."""
|
|
||||||
with self.lock:
|
|
||||||
if self.pipeline_running:
|
|
||||||
return False
|
|
||||||
self.pipeline_running = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
|
|
||||||
with self.lock:
|
|
||||||
self.scan_library_automation_id = automation_id
|
|
||||||
|
|
||||||
def set_pipeline_running(self, value: bool) -> None:
|
|
||||||
with self.lock:
|
|
||||||
self.pipeline_running = value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AutomationDeps:
|
|
||||||
"""Bundle of every callable + client an automation handler may need.
|
|
||||||
|
|
||||||
Add fields as new handlers are extracted. Every field is required
|
|
||||||
at construction (no defaults) so a missing dep fails loudly at
|
|
||||||
startup, not silently mid-handler.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- Engine + shared state ---
|
|
||||||
engine: Any # AutomationEngine instance
|
|
||||||
state: AutomationState
|
|
||||||
config_manager: Any # config.settings.ConfigManager singleton
|
|
||||||
update_progress: Callable[..., None] # _update_automation_progress
|
|
||||||
logger: Any # module-level logger from utils.logging_config
|
|
||||||
|
|
||||||
# --- Service clients (each may be None depending on user config) ---
|
|
||||||
get_database: Callable[[], Any] # late-binding so tests don't need DB
|
|
||||||
spotify_client: Any
|
|
||||||
tidal_client: Any
|
|
||||||
web_scan_manager: Any
|
|
||||||
|
|
||||||
# --- Background-task entry points ---
|
|
||||||
process_wishlist_automatically: Callable[..., Any]
|
|
||||||
process_watchlist_scan_automatically: Callable[..., Any]
|
|
||||||
is_wishlist_actually_processing: Callable[[], bool]
|
|
||||||
is_watchlist_actually_scanning: Callable[[], bool]
|
|
||||||
get_watchlist_scan_state: Callable[[], dict] # accessor returns the live mutable dict
|
|
||||||
|
|
||||||
# --- Playlist pipeline entry points ---
|
|
||||||
run_playlist_discovery_worker: Callable[..., Any]
|
|
||||||
run_sync_task: Callable[..., Any]
|
|
||||||
run_playlist_organize_download: Callable[..., Dict[str, Any]]
|
|
||||||
missing_download_executor: Any
|
|
||||||
load_sync_status_file: Callable[[], dict]
|
|
||||||
get_deezer_client: Callable[[], Any]
|
|
||||||
parse_youtube_playlist: Callable[[str], Any]
|
|
||||||
get_sync_states: Callable[[], dict] # accessor returns the live dict shared with the sync UI
|
|
||||||
|
|
||||||
# --- Database update + quality scanner (shared state + executors) ---
|
|
||||||
set_db_update_automation_id: Callable[[Optional[str]], None] # syncs the legacy `_db_update_automation_id` global so the live DB-update progress callbacks (which still read the global directly) keep firing for the active automation
|
|
||||||
get_db_update_state: Callable[[], dict]
|
|
||||||
db_update_lock: Any # threading.Lock
|
|
||||||
db_update_executor: Any # ThreadPoolExecutor
|
|
||||||
run_db_update_task: Callable[..., Any]
|
|
||||||
run_deep_scan_task: Callable[..., Any]
|
|
||||||
get_duplicate_cleaner_state: Callable[[], dict]
|
|
||||||
duplicate_cleaner_lock: Any
|
|
||||||
duplicate_cleaner_executor: Any
|
|
||||||
run_duplicate_cleaner: Callable[..., Any]
|
|
||||||
# Triggers a "Run Now" of a library-maintenance repair job by id (e.g.
|
|
||||||
# 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old
|
|
||||||
# standalone quality-scanner executor/state (the scanner is now a repair job).
|
|
||||||
run_repair_job_now: Callable[[str], Any]
|
|
||||||
|
|
||||||
# --- Download orchestrator + queue accessors ---
|
|
||||||
download_orchestrator: Any
|
|
||||||
run_async: Callable[..., Any]
|
|
||||||
tasks_lock: Any
|
|
||||||
get_download_batches: Callable[[], dict]
|
|
||||||
get_download_tasks: Callable[[], dict]
|
|
||||||
sweep_empty_download_directories: Callable[[], int]
|
|
||||||
get_staging_path: Callable[[], str]
|
|
||||||
|
|
||||||
# --- Maintenance helpers ---
|
|
||||||
docker_resolve_path: Callable[[str], str]
|
|
||||||
get_current_profile_id: Callable[[], int]
|
|
||||||
get_watchlist_scanner: Callable[[Any], Any]
|
|
||||||
get_app: Callable[[], Any] # Flask app for test_client (beatport refresh)
|
|
||||||
get_beatport_data_cache: Callable[[], dict]
|
|
||||||
|
|
||||||
# --- Progress + history callbacks (used by register_all to wire
|
|
||||||
# the engine's progress callback hooks). ---
|
|
||||||
init_automation_progress: Callable[..., Any]
|
|
||||||
record_progress_history: Callable[..., Any]
|
|
||||||
|
|
||||||
# --- Personalized playlist pipeline ---
|
|
||||||
# Lazy builder so the pipeline handler can construct a fresh
|
|
||||||
# PersonalizedPlaylistManager per run (cheap accessors inside,
|
|
||||||
# no caching needed yet).
|
|
||||||
build_personalized_manager: Callable[[], Any]
|
|
||||||
|
|
||||||
# --- Unified PlaylistSource registry ---
|
|
||||||
# Optional so test fixtures that don't exercise refresh_mirrored
|
|
||||||
# can keep their existing scaffolding. Production wiring in
|
|
||||||
# ``web_server.py`` always populates it via
|
|
||||||
# ``core.playlists.sources.bootstrap.build_playlist_source_registry``.
|
|
||||||
playlist_source_registry: Optional[Any] = None
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"""Per-action automation handlers.
|
|
||||||
|
|
||||||
Each module in this subpackage exposes one top-level handler function
|
|
||||||
(or a small cluster of related handlers) of the form::
|
|
||||||
|
|
||||||
def auto_<action_name>(config: dict, deps: AutomationDeps) -> dict
|
|
||||||
|
|
||||||
The ``register_all`` helper in :mod:`registration` wires every handler
|
|
||||||
to the engine in one place. ``web_server.py`` calls
|
|
||||||
``register_all(deps)`` once at startup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
|
||||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
|
||||||
from core.automation.handlers.scan_library import auto_scan_library
|
|
||||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
|
||||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
|
||||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
|
||||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
|
||||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
|
||||||
from core.automation.handlers.database_update import auto_start_database_update, auto_deep_scan_library
|
|
||||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
|
||||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
|
||||||
from core.automation.handlers.maintenance import (
|
|
||||||
auto_clear_quarantine,
|
|
||||||
auto_cleanup_wishlist,
|
|
||||||
auto_update_discovery_pool,
|
|
||||||
auto_backup_database,
|
|
||||||
auto_refresh_beatport_cache,
|
|
||||||
)
|
|
||||||
from core.automation.handlers.download_cleanup import (
|
|
||||||
auto_clean_search_history,
|
|
||||||
auto_clean_completed_downloads,
|
|
||||||
auto_full_cleanup,
|
|
||||||
)
|
|
||||||
from core.automation.handlers.run_script import auto_run_script
|
|
||||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
|
||||||
from core.automation.handlers.registration import register_all
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'auto_process_wishlist',
|
|
||||||
'auto_scan_watchlist',
|
|
||||||
'auto_scan_library',
|
|
||||||
'auto_refresh_mirrored',
|
|
||||||
'auto_sync_playlist',
|
|
||||||
'auto_discover_playlist',
|
|
||||||
'auto_playlist_pipeline',
|
|
||||||
'auto_personalized_pipeline',
|
|
||||||
'auto_start_database_update',
|
|
||||||
'auto_deep_scan_library',
|
|
||||||
'auto_run_duplicate_cleaner',
|
|
||||||
'auto_start_quality_scan',
|
|
||||||
'auto_clear_quarantine',
|
|
||||||
'auto_cleanup_wishlist',
|
|
||||||
'auto_update_discovery_pool',
|
|
||||||
'auto_backup_database',
|
|
||||||
'auto_refresh_beatport_cache',
|
|
||||||
'auto_clean_search_history',
|
|
||||||
'auto_clean_completed_downloads',
|
|
||||||
'auto_full_cleanup',
|
|
||||||
'auto_run_script',
|
|
||||||
'auto_search_and_download',
|
|
||||||
'register_all',
|
|
||||||
]
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
"""Shared helpers between mirrored + personalized playlist pipelines.
|
|
||||||
|
|
||||||
Both pipelines end in the same shape:
|
|
||||||
1. SYNC each playlist to the active media server.
|
|
||||||
2. WISHLIST: trigger the wishlist processor for missing tracks.
|
|
||||||
|
|
||||||
The differing prefix (mirrored = REFRESH external sources + DISCOVER
|
|
||||||
metadata; personalized = SNAPSHOT manager-backed playlists) is owned
|
|
||||||
by each pipeline. This module owns the SYNC + WISHLIST tail so both
|
|
||||||
pipelines stay consistent + DRY.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
# Per-playlist sync poll cap (mirrored side already used this).
|
|
||||||
_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS = 600
|
|
||||||
# Sync-status final-state markers.
|
|
||||||
_SYNC_TERMINAL_STATUSES = ('finished', 'complete', 'error', 'failed')
|
|
||||||
|
|
||||||
|
|
||||||
def run_sync_and_wishlist(
|
|
||||||
deps: AutomationDeps,
|
|
||||||
automation_id: Optional[str],
|
|
||||||
playlists: List[Dict[str, Any]],
|
|
||||||
*,
|
|
||||||
sync_one_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
|
|
||||||
sync_id_for_fn: Callable[[Dict[str, Any]], str],
|
|
||||||
skip_wishlist: bool = False,
|
|
||||||
progress_start: int = 56,
|
|
||||||
progress_end: int = 85,
|
|
||||||
sync_phase_label: str = 'Phase: Syncing to server...',
|
|
||||||
sync_phase_start_log: str = 'Sync',
|
|
||||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
|
||||||
wishlist_phase_start_log: str = 'Wishlist',
|
|
||||||
) -> Dict[str, int]:
|
|
||||||
"""Run the SYNC + WISHLIST tail of a playlist pipeline.
|
|
||||||
|
|
||||||
The caller supplies:
|
|
||||||
- ``playlists``: list of playlist payload dicts. Each must have at
|
|
||||||
least a ``name`` key (used in progress logs). The shape beyond
|
|
||||||
``name`` is opaque to the helper — ``sync_one_fn`` receives the
|
|
||||||
payload and returns a sync_result dict.
|
|
||||||
- ``sync_one_fn(payload) -> sync_result``: launches sync for one
|
|
||||||
playlist. Result dict must carry ``status`` ∈ ``('started',
|
|
||||||
'skipped', 'error')`` and may carry ``reason``.
|
|
||||||
- ``sync_id_for_fn(payload) -> str``: returns the sync-state key
|
|
||||||
the helper polls on (so we can wait for the background sync
|
|
||||||
thread to complete + read the matched_tracks count).
|
|
||||||
|
|
||||||
Returns ``{'synced': int, 'skipped': int, 'errors': int,
|
|
||||||
'wishlist_queued': int}`` so the caller can stitch it into its
|
|
||||||
final status.
|
|
||||||
"""
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=progress_start,
|
|
||||||
phase=sync_phase_label,
|
|
||||||
log_line=sync_phase_start_log,
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
total_synced = 0
|
|
||||||
total_skipped = 0
|
|
||||||
sync_errors = 0
|
|
||||||
sync_states = deps.get_sync_states()
|
|
||||||
n_playlists = max(1, len(playlists))
|
|
||||||
progress_span = max(1, progress_end - progress_start - 1)
|
|
||||||
|
|
||||||
for pl_idx, pl in enumerate(playlists):
|
|
||||||
pl_name = pl.get('name', '')
|
|
||||||
sync_result = sync_one_fn(pl)
|
|
||||||
sync_status = sync_result.get('status', '')
|
|
||||||
|
|
||||||
if sync_status == 'started':
|
|
||||||
sync_id = sync_id_for_fn(pl)
|
|
||||||
sync_poll_start = time.time()
|
|
||||||
timed_out = True
|
|
||||||
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
|
|
||||||
if (sync_id in sync_states
|
|
||||||
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
|
|
||||||
timed_out = False
|
|
||||||
break
|
|
||||||
time.sleep(2)
|
|
||||||
elapsed = int(time.time() - sync_poll_start)
|
|
||||||
sub_progress = progress_start + 1 + ((pl_idx + 1) / n_playlists) * progress_span
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=min(int(sub_progress), progress_end - 1),
|
|
||||||
phase=f'{sync_phase_label.rstrip(".")} — "{pl_name}" ({elapsed}s)',
|
|
||||||
)
|
|
||||||
|
|
||||||
ss = sync_states.get(sync_id, {})
|
|
||||||
final_status = ss.get('status')
|
|
||||||
if timed_out:
|
|
||||||
sync_errors += 1
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if final_status in ('error', 'failed'):
|
|
||||||
sync_errors += 1
|
|
||||||
reason = ss.get('error') or ss.get('reason') or 'background sync failed'
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Sync error "{pl_name}": {reason}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ss_result = ss.get('result', ss.get('progress', {}))
|
|
||||||
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
|
|
||||||
total_synced += int(matched) if matched else 0
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Synced "{pl_name}": {matched} tracks matched',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
|
|
||||||
elif sync_status == 'skipped':
|
|
||||||
total_skipped += 1
|
|
||||||
reason = sync_result.get('reason', 'unchanged')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Skipped "{pl_name}": {reason}',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
elif sync_status == 'error':
|
|
||||||
sync_errors += 1
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Sync error "{pl_name}": {sync_result.get("reason", "unknown")}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=progress_end,
|
|
||||||
phase=f'{sync_phase_label.rstrip(".")} complete',
|
|
||||||
log_line=f'Sync done: {total_synced} matched, {total_skipped} skipped, {sync_errors} errors',
|
|
||||||
log_type='success' if sync_errors == 0 else 'warning',
|
|
||||||
)
|
|
||||||
|
|
||||||
organize_playlists = [pl for pl in playlists if pl.get('organize_by_playlist')]
|
|
||||||
organize_started = 0
|
|
||||||
if organize_playlists and hasattr(deps, 'run_playlist_organize_download'):
|
|
||||||
for pl in organize_playlists:
|
|
||||||
pl_id = pl.get('id')
|
|
||||||
if not pl_id:
|
|
||||||
continue
|
|
||||||
pl_name = pl.get('name', '')
|
|
||||||
try:
|
|
||||||
org_result = deps.run_playlist_organize_download(
|
|
||||||
mirrored_playlist_id=int(pl_id),
|
|
||||||
automation_id=automation_id,
|
|
||||||
)
|
|
||||||
if org_result.get('status') == 'started':
|
|
||||||
organize_started += 1
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Organize download started for "{pl_name}"',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
elif org_result.get('status') == 'skipped':
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Organize download skipped for "{pl_name}": {org_result.get("reason", "")}',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
except Exception as org_err: # noqa: BLE001
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Organize download error for "{pl_name}": {org_err}',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
|
|
||||||
all_organize = bool(playlists) and len(organize_playlists) == len(playlists)
|
|
||||||
effective_skip_wishlist = skip_wishlist or all_organize
|
|
||||||
|
|
||||||
wishlist_queued = run_wishlist_phase(
|
|
||||||
deps, automation_id,
|
|
||||||
skip=effective_skip_wishlist,
|
|
||||||
progress_pct=progress_end + 1,
|
|
||||||
wishlist_phase_label=wishlist_phase_label,
|
|
||||||
wishlist_phase_start_log=wishlist_phase_start_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'synced': total_synced,
|
|
||||||
'skipped': total_skipped,
|
|
||||||
'errors': sync_errors,
|
|
||||||
'wishlist_queued': wishlist_queued,
|
|
||||||
'organize_downloads_started': organize_started,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_wishlist_phase(
|
|
||||||
deps: AutomationDeps,
|
|
||||||
automation_id: Optional[str],
|
|
||||||
*,
|
|
||||||
skip: bool,
|
|
||||||
progress_pct: int,
|
|
||||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
|
||||||
wishlist_phase_start_log: str = 'Wishlist',
|
|
||||||
) -> int:
|
|
||||||
"""Trigger the wishlist processor unless skipped or already running.
|
|
||||||
|
|
||||||
Returns 1 when the processor was triggered, 0 otherwise. Errors are
|
|
||||||
logged but never raised — wishlist failure should not abort the
|
|
||||||
pipeline."""
|
|
||||||
if skip:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=progress_pct,
|
|
||||||
log_line=f'{wishlist_phase_start_log}: skipped (disabled)',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=progress_pct,
|
|
||||||
phase=wishlist_phase_label,
|
|
||||||
log_line=wishlist_phase_start_log,
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not deps.is_wishlist_actually_processing():
|
|
||||||
deps.process_wishlist_automatically(automation_id=None)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line='Wishlist processing triggered',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line='Wishlist already running — skipped',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
except Exception as e: # noqa: BLE001 — wishlist failure must never abort pipeline
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Wishlist error: {e}',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['run_sync_and_wishlist', 'run_wishlist_phase']
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
"""Automation handlers: ``start_database_update`` and
|
|
||||||
``deep_scan_library`` actions.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_start_database_update`` and ``_auto_deep_scan_library``
|
|
||||||
closures). Both share the same ``db_update_state`` / executor / lock
|
|
||||||
infrastructure -- the only difference is which task they submit
|
|
||||||
(``run_db_update_task`` vs ``run_deep_scan_task``).
|
|
||||||
|
|
||||||
Pattern: pre-set state to running, submit task to executor, then
|
|
||||||
poll the state dict until it transitions away from ``running``.
|
|
||||||
Stall-detection emits a warning every 10 minutes when progress
|
|
||||||
hasn't budged. 2-hour outer timeout caps the worst case.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
# Time out on STALL (no progress), not total runtime: a large library can scan
|
|
||||||
# for many hours while progressing fine — a hard total cap would falsely mark a
|
|
||||||
# healthy scan 'error' (the scan thread keeps running uncancelled). We only give
|
|
||||||
# up when progress hasn't moved for a long stretch, with a generous absolute
|
|
||||||
# backstop against a truly stuck monitor loop.
|
|
||||||
_STALL_WARNING_SECONDS = 600 # warn after 10 min with no progress (repeats)
|
|
||||||
_STALL_TIMEOUT_SECONDS = 1800 # 30 min with no progress at all = genuinely stalled
|
|
||||||
_ABSOLUTE_CAP_SECONDS = 86400 # 24h hard backstop (runaway-loop guard only)
|
|
||||||
_POLL_INTERVAL_SECONDS = 3
|
|
||||||
_INITIAL_DELAY_SECONDS = 1
|
|
||||||
|
|
||||||
|
|
||||||
def scan_wait_action(
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
idle_seconds: float,
|
|
||||||
total_seconds: float,
|
|
||||||
stall_timeout_s: float = _STALL_TIMEOUT_SECONDS,
|
|
||||||
stall_warn_s: float = _STALL_WARNING_SECONDS,
|
|
||||||
abs_cap_s: float = _ABSOLUTE_CAP_SECONDS,
|
|
||||||
) -> str:
|
|
||||||
"""Decide what the monitor loop should do on a poll tick (pure/testable).
|
|
||||||
|
|
||||||
``idle_seconds`` is time since progress last changed; ``total_seconds`` is
|
|
||||||
time since the wait began. Returns one of:
|
|
||||||
``'finished'`` (task no longer running), ``'stall_timeout'`` (no progress for
|
|
||||||
too long → give up), ``'abs_timeout'`` (absolute backstop), ``'warn'``
|
|
||||||
(stalled long enough to warn but not give up), or ``'continue'``.
|
|
||||||
|
|
||||||
Crucially, an actively-progressing scan keeps resetting ``idle_seconds``, so
|
|
||||||
it never hits ``stall_timeout`` no matter how long the whole scan takes.
|
|
||||||
"""
|
|
||||||
if status != 'running':
|
|
||||||
return 'finished'
|
|
||||||
if total_seconds >= abs_cap_s:
|
|
||||||
return 'abs_timeout'
|
|
||||||
if idle_seconds >= stall_timeout_s:
|
|
||||||
return 'stall_timeout'
|
|
||||||
if idle_seconds >= stall_warn_s:
|
|
||||||
return 'warn'
|
|
||||||
return 'continue'
|
|
||||||
|
|
||||||
|
|
||||||
def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run a full or incremental DB update via ``run_db_update_task``."""
|
|
||||||
return _run_with_progress(
|
|
||||||
config, deps,
|
|
||||||
task=deps.run_db_update_task,
|
|
||||||
task_args=(config.get('full_refresh', False), deps.config_manager.get_active_media_server()),
|
|
||||||
initial_phase='Initializing...',
|
|
||||||
stall_label='Database update',
|
|
||||||
finished_extras=lambda: {'full_refresh': str(config.get('full_refresh', False))},
|
|
||||||
timeout_label='Database update timed out after 24 hours',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def auto_deep_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run a deep library scan via ``run_deep_scan_task``."""
|
|
||||||
return _run_with_progress(
|
|
||||||
config, deps,
|
|
||||||
task=deps.run_deep_scan_task,
|
|
||||||
task_args=(deps.config_manager.get_active_media_server(),),
|
|
||||||
initial_phase='Deep scan: Initializing...',
|
|
||||||
stall_label='Deep scan',
|
|
||||||
finished_extras=lambda: {},
|
|
||||||
timeout_label='Deep scan timed out after 24 hours',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_with_progress(
|
|
||||||
config: Dict[str, Any],
|
|
||||||
deps: AutomationDeps,
|
|
||||||
*,
|
|
||||||
task,
|
|
||||||
task_args: tuple,
|
|
||||||
initial_phase: str,
|
|
||||||
stall_label: str,
|
|
||||||
finished_extras,
|
|
||||||
timeout_label: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Shared poll-and-wait body for both DB-update handlers."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
state = deps.get_db_update_state()
|
|
||||||
if state.get('status') == 'running':
|
|
||||||
return {'status': 'skipped', 'reason': 'Database update already running'}
|
|
||||||
deps.state.db_update_automation_id = automation_id
|
|
||||||
# Sync legacy module global so the DB-update progress callbacks
|
|
||||||
# (still living in web_server.py) emit against this automation.
|
|
||||||
deps.set_db_update_automation_id(automation_id)
|
|
||||||
|
|
||||||
with deps.db_update_lock:
|
|
||||||
state.update({
|
|
||||||
'status': 'running', 'phase': initial_phase,
|
|
||||||
'progress': 0, 'current_item': '', 'processed': 0, 'total': 0,
|
|
||||||
'error_message': '',
|
|
||||||
})
|
|
||||||
deps.db_update_executor.submit(task, *task_args)
|
|
||||||
|
|
||||||
# Monitor progress (callbacks handle card updates, we just block until done).
|
|
||||||
# We time out on STALL, not total runtime: ``processed`` advances on every
|
|
||||||
# artist, so an actively-progressing scan keeps resetting the idle clock and
|
|
||||||
# is never falsely failed no matter how long the whole library takes.
|
|
||||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
|
||||||
poll_start = time.time()
|
|
||||||
last_progress_time = time.time()
|
|
||||||
# Any of these advancing means the scan is alive. current_item (the artist
|
|
||||||
# being processed) changes every artist even when the rounded progress %
|
|
||||||
# holds steady, so it guards against a false stall during slow stretches.
|
|
||||||
last_progress_val = (0, 0, '')
|
|
||||||
last_warn_time = 0.0
|
|
||||||
outcome = 'finished'
|
|
||||||
while True:
|
|
||||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
||||||
now = time.time()
|
|
||||||
with deps.db_update_lock:
|
|
||||||
current_status = state.get('status', 'idle')
|
|
||||||
current_val = (state.get('processed', 0), state.get('progress', 0),
|
|
||||||
state.get('current_item', ''))
|
|
||||||
if current_val != last_progress_val:
|
|
||||||
last_progress_val = current_val
|
|
||||||
last_progress_time = now
|
|
||||||
|
|
||||||
action = scan_wait_action(
|
|
||||||
status=current_status,
|
|
||||||
idle_seconds=now - last_progress_time,
|
|
||||||
total_seconds=now - poll_start,
|
|
||||||
)
|
|
||||||
if action in ('finished', 'stall_timeout', 'abs_timeout'):
|
|
||||||
outcome = action
|
|
||||||
break
|
|
||||||
if action == 'warn' and (now - last_warn_time) > _STALL_WARNING_SECONDS:
|
|
||||||
idle_min = int((now - last_progress_time) / 60)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'{stall_label} — no progress for {idle_min} min, still waiting...',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
last_warn_time = now
|
|
||||||
|
|
||||||
if outcome == 'stall_timeout':
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error', phase='Stalled',
|
|
||||||
log_line=f'{stall_label} made no progress for {_STALL_TIMEOUT_SECONDS // 60} minutes — giving up',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': 'Stalled (no progress)', '_manages_own_progress': True}
|
|
||||||
if outcome == 'abs_timeout':
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error',
|
|
||||||
phase='Timed out', log_line=timeout_label, log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
|
||||||
|
|
||||||
# Finished/error callback already updated the card — return matching status.
|
|
||||||
with deps.db_update_lock:
|
|
||||||
final_status = state.get('status', 'unknown')
|
|
||||||
if final_status == 'error':
|
|
||||||
return {
|
|
||||||
'status': 'error',
|
|
||||||
'reason': state.get('error_message', 'Unknown error'),
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
with deps.db_update_lock:
|
|
||||||
stats = {
|
|
||||||
'status': 'completed', '_manages_own_progress': True,
|
|
||||||
'artists': state.get('total', 0),
|
|
||||||
'albums': state.get('total_albums', 0),
|
|
||||||
'tracks': state.get('total_tracks', 0),
|
|
||||||
'removed_artists': state.get('removed_artists', 0),
|
|
||||||
'removed_albums': state.get('removed_albums', 0),
|
|
||||||
'removed_tracks': state.get('removed_tracks', 0),
|
|
||||||
}
|
|
||||||
stats.update(finished_extras())
|
|
||||||
return stats
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""Automation handler: ``discover_playlist`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_discover_playlist`` closure). Kicks off background discovery
|
|
||||||
of official Spotify / iTunes metadata for mirrored playlist tracks.
|
|
||||||
The worker runs in a daemon thread and emits its own progress; this
|
|
||||||
handler returns immediately after launching it (``_manages_own_progress``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_discover_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Discover official Spotify/iTunes metadata for mirrored
|
|
||||||
playlist tracks. Runs the worker in a background thread."""
|
|
||||||
db = deps.get_database()
|
|
||||||
playlist_id = config.get('playlist_id')
|
|
||||||
discover_all = config.get('all', False)
|
|
||||||
|
|
||||||
if discover_all:
|
|
||||||
playlists = db.get_mirrored_playlists()
|
|
||||||
elif playlist_id:
|
|
||||||
p = db.get_mirrored_playlist(int(playlist_id))
|
|
||||||
playlists = [p] if p else []
|
|
||||||
else:
|
|
||||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
|
||||||
|
|
||||||
if not playlists:
|
|
||||||
return {'status': 'error', 'reason': 'No playlists found'}
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=deps.run_playlist_discovery_worker,
|
|
||||||
args=(playlists, config.get('_automation_id')),
|
|
||||||
daemon=True,
|
|
||||||
name='auto-discover-playlist',
|
|
||||||
).start()
|
|
||||||
names = ', '.join(p['name'] for p in playlists[:3])
|
|
||||||
return {
|
|
||||||
'status': 'started',
|
|
||||||
'playlist_count': str(len(playlists)),
|
|
||||||
'playlists': names,
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
"""Automation handlers: download-queue cleanup actions.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers``:
|
|
||||||
- ``clean_search_history`` → :func:`auto_clean_search_history`
|
|
||||||
- ``clean_completed_downloads`` → :func:`auto_clean_completed_downloads`
|
|
||||||
- ``full_cleanup`` → :func:`auto_full_cleanup`
|
|
||||||
|
|
||||||
All three share the download-orchestrator + tasks_lock /
|
|
||||||
download_batches / download_tasks accessors. ``full_cleanup`` is a
|
|
||||||
multi-step orchestration that pulls in quarantine purge + staging
|
|
||||||
sweep on top of the queue cleanup -- kept as one big handler since
|
|
||||||
its phases share state-detection logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil as _shutil
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
# ─── clean_search_history ────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_clean_search_history(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Remove old searches from Soulseek when configured."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
# Skip if soulseek is not the active download source or in hybrid order.
|
|
||||||
dl_mode = deps.config_manager.get('download_source.mode', 'hybrid')
|
|
||||||
hybrid_order = deps.config_manager.get(
|
|
||||||
'download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'],
|
|
||||||
)
|
|
||||||
soulseek_active = (
|
|
||||||
dl_mode == 'soulseek'
|
|
||||||
or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)
|
|
||||||
)
|
|
||||||
# Reach the underlying SoulseekClient via the orchestrator's
|
|
||||||
# generic accessor.
|
|
||||||
slskd = deps.download_orchestrator.client('soulseek') if deps.download_orchestrator else None
|
|
||||||
if not soulseek_active or not slskd or not slskd.base_url:
|
|
||||||
deps.update_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip')
|
|
||||||
return {'status': 'skipped'}
|
|
||||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Auto-clear disabled in settings', log_type='skip',
|
|
||||||
)
|
|
||||||
return {'status': 'skipped'}
|
|
||||||
try:
|
|
||||||
success = deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
|
||||||
keep_searches=50, trigger_threshold=200,
|
|
||||||
))
|
|
||||||
if success:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line='Search history maintenance completed',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed'}
|
|
||||||
else:
|
|
||||||
deps.update_progress(automation_id, log_line='No cleanup needed', log_type='skip')
|
|
||||||
return {'status': 'completed'}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
|
||||||
return {'status': 'error', 'error': str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── clean_completed_downloads ───────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_clean_completed_downloads(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Clear completed downloads + sweep empty download directories.
|
|
||||||
Skips when active batches or post-processing is in flight."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
try:
|
|
||||||
has_active_batches = False
|
|
||||||
has_post_processing = False
|
|
||||||
with deps.tasks_lock:
|
|
||||||
batches = deps.get_download_batches()
|
|
||||||
for batch_data in batches.values():
|
|
||||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
|
||||||
has_active_batches = True
|
|
||||||
break
|
|
||||||
if not has_active_batches:
|
|
||||||
tasks = deps.get_download_tasks()
|
|
||||||
for task_data in tasks.values():
|
|
||||||
if task_data.get('status') == 'post_processing':
|
|
||||||
has_post_processing = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if has_active_batches:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Skipped — downloads active', log_type='skip',
|
|
||||||
)
|
|
||||||
return {'status': 'completed'}
|
|
||||||
|
|
||||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
|
||||||
if not has_post_processing:
|
|
||||||
deps.sweep_empty_download_directories()
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Download cleanup completed', log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed'}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
|
||||||
return {'status': 'error', 'reason': str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── full_cleanup ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_full_cleanup(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run all cleanup tasks: quarantine purge → download queue clear
|
|
||||||
→ empty-dir sweep → staging sweep → search history."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
steps = []
|
|
||||||
|
|
||||||
# --- 1. Clear quarantine ---
|
|
||||||
deps.update_progress(automation_id, phase='Clearing quarantine...', progress=0)
|
|
||||||
quarantine_path = os.path.join(
|
|
||||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
|
||||||
'ss_quarantine',
|
|
||||||
)
|
|
||||||
q_removed = 0
|
|
||||||
if os.path.exists(quarantine_path):
|
|
||||||
for f in os.listdir(quarantine_path):
|
|
||||||
fp = os.path.join(quarantine_path, f)
|
|
||||||
try:
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
os.remove(fp)
|
|
||||||
q_removed += 1
|
|
||||||
elif os.path.isdir(fp):
|
|
||||||
_shutil.rmtree(fp)
|
|
||||||
q_removed += 1
|
|
||||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
|
||||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
|
||||||
steps.append(f'Quarantine: removed {q_removed} items')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Quarantine: removed {q_removed} items',
|
|
||||||
log_type='success' if q_removed else 'info',
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 2. Clear completed/errored/cancelled downloads from Soulseek queue ---
|
|
||||||
deps.update_progress(automation_id, phase='Clearing download queue...', progress=20)
|
|
||||||
has_active_batches = False
|
|
||||||
has_post_processing = False
|
|
||||||
with deps.tasks_lock:
|
|
||||||
batches = deps.get_download_batches()
|
|
||||||
for batch_data in batches.values():
|
|
||||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
|
||||||
has_active_batches = True
|
|
||||||
break
|
|
||||||
if not has_active_batches:
|
|
||||||
tasks = deps.get_download_tasks()
|
|
||||||
for task_data in tasks.values():
|
|
||||||
if task_data.get('status') == 'post_processing':
|
|
||||||
has_post_processing = True
|
|
||||||
break
|
|
||||||
if has_active_batches:
|
|
||||||
steps.append('Download queue: skipped (active batches)')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line='Download queue: skipped (active batches)',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
|
||||||
steps.append('Download queue: cleared')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Download queue: cleared', log_type='success',
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
|
||||||
steps.append(f'Download queue: error ({e})')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Download queue: error ({e})',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 3. Sweep empty download directories ---
|
|
||||||
deps.update_progress(automation_id, phase='Sweeping empty directories...', progress=40)
|
|
||||||
if has_active_batches or has_post_processing:
|
|
||||||
reason = 'active batches' if has_active_batches else 'post-processing active'
|
|
||||||
steps.append(f'Empty directories: skipped ({reason})')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Empty directories: skipped ({reason})',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
dirs_removed = deps.sweep_empty_download_directories()
|
|
||||||
steps.append(f'Empty directories: removed {dirs_removed}')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Empty directories: removed {dirs_removed}',
|
|
||||||
log_type='success' if dirs_removed else 'info',
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 4. Sweep empty staging directories ---
|
|
||||||
deps.update_progress(automation_id, phase='Sweeping import folder...', progress=60)
|
|
||||||
staging_path = deps.get_staging_path()
|
|
||||||
s_removed = 0
|
|
||||||
if os.path.isdir(staging_path):
|
|
||||||
for dirpath, _dirnames, _filenames in os.walk(staging_path, topdown=False):
|
|
||||||
if os.path.normpath(dirpath) == os.path.normpath(staging_path):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
entries = os.listdir(dirpath)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
visible = [e for e in entries if not e.startswith('.')]
|
|
||||||
if not visible:
|
|
||||||
for hidden in entries:
|
|
||||||
try:
|
|
||||||
os.remove(os.path.join(dirpath, hidden))
|
|
||||||
except Exception as e: # noqa: BLE001 — best-effort
|
|
||||||
deps.logger.debug("hidden file cleanup failed: %s", e)
|
|
||||||
try:
|
|
||||||
os.rmdir(dirpath)
|
|
||||||
s_removed += 1
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
steps.append(f'Staging: removed {s_removed} empty directories')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Staging: removed {s_removed} empty directories',
|
|
||||||
log_type='success' if s_removed else 'info',
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 5. Clean search history (if enabled) ---
|
|
||||||
deps.update_progress(automation_id, phase='Cleaning search history...', progress=80)
|
|
||||||
try:
|
|
||||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
|
||||||
steps.append('Search cleanup: disabled in settings')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Search cleanup: disabled in settings', log_type='skip',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
|
||||||
keep_searches=50, trigger_threshold=200,
|
|
||||||
))
|
|
||||||
steps.append('Search history: cleaned')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='Search history: cleaned', log_type='success',
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
|
||||||
steps.append(f'Search history: error ({e})')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line=f'Search history: error ({e})', log_type='error',
|
|
||||||
)
|
|
||||||
|
|
||||||
total_removed = q_removed + s_removed
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100,
|
|
||||||
phase='Complete',
|
|
||||||
log_line=f'Full cleanup complete — {total_removed} items removed',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'quarantine_removed': str(q_removed),
|
|
||||||
'staging_removed': str(s_removed),
|
|
||||||
'total_removed': str(total_removed),
|
|
||||||
'steps': steps,
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
"""Automation handler: ``run_duplicate_cleaner`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_run_duplicate_cleaner`` closure). Submits the duplicate
|
|
||||||
cleaner to its executor, then polls the shared state dict until
|
|
||||||
the worker transitions away from ``running``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
|
||||||
_POLL_INTERVAL_SECONDS = 3
|
|
||||||
_INITIAL_DELAY_SECONDS = 1
|
|
||||||
|
|
||||||
|
|
||||||
def auto_run_duplicate_cleaner(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Kick off the duplicate cleaner and report final stats."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
state = deps.get_duplicate_cleaner_state()
|
|
||||||
if state.get('status') == 'running':
|
|
||||||
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
|
|
||||||
|
|
||||||
# Pre-set status before submit so the polling loop doesn't see a
|
|
||||||
# stale 'finished' from a previous run.
|
|
||||||
with deps.duplicate_cleaner_lock:
|
|
||||||
state['status'] = 'running'
|
|
||||||
deps.duplicate_cleaner_executor.submit(deps.run_duplicate_cleaner)
|
|
||||||
deps.update_progress(automation_id, log_line='Duplicate cleaner started', log_type='info')
|
|
||||||
|
|
||||||
# Monitor progress (max 2 hours).
|
|
||||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
|
||||||
poll_start = time.time()
|
|
||||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
|
||||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
||||||
current_status = state.get('status', 'idle')
|
|
||||||
if current_status not in ('running',):
|
|
||||||
break
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
phase=state.get('phase', 'Scanning...'),
|
|
||||||
progress=state.get('progress', 0),
|
|
||||||
processed=state.get('files_scanned', 0),
|
|
||||||
total=state.get('total_files', 0),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 2-hour timeout reached.
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error',
|
|
||||||
phase='Timed out',
|
|
||||||
log_line='Duplicate cleaner timed out after 2 hours',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
|
||||||
|
|
||||||
# Check actual exit status (could be 'finished' or 'error').
|
|
||||||
final_status = state.get('status', 'idle')
|
|
||||||
if final_status == 'error':
|
|
||||||
err = state.get('error_message', 'Unknown error')
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error', progress=100,
|
|
||||||
phase='Error', log_line=err, log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
|
||||||
|
|
||||||
dupes = state.get('duplicates_found', 0)
|
|
||||||
removed = state.get('deleted', 0)
|
|
||||||
space_freed = state.get('space_freed', 0)
|
|
||||||
scanned = state.get('files_scanned', 0)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100,
|
|
||||||
phase='Complete',
|
|
||||||
log_line=f'Found {dupes} duplicates, removed {removed} files',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'completed', '_manages_own_progress': True,
|
|
||||||
'files_scanned': scanned,
|
|
||||||
'duplicates_found': dupes,
|
|
||||||
'files_deleted': removed,
|
|
||||||
'space_freed_mb': round(space_freed / (1024 * 1024), 1),
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
"""Automation handlers: short maintenance actions.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers``:
|
|
||||||
- ``clear_quarantine`` → :func:`auto_clear_quarantine`
|
|
||||||
- ``cleanup_wishlist`` → :func:`auto_cleanup_wishlist`
|
|
||||||
- ``update_discovery_pool`` → :func:`auto_update_discovery_pool`
|
|
||||||
- ``backup_database`` → :func:`auto_backup_database`
|
|
||||||
- ``refresh_beatport_cache`` → :func:`auto_refresh_beatport_cache`
|
|
||||||
|
|
||||||
Each is a thin wrapper around an existing service / helper. Grouped
|
|
||||||
in one module because every body is short and they share no state
|
|
||||||
between them — splitting into per-handler files would just add
|
|
||||||
import noise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import glob as _glob
|
|
||||||
import os
|
|
||||||
import shutil as _shutil
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
# ─── clear_quarantine ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_clear_quarantine(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Purge every file/folder under the configured ss_quarantine path."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
quarantine_path = os.path.join(
|
|
||||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
|
||||||
'ss_quarantine',
|
|
||||||
)
|
|
||||||
if not os.path.exists(quarantine_path):
|
|
||||||
deps.update_progress(automation_id, log_line='No quarantine folder found', log_type='info')
|
|
||||||
return {'status': 'completed', 'removed': '0'}
|
|
||||||
removed = 0
|
|
||||||
for f in os.listdir(quarantine_path):
|
|
||||||
fp = os.path.join(quarantine_path, f)
|
|
||||||
try:
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
os.remove(fp)
|
|
||||||
removed += 1
|
|
||||||
elif os.path.isdir(fp):
|
|
||||||
_shutil.rmtree(fp)
|
|
||||||
removed += 1
|
|
||||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
|
||||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Removed {removed} quarantined items',
|
|
||||||
log_type='success' if removed > 0 else 'info',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'removed': str(removed)}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── cleanup_wishlist ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_cleanup_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Drop duplicate entries from the wishlist for the active profile."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
db = deps.get_database()
|
|
||||||
removed = db.remove_wishlist_duplicates(deps.get_current_profile_id())
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Removed {removed or 0} duplicate wishlist entries',
|
|
||||||
log_type='success' if removed else 'info',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'removed': str(removed or 0)}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── update_discovery_pool ───────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def auto_update_discovery_pool(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run an incremental refresh of the discovery pool via the
|
|
||||||
watchlist scanner."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
try:
|
|
||||||
scanner = deps.get_watchlist_scanner(deps.spotify_client)
|
|
||||||
deps.update_progress(automation_id, log_line='Updating discovery pool...', log_type='info')
|
|
||||||
scanner.update_discovery_pool_incremental(deps.get_current_profile_id())
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100,
|
|
||||||
phase='Complete', log_line='Discovery pool updated', log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', '_manages_own_progress': True}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error',
|
|
||||||
phase='Error', log_line=str(e), log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': str(e), '_manages_own_progress': True}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── backup_database ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_MAX_BACKUPS = 5
|
|
||||||
|
|
||||||
|
|
||||||
def auto_backup_database(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Create a hot SQLite backup, then prune old backups so only the
|
|
||||||
newest ``_MAX_BACKUPS`` remain."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
return {'status': 'error', 'reason': 'Database file not found'}
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
backup_path = f"{db_path}.backup_{timestamp}"
|
|
||||||
# safe_backup verifies source + result integrity, so an automated backup
|
|
||||||
# can never silently snapshot a corrupt DB (the incident where every
|
|
||||||
# rolling backup faithfully copied the corruption).
|
|
||||||
from core.db_integrity import DBIntegrityError, safe_backup, prune_backups
|
|
||||||
try:
|
|
||||||
safe_backup(db_path, backup_path)
|
|
||||||
except DBIntegrityError as integ:
|
|
||||||
deps.logger.error("Auto-backup refused — DB integrity check failed: %s", integ)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Backup SKIPPED — database failed integrity check: {integ}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': f'Database integrity check failed: {integ}'}
|
|
||||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
|
||||||
|
|
||||||
# Rolling cleanup — never evict the most-recent verified-healthy backup.
|
|
||||||
existing = list(_glob.glob(f"{db_path}.backup_*"))
|
|
||||||
for removed in prune_backups(existing, _MAX_BACKUPS):
|
|
||||||
try:
|
|
||||||
os.remove(removed)
|
|
||||||
except Exception as e: # noqa: BLE001 — best-effort cleanup
|
|
||||||
deps.logger.debug("rolling backup cleanup failed: %s", e)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Backup created: {size_mb}MB ({os.path.basename(backup_path)})',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── refresh_beatport_cache ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
_BEATPORT_SECTIONS = (
|
|
||||||
('hero_tracks', '/api/beatport/hero-tracks', 'Hero Tracks'),
|
|
||||||
('new_releases', '/api/beatport/new-releases', 'New Releases'),
|
|
||||||
('featured_charts', '/api/beatport/featured-charts', 'Featured Charts'),
|
|
||||||
('dj_charts', '/api/beatport/dj-charts', 'DJ Charts'),
|
|
||||||
('top_10_lists', '/api/beatport/homepage/top-10-lists', 'Top 10 Lists'),
|
|
||||||
('top_10_releases', '/api/beatport/homepage/top-10-releases-cards', 'Top 10 Releases'),
|
|
||||||
('hype_picks', '/api/beatport/hype-picks', 'Hype Picks'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def auto_refresh_beatport_cache(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Refresh Beatport homepage cache by calling each endpoint internally
|
|
||||||
via Flask's ``test_client``. Invalidates the homepage cache first
|
|
||||||
so endpoints re-scrape rather than returning stale data."""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
cache = deps.get_beatport_data_cache()
|
|
||||||
# Invalidate all homepage cache timestamps so endpoints re-scrape.
|
|
||||||
with cache['cache_lock']:
|
|
||||||
for key in cache['homepage']:
|
|
||||||
cache['homepage'][key]['timestamp'] = 0
|
|
||||||
cache['homepage'][key]['data'] = None
|
|
||||||
|
|
||||||
refreshed = 0
|
|
||||||
errors = []
|
|
||||||
app = deps.get_app()
|
|
||||||
with app.test_client() as client:
|
|
||||||
for idx, (_, endpoint, label) in enumerate(_BEATPORT_SECTIONS):
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=(idx / len(_BEATPORT_SECTIONS)) * 100,
|
|
||||||
phase=f'Scraping: {label}',
|
|
||||||
current_item=label,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resp = client.get(endpoint)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
refreshed += 1
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line=f'{label}: cached', log_type='success',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
errors.append(label)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'{label}: HTTP {resp.status_code}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001 — per-section best-effort
|
|
||||||
errors.append(label)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'{label}: {str(e)}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
if idx < len(_BEATPORT_SECTIONS) - 1:
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100,
|
|
||||||
phase='Complete',
|
|
||||||
log_line=f'Refreshed {refreshed}/{len(_BEATPORT_SECTIONS)} sections',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'refreshed': str(refreshed),
|
|
||||||
'errors': str(len(errors)),
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
"""Personalized Playlist Pipeline automation handler.
|
|
||||||
|
|
||||||
Sibling to ``auto_playlist_pipeline`` (mirrored). Where the mirrored
|
|
||||||
pipeline runs REFRESH external sources → DISCOVER metadata → SYNC →
|
|
||||||
WISHLIST, the personalized pipeline is simpler:
|
|
||||||
|
|
||||||
SNAPSHOT → SYNC → WISHLIST
|
|
||||||
|
|
||||||
SNAPSHOT reads the persisted track list from
|
|
||||||
``PersonalizedPlaylistManager``. When ``refresh_first=True`` (config),
|
|
||||||
each playlist is refreshed BEFORE syncing — useful when the user
|
|
||||||
wants the cron to capture a fresh-each-run view (e.g. "give me a new
|
|
||||||
Hidden Gems set every night"). Default is to sync the existing
|
|
||||||
snapshot, on the assumption the user / a separate cron has already
|
|
||||||
refreshed when they wanted to.
|
|
||||||
|
|
||||||
Config schema:
|
|
||||||
{
|
|
||||||
'kinds': [
|
|
||||||
{'kind': 'hidden_gems'},
|
|
||||||
{'kind': 'time_machine', 'variant': '1980s'},
|
|
||||||
{'kind': 'seasonal_mix', 'variant': 'halloween'},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
'refresh_first': bool, # default false
|
|
||||||
'skip_wishlist': bool, # default false
|
|
||||||
}
|
|
||||||
|
|
||||||
Each kind dict has at minimum ``kind``; ``variant`` is required for
|
|
||||||
kinds that need it (time_machine, genre_playlist, daily_mix,
|
|
||||||
seasonal_mix). Singleton kinds (hidden_gems, discovery_shuffle,
|
|
||||||
popular_picks, fresh_tape, archives) ignore variant.
|
|
||||||
|
|
||||||
Pipeline-running flag (``deps.state.pipeline_running``) is shared
|
|
||||||
with the mirrored pipeline so the two can't overlap. (One sync
|
|
||||||
queue, one wishlist worker — overlapping triggers would step on
|
|
||||||
each other.)"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
|
||||||
|
|
||||||
|
|
||||||
# Sync state key prefix so personalized syncs don't collide with
|
|
||||||
# mirrored ones (`auto_mirror_<id>`).
|
|
||||||
_SYNC_ID_PREFIX = 'auto_personalized'
|
|
||||||
|
|
||||||
|
|
||||||
def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run SNAPSHOT → SYNC → WISHLIST for selected personalized playlists."""
|
|
||||||
deps.state.set_pipeline_running(True)
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
pipeline_start = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
kinds_config = config.get('kinds') or []
|
|
||||||
if not isinstance(kinds_config, list) or not kinds_config:
|
|
||||||
deps.state.set_pipeline_running(False)
|
|
||||||
return {
|
|
||||||
'status': 'error',
|
|
||||||
'error': 'No personalized playlist kinds selected',
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh_first = bool(config.get('refresh_first', False))
|
|
||||||
skip_wishlist = bool(config.get('skip_wishlist', False))
|
|
||||||
|
|
||||||
manager = deps.build_personalized_manager()
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=2,
|
|
||||||
phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)',
|
|
||||||
log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)',
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── PHASE 1: SNAPSHOT (optionally refresh) ──────────────────
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=3,
|
|
||||||
phase='Phase 1/2: Loading snapshots...' if not refresh_first
|
|
||||||
else 'Phase 1/2: Refreshing snapshots...',
|
|
||||||
log_line='Phase 1: Snapshot' + (' (with refresh)' if refresh_first else ''),
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_id = deps.get_current_profile_id()
|
|
||||||
playload_payloads = _build_payloads_for_kinds(
|
|
||||||
deps, manager, kinds_config, profile_id,
|
|
||||||
automation_id=automation_id,
|
|
||||||
refresh_first=refresh_first,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not playload_payloads:
|
|
||||||
deps.state.set_pipeline_running(False)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='finished', progress=100,
|
|
||||||
phase='No playlists to sync',
|
|
||||||
log_line='No personalized playlists had tracks to sync',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
'playlists_synced': '0',
|
|
||||||
'tracks_synced': '0',
|
|
||||||
'duration_seconds': str(int(time.time() - pipeline_start)),
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=50,
|
|
||||||
phase='Phase 1/2: Snapshot complete',
|
|
||||||
log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── PHASE 2: SYNC + WISHLIST (shared helper) ────────────────
|
|
||||||
sync_summary = run_sync_and_wishlist(
|
|
||||||
deps,
|
|
||||||
automation_id,
|
|
||||||
playload_payloads,
|
|
||||||
sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl),
|
|
||||||
sync_id_for_fn=lambda pl: pl['sync_id'],
|
|
||||||
skip_wishlist=skip_wishlist,
|
|
||||||
progress_start=51,
|
|
||||||
progress_end=90,
|
|
||||||
sync_phase_label='Phase 2/2: Syncing to server...',
|
|
||||||
sync_phase_start_log='Phase 2: Sync',
|
|
||||||
wishlist_phase_label='Phase 2/2: Processing wishlist...',
|
|
||||||
wishlist_phase_start_log='Wishlist',
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── COMPLETE ────────────────────────────────────────────────
|
|
||||||
duration = int(time.time() - pipeline_start)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='finished', progress=100,
|
|
||||||
phase='Pipeline complete',
|
|
||||||
log_line=f'Personalized pipeline finished in {duration // 60}m {duration % 60}s',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
|
|
||||||
deps.state.set_pipeline_running(False)
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
'playlists_synced': str(len(playload_payloads)),
|
|
||||||
'tracks_synced': str(sync_summary['synced']),
|
|
||||||
'sync_skipped': str(sync_summary['skipped']),
|
|
||||||
'wishlist_queued': str(sync_summary['wishlist_queued']),
|
|
||||||
'duration_seconds': str(duration),
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into engine
|
|
||||||
deps.state.set_pipeline_running(False)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='error', progress=100,
|
|
||||||
phase='Pipeline error',
|
|
||||||
log_line=f'Personalized pipeline failed: {e}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_payloads_for_kinds(
|
|
||||||
deps: AutomationDeps,
|
|
||||||
manager: Any,
|
|
||||||
kinds_config: List[Dict[str, Any]],
|
|
||||||
profile_id: int,
|
|
||||||
*,
|
|
||||||
automation_id: Optional[str],
|
|
||||||
refresh_first: bool,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Resolve each requested kind+variant into a sync-payload dict.
|
|
||||||
|
|
||||||
Each payload has: ``{'name', 'kind', 'variant', 'tracks_json',
|
|
||||||
'image_url', 'sync_id'}``. Playlists with no tracks (e.g. a
|
|
||||||
seasonal mix that hasn't been populated yet) are omitted from
|
|
||||||
the result so the sync loop doesn't waste time on empty pushes.
|
|
||||||
"""
|
|
||||||
payloads: List[Dict[str, Any]] = []
|
|
||||||
for entry in kinds_config:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
kind = entry.get('kind')
|
|
||||||
variant = entry.get('variant') or ''
|
|
||||||
if not kind:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Refresh when ANY of:
|
|
||||||
# - explicit user flag (cron use case: regenerate each run)
|
|
||||||
# - snapshot marked stale by upstream data refresher
|
|
||||||
# - playlist was never generated yet (auto-created by
|
|
||||||
# ensure_playlist; track_count=0, last_generated_at=NULL).
|
|
||||||
# Without this branch, a first-run pipeline reads the
|
|
||||||
# empty snapshot and silently skips — user picks a kind,
|
|
||||||
# hits run, gets "No tracks to sync" with no clue why.
|
|
||||||
if refresh_first:
|
|
||||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
|
||||||
else:
|
|
||||||
existing = manager.ensure_playlist(kind, variant, profile_id)
|
|
||||||
needs_first_gen = existing.last_generated_at is None
|
|
||||||
if existing.is_stale or needs_first_gen:
|
|
||||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
|
||||||
else:
|
|
||||||
record = existing
|
|
||||||
except Exception as exc: # noqa: BLE001 — log + continue with next kind
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Skipping {kind}{("/" + variant) if variant else ""}: {exc}',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
tracks = manager.get_playlist_tracks(record.id)
|
|
||||||
if not tracks:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'No tracks in {record.name} — skipping sync',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
tracks_json = [_track_to_sync_shape(t) for t in tracks]
|
|
||||||
payloads.append({
|
|
||||||
'name': record.name,
|
|
||||||
'kind': record.kind,
|
|
||||||
'variant': record.variant,
|
|
||||||
'tracks_json': tracks_json,
|
|
||||||
'image_url': '', # personalized playlists don't have a cover image yet
|
|
||||||
'sync_id': f'{_SYNC_ID_PREFIX}_{record.kind}_{record.variant or "_"}',
|
|
||||||
})
|
|
||||||
return payloads
|
|
||||||
|
|
||||||
|
|
||||||
def _track_to_sync_shape(track: Any) -> Dict[str, Any]:
|
|
||||||
"""Convert a personalized.types.Track into the dict shape
|
|
||||||
`_run_sync_task` expects. Mirrors what the mirrored pipeline
|
|
||||||
builds from extra_data.matched_data, preserving enriched metadata
|
|
||||||
from personalized snapshots when available."""
|
|
||||||
primary_id = track.spotify_track_id or track.itunes_track_id or track.deezer_track_id or ''
|
|
||||||
rich_data = _coerce_track_data_json(getattr(track, 'track_data_json', None))
|
|
||||||
|
|
||||||
if not rich_data:
|
|
||||||
album = {'name': track.album_name or ''}
|
|
||||||
cover_url = getattr(track, 'album_cover_url', None)
|
|
||||||
if cover_url:
|
|
||||||
album['images'] = [{'url': cover_url}]
|
|
||||||
return {
|
|
||||||
'name': track.track_name,
|
|
||||||
'artists': [{'name': track.artist_name}],
|
|
||||||
'album': album,
|
|
||||||
'duration_ms': int(track.duration_ms or 0),
|
|
||||||
'id': primary_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = dict(rich_data)
|
|
||||||
cover_url = (
|
|
||||||
getattr(track, 'album_cover_url', None)
|
|
||||||
or payload.get('album_cover_url')
|
|
||||||
or payload.get('image_url')
|
|
||||||
)
|
|
||||||
payload['id'] = payload.get('id') or primary_id
|
|
||||||
payload['name'] = payload.get('name') or track.track_name
|
|
||||||
payload['artists'] = _normalize_artists(payload.get('artists'), track.artist_name)
|
|
||||||
payload['album'] = _normalize_album(payload.get('album'), track, cover_url=cover_url)
|
|
||||||
payload['duration_ms'] = int(payload.get('duration_ms') or track.duration_ms or 0)
|
|
||||||
if 'popularity' not in payload and getattr(track, 'popularity', None) is not None:
|
|
||||||
payload['popularity'] = int(track.popularity or 0)
|
|
||||||
|
|
||||||
if cover_url and not payload.get('image_url'):
|
|
||||||
payload['image_url'] = cover_url
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_track_data_json(value: Any) -> Dict[str, Any]:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
try:
|
|
||||||
loaded = json.loads(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {}
|
|
||||||
return loaded if isinstance(loaded, dict) else {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_artists(artists: Any, fallback_artist: str) -> List[Dict[str, Any]]:
|
|
||||||
if not artists:
|
|
||||||
return [{'name': fallback_artist or 'Unknown Artist'}]
|
|
||||||
if isinstance(artists, list):
|
|
||||||
normalized = []
|
|
||||||
for artist in artists:
|
|
||||||
if isinstance(artist, dict):
|
|
||||||
normalized.append(artist if artist.get('name') else {'name': str(artist)})
|
|
||||||
elif isinstance(artist, str):
|
|
||||||
normalized.append({'name': artist})
|
|
||||||
else:
|
|
||||||
normalized.append({'name': str(artist)})
|
|
||||||
return normalized or [{'name': fallback_artist or 'Unknown Artist'}]
|
|
||||||
if isinstance(artists, dict):
|
|
||||||
return [artists if artists.get('name') else {'name': str(artists)}]
|
|
||||||
return [{'name': str(artists)}]
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_album(album: Any, track: Any, cover_url: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
if isinstance(album, dict):
|
|
||||||
normalized = dict(album)
|
|
||||||
else:
|
|
||||||
normalized = {'name': str(album) if album else (track.album_name or '')}
|
|
||||||
|
|
||||||
normalized['name'] = normalized.get('name') or track.album_name or ''
|
|
||||||
images = normalized.get('images')
|
|
||||||
if not isinstance(images, list):
|
|
||||||
images = []
|
|
||||||
if cover_url and not images:
|
|
||||||
images = [{'url': cover_url}]
|
|
||||||
if images:
|
|
||||||
normalized['images'] = images
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_personalized_playlist(deps: AutomationDeps, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Launch a personalized playlist sync via _run_sync_task on a
|
|
||||||
daemon thread + return immediately with status='started'.
|
|
||||||
|
|
||||||
Mirrors the mirrored ``auto_sync_playlist`` return contract so the
|
|
||||||
shared helper can poll on ``sync_states[sync_id]`` and aggregate
|
|
||||||
results identically."""
|
|
||||||
sync_id = payload['sync_id']
|
|
||||||
name = payload['name']
|
|
||||||
tracks_json = payload['tracks_json']
|
|
||||||
profile_id = deps.get_current_profile_id()
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=deps.run_sync_task,
|
|
||||||
args=(sync_id, name, tracks_json, None, profile_id, payload.get('image_url', '')),
|
|
||||||
daemon=True,
|
|
||||||
name=f'auto-personalized-{sync_id}',
|
|
||||||
).start()
|
|
||||||
return {
|
|
||||||
'status': 'started',
|
|
||||||
'playlist_name': name,
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Automation adapter for the mirrored playlist pipeline.
|
|
||||||
|
|
||||||
The actual all-in-one playlist lifecycle lives in
|
|
||||||
``core.playlists.pipeline`` so it can be reused by non-automation UI actions.
|
|
||||||
This module only wires automation-specific dependencies and handlers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
|
||||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
|
||||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
|
||||||
from core.playlists.pipeline import run_mirrored_playlist_pipeline
|
|
||||||
|
|
||||||
|
|
||||||
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists."""
|
|
||||||
return run_mirrored_playlist_pipeline(
|
|
||||||
config,
|
|
||||||
deps,
|
|
||||||
refresh_fn=auto_refresh_mirrored,
|
|
||||||
sync_one_fn=auto_sync_playlist,
|
|
||||||
sync_and_wishlist_fn=run_sync_and_wishlist,
|
|
||||||
)
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Automation handler: ``process_wishlist`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_process_wishlist`` closure). Wishlist processing is async —
|
|
||||||
the helper submits a batch to an executor and returns immediately;
|
|
||||||
per-track stats arrive later via batch-completion callbacks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_process_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Kick off the wishlist processor for an automation trigger.
|
|
||||||
|
|
||||||
Returns immediately after submission; the wishlist worker emits
|
|
||||||
per-batch progress via its own callbacks. We only report
|
|
||||||
``status: completed`` to mark the trigger fired successfully.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deps.process_wishlist_automatically(automation_id=config.get('_automation_id'))
|
|
||||||
return {'status': 'completed'}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
|
||||||
return {'status': 'error', 'error': str(e)}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""Progress + history callbacks the automation engine invokes around
|
|
||||||
each handler run.
|
|
||||||
|
|
||||||
Lifted from the closures at the bottom of
|
|
||||||
``web_server._register_automation_handlers``:
|
|
||||||
- ``_progress_init`` → :func:`progress_init`
|
|
||||||
- ``_progress_finish`` → :func:`progress_finish`
|
|
||||||
- ``_record_automation_history`` → :func:`record_history`
|
|
||||||
- ``_on_library_scan_completed`` → :func:`on_library_scan_completed`
|
|
||||||
|
|
||||||
The engine accepts four callables via
|
|
||||||
``register_progress_callbacks(init, finish, update, history)``;
|
|
||||||
``registration.register_all`` wires these here. The
|
|
||||||
``library_scan_completed`` callback is registered separately on the
|
|
||||||
``web_scan_manager`` (when one is available) -- see
|
|
||||||
``register_library_scan_completed_emitter``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def progress_init(aid: Any, name: str, action_type: str, deps: AutomationDeps) -> None:
|
|
||||||
"""Initialize per-automation progress state when the engine starts
|
|
||||||
a handler. Thin wrapper so the engine receives a closure that
|
|
||||||
delegates into the live progress tracker."""
|
|
||||||
deps.init_automation_progress(aid, name, action_type)
|
|
||||||
|
|
||||||
|
|
||||||
def progress_finish(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
|
||||||
"""Emit the final progress update when a handler returns.
|
|
||||||
|
|
||||||
Skipped for handlers that manage their own progress lifecycle
|
|
||||||
(they call ``update_progress(status='finished')`` themselves and
|
|
||||||
set ``_manages_own_progress: True`` in the returned dict).
|
|
||||||
Otherwise translates the handler's status into a finished/error
|
|
||||||
progress emit with a status-appropriate phase + log line.
|
|
||||||
"""
|
|
||||||
if result.get('_manages_own_progress'):
|
|
||||||
return
|
|
||||||
result_status = result.get('status', '')
|
|
||||||
status = 'error' if result_status == 'error' else 'finished'
|
|
||||||
msg = result.get('error', result.get('reason', result_status or 'done'))
|
|
||||||
deps.update_progress(
|
|
||||||
aid,
|
|
||||||
status=status,
|
|
||||||
progress=100,
|
|
||||||
phase='Error' if status == 'error' else 'Complete',
|
|
||||||
log_line=msg,
|
|
||||||
log_type='error' if status == 'error' else 'success',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record_history(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
|
||||||
"""Capture progress state into run history before the engine's
|
|
||||||
cleanup pass clears it. Thin wrapper so the engine sees a stable
|
|
||||||
callable."""
|
|
||||||
deps.record_progress_history(aid, result, deps.get_database())
|
|
||||||
|
|
||||||
|
|
||||||
def on_library_scan_completed(deps: AutomationDeps) -> None:
|
|
||||||
"""Emit the ``library_scan_completed`` automation event with the
|
|
||||||
active media-server type. Replaces the hard-coded
|
|
||||||
``scan_completion_callback → trigger_automatic_database_update``
|
|
||||||
chain so any automation can listen for scan completion as a
|
|
||||||
trigger."""
|
|
||||||
if not deps.engine:
|
|
||||||
return
|
|
||||||
server_type = (
|
|
||||||
getattr(deps.web_scan_manager, '_current_server_type', None)
|
|
||||||
or 'unknown'
|
|
||||||
)
|
|
||||||
deps.engine.emit('library_scan_completed', {
|
|
||||||
'server_type': server_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def register_library_scan_completed_emitter(deps: AutomationDeps) -> None:
|
|
||||||
"""Wire :func:`on_library_scan_completed` to the
|
|
||||||
``web_scan_manager``'s scan-completion callback list. No-op when
|
|
||||||
no scan manager is configured (e.g. headless / test contexts)."""
|
|
||||||
if not deps.web_scan_manager:
|
|
||||||
return
|
|
||||||
deps.web_scan_manager.add_scan_completion_callback(
|
|
||||||
lambda: on_library_scan_completed(deps),
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"""Automation handler: ``start_quality_scan`` action.
|
|
||||||
|
|
||||||
The quality scanner was redesigned from an auto-acting tool into the
|
|
||||||
``quality_upgrade`` library-maintenance repair job (findings-based, reviewed
|
|
||||||
before anything is wishlisted). This action now simply triggers a "Run Now" of
|
|
||||||
that job; its progress and findings surface in Library Maintenance. The action
|
|
||||||
name is kept so existing automation rules keep working.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
|
|
||||||
triggered = deps.run_repair_job_now('quality_upgrade')
|
|
||||||
if not triggered:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='error', phase='Unavailable',
|
|
||||||
log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': 'library worker unavailable',
|
|
||||||
'_manages_own_progress': True}
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100, phase='Triggered',
|
|
||||||
log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'triggered': True, '_manages_own_progress': True}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
"""Automation handler: ``refresh_mirrored`` action.
|
|
||||||
|
|
||||||
Re-pulls track lists from each mirrored playlist's source via the
|
|
||||||
unified ``PlaylistSourceRegistry`` (Phase 1 of the Discover-to-Sync
|
|
||||||
unification). The pre-extraction handler had ~190 lines of per-source
|
|
||||||
if/elif branches; this version delegates to the adapter for each
|
|
||||||
source, leaving the handler responsible only for:
|
|
||||||
|
|
||||||
- filtering sources that can't be refreshed (``file``, ``beatport``),
|
|
||||||
- extracting upstream URLs from the stored ``description`` for URL-
|
|
||||||
backed sources (``spotify_public``, ``youtube``),
|
|
||||||
- the Spotify-public → authenticated-Spotify fallback (uses the
|
|
||||||
``spotify`` adapter when the user is signed in so the mirror keeps
|
|
||||||
album art),
|
|
||||||
- the Tidal-not-authenticated skip log type (vs error),
|
|
||||||
- preserving existing per-track ``extra_data`` on tracks that survive
|
|
||||||
the refresh, and
|
|
||||||
- emitting the ``playlist_changed`` automation event when the track
|
|
||||||
set actually shifts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
from core.playlists.source_refs import require_refresh_url
|
|
||||||
from core.playlists.sources import PlaylistDetail, to_mirror_track_dict
|
|
||||||
from core.playlists.sources.base import (
|
|
||||||
SOURCE_SPOTIFY,
|
|
||||||
SOURCE_SPOTIFY_PUBLIC,
|
|
||||||
SOURCE_TIDAL,
|
|
||||||
SOURCE_YOUTUBE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Sources that store the upstream URL in ``description`` (because their
|
|
||||||
# ``source_playlist_id`` is a deterministic hash, not the native ID).
|
|
||||||
# The refresh path has to recover the URL before calling the adapter.
|
|
||||||
_URL_BACKED_SOURCES = {SOURCE_SPOTIFY_PUBLIC, SOURCE_YOUTUBE}
|
|
||||||
|
|
||||||
|
|
||||||
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Refresh mirrored playlist(s) from source.
|
|
||||||
|
|
||||||
Returns ``{'status': 'completed', 'refreshed': '<int>',
|
|
||||||
'errors': '<int>'}`` on success (counts stringified to match the
|
|
||||||
automation engine's stat-rendering convention).
|
|
||||||
"""
|
|
||||||
db = deps.get_database()
|
|
||||||
playlist_id = config.get('playlist_id')
|
|
||||||
refresh_all = config.get('all', False)
|
|
||||||
auto_id = config.get('_automation_id')
|
|
||||||
# Pipeline runs (``run_mirrored_playlist_pipeline``) set this flag
|
|
||||||
# because Phase 2 of the pipeline already runs the playlist
|
|
||||||
# discovery worker — the same matching engine ``_maybe_discover``
|
|
||||||
# would call here. Running both means LB tracks discover twice
|
|
||||||
# AND the refresh-side discovery blocks 5+ minutes with no
|
|
||||||
# progress emission, leaving the UI stuck on "Refreshing:" until
|
|
||||||
# the loop returns. Standalone callers (Sync page, registration
|
|
||||||
# action) leave it False so LB tracks still get matched_data on
|
|
||||||
# refresh.
|
|
||||||
skip_discovery = bool(config.get('skip_discovery', False))
|
|
||||||
|
|
||||||
if refresh_all:
|
|
||||||
playlists = db.get_mirrored_playlists()
|
|
||||||
elif playlist_id:
|
|
||||||
p = db.get_mirrored_playlist(int(playlist_id))
|
|
||||||
playlists = [p] if p else []
|
|
||||||
else:
|
|
||||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
|
||||||
|
|
||||||
# Filter out sources that can't be refreshed (no external API).
|
|
||||||
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
|
||||||
|
|
||||||
refreshed = 0
|
|
||||||
errors: List[str] = []
|
|
||||||
for idx, pl in enumerate(playlists):
|
|
||||||
try:
|
|
||||||
source = pl.get('source', '')
|
|
||||||
source_id = pl.get('source_playlist_id', '')
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
progress=(idx / max(1, len(playlists))) * 100,
|
|
||||||
phase=f'Refreshing: "{pl.get("name", "")}"',
|
|
||||||
current_item=pl.get('name', ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
detail = _fetch_detail(source, source_id, pl, deps, auto_id)
|
|
||||||
if detail is None:
|
|
||||||
# _fetch_detail already logged the specific failure;
|
|
||||||
# mark the playlist as a generic refresh error so the
|
|
||||||
# automation result tally matches the legacy handler.
|
|
||||||
errors.append(f"{pl.get('name', '?')}: no tracks returned from source")
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sources that return MB-metadata-only tracks (LB, Last.fm)
|
|
||||||
# mark them ``needs_discovery=True``. Hand them to the
|
|
||||||
# adapter's matcher so the resulting mirror rows carry
|
|
||||||
# provider IDs + matched_data, ready for the sync pipeline.
|
|
||||||
#
|
|
||||||
# Pipeline runs skip this because Phase 2's discovery
|
|
||||||
# worker handles it with proper progress emission — see
|
|
||||||
# ``skip_discovery`` resolution at the top of this fn.
|
|
||||||
detail_tracks = (
|
|
||||||
detail.tracks if skip_discovery
|
|
||||||
else _maybe_discover(detail.tracks, source, deps)
|
|
||||||
)
|
|
||||||
|
|
||||||
tracks = [to_mirror_track_dict(t) for t in detail_tracks]
|
|
||||||
refreshed += _commit_refresh(pl, source, source_id, tracks, db, deps, auto_id)
|
|
||||||
except _SkipPlaylist:
|
|
||||||
# Source-specific soft-skip (e.g. Tidal not authenticated).
|
|
||||||
# Logging was already emitted; do not count as error.
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'Error: {pl.get("name", "?")} — {str(e)}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_discover(
|
|
||||||
tracks: List[Any],
|
|
||||||
source: str,
|
|
||||||
deps: AutomationDeps,
|
|
||||||
) -> List[Any]:
|
|
||||||
"""Run the adapter's ``discover_tracks`` when any track needs it.
|
|
||||||
|
|
||||||
Most sources are no-ops here (their tracks have provider IDs
|
|
||||||
already). LB / Last.fm are the ones that actually do work."""
|
|
||||||
if not tracks:
|
|
||||||
return tracks
|
|
||||||
if not any(getattr(t, "needs_discovery", False) for t in tracks):
|
|
||||||
return tracks
|
|
||||||
registry = deps.playlist_source_registry
|
|
||||||
if registry is None:
|
|
||||||
return tracks
|
|
||||||
adapter = registry.get_source(source)
|
|
||||||
if adapter is None:
|
|
||||||
return tracks
|
|
||||||
try:
|
|
||||||
return adapter.discover_tracks(tracks)
|
|
||||||
except Exception as exc:
|
|
||||||
deps.logger.warning(f"{source} discover_tracks failed: {exc}")
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
|
|
||||||
class _SkipPlaylist(Exception):
|
|
||||||
"""Internal sentinel: source-specific soft-skip (e.g. not authed).
|
|
||||||
|
|
||||||
The per-playlist loop catches it specifically so the skip isn't
|
|
||||||
counted in the error tally — matches the pre-extraction behavior
|
|
||||||
where ``continue`` was used inline."""
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_detail(
|
|
||||||
source: str,
|
|
||||||
source_id: str,
|
|
||||||
pl: Dict[str, Any],
|
|
||||||
deps: AutomationDeps,
|
|
||||||
auto_id: Optional[str],
|
|
||||||
) -> Optional[PlaylistDetail]:
|
|
||||||
"""Resolve the playlist's tracks through the registry.
|
|
||||||
|
|
||||||
Handler-level branches (URL extraction, Spotify-public→authed
|
|
||||||
fallback, Tidal not-authed skip) live here; everything else
|
|
||||||
delegates to the adapter."""
|
|
||||||
registry = deps.playlist_source_registry
|
|
||||||
if registry is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# URL-backed sources: pull the upstream URL out of `description`.
|
|
||||||
playlist_input = source_id
|
|
||||||
if source in _URL_BACKED_SOURCES:
|
|
||||||
# ``require_refresh_url`` raises ValueError on missing URL.
|
|
||||||
# The outer try/except in the loop catches it and reports as
|
|
||||||
# an error — matching the pre-extraction behavior.
|
|
||||||
playlist_input = require_refresh_url(
|
|
||||||
source, pl.get('description', ''), pl.get('name', '')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spotify-public refresh: prefer the authenticated Spotify API
|
|
||||||
# when the user is signed in. Better album art, matches the
|
|
||||||
# pre-extraction handler. Falls through to the public scraper on
|
|
||||||
# auth failure or non-playlist URL types (e.g. album URLs).
|
|
||||||
if source == SOURCE_SPOTIFY_PUBLIC:
|
|
||||||
detail = _try_spotify_authed_for_public(playlist_input, deps)
|
|
||||||
if detail is not None:
|
|
||||||
return detail
|
|
||||||
|
|
||||||
# Tidal not-authed: soft-skip with a 'skip' log line, not an error.
|
|
||||||
if source == SOURCE_TIDAL:
|
|
||||||
tidal_source = registry.get_source(SOURCE_TIDAL)
|
|
||||||
if tidal_source is None or not tidal_source.is_authenticated():
|
|
||||||
deps.logger.warning(
|
|
||||||
f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'"
|
|
||||||
)
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
raise _SkipPlaylist
|
|
||||||
|
|
||||||
adapter = registry.get_source(source)
|
|
||||||
if adapter is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return adapter.refresh_playlist(playlist_input)
|
|
||||||
except Exception as exc:
|
|
||||||
deps.logger.warning(
|
|
||||||
f"{source} playlist refresh failed for {playlist_input}: {exc}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _try_spotify_authed_for_public(
|
|
||||||
spotify_url: str, deps: AutomationDeps
|
|
||||||
) -> Optional[PlaylistDetail]:
|
|
||||||
"""Best-effort: use the authenticated Spotify adapter on a public URL.
|
|
||||||
|
|
||||||
Returns ``None`` to signal "fall through to the public-scraper
|
|
||||||
adapter" — never raises. Only applies to ``playlist``-type URLs;
|
|
||||||
album URLs fall through unconditionally."""
|
|
||||||
if not spotify_url:
|
|
||||||
return None
|
|
||||||
spotify_client = deps.spotify_client
|
|
||||||
if spotify_client is None or not spotify_client.is_spotify_authenticated():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from core.spotify_public_scraper import parse_spotify_url
|
|
||||||
parsed = parse_spotify_url(spotify_url)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if not parsed or parsed.get('type') != 'playlist':
|
|
||||||
return None
|
|
||||||
adapter = deps.playlist_source_registry.get_source(SOURCE_SPOTIFY)
|
|
||||||
if adapter is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return adapter.refresh_playlist(parsed['id'])
|
|
||||||
except Exception as exc:
|
|
||||||
deps.logger.debug(f"Spotify authed fallback for public mirror failed: {exc}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _commit_refresh(
|
|
||||||
pl: Dict[str, Any],
|
|
||||||
source: str,
|
|
||||||
source_id: str,
|
|
||||||
tracks: List[Dict[str, Any]],
|
|
||||||
db: Any,
|
|
||||||
deps: AutomationDeps,
|
|
||||||
auto_id: Optional[str],
|
|
||||||
) -> int:
|
|
||||||
"""Persist the refreshed track list + emit playlist_changed when delta.
|
|
||||||
|
|
||||||
Returns 1 when a refresh successfully landed, 0 otherwise. The
|
|
||||||
caller is responsible for incrementing the running tally."""
|
|
||||||
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
|
|
||||||
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
|
|
||||||
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
|
|
||||||
|
|
||||||
# Preserve existing extra_data (matched_data + discovery state)
|
|
||||||
# for tracks that still exist in the refreshed snapshot, unless
|
|
||||||
# the adapter already provided fresh extra_data for that track.
|
|
||||||
old_extra_map = (
|
|
||||||
db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {}
|
|
||||||
)
|
|
||||||
for t in tracks:
|
|
||||||
sid = t.get('source_track_id', '')
|
|
||||||
if sid and sid in old_extra_map and 'extra_data' not in t:
|
|
||||||
t['extra_data'] = old_extra_map[sid]
|
|
||||||
|
|
||||||
db.mirror_playlist(
|
|
||||||
source=source,
|
|
||||||
source_playlist_id=source_id,
|
|
||||||
name=pl['name'],
|
|
||||||
tracks=tracks,
|
|
||||||
profile_id=pl.get('profile_id', 1),
|
|
||||||
description=pl.get('description'),
|
|
||||||
owner=pl.get('owner'),
|
|
||||||
image_url=pl.get('image_url'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Membership just changed — if this playlist is organize-by-playlist, rebuild
|
|
||||||
# its folder (with prune) so a track that LEFT the playlist has its symlink
|
|
||||||
# cleaned up now. Gated to organized playlists, non-fatal — never disturbs
|
|
||||||
# the refresh. (Additions are handled by the post-download reconcile.)
|
|
||||||
try:
|
|
||||||
from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized
|
|
||||||
rebuild_mirrored_playlist_if_organized(
|
|
||||||
db, deps.config_manager, pl.get('id'), profile_id=pl.get('profile_id', 1)
|
|
||||||
)
|
|
||||||
except Exception as _mat_err:
|
|
||||||
deps.logger.debug(f"[Playlist Folder] mirror-refresh cleanup skipped: {_mat_err}")
|
|
||||||
|
|
||||||
if old_ids != new_ids:
|
|
||||||
added = len(new_ids - old_ids)
|
|
||||||
removed = len(old_ids - new_ids)
|
|
||||||
deps.logger.info(
|
|
||||||
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
|
||||||
f"{added} added, {removed} removed (old={len(old_ids)}, new={len(new_ids)})"
|
|
||||||
)
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'"{pl.get("name", "")}" — {added} added, {removed} removed',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if deps.engine:
|
|
||||||
deps.engine.emit('playlist_changed', {
|
|
||||||
'playlist_name': pl.get('name', ''),
|
|
||||||
'playlist_id': str(pl.get('id', '')),
|
|
||||||
'old_count': str(len(old_ids)),
|
|
||||||
'new_count': str(len(new_ids)),
|
|
||||||
'added': str(added),
|
|
||||||
'removed': str(removed),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
deps.logger.debug("playlist_synced automation emit failed: %s", e)
|
|
||||||
else:
|
|
||||||
deps.logger.warning(
|
|
||||||
f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})"
|
|
||||||
)
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'No changes: "{pl.get("name", "")}"',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
|
|
||||||
return 1
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
"""One-stop registration of every extracted automation handler.
|
|
||||||
|
|
||||||
``web_server`` builds the deps once at startup and calls
|
|
||||||
:func:`register_all` here. Each new handler module gets one line in
|
|
||||||
this file when it lands.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
|
||||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
|
||||||
from core.automation.handlers.scan_library import auto_scan_library
|
|
||||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
|
||||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
|
||||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
|
||||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
|
||||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
|
||||||
from core.automation.handlers.database_update import (
|
|
||||||
auto_start_database_update, auto_deep_scan_library,
|
|
||||||
)
|
|
||||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
|
||||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
|
||||||
from core.automation.handlers.maintenance import (
|
|
||||||
auto_clear_quarantine,
|
|
||||||
auto_cleanup_wishlist,
|
|
||||||
auto_update_discovery_pool,
|
|
||||||
auto_backup_database,
|
|
||||||
auto_refresh_beatport_cache,
|
|
||||||
)
|
|
||||||
from core.automation.handlers.download_cleanup import (
|
|
||||||
auto_clean_search_history,
|
|
||||||
auto_clean_completed_downloads,
|
|
||||||
auto_full_cleanup,
|
|
||||||
)
|
|
||||||
from core.automation.handlers.run_script import auto_run_script
|
|
||||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
|
||||||
from core.automation.handlers.progress_callbacks import (
|
|
||||||
progress_init,
|
|
||||||
progress_finish,
|
|
||||||
record_history,
|
|
||||||
register_library_scan_completed_emitter,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_all(deps: AutomationDeps) -> None:
|
|
||||||
"""Wire every extracted handler to the engine.
|
|
||||||
|
|
||||||
Each ``register_action_handler`` call binds the action name (the
|
|
||||||
string the trigger uses to look up its action) to a thin lambda
|
|
||||||
that injects ``deps`` and forwards the engine-supplied config.
|
|
||||||
Guards stay alongside their handler so duplicate-run prevention
|
|
||||||
behaves identically to the pre-extraction code.
|
|
||||||
"""
|
|
||||||
engine = deps.engine
|
|
||||||
|
|
||||||
# Self-guards prevent duplicate runs of the SAME operation, but
|
|
||||||
# different operations can run concurrently — wishlist downloads
|
|
||||||
# use bandwidth, watchlist scans use API calls, library scans use
|
|
||||||
# media-server CPU. Different resources, no contention.
|
|
||||||
engine.register_action_handler(
|
|
||||||
'process_wishlist',
|
|
||||||
lambda config: auto_process_wishlist(config, deps),
|
|
||||||
guard_fn=deps.is_wishlist_actually_processing,
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'scan_watchlist',
|
|
||||||
lambda config: auto_scan_watchlist(config, deps),
|
|
||||||
guard_fn=deps.is_watchlist_actually_scanning,
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'scan_library',
|
|
||||||
lambda config: auto_scan_library(config, deps),
|
|
||||||
deps.state.is_scan_library_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Playlist lifecycle handlers. The pipeline composes refresh +
|
|
||||||
# sync + discover (it imports them directly), so all four ship
|
|
||||||
# together. The pipeline guard prevents an in-flight pipeline
|
|
||||||
# from being re-triggered mid-run.
|
|
||||||
engine.register_action_handler(
|
|
||||||
'refresh_mirrored',
|
|
||||||
lambda config: auto_refresh_mirrored(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'sync_playlist',
|
|
||||||
lambda config: auto_sync_playlist(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'discover_playlist',
|
|
||||||
lambda config: auto_discover_playlist(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'playlist_pipeline',
|
|
||||||
lambda config: auto_playlist_pipeline(config, deps),
|
|
||||||
deps.state.is_pipeline_running,
|
|
||||||
)
|
|
||||||
# Personalized pipeline shares the pipeline_running flag with the
|
|
||||||
# mirrored pipeline so the two can't overlap (single sync queue,
|
|
||||||
# single wishlist worker).
|
|
||||||
engine.register_action_handler(
|
|
||||||
'personalized_pipeline',
|
|
||||||
lambda config: auto_personalized_pipeline(config, deps),
|
|
||||||
deps.state.is_pipeline_running,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Database update + deep scan share the db_update_state guard —
|
|
||||||
# only one operation can mutate that state at a time.
|
|
||||||
engine.register_action_handler(
|
|
||||||
'start_database_update',
|
|
||||||
lambda config: auto_start_database_update(config, deps),
|
|
||||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'deep_scan_library',
|
|
||||||
lambda config: auto_deep_scan_library(config, deps),
|
|
||||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'run_duplicate_cleaner',
|
|
||||||
lambda config: auto_run_duplicate_cleaner(config, deps),
|
|
||||||
lambda: deps.get_duplicate_cleaner_state().get('status') == 'running',
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'clear_quarantine',
|
|
||||||
lambda config: auto_clear_quarantine(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'cleanup_wishlist',
|
|
||||||
lambda config: auto_cleanup_wishlist(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'update_discovery_pool',
|
|
||||||
lambda config: auto_update_discovery_pool(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'start_quality_scan',
|
|
||||||
lambda config: auto_start_quality_scan(config, deps),
|
|
||||||
lambda: False, # repair worker dedupes Run-Now requests itself
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'backup_database',
|
|
||||||
lambda config: auto_backup_database(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'refresh_beatport_cache',
|
|
||||||
lambda config: auto_refresh_beatport_cache(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'clean_search_history',
|
|
||||||
lambda config: auto_clean_search_history(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'clean_completed_downloads',
|
|
||||||
lambda config: auto_clean_completed_downloads(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'full_cleanup',
|
|
||||||
lambda config: auto_full_cleanup(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'run_script',
|
|
||||||
lambda config: auto_run_script(config, deps),
|
|
||||||
)
|
|
||||||
engine.register_action_handler(
|
|
||||||
'search_and_download',
|
|
||||||
lambda config: auto_search_and_download(config, deps),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Progress + history callbacks: the engine invokes these around
|
|
||||||
# each handler run. Lift the closures from
|
|
||||||
# `web_server._register_automation_handlers` into thin lambdas
|
|
||||||
# that delegate into the extracted top-level functions.
|
|
||||||
engine.register_progress_callbacks(
|
|
||||||
lambda aid, name, action_type: progress_init(aid, name, action_type, deps),
|
|
||||||
lambda aid, result: progress_finish(aid, result, deps),
|
|
||||||
deps.update_progress,
|
|
||||||
lambda aid, result: record_history(aid, result, deps),
|
|
||||||
)
|
|
||||||
|
|
||||||
# `library_scan_completed` event: when the media-server scan
|
|
||||||
# manager finishes a scan, emit the event so any automation can
|
|
||||||
# trigger off it. No-op when no scan manager is configured.
|
|
||||||
register_library_scan_completed_emitter(deps)
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""Automation handler: ``run_script`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_run_script`` closure). Runs a user-provided shell or Python
|
|
||||||
script from the configured scripts directory with bounded timeout +
|
|
||||||
captured stdout/stderr. Path-traversal guard ensures users can't
|
|
||||||
escape the scripts directory.
|
|
||||||
|
|
||||||
Environment variables exposed to the script:
|
|
||||||
- ``SOULSYNC_EVENT``: triggering event type (when fired by an event)
|
|
||||||
- ``SOULSYNC_AUTOMATION``: automation name
|
|
||||||
- ``SOULSYNC_SCRIPTS_DIR``: absolute path to the scripts dir
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess as _sp
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
_MAX_TIMEOUT_SECONDS = 300 # Hard cap on user-supplied timeout config.
|
|
||||||
|
|
||||||
|
|
||||||
def auto_run_script(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
script_name = config.get('script_name', '')
|
|
||||||
timeout = min(int(config.get('timeout', 60)), _MAX_TIMEOUT_SECONDS)
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
|
|
||||||
if not script_name:
|
|
||||||
return {'status': 'error', 'error': 'No script selected'}
|
|
||||||
|
|
||||||
scripts_dir = deps.docker_resolve_path(deps.config_manager.get('scripts.path', './scripts'))
|
|
||||||
if not scripts_dir or not os.path.isdir(scripts_dir):
|
|
||||||
os.makedirs(scripts_dir, exist_ok=True)
|
|
||||||
return {
|
|
||||||
'status': 'error',
|
|
||||||
'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.',
|
|
||||||
}
|
|
||||||
|
|
||||||
script_path = os.path.join(scripts_dir, script_name)
|
|
||||||
script_path = os.path.realpath(script_path)
|
|
||||||
|
|
||||||
# Security: block path traversal — script must resolve under
|
|
||||||
# the scripts dir, no symlinks/.. tricks allowed out.
|
|
||||||
if not script_path.startswith(os.path.realpath(scripts_dir)):
|
|
||||||
return {'status': 'error', 'error': 'Script path traversal blocked'}
|
|
||||||
|
|
||||||
if not os.path.isfile(script_path):
|
|
||||||
return {'status': 'error', 'error': f'Script not found: {script_name}'}
|
|
||||||
|
|
||||||
deps.update_progress(automation_id, phase=f'Running {script_name}...', progress=10)
|
|
||||||
|
|
||||||
# Build environment with SoulSync context.
|
|
||||||
env = os.environ.copy()
|
|
||||||
event_data = config.get('_event_data') or {}
|
|
||||||
env['SOULSYNC_EVENT'] = str(event_data.get('type', ''))
|
|
||||||
env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '')
|
|
||||||
env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Determine how to run the script.
|
|
||||||
if script_path.endswith('.py'):
|
|
||||||
cmd = ['python', script_path]
|
|
||||||
elif script_path.endswith('.sh'):
|
|
||||||
cmd = ['bash', script_path]
|
|
||||||
else:
|
|
||||||
cmd = [script_path]
|
|
||||||
|
|
||||||
result = _sp.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True, text=True, timeout=timeout,
|
|
||||||
cwd=scripts_dir, env=env,
|
|
||||||
)
|
|
||||||
|
|
||||||
deps.update_progress(automation_id, phase='Script completed', progress=100)
|
|
||||||
|
|
||||||
stdout = result.stdout[:2000] if result.stdout else ''
|
|
||||||
stderr = result.stderr[:1000] if result.stderr else ''
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
deps.logger.info(f"Script '{script_name}' completed (exit 0)")
|
|
||||||
else:
|
|
||||||
deps.logger.warning(f"Script '{script_name}' exited with code {result.returncode}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'status': 'completed' if result.returncode == 0 else 'error',
|
|
||||||
'exit_code': str(result.returncode),
|
|
||||||
'stdout': stdout,
|
|
||||||
'stderr': stderr,
|
|
||||||
'script': script_name,
|
|
||||||
}
|
|
||||||
except _sp.TimeoutExpired:
|
|
||||||
deps.update_progress(automation_id, phase='Script timed out', progress=100)
|
|
||||||
return {
|
|
||||||
'status': 'error',
|
|
||||||
'error': f'Script timed out after {timeout}s',
|
|
||||||
'script': script_name,
|
|
||||||
}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
|
||||||
return {'status': 'error', 'error': str(e), 'script': script_name}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""Automation handler: ``scan_library`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_scan_library`` closure). The handler triggers a media-server
|
|
||||||
scan via ``web_scan_manager``, then polls the manager's status until
|
|
||||||
the scan completes (or a 30-minute timeout fires). Progress phases
|
|
||||||
are emitted via :func:`AutomationDeps.update_progress` so the
|
|
||||||
trigger card stays current throughout the run.
|
|
||||||
|
|
||||||
The handler manages its own progress reporting (it sets
|
|
||||||
``_manages_own_progress: True`` in the result) so the engine doesn't
|
|
||||||
overwrite the live phase string with a generic 'completed' label.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
# Outer poll cap — covers extreme worst case (long Plex scans on
|
|
||||||
# huge libraries). Past this point we surface a clear timeout error
|
|
||||||
# so users notice rather than letting the trigger hang forever.
|
|
||||||
_SCAN_TIMEOUT_SECONDS = 1800
|
|
||||||
|
|
||||||
# Per-phase poll intervals.
|
|
||||||
_POLL_SCHEDULED_SECONDS = 2
|
|
||||||
_POLL_SCANNING_SECONDS = 5
|
|
||||||
_POLL_UNKNOWN_SECONDS = 2
|
|
||||||
|
|
||||||
# Progress percentage waypoints.
|
|
||||||
_PROGRESS_SCHEDULED_MAX = 14
|
|
||||||
_PROGRESS_SCAN_START = 15
|
|
||||||
_PROGRESS_SCAN_MAX = 95
|
|
||||||
|
|
||||||
|
|
||||||
def auto_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run a media-server library scan and stream progress to the
|
|
||||||
trigger card.
|
|
||||||
|
|
||||||
Returns one of:
|
|
||||||
- ``{'status': 'completed', '_manages_own_progress': True, ...}``
|
|
||||||
- ``{'status': 'skipped', 'reason': 'Scan already being tracked'}``
|
|
||||||
- ``{'status': 'error', 'reason': '...', '_manages_own_progress': True}``
|
|
||||||
"""
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
|
|
||||||
if not deps.web_scan_manager:
|
|
||||||
return {'status': 'error', 'reason': 'Scan manager not available'}
|
|
||||||
|
|
||||||
# If another automation is already tracking the scan, just forward
|
|
||||||
# the request — the original tracker keeps emitting progress.
|
|
||||||
if deps.state.is_scan_library_active():
|
|
||||||
deps.web_scan_manager.request_scan('Automation trigger (additional batch)')
|
|
||||||
return {'status': 'skipped', 'reason': 'Scan already being tracked'}
|
|
||||||
|
|
||||||
deps.state.set_scan_library_id(automation_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = deps.web_scan_manager.request_scan('Automation trigger')
|
|
||||||
scan_status_val = result.get('status', 'unknown')
|
|
||||||
|
|
||||||
if scan_status_val == 'queued':
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line='Scan already in progress — waiting for completion',
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
delay = result.get('delay_seconds', 60)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Scan scheduled (debounce: {delay}s)',
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Unified polling loop — handles debounce → scanning → idle.
|
|
||||||
poll_start = time.time()
|
|
||||||
scan_started = (scan_status_val == 'queued')
|
|
||||||
while time.time() - poll_start < _SCAN_TIMEOUT_SECONDS:
|
|
||||||
status = deps.web_scan_manager.get_scan_status()
|
|
||||||
st = status.get('status')
|
|
||||||
|
|
||||||
if st == 'idle':
|
|
||||||
break # Scan completed (or finished before we polled)
|
|
||||||
|
|
||||||
if st == 'scheduled':
|
|
||||||
elapsed = int(time.time() - poll_start)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
phase=f'Waiting for scan to start... ({elapsed}s)',
|
|
||||||
progress=min(int(elapsed / 60 * 10), _PROGRESS_SCHEDULED_MAX),
|
|
||||||
)
|
|
||||||
time.sleep(_POLL_SCHEDULED_SECONDS)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if st == 'scanning':
|
|
||||||
if not scan_started:
|
|
||||||
scan_started = True
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
progress=_PROGRESS_SCAN_START,
|
|
||||||
log_line='Scan triggered on media server',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
elapsed = status.get('elapsed_seconds', 0)
|
|
||||||
max_time = status.get('max_time_seconds', 300)
|
|
||||||
pct = min(_PROGRESS_SCAN_START + int(elapsed / max_time * 80), _PROGRESS_SCAN_MAX)
|
|
||||||
mins, secs = divmod(elapsed, 60)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
phase=f'Library scan in progress... ({mins}m {secs}s)',
|
|
||||||
progress=pct,
|
|
||||||
)
|
|
||||||
time.sleep(_POLL_SCANNING_SECONDS)
|
|
||||||
continue
|
|
||||||
|
|
||||||
time.sleep(_POLL_UNKNOWN_SECONDS)
|
|
||||||
else:
|
|
||||||
# 30-min timeout reached
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='error',
|
|
||||||
phase='Timed out',
|
|
||||||
log_line='Library scan timed out after 30 minutes',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
|
||||||
|
|
||||||
elapsed = round(time.time() - poll_start, 1)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='finished',
|
|
||||||
progress=100,
|
|
||||||
phase='Complete',
|
|
||||||
log_line='Library scan completed',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
'scan_duration_seconds': elapsed,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
status='error',
|
|
||||||
phase='Error',
|
|
||||||
log_line=str(e),
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
|
||||||
|
|
||||||
finally:
|
|
||||||
deps.state.set_scan_library_id(None)
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""Automation handler: ``scan_watchlist`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_scan_watchlist`` closure). The watchlist scanner returns
|
|
||||||
summary stats for the trigger card only when a fresh scan actually
|
|
||||||
ran — detected by snapshotting ``id(state_dict)`` before/after, since
|
|
||||||
the live processor reassigns the dict on each new scan.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_scan_watchlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Run a watchlist scan when the automation triggers.
|
|
||||||
|
|
||||||
Pre-scan we capture ``id(watchlist_scan_state)`` so we can tell
|
|
||||||
afterwards whether the worker ran (and reassigned the state dict)
|
|
||||||
or short-circuited (kept the same dict). Only fresh scans report
|
|
||||||
summary stats — repeat triggers without an intervening run return
|
|
||||||
a bare ``completed``.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
pre_state = deps.get_watchlist_scan_state()
|
|
||||||
pre_state_id = id(pre_state)
|
|
||||||
deps.process_watchlist_scan_automatically(
|
|
||||||
automation_id=config.get('_automation_id'),
|
|
||||||
profile_id=config.get('_profile_id'),
|
|
||||||
)
|
|
||||||
post_state = deps.get_watchlist_scan_state()
|
|
||||||
# Fresh scan = state dict was reassigned mid-run.
|
|
||||||
if id(post_state) != pre_state_id:
|
|
||||||
summary = post_state.get('summary', {}) if isinstance(post_state, dict) else {}
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'artists_scanned': summary.get('total_artists', 0),
|
|
||||||
'successful_scans': summary.get('successful_scans', 0),
|
|
||||||
'new_tracks_found': summary.get('new_tracks_found', 0),
|
|
||||||
'tracks_added_to_wishlist': summary.get('tracks_added_to_wishlist', 0),
|
|
||||||
}
|
|
||||||
return {'status': 'completed'}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
|
||||||
return {'status': 'error', 'error': str(e)}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"""Automation handler: ``search_and_download`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_search_and_download`` closure). Searches for a track by
|
|
||||||
name/artist string and dispatches the best match through the
|
|
||||||
download orchestrator. Query can come from the trigger config
|
|
||||||
(direct value) or from event data (e.g. webhook payload).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_search_and_download(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
automation_id = config.get('_automation_id')
|
|
||||||
query = config.get('query', '').strip()
|
|
||||||
# Event-triggered: pull query from event data (e.g. webhook_received).
|
|
||||||
if not query:
|
|
||||||
event_data = config.get('_event_data', {})
|
|
||||||
query = (event_data.get('query', '') or '').strip()
|
|
||||||
if not query:
|
|
||||||
if automation_id:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line='No search query provided', log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'error': 'No search query provided'}
|
|
||||||
try:
|
|
||||||
if automation_id:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, phase='Searching',
|
|
||||||
log_line=f'Searching: {query}', log_type='info',
|
|
||||||
)
|
|
||||||
result = deps.run_async(deps.download_orchestrator.search_and_download_best(query))
|
|
||||||
if result:
|
|
||||||
if automation_id:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'Download started for: {query}',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
return {'status': 'completed', 'query': query, 'download_id': result}
|
|
||||||
if automation_id:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id,
|
|
||||||
log_line=f'No match found for: {query}',
|
|
||||||
log_type='warning',
|
|
||||||
)
|
|
||||||
return {'status': 'not_found', 'query': query, 'error': 'No match found'}
|
|
||||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
|
||||||
if automation_id:
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, log_line=f'Error: {e}', log_type='error',
|
|
||||||
)
|
|
||||||
return {'status': 'error', 'query': query, 'error': str(e)}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
"""Automation handler: ``sync_playlist`` action.
|
|
||||||
|
|
||||||
Lifted from ``web_server._register_automation_handlers`` (the
|
|
||||||
``_auto_sync_playlist`` closure). Syncs a mirrored playlist to the
|
|
||||||
configured media server, using discovered metadata when available
|
|
||||||
and skipping undiscovered tracks. When triggered on a schedule with
|
|
||||||
no track changes since the last sync, short-circuits with
|
|
||||||
``status: skipped`` (saves Plex / Jellyfin / Navidrome from
|
|
||||||
needless rewrites)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from core.automation.deps import AutomationDeps
|
|
||||||
|
|
||||||
|
|
||||||
def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
||||||
"""Sync a mirrored playlist to the active media server.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Tracks with discovered metadata (extra_data.discovered + matched_data)
|
|
||||||
are routed via the official metadata.
|
|
||||||
- Tracks with a Spotify hint (real Spotify ID from the embed
|
|
||||||
scraper) are included so they can still hit Soulseek + the
|
|
||||||
wishlist.
|
|
||||||
- Tracks with neither are counted as ``skipped_tracks``.
|
|
||||||
- Empty result → ``status: skipped`` with the skipped count.
|
|
||||||
- Same track set as last sync (matched_tracks unchanged) →
|
|
||||||
``status: skipped`` (no-op).
|
|
||||||
- Otherwise spawns a daemon thread running ``run_sync_task`` and
|
|
||||||
returns ``status: started`` with ``_manages_own_progress: True``.
|
|
||||||
"""
|
|
||||||
auto_id = config.get('_automation_id')
|
|
||||||
playlist_id = config.get('playlist_id')
|
|
||||||
if not playlist_id:
|
|
||||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
|
||||||
|
|
||||||
db = deps.get_database()
|
|
||||||
pl = db.get_mirrored_playlist(int(playlist_id))
|
|
||||||
if not pl:
|
|
||||||
return {'status': 'error', 'reason': 'Playlist not found'}
|
|
||||||
|
|
||||||
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
|
|
||||||
if not tracks:
|
|
||||||
return {'status': 'error', 'reason': 'No tracks in playlist'}
|
|
||||||
|
|
||||||
# Convert mirrored tracks to format expected by run_sync_task.
|
|
||||||
# Use discovered metadata when available, fall back to Spotify
|
|
||||||
# hint or raw playlist fields when not.
|
|
||||||
tracks_json = []
|
|
||||||
skipped_count = 0
|
|
||||||
|
|
||||||
for t in tracks:
|
|
||||||
# Parse extra_data for discovery info.
|
|
||||||
extra = {}
|
|
||||||
if t.get('extra_data'):
|
|
||||||
try:
|
|
||||||
extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data']
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if extra.get('discovered') and extra.get('matched_data'):
|
|
||||||
# Use official discovered metadata.
|
|
||||||
md = extra['matched_data']
|
|
||||||
album_raw = md.get('album', '')
|
|
||||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
|
||||||
_track_entry = {
|
|
||||||
'name': md.get('name', ''),
|
|
||||||
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
|
|
||||||
'album': album_obj,
|
|
||||||
'duration_ms': md.get('duration_ms', 0),
|
|
||||||
'id': md.get('id', ''),
|
|
||||||
}
|
|
||||||
if md.get('track_number'):
|
|
||||||
_track_entry['track_number'] = md['track_number']
|
|
||||||
if md.get('disc_number'):
|
|
||||||
_track_entry['disc_number'] = md['disc_number']
|
|
||||||
tracks_json.append(_track_entry)
|
|
||||||
else:
|
|
||||||
# NOT discovered — try to include using available metadata so
|
|
||||||
# the track can still be searched on Soulseek and added to
|
|
||||||
# wishlist. Without this, failed discovery blocks the entire
|
|
||||||
# download pipeline.
|
|
||||||
#
|
|
||||||
# Priority: spotify_hint (has real Spotify ID from embed
|
|
||||||
# scraper) > raw playlist fields (only if source_track_id
|
|
||||||
# is valid).
|
|
||||||
hint = extra.get('spotify_hint', {})
|
|
||||||
# Build album object with cover art from the mirrored playlist track.
|
|
||||||
track_image = (t.get('image_url') or '').strip()
|
|
||||||
album_obj = {
|
|
||||||
'name': (t.get('album_name') or '').strip(),
|
|
||||||
'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [],
|
|
||||||
}
|
|
||||||
|
|
||||||
if hint.get('id') and hint.get('name'):
|
|
||||||
# spotify_hint has proper Spotify track ID + metadata from embed scraper.
|
|
||||||
hint_artists = hint.get('artists', [])
|
|
||||||
if hint_artists and isinstance(hint_artists[0], str):
|
|
||||||
hint_artists = [{'name': a} for a in hint_artists]
|
|
||||||
elif hint_artists and isinstance(hint_artists[0], dict):
|
|
||||||
pass # Already in correct format
|
|
||||||
else:
|
|
||||||
hint_artists = [{'name': t.get('artist_name', '')}]
|
|
||||||
tracks_json.append({
|
|
||||||
'name': hint['name'],
|
|
||||||
'artists': hint_artists,
|
|
||||||
'album': album_obj,
|
|
||||||
'duration_ms': t.get('duration_ms', 0),
|
|
||||||
'id': hint['id'],
|
|
||||||
})
|
|
||||||
elif t.get('source_track_id') and (t.get('track_name') or '').strip():
|
|
||||||
# Has a valid source ID and track name — usable for wishlist.
|
|
||||||
tracks_json.append({
|
|
||||||
'name': t['track_name'].strip(),
|
|
||||||
'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}],
|
|
||||||
'album': album_obj,
|
|
||||||
'duration_ms': t.get('duration_ms', 0),
|
|
||||||
'id': t['source_track_id'],
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
skipped_count += 1 # No usable ID or name — truly can't process.
|
|
||||||
|
|
||||||
if not tracks_json:
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'No discovered tracks — {skipped_count} need discovery first',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'skipped',
|
|
||||||
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
|
|
||||||
'skipped_tracks': str(skipped_count),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Preflight: hash the track list and compare against last sync.
|
|
||||||
# Skip if the exact same set of tracks was already synced and
|
|
||||||
# everything matched (no-op preserves Plex / Jellyfin / Navidrome
|
|
||||||
# from needless rewrites).
|
|
||||||
track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
|
||||||
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
|
|
||||||
|
|
||||||
sync_id_key = f"auto_mirror_{playlist_id}"
|
|
||||||
# Full mirror identity (every source_track_id on the playlist). tracks_hash
|
|
||||||
# only covers tracks_json — if a new mirror row is skipped (no discovery /
|
|
||||||
# no source id), tracks_hash stays identical to the pre-add sync and we
|
|
||||||
# used to no-op with "unchanged" while the new song never hit wishlist.
|
|
||||||
mirror_ids_str = ','.join(
|
|
||||||
sorted(t.get('source_track_id', '') or '' for t in tracks if t.get('source_track_id'))
|
|
||||||
)
|
|
||||||
mirror_tracks_hash = hashlib.md5(mirror_ids_str.encode()).hexdigest() if mirror_ids_str else ''
|
|
||||||
|
|
||||||
event_data = config.get('_event_data') or {}
|
|
||||||
try:
|
|
||||||
tracks_added = int(event_data.get('added') or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
tracks_added = 0
|
|
||||||
force_sync = tracks_added > 0 or skipped_count > 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
sync_statuses = deps.load_sync_status_file()
|
|
||||||
last_status = sync_statuses.get(sync_id_key, {})
|
|
||||||
last_hash = last_status.get('tracks_hash', '')
|
|
||||||
last_mirror_hash = last_status.get('mirror_tracks_hash', '')
|
|
||||||
last_matched = last_status.get('matched_tracks', -1)
|
|
||||||
|
|
||||||
mirror_changed = bool(mirror_tracks_hash) and mirror_tracks_hash != last_mirror_hash
|
|
||||||
if (
|
|
||||||
not force_sync
|
|
||||||
and not mirror_changed
|
|
||||||
and last_hash == tracks_hash
|
|
||||||
and last_matched >= len(tracks_json)
|
|
||||||
):
|
|
||||||
# Exact same tracks, all matched last time — nothing to do.
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=f'All {len(tracks_json)} tracks unchanged since last sync — skipping',
|
|
||||||
log_type='skip',
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'status': 'skipped',
|
|
||||||
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
|
||||||
}
|
|
||||||
if force_sync and last_hash == tracks_hash and last_matched >= len(tracks_json):
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line=(
|
|
||||||
f'Forcing sync: playlist changed ({tracks_added} added) or '
|
|
||||||
f'{skipped_count} track(s) need discovery'
|
|
||||||
),
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
elif mirror_changed:
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
log_line='Mirror track list changed — running sync',
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
deps.logger.debug("mirror sync last-status read: %s", e)
|
|
||||||
|
|
||||||
# Sync under the user's custom alias when set, else the upstream name (#865
|
|
||||||
# follow-up). The server-side playlist is named with this.
|
|
||||||
from core.playlists.naming import effective_mirrored_name
|
|
||||||
sync_name = effective_mirrored_name(pl) or pl.get('name') or 'Playlist'
|
|
||||||
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
progress=50,
|
|
||||||
phase=f'Syncing "{sync_name}"',
|
|
||||||
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
|
||||||
log_type='info',
|
|
||||||
)
|
|
||||||
|
|
||||||
sync_id = f"auto_mirror_{playlist_id}"
|
|
||||||
deps.update_progress(
|
|
||||||
auto_id,
|
|
||||||
progress=90,
|
|
||||||
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
|
||||||
log_type='success',
|
|
||||||
)
|
|
||||||
skip_wishlist_add = bool(pl.get('organize_by_playlist'))
|
|
||||||
threading.Thread(
|
|
||||||
target=deps.run_sync_task,
|
|
||||||
args=(sync_id, sync_name, tracks_json, auto_id, 1, pl.get('image_url', '')),
|
|
||||||
kwargs={'skip_wishlist_add': skip_wishlist_add},
|
|
||||||
daemon=True,
|
|
||||||
name=f'auto-sync-{playlist_id}',
|
|
||||||
).start()
|
|
||||||
return {
|
|
||||||
'status': 'started',
|
|
||||||
'playlist_name': sync_name,
|
|
||||||
'discovered_tracks': str(len(tracks_json)),
|
|
||||||
'skipped_tracks': str(skipped_count),
|
|
||||||
'_manages_own_progress': True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
"""Automation progress tracking.
|
|
||||||
|
|
||||||
Owns the in-memory progress state dict that backs both
|
|
||||||
`/api/automations/progress` polling and the WebSocket
|
|
||||||
`automation:progress` push emitter. State is per-automation, capped at
|
|
||||||
50 log entries each, and finished/error states are reaped 60s after
|
|
||||||
they finish so the frontend has a window to show the final state.
|
|
||||||
|
|
||||||
Functions are written so the route layer / engine callbacks can pass
|
|
||||||
their own socketio emitter, db handle, and shutdown flag. The progress
|
|
||||||
state dict (`progress_states`) and its lock (`progress_lock`) are
|
|
||||||
module-level so all callers share one view — same as the original
|
|
||||||
web_server.py globals.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Shared mutable state — module globals so every caller (routes, engine
|
|
||||||
# progress callbacks, emit loop) sees the same dict. Mirrors the original
|
|
||||||
# `automation_progress_states` / `automation_progress_lock` in web_server.
|
|
||||||
progress_states: dict[int, dict] = {}
|
|
||||||
progress_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def init_progress(automation_id: int, automation_name: str, action_type: str) -> None:
|
|
||||||
"""Initialize progress state when an automation starts running."""
|
|
||||||
with progress_lock:
|
|
||||||
progress_states[automation_id] = {
|
|
||||||
'status': 'running',
|
|
||||||
'action_type': action_type,
|
|
||||||
'progress': 0,
|
|
||||||
'phase': 'Starting...',
|
|
||||||
'current_item': '',
|
|
||||||
'processed': 0,
|
|
||||||
'total': 0,
|
|
||||||
'log': [{'type': 'info', 'text': f'Starting {automation_name}'}],
|
|
||||||
'started_at': datetime.now(timezone.utc).isoformat(),
|
|
||||||
'finished_at': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def update_progress(
|
|
||||||
automation_id: Optional[int],
|
|
||||||
*,
|
|
||||||
socketio_emit: Optional[Callable[[str, Any], None]] = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
"""Update progress state from handler threads. Thread-safe.
|
|
||||||
|
|
||||||
`socketio_emit` lets callers wire in the live socketio.emit so that
|
|
||||||
finished/error transitions push immediately without waiting for the
|
|
||||||
1s emitter loop. Falls back to no-op if not provided.
|
|
||||||
"""
|
|
||||||
if automation_id is None:
|
|
||||||
return
|
|
||||||
with progress_lock:
|
|
||||||
state = progress_states.get(automation_id)
|
|
||||||
if not state:
|
|
||||||
return
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if k == 'log_line':
|
|
||||||
state['log'].append({'type': kwargs.get('log_type', 'info'), 'text': v})
|
|
||||||
if len(state['log']) > 50:
|
|
||||||
state['log'] = state['log'][-50:]
|
|
||||||
elif k != 'log_type':
|
|
||||||
state[k] = v
|
|
||||||
if kwargs.get('status') in ('finished', 'error'):
|
|
||||||
state['finished_at'] = datetime.now(timezone.utc).isoformat()
|
|
||||||
if socketio_emit is not None:
|
|
||||||
try:
|
|
||||||
socketio_emit('automation:progress', {str(automation_id): dict(state)})
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("socketio progress emit: %s", e)
|
|
||||||
|
|
||||||
|
|
||||||
def get_running_progress() -> dict[str, dict]:
|
|
||||||
"""Snapshot of running/finished/error states for the polling endpoint."""
|
|
||||||
with progress_lock:
|
|
||||||
result: dict[str, dict] = {}
|
|
||||||
for aid, state in progress_states.items():
|
|
||||||
if state['status'] in ('running', 'finished', 'error'):
|
|
||||||
cp = dict(state)
|
|
||||||
cp['log'] = list(state['log'])
|
|
||||||
result[str(aid)] = cp
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def record_history(
|
|
||||||
automation_id: int,
|
|
||||||
result: dict,
|
|
||||||
database,
|
|
||||||
) -> None:
|
|
||||||
"""Capture progress state into run history before cleanup clears it.
|
|
||||||
|
|
||||||
`database` is passed in so the function works without a `get_database()`
|
|
||||||
global.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with progress_lock:
|
|
||||||
state = progress_states.get(automation_id)
|
|
||||||
if state:
|
|
||||||
started_at = state.get('started_at')
|
|
||||||
finished_at = state.get('finished_at') or datetime.now(timezone.utc).isoformat()
|
|
||||||
log_entries = list(state.get('log', []))
|
|
||||||
else:
|
|
||||||
started_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
finished_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
log_entries = []
|
|
||||||
|
|
||||||
duration = None
|
|
||||||
if started_at and finished_at:
|
|
||||||
try:
|
|
||||||
t0 = datetime.fromisoformat(started_at)
|
|
||||||
t1 = datetime.fromisoformat(finished_at)
|
|
||||||
duration = (t1 - t0).total_seconds()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("duration parse: %s", e)
|
|
||||||
|
|
||||||
r_status = result.get('status', 'completed') if result else 'completed'
|
|
||||||
if r_status == 'error':
|
|
||||||
status = 'error'
|
|
||||||
elif r_status == 'skipped':
|
|
||||||
status = 'skipped'
|
|
||||||
elif r_status == 'timeout':
|
|
||||||
status = 'timeout'
|
|
||||||
else:
|
|
||||||
status = 'completed'
|
|
||||||
|
|
||||||
summary = None
|
|
||||||
for entry in reversed(log_entries):
|
|
||||||
if entry.get('type') in ('success', 'error'):
|
|
||||||
summary = entry.get('text', '')
|
|
||||||
break
|
|
||||||
if not summary and log_entries:
|
|
||||||
summary = log_entries[-1].get('text', '')
|
|
||||||
if not summary and result:
|
|
||||||
summary = result.get('reason') or result.get('error') or result.get('status', '')
|
|
||||||
|
|
||||||
result_json = json.dumps({k: v for k, v in result.items() if not k.startswith('_')}) if result else None
|
|
||||||
log_json = json.dumps(log_entries) if log_entries else None
|
|
||||||
|
|
||||||
database.insert_automation_run_history(
|
|
||||||
automation_id=automation_id,
|
|
||||||
started_at=started_at,
|
|
||||||
finished_at=finished_at,
|
|
||||||
duration_seconds=duration,
|
|
||||||
status=status,
|
|
||||||
summary=summary,
|
|
||||||
result_json=result_json,
|
|
||||||
log_lines=log_json,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error recording automation history for {automation_id}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def emit_progress_loop(
|
|
||||||
socketio,
|
|
||||||
*,
|
|
||||||
is_shutting_down: Callable[[], bool],
|
|
||||||
poll_interval: float = 1.0,
|
|
||||||
timeout_seconds: int = 7200,
|
|
||||||
cleanup_after_seconds: int = 60,
|
|
||||||
) -> None:
|
|
||||||
"""Push `automation:progress` events for active automations.
|
|
||||||
|
|
||||||
Long-running loop — caller wires this into a socketio background task.
|
|
||||||
- Times out zombie running states after `timeout_seconds` (default 2h).
|
|
||||||
- Reaps finished/error states `cleanup_after_seconds` after finish so the
|
|
||||||
frontend has a final-state window before they disappear.
|
|
||||||
"""
|
|
||||||
while not is_shutting_down():
|
|
||||||
socketio.sleep(poll_interval)
|
|
||||||
try:
|
|
||||||
with progress_lock:
|
|
||||||
active: dict[str, dict] = {}
|
|
||||||
stale: list[int] = []
|
|
||||||
now = datetime.now()
|
|
||||||
for aid, state in progress_states.items():
|
|
||||||
if state['status'] == 'running':
|
|
||||||
try:
|
|
||||||
started = datetime.fromisoformat(state.get('started_at', ''))
|
|
||||||
if (now - started).total_seconds() > timeout_seconds:
|
|
||||||
state['status'] = 'error'
|
|
||||||
state['phase'] = 'Timed out'
|
|
||||||
state['finished_at'] = now.isoformat()
|
|
||||||
state['log'].append({'type': 'error', 'text': f'Timed out after {timeout_seconds // 3600} hours'})
|
|
||||||
cp = dict(state)
|
|
||||||
cp['log'] = list(state['log'])
|
|
||||||
active[str(aid)] = cp
|
|
||||||
continue
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
cp = dict(state)
|
|
||||||
cp['log'] = list(state['log'])
|
|
||||||
active[str(aid)] = cp
|
|
||||||
elif state['status'] in ('finished', 'error') and state.get('finished_at'):
|
|
||||||
try:
|
|
||||||
finished_time = datetime.fromisoformat(state['finished_at'])
|
|
||||||
if (now - finished_time).total_seconds() > cleanup_after_seconds:
|
|
||||||
stale.append(aid)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
stale.append(aid)
|
|
||||||
for aid in stale:
|
|
||||||
del progress_states[aid]
|
|
||||||
if active:
|
|
||||||
socketio.emit('automation:progress', active)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error emitting automation progress: {e}")
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
"""Pure functions for computing the next-run datetime of a scheduled
|
|
||||||
automation trigger.
|
|
||||||
|
|
||||||
The Auto-Sync schedule board currently exposes interval-based scheduling
|
|
||||||
(``every N hours``) backed by ``trigger_type='schedule'``. The
|
|
||||||
automation engine ALSO supports ``daily_time`` and ``weekly_time``
|
|
||||||
triggers via separate ``_setup_*_trigger`` methods inline on the engine
|
|
||||||
class. None of that logic is currently testable in isolation — the
|
|
||||||
engine's ``_finish_run`` reaches for ``datetime.now()``, threads it
|
|
||||||
through ``_next_weekly_occurrence``, and writes the result to the DB,
|
|
||||||
all on the same call.
|
|
||||||
|
|
||||||
This module lifts the "given a trigger config, what's the next run?"
|
|
||||||
question out of the engine into a pure function:
|
|
||||||
|
|
||||||
next_run_at(trigger_type, trigger_config, now_utc, default_tz)
|
|
||||||
-> Optional[datetime]
|
|
||||||
|
|
||||||
That means:
|
|
||||||
- ``now_utc`` is INJECTED, not pulled from the system clock. Tests
|
|
||||||
freeze time without monkeypatching ``datetime.now``.
|
|
||||||
- ``default_tz`` is INJECTED. Daily / weekly / monthly schedules are
|
|
||||||
inherently in the USER'S timezone (cron "every Monday at 9am" is
|
|
||||||
not UTC), and the historic engine implicitly used the server's
|
|
||||||
local tz via naive ``datetime.now()``. That broke for users on a
|
|
||||||
different tz than their server. The pure function takes the tz
|
|
||||||
explicitly so the caller controls it.
|
|
||||||
- Returns an aware UTC ``datetime`` ready to serialise to the DB's
|
|
||||||
``next_run`` string column, or ``None`` for unrecognised /
|
|
||||||
event-based triggers (engine should not store a next_run for those).
|
|
||||||
|
|
||||||
PR 1 of the schedule-types feature ships ONLY this module + tests.
|
|
||||||
The engine continues to compute next_run via its existing inline
|
|
||||||
helpers; PR 2 collapses those into a single ``next_run_at`` call.
|
|
||||||
Net behavior is identical until the engine is wired through — this
|
|
||||||
PR is pure plumbing.
|
|
||||||
|
|
||||||
Schedule types supported here:
|
|
||||||
|
|
||||||
- ``schedule`` (interval): ``{interval: N, unit: 'minutes'|'hours'|'days'}``
|
|
||||||
— adds the interval to ``now_utc``; no tz needed.
|
|
||||||
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — runs every day at
|
|
||||||
the given local time in the given timezone. ``tz`` falls back to
|
|
||||||
``default_tz`` when absent.
|
|
||||||
- ``weekly_time``: ``{time: 'HH:MM', days: ['mon','wed',...], tz: '<IANA>'}``
|
|
||||||
— runs on the matching weekday(s) at the given local time. Empty
|
|
||||||
``days`` list means "every day" (matches the engine's existing
|
|
||||||
fallback in ``_next_weekly_occurrence``).
|
|
||||||
- ``monthly_time``: ``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}``
|
|
||||||
— runs on the given day each month. Days that don't exist in a
|
|
||||||
given month (Feb 30, Apr 31) clamp to the LAST valid day of that
|
|
||||||
month rather than skipping the run entirely; missing a whole
|
|
||||||
month silently because the schedule was over-eager is worse than
|
|
||||||
running a day early.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("automation.schedule")
|
|
||||||
|
|
||||||
|
|
||||||
# Unknown-tz names already warned about in this process — avoids
|
|
||||||
# spamming the log on every poll cycle for the same misconfigured row.
|
|
||||||
_UNKNOWN_TZ_WARNED: set = set()
|
|
||||||
|
|
||||||
|
|
||||||
# Weekday abbreviation → ``datetime.weekday()`` index (Mon=0..Sun=6).
|
|
||||||
# Mirrors the engine's existing ``_next_weekly_occurrence`` mapping so
|
|
||||||
# schedules created against either implementation accept the same
|
|
||||||
# ``days`` strings.
|
|
||||||
_WEEKDAY_MAP = {
|
|
||||||
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Interval multipliers — kept aligned with the engine's existing
|
|
||||||
# ``_calc_delay_seconds`` in ``core/automation_engine.py``. Adding
|
|
||||||
# entries here without also updating the engine would silently drift:
|
|
||||||
# this function would honour the new unit while the live engine path
|
|
||||||
# defaults it to hours. Keep the maps in sync until PR 2 collapses the
|
|
||||||
# engine through this function.
|
|
||||||
_INTERVAL_MULTIPLIERS = {
|
|
||||||
'minutes': 60,
|
|
||||||
'hours': 60 * 60,
|
|
||||||
'days': 60 * 60 * 24,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def next_run_at(
|
|
||||||
trigger_type: str,
|
|
||||||
trigger_config: Dict[str, Any],
|
|
||||||
now_utc: datetime,
|
|
||||||
default_tz: str = 'UTC',
|
|
||||||
) -> Optional[datetime]:
|
|
||||||
"""Compute the next-run timestamp (UTC, aware) for a scheduled
|
|
||||||
trigger. Returns ``None`` for unrecognised types or event-based
|
|
||||||
triggers — callers should not write a next_run for those.
|
|
||||||
|
|
||||||
See module docstring for supported trigger types + config shapes.
|
|
||||||
"""
|
|
||||||
if not isinstance(trigger_config, dict):
|
|
||||||
trigger_config = {}
|
|
||||||
|
|
||||||
if trigger_type == 'schedule':
|
|
||||||
return _next_interval(trigger_config, now_utc)
|
|
||||||
if trigger_type == 'daily_time':
|
|
||||||
return _next_daily(trigger_config, now_utc, default_tz)
|
|
||||||
if trigger_type == 'weekly_time':
|
|
||||||
return _next_weekly(trigger_config, now_utc, default_tz)
|
|
||||||
if trigger_type == 'monthly_time':
|
|
||||||
return _next_monthly(trigger_config, now_utc, default_tz)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Interval
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _next_interval(config: Dict[str, Any], now_utc: datetime) -> datetime:
|
|
||||||
"""``{interval: N, unit: 'hours'}`` → ``now_utc + N hours``.
|
|
||||||
|
|
||||||
Mirrors the engine's existing ``_calc_delay_seconds``. Unit defaults
|
|
||||||
to ``hours`` for backward compat with legacy DB rows that pre-date
|
|
||||||
the unit field being mandatory; interval defaults to 1 so a fully
|
|
||||||
empty config doesn't divide-by-zero or schedule for the past."""
|
|
||||||
try:
|
|
||||||
interval = max(int(config.get('interval', 1)), 1)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
interval = 1
|
|
||||||
unit = config.get('unit') or 'hours'
|
|
||||||
seconds = interval * _INTERVAL_MULTIPLIERS.get(unit, _INTERVAL_MULTIPLIERS['hours'])
|
|
||||||
return _ensure_utc(now_utc) + timedelta(seconds=seconds)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Daily
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _next_daily(
|
|
||||||
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
||||||
) -> datetime:
|
|
||||||
"""``{time: 'HH:MM', tz: '<IANA>'}`` → next occurrence of that
|
|
||||||
wall-clock time in the user's timezone, expressed as aware UTC.
|
|
||||||
|
|
||||||
DST-aware via ``zoneinfo``: when the local time falls during a
|
|
||||||
spring-forward gap, the ``replace`` lands on a non-existent
|
|
||||||
instant; ``zoneinfo`` resolves that to the gap's later side
|
|
||||||
(e.g. 02:30 on the DST-forward day becomes 03:30 local). Tests
|
|
||||||
pin both spring-forward and fall-back behaviour."""
|
|
||||||
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
||||||
hour, minute = _parse_hhmm(config.get('time'))
|
|
||||||
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
||||||
target_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
||||||
if target_local <= now_local:
|
|
||||||
target_local = target_local + timedelta(days=1)
|
|
||||||
return target_local.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Weekly
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _next_weekly(
|
|
||||||
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
||||||
) -> datetime:
|
|
||||||
"""``{time: 'HH:MM', days: ['mon',...], tz: '<IANA>'}`` → next
|
|
||||||
occurrence of that wall-clock time on any of the listed weekdays
|
|
||||||
in the user's timezone.
|
|
||||||
|
|
||||||
Empty ``days`` list ≡ every day, matching the engine's existing
|
|
||||||
fallback. Unrecognised day abbreviations are silently dropped
|
|
||||||
(an empty result-set then triggers the every-day fallback)."""
|
|
||||||
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
||||||
hour, minute = _parse_hhmm(config.get('time'))
|
|
||||||
days = _parse_weekdays(config.get('days'))
|
|
||||||
|
|
||||||
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
||||||
# Scan today + next 7 days; the matching day with a future
|
|
||||||
# local time wins. 8-day scan is enough to handle the case where
|
|
||||||
# today already passed the time AND today is the only allowed
|
|
||||||
# weekday (next occurrence is exactly one week out).
|
|
||||||
for offset in range(8):
|
|
||||||
candidate = now_local + timedelta(days=offset)
|
|
||||||
if candidate.weekday() not in days:
|
|
||||||
continue
|
|
||||||
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
||||||
if target > now_local:
|
|
||||||
return target.astimezone(timezone.utc)
|
|
||||||
# Shouldn't reach: 8-day scan always finds a hit when ``days``
|
|
||||||
# is non-empty. Defensive fallback: next week, same weekday as today.
|
|
||||||
fallback = (now_local + timedelta(days=7)).replace(
|
|
||||||
hour=hour, minute=minute, second=0, microsecond=0,
|
|
||||||
)
|
|
||||||
return fallback.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Monthly
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _next_monthly(
|
|
||||||
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
||||||
) -> datetime:
|
|
||||||
"""``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}`` → next
|
|
||||||
occurrence in the user's timezone.
|
|
||||||
|
|
||||||
``day_of_month`` is clamped to ``[1, 31]``. When the target day
|
|
||||||
doesn't exist in a given month (Feb 30, Apr 31), the schedule
|
|
||||||
falls back to the LAST valid day of that month — running a day
|
|
||||||
or two early in short months is less surprising than skipping
|
|
||||||
a month entirely. This matches the convention every cron
|
|
||||||
implementation in the wild settled on."""
|
|
||||||
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
||||||
hour, minute = _parse_hhmm(config.get('time'))
|
|
||||||
raw_day = config.get('day_of_month', 1)
|
|
||||||
try:
|
|
||||||
target_day = max(1, min(31, int(raw_day)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
target_day = 1
|
|
||||||
|
|
||||||
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
||||||
# Try this month first; if the target day has already passed
|
|
||||||
# (or doesn't exist this month and the clamped day is in the
|
|
||||||
# past), advance to next month. Loop bounded to 12 iterations
|
|
||||||
# so a pathologically broken config can't infinite-loop us.
|
|
||||||
year, month = now_local.year, now_local.month
|
|
||||||
for _ in range(12):
|
|
||||||
day = min(target_day, _days_in_month(year, month))
|
|
||||||
target = now_local.replace(
|
|
||||||
year=year, month=month, day=day,
|
|
||||||
hour=hour, minute=minute, second=0, microsecond=0,
|
|
||||||
)
|
|
||||||
if target > now_local:
|
|
||||||
return target.astimezone(timezone.utc)
|
|
||||||
# Roll to next month.
|
|
||||||
if month == 12:
|
|
||||||
year, month = year + 1, 1
|
|
||||||
else:
|
|
||||||
month += 1
|
|
||||||
# Defensive — should be unreachable.
|
|
||||||
return (now_local + timedelta(days=30)).astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_utc(dt: datetime) -> datetime:
|
|
||||||
"""Coerce a possibly-naive datetime to aware UTC. Naive inputs
|
|
||||||
are assumed UTC (matches the convention the engine uses when
|
|
||||||
parsing the DB ``next_run`` column)."""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tz(name: Optional[str]):
|
|
||||||
"""Look up an IANA tz by name. Falls back to UTC when the name is
|
|
||||||
unknown — ``ZoneInfoNotFoundError`` is the symptom of either a
|
|
||||||
typo in the tz string or ``tzdata`` missing on the host. Logged
|
|
||||||
once per unknown name so the user can see WHY their schedule
|
|
||||||
isn't running in the timezone they configured."""
|
|
||||||
if not name:
|
|
||||||
return timezone.utc
|
|
||||||
try:
|
|
||||||
return ZoneInfo(name)
|
|
||||||
except ZoneInfoNotFoundError:
|
|
||||||
if name not in _UNKNOWN_TZ_WARNED:
|
|
||||||
_UNKNOWN_TZ_WARNED.add(name)
|
|
||||||
logger.warning(
|
|
||||||
"Unknown timezone %r — schedule will run against UTC. "
|
|
||||||
"Check the spelling (IANA format like 'America/Los_Angeles') "
|
|
||||||
"or install the `tzdata` package on minimal hosts.",
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
return timezone.utc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_hhmm(time_str: Optional[str]) -> tuple:
|
|
||||||
"""Parse ``HH:MM`` → ``(hour, minute)``. Defaults to 00:00 on
|
|
||||||
garbage input — same defensive shape as the engine's existing
|
|
||||||
daily/weekly time parsing."""
|
|
||||||
if not isinstance(time_str, str):
|
|
||||||
return 0, 0
|
|
||||||
try:
|
|
||||||
h, m = time_str.split(':', 1)
|
|
||||||
return max(0, min(23, int(h))), max(0, min(59, int(m)))
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
return 0, 0
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_weekdays(days) -> set:
|
|
||||||
"""``['mon', 'wed']`` → ``{0, 2}``. Empty / missing / all-invalid
|
|
||||||
list returns ``set(range(7))`` ("every day"), matching the
|
|
||||||
engine's existing ``_next_weekly_occurrence`` fallback."""
|
|
||||||
if not isinstance(days, (list, tuple)):
|
|
||||||
return set(range(7))
|
|
||||||
parsed = {_WEEKDAY_MAP[d.lower()] for d in days
|
|
||||||
if isinstance(d, str) and d.lower() in _WEEKDAY_MAP}
|
|
||||||
return parsed or set(range(7))
|
|
||||||
|
|
||||||
|
|
||||||
def _days_in_month(year: int, month: int) -> int:
|
|
||||||
"""Last calendar day of ``year-month``. Stdlib-only — no calendar
|
|
||||||
module import needed; cycle through the 12 months."""
|
|
||||||
if month == 12:
|
|
||||||
next_first = datetime(year + 1, 1, 1)
|
|
||||||
else:
|
|
||||||
next_first = datetime(year, month + 1, 1)
|
|
||||||
last_day = next_first - timedelta(days=1)
|
|
||||||
return last_day.day
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""Automation signal helpers — name collection for autocomplete.
|
|
||||||
|
|
||||||
Signal cycle detection itself lives in core/automation_engine.py
|
|
||||||
(`detect_signal_cycles`); this module just enumerates known signal
|
|
||||||
names from the saved automation set so the builder UI can autocomplete.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def collect_known_signals(database) -> list[str]:
|
|
||||||
"""Return sorted, deduped signal names referenced by any saved automation.
|
|
||||||
|
|
||||||
Walks every automation and pulls signal names from both the
|
|
||||||
`signal_received` trigger config and any `fire_signal` then-actions.
|
|
||||||
Errors at every layer are swallowed — the autocomplete is best-effort.
|
|
||||||
"""
|
|
||||||
signals: set[str] = set()
|
|
||||||
try:
|
|
||||||
for auto in database.get_automations():
|
|
||||||
if auto.get('trigger_type') == 'signal_received':
|
|
||||||
try:
|
|
||||||
tc = json.loads(auto.get('trigger_config') or '{}')
|
|
||||||
sig = tc.get('signal_name', '').strip()
|
|
||||||
if sig:
|
|
||||||
signals.add(sig)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
ta = json.loads(auto.get('then_actions') or '[]')
|
|
||||||
for item in ta:
|
|
||||||
if item.get('type') == 'fire_signal':
|
|
||||||
sig = item.get('config', {}).get('signal_name', '').strip()
|
|
||||||
if sig:
|
|
||||||
signals.add(sig)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("collect known signals failed: %s", e)
|
|
||||||
return sorted(signals)
|
|
||||||
|
|
@ -18,14 +18,10 @@ import time
|
||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
from core.automation.schedule import next_run_at
|
|
||||||
|
|
||||||
logger = get_logger("automation_engine")
|
logger = get_logger("automation_engine")
|
||||||
|
|
||||||
|
|
||||||
def _utcnow():
|
def _utcnow():
|
||||||
"""Return current UTC time as timezone-aware datetime."""
|
"""Return current UTC time as timezone-aware datetime."""
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
@ -38,42 +34,6 @@ def _utc_after(seconds):
|
||||||
"""Return UTC time N seconds from now as naive string for DB storage."""
|
"""Return UTC time N seconds from now as naive string for DB storage."""
|
||||||
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
|
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
def _dt_to_db_str(dt: datetime) -> str:
|
|
||||||
"""Convert an aware-UTC datetime to the naive-UTC string the DB
|
|
||||||
``next_run`` column stores. Centralised so a tz mistake here
|
|
||||||
surfaces in one place, not scattered through every caller of
|
|
||||||
``next_run_at``."""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
return dt.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_system_default_tz() -> str:
|
|
||||||
"""Return the IANA tz name the engine uses when a schedule's
|
|
||||||
trigger config doesn't carry an explicit ``tz`` field.
|
|
||||||
|
|
||||||
Existing daily / weekly rows pre-date the ``tz`` field — the
|
|
||||||
historic engine computed delays from naive ``datetime.now()``,
|
|
||||||
which is implicitly the server's local timezone. Falling back to
|
|
||||||
that same tz here preserves "every Monday at 09:00" running at
|
|
||||||
09:00 server local for rows that already exist in the DB.
|
|
||||||
Without ``tzlocal`` installed (or a system without a discoverable
|
|
||||||
tz), falls back to UTC."""
|
|
||||||
try:
|
|
||||||
import tzlocal
|
|
||||||
return tzlocal.get_localzone_name() or 'UTC'
|
|
||||||
except Exception:
|
|
||||||
return 'UTC'
|
|
||||||
|
|
||||||
|
|
||||||
# Server-local tz cached at import time. Re-reading per-call is
|
|
||||||
# pointless: the host's timezone doesn't change while the process is
|
|
||||||
# running. Tests that need a different default tz inject it through
|
|
||||||
# the engine's ``_default_tz`` attribute or via the
|
|
||||||
# ``automation.default_timezone`` config key.
|
|
||||||
_SYSTEM_DEFAULT_TZ = _resolve_system_default_tz()
|
|
||||||
|
|
||||||
SYSTEM_AUTOMATIONS = [
|
SYSTEM_AUTOMATIONS = [
|
||||||
{
|
{
|
||||||
'name': 'Auto-Process Wishlist',
|
'name': 'Auto-Process Wishlist',
|
||||||
|
|
@ -174,25 +134,11 @@ class AutomationEngine:
|
||||||
self._max_chain_depth = 5
|
self._max_chain_depth = 5
|
||||||
self._signal_cooldown_seconds = 10
|
self._signal_cooldown_seconds = 10
|
||||||
|
|
||||||
# Default tz used when a schedule's ``trigger_config`` doesn't
|
|
||||||
# carry an explicit ``tz`` field — preserves historic behaviour
|
|
||||||
# for daily / weekly rows created before the field existed
|
|
||||||
# (engine used naive ``datetime.now()`` = server local). Reads
|
|
||||||
# from the ``automation.default_timezone`` config key first to
|
|
||||||
# let users override without touching env vars; falls back to
|
|
||||||
# the system-detected local tz.
|
|
||||||
try:
|
|
||||||
from config.settings import config_manager
|
|
||||||
self._default_tz = (config_manager.get('automation.default_timezone', '') or _SYSTEM_DEFAULT_TZ)
|
|
||||||
except Exception:
|
|
||||||
self._default_tz = _SYSTEM_DEFAULT_TZ
|
|
||||||
|
|
||||||
# Trigger registry: type → setup function (schedule only — events use emit())
|
# Trigger registry: type → setup function (schedule only — events use emit())
|
||||||
self._trigger_handlers = {
|
self._trigger_handlers = {
|
||||||
'schedule': self._setup_schedule_trigger,
|
'schedule': self._setup_schedule_trigger,
|
||||||
'daily_time': self._setup_daily_time_trigger,
|
'daily_time': self._setup_daily_time_trigger,
|
||||||
'weekly_time': self._setup_weekly_time_trigger,
|
'weekly_time': self._setup_weekly_time_trigger,
|
||||||
'monthly_time': self._setup_monthly_time_trigger,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Action Handler Registration ---
|
# --- Action Handler Registration ---
|
||||||
|
|
@ -324,7 +270,7 @@ class AutomationEngine:
|
||||||
"""Cancel all timers on shutdown."""
|
"""Cancel all timers on shutdown."""
|
||||||
self._running = False
|
self._running = False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for _aid, timer in self._timers.items():
|
for aid, timer in self._timers.items():
|
||||||
timer.cancel()
|
timer.cancel()
|
||||||
count = len(self._timers)
|
count = len(self._timers)
|
||||||
self._timers.clear()
|
self._timers.clear()
|
||||||
|
|
@ -509,10 +455,8 @@ class AutomationEngine:
|
||||||
if delay_minutes and delay_minutes > 0:
|
if delay_minutes and delay_minutes > 0:
|
||||||
# Initialize progress BEFORE delay so card glows during wait
|
# Initialize progress BEFORE delay so card glows during wait
|
||||||
if self._progress_init_fn:
|
if self._progress_init_fn:
|
||||||
try:
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("event progress init (delay): %s", e)
|
|
||||||
_delay_already_inited = True
|
_delay_already_inited = True
|
||||||
|
|
||||||
delay_seconds = int(delay_minutes) * 60
|
delay_seconds = int(delay_minutes) * 60
|
||||||
|
|
@ -542,17 +486,13 @@ class AutomationEngine:
|
||||||
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
||||||
# If progress was initialized during delay, finalize it
|
# If progress was initialized during delay, finalize it
|
||||||
if _delay_already_inited and self._progress_finish_fn:
|
if _delay_already_inited and self._progress_finish_fn:
|
||||||
try:
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
self._progress_finish_fn(automation_id, result)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("event progress finish (skipped): %s", e)
|
|
||||||
else:
|
else:
|
||||||
# Initialize progress tracking (skip if already done during delay)
|
# Initialize progress tracking (skip if already done during delay)
|
||||||
if not _delay_already_inited and self._progress_init_fn:
|
if not _delay_already_inited and self._progress_init_fn:
|
||||||
try:
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("event progress init: %s", e)
|
|
||||||
try:
|
try:
|
||||||
result = handler_info['handler'](action_config) or {}
|
result = handler_info['handler'](action_config) or {}
|
||||||
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
||||||
|
|
@ -561,10 +501,8 @@ class AutomationEngine:
|
||||||
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
||||||
# Finalize progress tracking
|
# Finalize progress tracking
|
||||||
if self._progress_finish_fn:
|
if self._progress_finish_fn:
|
||||||
try:
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
self._progress_finish_fn(automation_id, result)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("event progress finish: %s", e)
|
|
||||||
|
|
||||||
# Merge event data into result for then-action variables
|
# Merge event data into result for then-action variables
|
||||||
merged = {**event_data, **result}
|
merged = {**event_data, **result}
|
||||||
|
|
@ -577,24 +515,14 @@ class AutomationEngine:
|
||||||
|
|
||||||
# Update run stats (no reschedule — event triggers don't use timers)
|
# Update run stats (no reschedule — event triggers don't use timers)
|
||||||
last_result = json.dumps({k: v for k, v in merged.items() if not k.startswith('_')})
|
last_result = json.dumps({k: v for k, v in merged.items() if not k.startswith('_')})
|
||||||
# Surface every failure mode to last_error: handlers in this codebase use
|
error = result.get('error') if result.get('status') == 'error' else None
|
||||||
# 'error', 'reason', or 'message' interchangeably when returning gracefully.
|
|
||||||
if result.get('status') == 'error':
|
|
||||||
error = (
|
|
||||||
result.get('error')
|
|
||||||
or result.get('reason')
|
|
||||||
or result.get('message')
|
|
||||||
or 'Handler reported failure'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error = None
|
|
||||||
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
|
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
|
||||||
|
|
||||||
if self._history_record_fn:
|
if self._history_record_fn:
|
||||||
try:
|
try:
|
||||||
self._history_record_fn(automation_id, result)
|
self._history_record_fn(automation_id, result)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("history record failed: %s", e)
|
pass
|
||||||
|
|
||||||
# --- Schedule Execution (timer-based) ---
|
# --- Schedule Execution (timer-based) ---
|
||||||
|
|
||||||
|
|
@ -635,11 +563,6 @@ class AutomationEngine:
|
||||||
action_config['_automation_name'] = auto.get('name', '')
|
action_config['_automation_name'] = auto.get('name', '')
|
||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
action_config['_profile_id'] = profile_id
|
action_config['_profile_id'] = profile_id
|
||||||
# The profile this run acts AS: an explicit trigger profile, else the
|
|
||||||
# automation's owner, else admin. System + admin automations are
|
|
||||||
# profile 1, so this is a no-op for them — only non-admin-owned
|
|
||||||
# automations gain their correct identity in the background.
|
|
||||||
_effective_profile_id = profile_id if profile_id is not None else (auto.get('profile_id') or 1)
|
|
||||||
|
|
||||||
# Action delay (skipped for manual run_now)
|
# Action delay (skipped for manual run_now)
|
||||||
delay_minutes = action_config.get('delay', 0)
|
delay_minutes = action_config.get('delay', 0)
|
||||||
|
|
@ -647,10 +570,8 @@ class AutomationEngine:
|
||||||
if not skip_delay and delay_minutes and delay_minutes > 0:
|
if not skip_delay and delay_minutes and delay_minutes > 0:
|
||||||
# Initialize progress BEFORE delay so card glows during wait
|
# Initialize progress BEFORE delay so card glows during wait
|
||||||
if self._progress_init_fn:
|
if self._progress_init_fn:
|
||||||
try:
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("scheduled progress init (delay): %s", e)
|
|
||||||
_delay_already_inited = True
|
_delay_already_inited = True
|
||||||
|
|
||||||
delay_seconds = int(delay_minutes) * 60
|
delay_seconds = int(delay_minutes) * 60
|
||||||
|
|
@ -672,55 +593,31 @@ class AutomationEngine:
|
||||||
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
||||||
# If progress was initialized during delay, finalize it
|
# If progress was initialized during delay, finalize it
|
||||||
if _delay_already_inited and self._progress_finish_fn:
|
if _delay_already_inited and self._progress_finish_fn:
|
||||||
try:
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
self._progress_finish_fn(automation_id, result)
|
except Exception: pass
|
||||||
except Exception as e:
|
self._finish_run(auto, automation_id, result, error=None)
|
||||||
logger.debug("scheduled progress finish (skipped): %s", e)
|
|
||||||
self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize progress tracking (skip if already done during delay)
|
# Initialize progress tracking (skip if already done during delay)
|
||||||
if not _delay_already_inited and self._progress_init_fn:
|
if not _delay_already_inited and self._progress_init_fn:
|
||||||
try:
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("scheduled progress init: %s", e)
|
|
||||||
|
|
||||||
# Execute the action under the owner's profile so get_current_profile_id()
|
# Execute the action
|
||||||
# (and the per-profile clients it resolves) act as the automation's owner
|
|
||||||
# in the background, not admin. Reset in finally so a pooled thread can't
|
|
||||||
# leak the override to the next job.
|
|
||||||
error = None
|
error = None
|
||||||
result = {}
|
result = {}
|
||||||
from core.profile_context import set_background_profile, reset_background_profile
|
|
||||||
_bg_token = set_background_profile(_effective_profile_id)
|
|
||||||
try:
|
try:
|
||||||
result = handler_info['handler'](action_config) or {}
|
result = handler_info['handler'](action_config) or {}
|
||||||
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
|
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
|
||||||
# Handlers may signal failure by RETURNING {'status': 'error', ...} instead of
|
|
||||||
# raising. Surface that to the DB so `last_error` reflects every failure mode,
|
|
||||||
# not just uncaught exceptions. Falls back through ('error', 'reason', 'message')
|
|
||||||
# because handlers in this codebase aren't consistent about which key they set.
|
|
||||||
if result.get('status') == 'error':
|
|
||||||
error = (
|
|
||||||
result.get('error')
|
|
||||||
or result.get('reason')
|
|
||||||
or result.get('message')
|
|
||||||
or 'Handler reported failure'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
result = {'status': 'error', 'error': error}
|
result = {'status': 'error', 'error': error}
|
||||||
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
||||||
finally:
|
|
||||||
reset_background_profile(_bg_token)
|
|
||||||
|
|
||||||
# Finalize progress tracking
|
# Finalize progress tracking
|
||||||
if self._progress_finish_fn:
|
if self._progress_finish_fn:
|
||||||
try:
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
self._progress_finish_fn(automation_id, result)
|
except Exception: pass
|
||||||
except Exception as e:
|
|
||||||
logger.debug("scheduled progress finish: %s", e)
|
|
||||||
|
|
||||||
# Execute then-actions (notifications + fire_signal)
|
# Execute then-actions (notifications + fire_signal)
|
||||||
try:
|
try:
|
||||||
|
|
@ -730,7 +627,7 @@ class AutomationEngine:
|
||||||
|
|
||||||
self._finish_run(auto, automation_id, result, error)
|
self._finish_run(auto, automation_id, result, error)
|
||||||
|
|
||||||
def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None):
|
def _finish_run(self, auto, automation_id, result, error):
|
||||||
"""Update DB with run stats and reschedule."""
|
"""Update DB with run stats and reschedule."""
|
||||||
next_run_str = None
|
next_run_str = None
|
||||||
trigger_type = auto.get('trigger_type', '')
|
trigger_type = auto.get('trigger_type', '')
|
||||||
|
|
@ -738,25 +635,25 @@ class AutomationEngine:
|
||||||
if trigger_type in self._trigger_handlers:
|
if trigger_type in self._trigger_handlers:
|
||||||
try:
|
try:
|
||||||
trigger_config = json.loads(auto.get('trigger_config') or '{}')
|
trigger_config = json.loads(auto.get('trigger_config') or '{}')
|
||||||
if retry_delay_seconds:
|
if trigger_type == 'daily_time':
|
||||||
next_run_str = _utc_after(retry_delay_seconds)
|
# Next run is tomorrow at the configured time (compute delay from local time, store as UTC)
|
||||||
|
time_str = trigger_config.get('time', '00:00')
|
||||||
|
hour, minute = map(int, time_str.split(':'))
|
||||||
|
now_local = datetime.now()
|
||||||
|
target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
|
||||||
|
next_run_str = _utc_after((target - now_local).total_seconds())
|
||||||
|
elif trigger_type == 'weekly_time':
|
||||||
|
time_str = trigger_config.get('time', '00:00')
|
||||||
|
hour, minute = map(int, time_str.split(':'))
|
||||||
|
now_local = datetime.now()
|
||||||
|
target = self._next_weekly_occurrence(hour, minute, trigger_config.get('days', []))
|
||||||
|
next_run_str = _utc_after((target - now_local).total_seconds())
|
||||||
else:
|
else:
|
||||||
# Single integration point with ``next_run_at``. The
|
delay = self._calc_delay_seconds(trigger_config)
|
||||||
# helper handles every trigger type the engine
|
if delay:
|
||||||
# supports (interval / daily / weekly / monthly) and
|
next_run_str = _utc_after(delay)
|
||||||
# returns aware-UTC; ``_dt_to_db_str`` normalises to
|
except Exception:
|
||||||
# the naive-UTC string the DB column stores. Tests
|
pass
|
||||||
# injecting a different ``now_utc`` patch this same
|
|
||||||
# path — no scattered ``datetime.now()`` calls left.
|
|
||||||
next_run_dt = next_run_at(
|
|
||||||
trigger_type, trigger_config,
|
|
||||||
now_utc=_utcnow(),
|
|
||||||
default_tz=self._default_tz,
|
|
||||||
)
|
|
||||||
if next_run_dt is not None:
|
|
||||||
next_run_str = _dt_to_db_str(next_run_dt)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("next run calc failed: %s", e)
|
|
||||||
|
|
||||||
last_result = json.dumps(result) if result else None
|
last_result = json.dumps(result) if result else None
|
||||||
self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result)
|
self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result)
|
||||||
|
|
@ -764,8 +661,8 @@ class AutomationEngine:
|
||||||
if self._history_record_fn:
|
if self._history_record_fn:
|
||||||
try:
|
try:
|
||||||
self._history_record_fn(automation_id, result)
|
self._history_record_fn(automation_id, result)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("history record failed: %s", e)
|
pass
|
||||||
|
|
||||||
if self._running:
|
if self._running:
|
||||||
self.schedule_automation(automation_id)
|
self.schedule_automation(automation_id)
|
||||||
|
|
@ -828,72 +725,22 @@ class AutomationEngine:
|
||||||
logger.debug(f"Scheduled automation {automation_id} in {delay:.0f}s")
|
logger.debug(f"Scheduled automation {automation_id} in {delay:.0f}s")
|
||||||
|
|
||||||
def _setup_daily_time_trigger(self, automation_id, config):
|
def _setup_daily_time_trigger(self, automation_id, config):
|
||||||
"""Config: ``{"time": "03:00", "tz": "<IANA>"}`` — runs daily
|
"""Config: {"time": "03:00"} — runs daily at the specified local time."""
|
||||||
at the specified local time. Tz defaults to ``self._default_tz``
|
time_str = config.get('time', '00:00')
|
||||||
when absent."""
|
try:
|
||||||
self._setup_timed_trigger(automation_id, 'daily_time', config,
|
hour, minute = map(int, time_str.split(':'))
|
||||||
label=f"Daily at {config.get('time', '00:00')}")
|
except (ValueError, AttributeError):
|
||||||
|
hour, minute = 0, 0
|
||||||
|
|
||||||
def _setup_weekly_time_trigger(self, automation_id, config):
|
now_local = datetime.now()
|
||||||
"""Config: ``{"time": "03:00", "days": ["mon","wed","fri"], "tz": "<IANA>"}``."""
|
target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
day_names = ', '.join(config.get('days') or []) or 'every day'
|
if target <= now_local:
|
||||||
self._setup_timed_trigger(automation_id, 'weekly_time', config,
|
target += timedelta(days=1)
|
||||||
label=f"Weekly {config.get('time', '00:00')} on {day_names}")
|
|
||||||
|
|
||||||
def _setup_monthly_time_trigger(self, automation_id, config):
|
delay = (target - now_local).total_seconds()
|
||||||
"""Config: ``{"time": "09:00", "day_of_month": 15, "tz": "<IANA>"}``.
|
|
||||||
|
|
||||||
Day clamped to [1, 31]; months too short for the target day
|
next_run_str = _utc_after(delay)
|
||||||
clamp to the last valid day (Feb 31 → Feb 28 / Feb 29 leap
|
self.db.update_automation(automation_id, next_run=next_run_str)
|
||||||
year) per standard cron convention — see
|
|
||||||
``core.automation.schedule._next_monthly`` for the rule."""
|
|
||||||
day = config.get('day_of_month', 1)
|
|
||||||
self._setup_timed_trigger(automation_id, 'monthly_time', config,
|
|
||||||
label=f"Monthly {config.get('time', '00:00')} on day {day}")
|
|
||||||
|
|
||||||
def _setup_timed_trigger(self, automation_id, trigger_type, config, *, label):
|
|
||||||
"""Shared setup for daily / weekly / monthly time triggers.
|
|
||||||
|
|
||||||
All three flow through the same skeleton: compute next-run
|
|
||||||
via ``next_run_at``, persist to DB, arm a ``threading.Timer``
|
|
||||||
that fires the automation when the delay elapses. Lifting
|
|
||||||
these out of three near-identical methods means there's one
|
|
||||||
place to fix when (e.g.) timer rearm semantics need a tweak.
|
|
||||||
|
|
||||||
Honours an existing future ``next_run`` row in the DB —
|
|
||||||
prevents losing a hand-edited next_run when the engine
|
|
||||||
reschedules at startup. Same guard as the interval path."""
|
|
||||||
target_dt = next_run_at(
|
|
||||||
trigger_type, config or {},
|
|
||||||
now_utc=_utcnow(),
|
|
||||||
default_tz=self._default_tz,
|
|
||||||
)
|
|
||||||
if target_dt is None:
|
|
||||||
logger.warning(
|
|
||||||
f"Skip scheduling automation {automation_id}: next_run_at returned "
|
|
||||||
f"None for {trigger_type!r}",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
delay = max(0.0, (target_dt - _utcnow()).total_seconds())
|
|
||||||
|
|
||||||
# If the DB already carries a future next_run, prefer it —
|
|
||||||
# matches the interval-path behaviour and lets manual edits
|
|
||||||
# or pending retries survive a process restart.
|
|
||||||
auto = self.db.get_automation(automation_id)
|
|
||||||
if auto and auto.get('next_run'):
|
|
||||||
try:
|
|
||||||
existing = datetime.strptime(
|
|
||||||
auto['next_run'], '%Y-%m-%d %H:%M:%S',
|
|
||||||
).replace(tzinfo=timezone.utc)
|
|
||||||
remaining = (existing - _utcnow()).total_seconds()
|
|
||||||
if remaining > 0:
|
|
||||||
delay = remaining
|
|
||||||
target_dt = existing
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.db.update_automation(automation_id, next_run=_dt_to_db_str(target_dt))
|
|
||||||
|
|
||||||
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
||||||
timer.daemon = True
|
timer.daemon = True
|
||||||
|
|
@ -902,7 +749,48 @@ class AutomationEngine:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._timers[automation_id] = timer
|
self._timers[automation_id] = timer
|
||||||
|
|
||||||
logger.debug(f"{label} automation {automation_id} scheduled (in {delay:.0f}s)")
|
logger.debug(f"Daily automation {automation_id} scheduled for {time_str} (in {delay:.0f}s)")
|
||||||
|
|
||||||
|
def _setup_weekly_time_trigger(self, automation_id, config):
|
||||||
|
"""Config: {"time": "03:00", "days": ["mon", "wed", "fri"]}"""
|
||||||
|
time_str = config.get('time', '00:00')
|
||||||
|
try:
|
||||||
|
hour, minute = map(int, time_str.split(':'))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
hour, minute = 0, 0
|
||||||
|
|
||||||
|
target = self._next_weekly_occurrence(hour, minute, config.get('days', []))
|
||||||
|
delay = (target - datetime.now()).total_seconds()
|
||||||
|
|
||||||
|
next_run_str = _utc_after(delay)
|
||||||
|
self.db.update_automation(automation_id, next_run=next_run_str)
|
||||||
|
|
||||||
|
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._timers[automation_id] = timer
|
||||||
|
|
||||||
|
day_names = ', '.join(config.get('days', [])) or 'every day'
|
||||||
|
logger.debug(f"Weekly automation {automation_id} scheduled for {time_str} on {day_names} (in {delay:.0f}s)")
|
||||||
|
|
||||||
|
def _next_weekly_occurrence(self, hour, minute, days):
|
||||||
|
"""Find the next datetime matching one of the given weekday abbreviations."""
|
||||||
|
day_map = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
|
||||||
|
allowed = {day_map[d] for d in days if d in day_map}
|
||||||
|
if not allowed:
|
||||||
|
allowed = set(range(7)) # no days selected = every day
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
for offset in range(8): # check today + next 7 days
|
||||||
|
candidate = now + timedelta(days=offset)
|
||||||
|
if candidate.weekday() in allowed:
|
||||||
|
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
if target > now:
|
||||||
|
return target
|
||||||
|
# Fallback: tomorrow (shouldn't happen with 8-day scan)
|
||||||
|
return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
|
||||||
|
|
||||||
# --- Then Actions (notifications + signals) ---
|
# --- Then Actions (notifications + signals) ---
|
||||||
|
|
||||||
|
|
@ -948,8 +836,6 @@ class AutomationEngine:
|
||||||
self._send_pushbullet_notification(c, variables)
|
self._send_pushbullet_notification(c, variables)
|
||||||
elif t == 'telegram':
|
elif t == 'telegram':
|
||||||
self._send_telegram_notification(c, variables)
|
self._send_telegram_notification(c, variables)
|
||||||
elif t == 'webhook':
|
|
||||||
self._send_webhook(c, variables)
|
|
||||||
elif t == 'fire_signal':
|
elif t == 'fire_signal':
|
||||||
sig = self._sanitize_signal_name(c.get('signal_name', ''))
|
sig = self._sanitize_signal_name(c.get('signal_name', ''))
|
||||||
if sig:
|
if sig:
|
||||||
|
|
@ -958,14 +844,6 @@ class AutomationEngine:
|
||||||
emit_data['signal_name'] = sig
|
emit_data['signal_name'] = sig
|
||||||
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
|
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
|
||||||
self.emit('signal:' + sig, emit_data)
|
self.emit('signal:' + sig, emit_data)
|
||||||
elif t == 'run_script':
|
|
||||||
handler = self._action_handlers.get('run_script')
|
|
||||||
if handler:
|
|
||||||
script_config = dict(c)
|
|
||||||
# Pass action result as environment context
|
|
||||||
script_config['_automation_name'] = automation.get('name', '')
|
|
||||||
script_config['_event_data'] = {'type': 'then_action', 'result': {k: str(v) for k, v in action_result.items() if not k.startswith('_')}}
|
|
||||||
handler['handler'](script_config)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")
|
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")
|
||||||
|
|
||||||
|
|
@ -1072,8 +950,6 @@ class AutomationEngine:
|
||||||
"""Send message via Telegram Bot API."""
|
"""Send message via Telegram Bot API."""
|
||||||
bot_token = config.get('bot_token', '').strip()
|
bot_token = config.get('bot_token', '').strip()
|
||||||
chat_id = config.get('chat_id', '').strip()
|
chat_id = config.get('chat_id', '').strip()
|
||||||
thread_id = config.get('thread_id', '').strip()
|
|
||||||
|
|
||||||
if not bot_token or not chat_id:
|
if not bot_token or not chat_id:
|
||||||
raise ValueError("Bot token and chat ID are required for Telegram")
|
raise ValueError("Bot token and chat ID are required for Telegram")
|
||||||
|
|
||||||
|
|
@ -1082,51 +958,11 @@ class AutomationEngine:
|
||||||
for key, value in variables.items():
|
for key, value in variables.items():
|
||||||
message = message.replace('{' + key + '}', value)
|
message = message.replace('{' + key + '}', value)
|
||||||
|
|
||||||
payload = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"}
|
|
||||||
if thread_id:
|
|
||||||
try:
|
|
||||||
payload["message_thread_id"] = int(thread_id)
|
|
||||||
except ValueError:
|
|
||||||
pass # invalid — fall back to main chat
|
|
||||||
|
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f'https://api.telegram.org/bot{bot_token}/sendMessage',
|
f'https://api.telegram.org/bot{bot_token}/sendMessage',
|
||||||
json=payload,
|
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
data = resp.json() if resp.status_code == 200 else {}
|
data = resp.json() if resp.status_code == 200 else {}
|
||||||
if not data.get('ok'):
|
if not data.get('ok'):
|
||||||
raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}")
|
raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
def _send_webhook(self, config, variables):
|
|
||||||
"""Send a POST request to a user-configured webhook URL with JSON payload."""
|
|
||||||
url = config.get('url', '').strip()
|
|
||||||
if not url:
|
|
||||||
raise ValueError("No webhook URL configured")
|
|
||||||
|
|
||||||
# Build headers — always include Content-Type, plus optional custom headers
|
|
||||||
headers = {'Content-Type': 'application/json'}
|
|
||||||
custom_headers = config.get('headers', '').strip()
|
|
||||||
if custom_headers:
|
|
||||||
for line in custom_headers.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if ':' in line:
|
|
||||||
key, value = line.split(':', 1)
|
|
||||||
# Substitute variables in header values
|
|
||||||
for vk, vv in variables.items():
|
|
||||||
value = value.replace('{' + vk + '}', vv)
|
|
||||||
headers[key.strip()] = value.strip()
|
|
||||||
|
|
||||||
# Build JSON payload with all variables
|
|
||||||
payload = dict(variables)
|
|
||||||
|
|
||||||
# Add custom message if configured
|
|
||||||
message = config.get('message', '').strip()
|
|
||||||
if message:
|
|
||||||
for key, value in variables.items():
|
|
||||||
message = message.replace('{' + key + '}', value)
|
|
||||||
payload['message'] = message
|
|
||||||
|
|
||||||
resp = requests.post(url, json=payload, headers=headers, timeout=15)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise RuntimeError(f"Webhook returned {resp.status_code}: {resp.text[:200]}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""Artist / album / track blocklist (the "proper" blacklist).
|
|
||||||
|
|
||||||
Distinct from ``download_blacklist`` (which skips one bad source file from one
|
|
||||||
Soulseek peer — untouched here). This blocklist bans an ARTIST, ALBUM, or
|
|
||||||
TRACK from being acquired, keyed by metadata-source IDs (Spotify / iTunes /
|
|
||||||
Deezer / MusicBrainz) so a ban survives a source switch.
|
|
||||||
|
|
||||||
Phase 1 enforces at the single ``add_to_wishlist`` chokepoint: every
|
|
||||||
auto-acquisition path (watchlist, discography backfill, repair, manual
|
|
||||||
wishlist add) funnels through it, so one guard covers them all.
|
|
||||||
|
|
||||||
- ``matching`` — the pure decision core (no DB, no I/O): build an index from
|
|
||||||
blocklist rows, ask whether a candidate is blocked, with artist→album→track
|
|
||||||
cascade.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from core.blocklist.matching import (
|
|
||||||
ENTITY_ALBUM,
|
|
||||||
ENTITY_ARTIST,
|
|
||||||
ENTITY_TRACK,
|
|
||||||
ENTITY_TYPES,
|
|
||||||
SOURCE_ID_FIELDS,
|
|
||||||
BlocklistIndex,
|
|
||||||
build_index,
|
|
||||||
candidate_block_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ENTITY_ARTIST",
|
|
||||||
"ENTITY_ALBUM",
|
|
||||||
"ENTITY_TRACK",
|
|
||||||
"ENTITY_TYPES",
|
|
||||||
"SOURCE_ID_FIELDS",
|
|
||||||
"BlocklistIndex",
|
|
||||||
"build_index",
|
|
||||||
"candidate_block_reason",
|
|
||||||
]
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
"""Cross-source ID backfill for blocklist entries.
|
|
||||||
|
|
||||||
When a user blocks an item, the modal gives us the ID for ONE source (the one
|
|
||||||
they searched). For the ban to survive a source switch, we resolve the OTHER
|
|
||||||
sources' IDs too — matching the blocked artist/album/track by name on each
|
|
||||||
source and taking a confident hit.
|
|
||||||
|
|
||||||
The resolution is kept pure + injected so it tests without a network: callers
|
|
||||||
pass a ``resolvers`` map ``{source: fn(entity_type, name, parent_name) -> id |
|
|
||||||
None}``. ``core/blocklist/runtime.py`` wires the real metadata clients.
|
|
||||||
|
|
||||||
Honest about fragility (acknowledged in design): artist matching is reliable,
|
|
||||||
album/track cross-source matching is best-effort (editions, common titles), so
|
|
||||||
a resolver returning None just leaves that source unmatched — the artist
|
|
||||||
name-fallback in matching.py covers artist gaps; album/track gaps mean that
|
|
||||||
ban only applies on sources where an ID resolved.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
from core.blocklist.matching import SOURCE_ID_FIELDS
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_missing_ids(
|
|
||||||
entry: Dict[str, Any],
|
|
||||||
resolvers: Dict[str, Callable[..., Optional[str]]],
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Return ``{id_column: resolved_id}`` for the sources currently missing an
|
|
||||||
ID on ``entry``. Never raises — a resolver that errors is skipped."""
|
|
||||||
out: Dict[str, str] = {}
|
|
||||||
entity_type = entry.get("entity_type")
|
|
||||||
name = entry.get("name")
|
|
||||||
parent = entry.get("parent_name")
|
|
||||||
if not entity_type or not name:
|
|
||||||
return out
|
|
||||||
for source, col in SOURCE_ID_FIELDS.items():
|
|
||||||
if entry.get(col):
|
|
||||||
continue # already known
|
|
||||||
fn = resolvers.get(source)
|
|
||||||
if not fn:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rid = fn(entity_type, name, parent)
|
|
||||||
except Exception:
|
|
||||||
rid = None
|
|
||||||
if rid:
|
|
||||||
out[col] = str(rid)
|
|
||||||
return out
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"""Pure blocklist matching — no DB, no I/O, fully unit-testable.
|
|
||||||
|
|
||||||
The brain of the blocklist: given the stored blocklist rows and a candidate
|
|
||||||
track being considered for the wishlist, decide whether it's blocked.
|
|
||||||
|
|
||||||
Design decisions (per Boulder):
|
|
||||||
- **ID-keyed.** Each row carries the candidate's IDs in up to four metadata
|
|
||||||
sources. A candidate is matched against the SAME source it came in on
|
|
||||||
(the wishlist payload carries active-source IDs), so a Deezer-numeric id
|
|
||||||
can't collide with an iTunes-numeric id of a different entity.
|
|
||||||
- **Cascade.** Blocking an artist blocks their albums + tracks; blocking an
|
|
||||||
album blocks its tracks. The candidate carries its own artist/album/track
|
|
||||||
IDs, so the check walks track → album → artist and blocks on the first hit.
|
|
||||||
- **Name fallback for ARTISTS only.** A blocked artist also matches by
|
|
||||||
case-folded name — this covers the window before the background ID-backfill
|
|
||||||
has resolved the active source's id. Albums/tracks do NOT fall back to name
|
|
||||||
(common titles like "Greatest Hits" would false-positive across artists);
|
|
||||||
they rely on IDs, which backfill fills in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
||||||
|
|
||||||
ENTITY_ARTIST = "artist"
|
|
||||||
ENTITY_ALBUM = "album"
|
|
||||||
ENTITY_TRACK = "track"
|
|
||||||
ENTITY_TYPES = (ENTITY_ARTIST, ENTITY_ALBUM, ENTITY_TRACK)
|
|
||||||
|
|
||||||
# Blocklist-row column → the metadata source it belongs to.
|
|
||||||
SOURCE_ID_FIELDS = {
|
|
||||||
"spotify": "spotify_id",
|
|
||||||
"itunes": "itunes_id",
|
|
||||||
"deezer": "deezer_id",
|
|
||||||
"musicbrainz": "musicbrainz_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _norm(text: Any) -> str:
|
|
||||||
return str(text or "").strip().casefold()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _TypeIndex:
|
|
||||||
# per-source set of blocked ids, plus a case-folded name set (artists only)
|
|
||||||
ids: Dict[str, Set[str]] = field(default_factory=lambda: {s: set() for s in SOURCE_ID_FIELDS})
|
|
||||||
names: Set[str] = field(default_factory=set)
|
|
||||||
|
|
||||||
def hit(self, source: Optional[str], entity_id: Any, name: Any, use_name: bool) -> bool:
|
|
||||||
if entity_id and source in self.ids and str(entity_id) in self.ids[source]:
|
|
||||||
return True
|
|
||||||
if use_name and name and _norm(name) in self.names:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BlocklistIndex:
|
|
||||||
"""Membership index built once per scan from the blocklist rows."""
|
|
||||||
artists: _TypeIndex = field(default_factory=_TypeIndex)
|
|
||||||
albums: _TypeIndex = field(default_factory=_TypeIndex)
|
|
||||||
tracks: _TypeIndex = field(default_factory=_TypeIndex)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
for ti in (self.artists, self.albums, self.tracks):
|
|
||||||
if ti.names or any(ti.ids.values()):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def build_index(rows: Iterable[Dict[str, Any]]) -> BlocklistIndex:
|
|
||||||
"""Build a BlocklistIndex from blocklist DB rows.
|
|
||||||
|
|
||||||
Each row needs ``entity_type``, ``name``, and the source id columns
|
|
||||||
(``spotify_id`` / ``itunes_id`` / ``deezer_id`` / ``musicbrainz_id``).
|
|
||||||
Unknown entity types are ignored."""
|
|
||||||
idx = BlocklistIndex()
|
|
||||||
by_type = {ENTITY_ARTIST: idx.artists, ENTITY_ALBUM: idx.albums, ENTITY_TRACK: idx.tracks}
|
|
||||||
for row in rows or []:
|
|
||||||
ti = by_type.get((row.get("entity_type") or "").strip().lower())
|
|
||||||
if ti is None:
|
|
||||||
continue
|
|
||||||
for source, col in SOURCE_ID_FIELDS.items():
|
|
||||||
val = row.get(col)
|
|
||||||
if val:
|
|
||||||
ti.ids[source].add(str(val))
|
|
||||||
name = row.get("name")
|
|
||||||
if name:
|
|
||||||
ti.names.add(_norm(name))
|
|
||||||
return idx
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_block_reason(
|
|
||||||
index: BlocklistIndex,
|
|
||||||
*,
|
|
||||||
source: Optional[str],
|
|
||||||
track_id: Any = None,
|
|
||||||
track_name: Any = None,
|
|
||||||
album_id: Any = None,
|
|
||||||
album_name: Any = None,
|
|
||||||
artists: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
) -> Optional[Tuple[str, str]]:
|
|
||||||
"""Return ``(entity_type, label)`` for the first cascade hit, else None.
|
|
||||||
|
|
||||||
``source`` is the metadata source the candidate IDs came from (the wishlist
|
|
||||||
payload's provider). ``artists`` is a list of ``{'id', 'name'}`` dicts.
|
|
||||||
Order matters only for the returned reason — any hit blocks."""
|
|
||||||
if index.is_empty:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Track level — id only (names too ambiguous to ban across artists).
|
|
||||||
if index.tracks.hit(source, track_id, track_name, use_name=False):
|
|
||||||
return (ENTITY_TRACK, str(track_name or track_id or "track"))
|
|
||||||
|
|
||||||
# Album level — id only.
|
|
||||||
if index.albums.hit(source, album_id, album_name, use_name=False):
|
|
||||||
return (ENTITY_ALBUM, str(album_name or album_id or "album"))
|
|
||||||
|
|
||||||
# Artist level — id OR case-folded name (safe + covers the backfill window).
|
|
||||||
for artist in artists or []:
|
|
||||||
a_id = artist.get("id") if isinstance(artist, dict) else None
|
|
||||||
a_name = artist.get("name") if isinstance(artist, dict) else artist
|
|
||||||
if index.artists.hit(source, a_id, a_name, use_name=True):
|
|
||||||
return (ENTITY_ARTIST, str(a_name or a_id or "artist"))
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""Wire real metadata clients to the blocklist backfill resolvers.
|
|
||||||
|
|
||||||
Resolves a blocked item's ID on each metadata source by searching that source
|
|
||||||
for the name and taking a confidently name-matched hit. Confidence = exact
|
|
||||||
significant-token match (drops articles/punctuation) so we never hang a wrong
|
|
||||||
ID on an entry. Albums/tracks additionally require the parent artist to match
|
|
||||||
when both sides expose one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("blocklist.runtime")
|
|
||||||
|
|
||||||
_STOP = {"the", "a", "an", "feat", "ft", "featuring", "with"}
|
|
||||||
|
|
||||||
|
|
||||||
def _tokens(text: Any) -> frozenset:
|
|
||||||
words = re.sub(r"[^a-z0-9]+", " ", str(text or "").lower()).split()
|
|
||||||
return frozenset(w for w in words if w not in _STOP)
|
|
||||||
|
|
||||||
|
|
||||||
def _name_of(obj: Any) -> str:
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return str(obj.get("name") or obj.get("title") or "")
|
|
||||||
return str(getattr(obj, "name", None) or getattr(obj, "title", None) or "")
|
|
||||||
|
|
||||||
|
|
||||||
def _id_of(obj: Any) -> Optional[str]:
|
|
||||||
val = obj.get("id") if isinstance(obj, dict) else getattr(obj, "id", None)
|
|
||||||
return str(val) if val else None
|
|
||||||
|
|
||||||
|
|
||||||
def _confident(result_name: str, want_name: str) -> bool:
|
|
||||||
rt, wt = _tokens(result_name), _tokens(want_name)
|
|
||||||
return bool(rt) and rt == wt
|
|
||||||
|
|
||||||
|
|
||||||
def _make_resolver(source: str) -> Callable[..., Optional[str]]:
|
|
||||||
def resolve(entity_type: str, name: str, parent_name: Optional[str] = None) -> Optional[str]:
|
|
||||||
from core.metadata.registry import get_client_for_source
|
|
||||||
client = get_client_for_source(source)
|
|
||||||
if not client:
|
|
||||||
return None
|
|
||||||
method = {
|
|
||||||
"artist": "search_artists",
|
|
||||||
"album": "search_albums",
|
|
||||||
"track": "search_tracks",
|
|
||||||
}.get(entity_type)
|
|
||||||
fn = getattr(client, method, None) if method else None
|
|
||||||
if not fn:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
results = fn(name, limit=5) or []
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("%s %s search failed for %r: %s", source, entity_type, name, e)
|
|
||||||
return None
|
|
||||||
for r in results:
|
|
||||||
if not _confident(_name_of(r), name):
|
|
||||||
continue
|
|
||||||
# For album/track, also require the artist to line up when known.
|
|
||||||
if entity_type in ("album", "track") and parent_name:
|
|
||||||
artists = (r.get("artists") if isinstance(r, dict) else getattr(r, "artists", None)) or []
|
|
||||||
cand_artists = " ".join(
|
|
||||||
a.get("name", "") if isinstance(a, dict) else str(a) for a in artists)
|
|
||||||
if _tokens(parent_name) and not (_tokens(parent_name) & _tokens(cand_artists)):
|
|
||||||
continue
|
|
||||||
rid = _id_of(r)
|
|
||||||
if rid:
|
|
||||||
return rid
|
|
||||||
return None
|
|
||||||
|
|
||||||
return resolve
|
|
||||||
|
|
||||||
|
|
||||||
def build_resolvers() -> Dict[str, Callable[..., Optional[str]]]:
|
|
||||||
"""Source→resolver map for core.blocklist.backfill.resolve_missing_ids."""
|
|
||||||
return {s: _make_resolver(s) for s in ("spotify", "itunes", "deezer", "musicbrainz")}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Boot-phase guard for non-blocking container startup.
|
|
||||||
|
|
||||||
While the gunicorn worker is importing ``web_server`` (module-level client and
|
|
||||||
worker initialization), external provider API probes must not block startup.
|
|
||||||
Network validation is deferred until ``mark_boot_complete()`` runs at the end
|
|
||||||
of that import pass.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
|
|
||||||
_boot_lock = threading.Lock()
|
|
||||||
_boot_active = True
|
|
||||||
|
|
||||||
|
|
||||||
def is_boot_phase() -> bool:
|
|
||||||
"""Return True while module import must avoid blocking provider API calls."""
|
|
||||||
with _boot_lock:
|
|
||||||
return _boot_active
|
|
||||||
|
|
||||||
|
|
||||||
def mark_boot_complete() -> None:
|
|
||||||
"""End the boot phase — provider clients may perform network probes again."""
|
|
||||||
global _boot_active
|
|
||||||
with _boot_lock:
|
|
||||||
_boot_active = False
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
"""Network detection — lifted from web_server.py.
|
|
||||||
|
|
||||||
Body is byte-identical to the original. Pure stdlib + requests, no
|
|
||||||
web_server-specific globals or runtime state.
|
|
||||||
"""
|
|
||||||
import ipaddress
|
|
||||||
import logging
|
|
||||||
import platform
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def run_detection(server_type):
|
|
||||||
"""
|
|
||||||
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
|
|
||||||
This implements the same scanning logic as the GUI's detection threads.
|
|
||||||
"""
|
|
||||||
logger.info(f"Running comprehensive detection for {server_type}...")
|
|
||||||
|
|
||||||
def get_network_info():
|
|
||||||
"""Get comprehensive network information with subnet detection"""
|
|
||||||
try:
|
|
||||||
# Get local IP using socket method
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(("8.8.8.8", 80))
|
|
||||||
local_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
# Try to get actual subnet mask
|
|
||||||
try:
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
# Windows: Use netsh to get subnet info
|
|
||||||
result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'],
|
|
||||||
capture_output=True, text=True, timeout=3)
|
|
||||||
# Parse output for subnet mask (simplified)
|
|
||||||
subnet_mask = "255.255.255.0" # Default fallback
|
|
||||||
else:
|
|
||||||
# Linux/Mac: Try to parse network interfaces
|
|
||||||
result = subprocess.run(['ip', 'route', 'show'],
|
|
||||||
capture_output=True, text=True, timeout=3)
|
|
||||||
subnet_mask = "255.255.255.0" # Default fallback
|
|
||||||
except:
|
|
||||||
subnet_mask = "255.255.255.0" # Default /24
|
|
||||||
|
|
||||||
# Calculate network range
|
|
||||||
network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False)
|
|
||||||
return str(network.network_address), str(network.netmask), local_ip, network
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Fallback to original method
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(("8.8.8.8", 80))
|
|
||||||
local_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
# Default to /24 network
|
|
||||||
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
|
|
||||||
return str(network.network_address), "255.255.255.0", local_ip, network
|
|
||||||
|
|
||||||
def test_plex_server(ip, port=32400):
|
|
||||||
"""Test if a Plex server is running at the given IP and port"""
|
|
||||||
try:
|
|
||||||
url = f"http://{ip}:{port}/web/index.html"
|
|
||||||
response = requests.get(url, timeout=2, allow_redirects=True)
|
|
||||||
|
|
||||||
# Check for Plex-specific indicators
|
|
||||||
if response.status_code == 200:
|
|
||||||
# Check if it's actually Plex
|
|
||||||
if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers):
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
# Also try the API endpoint
|
|
||||||
api_url = f"http://{ip}:{port}/identity"
|
|
||||||
api_response = requests.get(api_url, timeout=1)
|
|
||||||
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("plex probe %s: %s", ip, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_jellyfin_server(ip, port=8096):
|
|
||||||
"""Test if a Jellyfin server is running at the given IP and port"""
|
|
||||||
try:
|
|
||||||
# Try the system info endpoint first
|
|
||||||
url = f"http://{ip}:{port}/System/Info"
|
|
||||||
response = requests.get(url, timeout=2, allow_redirects=True)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
# Check if response contains Jellyfin-specific content
|
|
||||||
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
# Also try the web interface
|
|
||||||
web_url = f"http://{ip}:{port}/web/index.html"
|
|
||||||
web_response = requests.get(web_url, timeout=1)
|
|
||||||
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("jellyfin probe %s: %s", ip, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_slskd_server(ip, port=5030):
|
|
||||||
"""Test if a slskd server is running at the given IP and port"""
|
|
||||||
try:
|
|
||||||
# slskd specific API endpoint
|
|
||||||
url = f"http://{ip}:{port}/api/v0/session"
|
|
||||||
response = requests.get(url, timeout=2)
|
|
||||||
|
|
||||||
# slskd returns 401 when not authenticated, which is still a valid response
|
|
||||||
if response.status_code in [200, 401]:
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("slskd probe %s: %s", ip, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_navidrome_server(ip, port=4533):
|
|
||||||
"""Test if a Navidrome server is running at the given IP and port"""
|
|
||||||
try:
|
|
||||||
# Try Navidrome's ping endpoint (part of Subsonic API)
|
|
||||||
url = f"http://{ip}:{port}/rest/ping"
|
|
||||||
response = requests.get(url, timeout=2, params={
|
|
||||||
'u': 'test', # Dummy username for ping test
|
|
||||||
'v': '1.16.1', # API version
|
|
||||||
'c': 'soulsync', # Client name
|
|
||||||
'f': 'json' # Response format
|
|
||||||
})
|
|
||||||
|
|
||||||
# Navidrome should respond even with invalid credentials for ping
|
|
||||||
if response.status_code in [200, 401, 403]:
|
|
||||||
try:
|
|
||||||
data = response.json()
|
|
||||||
# Check for Subsonic/Navidrome API response structure
|
|
||||||
if 'subsonic-response' in data:
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("navidrome json parse: %s", e)
|
|
||||||
|
|
||||||
# Also try the web interface
|
|
||||||
web_url = f"http://{ip}:{port}/"
|
|
||||||
web_response = requests.get(web_url, timeout=2)
|
|
||||||
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
|
||||||
return f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("navidrome probe %s: %s", ip, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
network_addr, netmask, local_ip, network = get_network_info()
|
|
||||||
|
|
||||||
# Select the appropriate test function
|
|
||||||
test_functions = {
|
|
||||||
'plex': test_plex_server,
|
|
||||||
'jellyfin': test_jellyfin_server,
|
|
||||||
'navidrome': test_navidrome_server,
|
|
||||||
'slskd': test_slskd_server
|
|
||||||
}
|
|
||||||
|
|
||||||
test_func = test_functions.get(server_type)
|
|
||||||
if not test_func:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Priority 1: Test localhost first
|
|
||||||
logger.debug(f"Testing localhost for {server_type}...")
|
|
||||||
localhost_result = test_func("localhost")
|
|
||||||
if localhost_result:
|
|
||||||
logger.info(f"Found {server_type} at localhost!")
|
|
||||||
return localhost_result
|
|
||||||
|
|
||||||
# Priority 1.5: In Docker, try Docker host IP
|
|
||||||
import os
|
|
||||||
if os.path.exists('/.dockerenv'):
|
|
||||||
logger.info(f"Docker detected, testing Docker host for {server_type}...")
|
|
||||||
try:
|
|
||||||
# Try host.docker.internal (Windows/Mac)
|
|
||||||
host_result = test_func("host.docker.internal")
|
|
||||||
if host_result:
|
|
||||||
logger.info(f"Found {server_type} at Docker host!")
|
|
||||||
return host_result.replace("host.docker.internal", "localhost") # Convert back to localhost for config
|
|
||||||
|
|
||||||
# Try Docker bridge gateway (Linux)
|
|
||||||
gateway_result = test_func("172.17.0.1")
|
|
||||||
if gateway_result:
|
|
||||||
logger.info(f"Found {server_type} at Docker gateway!")
|
|
||||||
return gateway_result.replace("172.17.0.1", "localhost") # Convert back to localhost for config
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Docker host detection failed: {e}")
|
|
||||||
|
|
||||||
# Priority 2: Test local IP
|
|
||||||
logger.debug(f"Testing local IP {local_ip} for {server_type}...")
|
|
||||||
local_result = test_func(local_ip)
|
|
||||||
if local_result:
|
|
||||||
logger.info(f"Found {server_type} at {local_ip}!")
|
|
||||||
return local_result
|
|
||||||
|
|
||||||
# Priority 3: Test common IPs (router gateway, etc.)
|
|
||||||
common_ips = [
|
|
||||||
local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway
|
|
||||||
local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway
|
|
||||||
local_ip.rsplit('.', 1)[0] + '.100', # Common static IP
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.debug(f"Testing common IPs for {server_type}...")
|
|
||||||
for ip in common_ips:
|
|
||||||
logger.info(f" Checking {ip}...")
|
|
||||||
result = test_func(ip)
|
|
||||||
if result:
|
|
||||||
logger.info(f"Found {server_type} at {ip}!")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Priority 4: Scan the network range (limited to reasonable size)
|
|
||||||
network_hosts = list(network.hosts())
|
|
||||||
if len(network_hosts) > 50:
|
|
||||||
# Limit scan to reasonable size for performance
|
|
||||||
step = max(1, len(network_hosts) // 50)
|
|
||||||
network_hosts = network_hosts[::step]
|
|
||||||
|
|
||||||
logger.debug(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...")
|
|
||||||
|
|
||||||
# Use ThreadPoolExecutor for concurrent scanning (limited for web context)
|
|
||||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
||||||
# Submit all tasks
|
|
||||||
future_to_ip = {executor.submit(test_func, str(ip)): str(ip)
|
|
||||||
for ip in network_hosts}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for future in as_completed(future_to_ip):
|
|
||||||
ip = future_to_ip[future]
|
|
||||||
try:
|
|
||||||
result = future.result()
|
|
||||||
if result:
|
|
||||||
logger.info(f"Found {server_type} at {ip}!")
|
|
||||||
# Cancel all pending futures before returning
|
|
||||||
for f in future_to_ip:
|
|
||||||
if not f.done():
|
|
||||||
f.cancel()
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error testing {ip}: {e}")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in concurrent scanning: {e}")
|
|
||||||
|
|
||||||
logger.warning(f"No {server_type} server found on network")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during {server_type} detection: {e}")
|
|
||||||
return None
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
"""Service connection test — lifted from web_server.py.
|
|
||||||
|
|
||||||
The function body is byte-identical to the original. download_orchestrator,
|
|
||||||
qobuz_enrichment_worker, hydrabase_client, docker_resolve_url, and
|
|
||||||
docker_resolve_path are injected at runtime because they live in
|
|
||||||
web_server.py and are constructed there.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from config.settings import config_manager
|
|
||||||
from core.jellyfin_client import JellyfinClient
|
|
||||||
from core.metadata.registry import get_primary_source
|
|
||||||
from core.plex_client import PlexClient
|
|
||||||
from core.spotify_client import SpotifyClient
|
|
||||||
from core.tidal_client import TidalClient
|
|
||||||
from utils.async_helpers import run_async
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_metadata_fallback_source():
|
|
||||||
"""Mirror of web_server._get_metadata_fallback_source — delegates to registry."""
|
|
||||||
return get_primary_source()
|
|
||||||
|
|
||||||
|
|
||||||
# Injected at runtime via init().
|
|
||||||
download_orchestrator = None
|
|
||||||
qobuz_enrichment_worker = None
|
|
||||||
hydrabase_client = None
|
|
||||||
docker_resolve_url = None
|
|
||||||
docker_resolve_path = None
|
|
||||||
|
|
||||||
|
|
||||||
def init(
|
|
||||||
download_orchestrator_obj,
|
|
||||||
qobuz_worker,
|
|
||||||
hydrabase_client_obj,
|
|
||||||
docker_resolve_url_fn,
|
|
||||||
docker_resolve_path_fn,
|
|
||||||
):
|
|
||||||
"""Bind web_server-side helpers/globals so the lifted body can resolve them."""
|
|
||||||
global download_orchestrator, qobuz_enrichment_worker, hydrabase_client
|
|
||||||
global docker_resolve_url, docker_resolve_path
|
|
||||||
download_orchestrator = download_orchestrator_obj
|
|
||||||
qobuz_enrichment_worker = qobuz_worker
|
|
||||||
hydrabase_client = hydrabase_client_obj
|
|
||||||
docker_resolve_url = docker_resolve_url_fn
|
|
||||||
docker_resolve_path = docker_resolve_path_fn
|
|
||||||
|
|
||||||
|
|
||||||
def run_service_test(service, test_config):
|
|
||||||
"""
|
|
||||||
Performs the actual connection test for a given service.
|
|
||||||
This logic is adapted from your ServiceTestThread.
|
|
||||||
It temporarily modifies the config, runs the test, then restores the config.
|
|
||||||
"""
|
|
||||||
original_config = {}
|
|
||||||
try:
|
|
||||||
# 1. Save original config for the specific service
|
|
||||||
original_config = config_manager.get(service, {})
|
|
||||||
|
|
||||||
# 2. Temporarily set the new config for the test (with Docker URL resolution)
|
|
||||||
for key, value in test_config.items():
|
|
||||||
# Apply Docker URL resolution for URL/URI fields
|
|
||||||
if isinstance(value, str) and ('url' in key.lower() or 'uri' in key.lower()):
|
|
||||||
value = docker_resolve_url(value)
|
|
||||||
config_manager.set(f"{service}.{key}", value)
|
|
||||||
|
|
||||||
# 3. Run the test with the temporary config
|
|
||||||
if service == "spotify":
|
|
||||||
temp_client = SpotifyClient()
|
|
||||||
|
|
||||||
# Check if Spotify credentials are configured
|
|
||||||
spotify_config = config_manager.get('spotify', {})
|
|
||||||
spotify_configured = bool(spotify_config.get('client_id') and spotify_config.get('client_secret'))
|
|
||||||
|
|
||||||
if temp_client.is_authenticated():
|
|
||||||
# Determine which source is active
|
|
||||||
if temp_client.is_spotify_authenticated():
|
|
||||||
return True, "Spotify connection successful!"
|
|
||||||
else:
|
|
||||||
# Spotify-Free (no-auth) metadata path: officially unauthenticated,
|
|
||||||
# but the no-creds source is selected and available. Report it as the
|
|
||||||
# working source rather than the generic Deezer/Discogs/iTunes fallback.
|
|
||||||
try:
|
|
||||||
spotify_free_available = temp_client.is_spotify_metadata_available()
|
|
||||||
except Exception:
|
|
||||||
spotify_free_available = False
|
|
||||||
if spotify_free_available:
|
|
||||||
return True, "Spotify (no-auth) connection successful!"
|
|
||||||
# Using a different fallback metadata source
|
|
||||||
fb_src = _get_metadata_fallback_source()
|
|
||||||
fallback_name = 'Deezer' if fb_src == 'deezer' else 'Discogs' if fb_src == 'discogs' else 'iTunes'
|
|
||||||
if spotify_configured:
|
|
||||||
return True, f"{fallback_name} connection successful! (Spotify configured but not authenticated)"
|
|
||||||
else:
|
|
||||||
return True, f"{fallback_name} connection successful! (Spotify not configured)"
|
|
||||||
else:
|
|
||||||
return False, "Music service authentication failed. Check credentials and complete OAuth flow in browser if prompted."
|
|
||||||
elif service == "tidal":
|
|
||||||
temp_client = TidalClient()
|
|
||||||
if temp_client.is_authenticated():
|
|
||||||
user_info = temp_client.get_user_info()
|
|
||||||
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
|
|
||||||
return True, f"Tidal connection successful! Connected as: {username}"
|
|
||||||
else:
|
|
||||||
return False, "Tidal authentication failed. Please use the 'Authenticate' button and complete the flow in your browser."
|
|
||||||
elif service == "plex":
|
|
||||||
temp_client = PlexClient()
|
|
||||||
if temp_client.is_connected():
|
|
||||||
return True, f"Successfully connected to Plex server: {temp_client.server.friendlyName}"
|
|
||||||
else:
|
|
||||||
return False, "Could not connect to Plex. Check URL and Token."
|
|
||||||
elif service == "jellyfin":
|
|
||||||
temp_client = JellyfinClient()
|
|
||||||
if temp_client.is_connected():
|
|
||||||
# FIX: Check if server_info exists before accessing it.
|
|
||||||
server_name = "Unknown Server"
|
|
||||||
if hasattr(temp_client, 'server_info') and temp_client.server_info:
|
|
||||||
server_name = temp_client.server_info.get('ServerName', 'Unknown Server')
|
|
||||||
return True, f"Successfully connected to Jellyfin server: {server_name}"
|
|
||||||
else:
|
|
||||||
return False, "Could not connect to Jellyfin. Check URL and API Key."
|
|
||||||
elif service == "navidrome":
|
|
||||||
# Test Navidrome connection using Subsonic API
|
|
||||||
base_url = test_config.get('base_url', '')
|
|
||||||
username = test_config.get('username', '')
|
|
||||||
password = test_config.get('password', '')
|
|
||||||
|
|
||||||
if not all([base_url, username, password]):
|
|
||||||
return False, "Missing Navidrome URL, username, or password."
|
|
||||||
|
|
||||||
try:
|
|
||||||
import hashlib
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
# Generate salt and token for Subsonic API authentication
|
|
||||||
salt = ''.join(random.choices(string.ascii_letters + string.digits, k=6))
|
|
||||||
token = hashlib.md5((password + salt).encode()).hexdigest()
|
|
||||||
|
|
||||||
# Test ping endpoint
|
|
||||||
url = f"{base_url.rstrip('/')}/rest/ping"
|
|
||||||
response = requests.get(url, params={
|
|
||||||
'u': username,
|
|
||||||
't': token,
|
|
||||||
's': salt,
|
|
||||||
'v': '1.16.1',
|
|
||||||
'c': 'soulsync',
|
|
||||||
'f': 'json'
|
|
||||||
}, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('subsonic-response', {}).get('status') == 'ok':
|
|
||||||
server_version = data.get('subsonic-response', {}).get('version', 'Unknown')
|
|
||||||
return True, f"Successfully connected to Navidrome server (v{server_version})"
|
|
||||||
else:
|
|
||||||
error = data.get('subsonic-response', {}).get('error', {})
|
|
||||||
return False, f"Navidrome authentication failed: {error.get('message', 'Unknown error')}"
|
|
||||||
else:
|
|
||||||
return False, f"Could not connect to Navidrome server (HTTP {response.status_code})"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Navidrome connection error: {str(e)}"
|
|
||||||
elif service == "soulsync":
|
|
||||||
transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
|
||||||
if os.path.isdir(transfer_path):
|
|
||||||
# Quick check — count a few audio files to confirm it's a music folder
|
|
||||||
audio_exts = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav'}
|
|
||||||
count = 0
|
|
||||||
found_enough = False
|
|
||||||
for _root, _dirs, files in os.walk(transfer_path):
|
|
||||||
for f in files:
|
|
||||||
if os.path.splitext(f)[1].lower() in audio_exts:
|
|
||||||
count += 1
|
|
||||||
if count >= 10:
|
|
||||||
found_enough = True
|
|
||||||
break
|
|
||||||
if found_enough:
|
|
||||||
break
|
|
||||||
return True, f"SoulSync standalone ready! Output folder: {transfer_path}" + (f" ({count}+ audio files)" if count > 0 else " (empty)")
|
|
||||||
else:
|
|
||||||
return False, f"Output folder not found: {transfer_path}"
|
|
||||||
elif service == "soulseek":
|
|
||||||
if download_orchestrator is None:
|
|
||||||
return False, "Download orchestrator failed to initialize. Check server logs for startup errors."
|
|
||||||
|
|
||||||
# Test the orchestrator's configured download source (not just Soulseek)
|
|
||||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
|
||||||
|
|
||||||
if run_async(download_orchestrator.check_connection()):
|
|
||||||
# Success message based on active mode
|
|
||||||
mode_messages = {
|
|
||||||
'soulseek': "Successfully connected to Soulseek network via slskd.",
|
|
||||||
'youtube': "YouTube download source ready.",
|
|
||||||
'tidal': "Tidal download source ready.",
|
|
||||||
'qobuz': "Qobuz download source ready.",
|
|
||||||
'hifi': "HiFi download source ready.",
|
|
||||||
'deezer_dl': "Deezer download source ready.",
|
|
||||||
'amazon': "Amazon download source ready.",
|
|
||||||
'lidarr': "Lidarr download source ready.",
|
|
||||||
'soundcloud': "SoundCloud download source ready.",
|
|
||||||
'torrent': "Torrent download source ready.",
|
|
||||||
'usenet': "Usenet download source ready.",
|
|
||||||
'hybrid': "Download sources ready (Hybrid mode)."
|
|
||||||
}
|
|
||||||
message = mode_messages.get(download_mode, "Download source connected.")
|
|
||||||
return True, message
|
|
||||||
else:
|
|
||||||
# Failure message based on active mode
|
|
||||||
mode_errors = {
|
|
||||||
'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.",
|
|
||||||
'youtube': "YouTube download source not available.",
|
|
||||||
'tidal': "Tidal download source not available. Check authentication.",
|
|
||||||
'qobuz': "Qobuz download source not available. Check authentication.",
|
|
||||||
'hifi': "HiFi download source not available. Public API instances may be down.",
|
|
||||||
'deezer_dl': "Deezer download source not available. Check authentication.",
|
|
||||||
'amazon': "Amazon download source not available.",
|
|
||||||
'lidarr': "Lidarr download source not available. Check Lidarr URL and API key.",
|
|
||||||
'soundcloud': "SoundCloud download source not available.",
|
|
||||||
'torrent': "Torrent download source not available. Check Prowlarr and torrent client settings.",
|
|
||||||
'usenet': "Usenet download source not available. Check Prowlarr and usenet client settings.",
|
|
||||||
'hybrid': "Could not connect to download sources. Check configuration."
|
|
||||||
}
|
|
||||||
error = mode_errors.get(download_mode, "Download source connection failed.")
|
|
||||||
return False, error
|
|
||||||
elif service == "listenbrainz":
|
|
||||||
token = test_config.get('token', '')
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
return False, "Missing ListenBrainz user token."
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Test ListenBrainz API by validating the token
|
|
||||||
custom_base = test_config.get('base_url', '').rstrip('/')
|
|
||||||
if custom_base:
|
|
||||||
if not custom_base.endswith('/1'):
|
|
||||||
custom_base += '/1'
|
|
||||||
lb_api_base = custom_base
|
|
||||||
else:
|
|
||||||
lb_api_base = "https://api.listenbrainz.org/1"
|
|
||||||
url = f"{lb_api_base}/validate-token"
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'Token {token}'
|
|
||||||
}
|
|
||||||
response = requests.get(url, headers=headers, timeout=5)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('valid'):
|
|
||||||
username = data.get('user_name', 'Unknown')
|
|
||||||
return True, f"Successfully connected to ListenBrainz! Connected as: {username}"
|
|
||||||
else:
|
|
||||||
return False, "Invalid ListenBrainz token."
|
|
||||||
elif response.status_code == 401:
|
|
||||||
return False, "Invalid ListenBrainz token (unauthorized)."
|
|
||||||
else:
|
|
||||||
return False, f"Could not connect to ListenBrainz (HTTP {response.status_code})"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"ListenBrainz connection error: {str(e)}"
|
|
||||||
elif service == "acoustid":
|
|
||||||
api_key = test_config.get('api_key', '')
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
return False, "Missing AcoustID API key."
|
|
||||||
|
|
||||||
try:
|
|
||||||
from core.acoustid_client import AcoustIDClient, CHROMAPRINT_AVAILABLE, ACOUSTID_AVAILABLE, FPCALC_PATH
|
|
||||||
|
|
||||||
if not ACOUSTID_AVAILABLE:
|
|
||||||
return False, "pyacoustid library not installed. Run: pip install pyacoustid"
|
|
||||||
|
|
||||||
client = AcoustIDClient()
|
|
||||||
|
|
||||||
# Override the cached API key with the test config key
|
|
||||||
client._api_key = api_key
|
|
||||||
|
|
||||||
# Check chromaprint/fpcalc availability
|
|
||||||
if CHROMAPRINT_AVAILABLE and FPCALC_PATH:
|
|
||||||
fingerprint_status = f"fpcalc ready: {FPCALC_PATH}"
|
|
||||||
elif CHROMAPRINT_AVAILABLE:
|
|
||||||
fingerprint_status = "Fingerprint backend available"
|
|
||||||
else:
|
|
||||||
fingerprint_status = "fpcalc not found (will auto-download on first use)"
|
|
||||||
|
|
||||||
# Validate API key with test request
|
|
||||||
success, message = client.test_api_key()
|
|
||||||
if success:
|
|
||||||
return True, f"AcoustID API key is valid! {fingerprint_status}"
|
|
||||||
else:
|
|
||||||
return False, f"{message}. {fingerprint_status}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"AcoustID test error: {str(e)}"
|
|
||||||
elif service == "lastfm":
|
|
||||||
api_key = test_config.get('api_key', '')
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
return False, "Missing Last.fm API key."
|
|
||||||
|
|
||||||
try:
|
|
||||||
from core.lastfm_client import LastFMClient
|
|
||||||
client = LastFMClient(api_key=api_key)
|
|
||||||
if client.validate_api_key():
|
|
||||||
return True, "Successfully connected to Last.fm!"
|
|
||||||
else:
|
|
||||||
return False, "Invalid Last.fm API key."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Last.fm connection error: {str(e)}"
|
|
||||||
elif service == "genius":
|
|
||||||
access_token = test_config.get('access_token', '')
|
|
||||||
|
|
||||||
if not access_token:
|
|
||||||
return False, "Missing Genius access token."
|
|
||||||
|
|
||||||
try:
|
|
||||||
from core.genius_client import GeniusClient
|
|
||||||
client = GeniusClient(access_token=access_token)
|
|
||||||
if client.validate_token():
|
|
||||||
return True, "Successfully connected to Genius!"
|
|
||||||
else:
|
|
||||||
return False, "Invalid Genius access token."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Genius connection error: {str(e)}"
|
|
||||||
elif service == "usenet_client":
|
|
||||||
client_type = (config_manager.get('usenet_client.type', '') or '').strip().lower()
|
|
||||||
url = config_manager.get('usenet_client.url', '')
|
|
||||||
if not url:
|
|
||||||
return False, "Usenet client URL is required."
|
|
||||||
if not client_type:
|
|
||||||
return False, "Pick a usenet client (SABnzbd or NZBGet)."
|
|
||||||
try:
|
|
||||||
from core.usenet_clients import adapter_for_type as _usenet_adapter_for_type
|
|
||||||
adapter = _usenet_adapter_for_type(client_type)
|
|
||||||
if adapter is None:
|
|
||||||
return False, f"Unknown usenet client type: {client_type}"
|
|
||||||
if not adapter.is_configured():
|
|
||||||
if client_type == "sabnzbd":
|
|
||||||
return False, "SABnzbd needs both URL and API key."
|
|
||||||
return False, "NZBGet needs URL, username, and password."
|
|
||||||
if run_async(adapter.check_connection()):
|
|
||||||
return True, f"Connected to {client_type}"
|
|
||||||
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Usenet client connection error: {str(e)}"
|
|
||||||
elif service == "torrent_client":
|
|
||||||
client_type = (config_manager.get('torrent_client.type', '') or '').strip().lower()
|
|
||||||
url = config_manager.get('torrent_client.url', '')
|
|
||||||
if not url:
|
|
||||||
return False, "Torrent client URL is required."
|
|
||||||
if not client_type:
|
|
||||||
return False, "Pick a torrent client (qBittorrent, Transmission, or Deluge)."
|
|
||||||
try:
|
|
||||||
from core.torrent_clients import adapter_for_type
|
|
||||||
adapter = adapter_for_type(client_type)
|
|
||||||
if adapter is None:
|
|
||||||
return False, f"Unknown torrent client type: {client_type}"
|
|
||||||
if not adapter.is_configured():
|
|
||||||
return False, "Torrent client missing required credentials."
|
|
||||||
if run_async(adapter.check_connection()):
|
|
||||||
return True, f"Connected to {client_type}"
|
|
||||||
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Torrent client connection error: {str(e)}"
|
|
||||||
elif service == "prowlarr":
|
|
||||||
url = config_manager.get('prowlarr.url', '')
|
|
||||||
api_key = config_manager.get('prowlarr.api_key', '')
|
|
||||||
if not url or not api_key:
|
|
||||||
return False, "Prowlarr URL and API key are required."
|
|
||||||
try:
|
|
||||||
import requests as _req
|
|
||||||
resp = _req.get(f"{url.rstrip('/')}/api/v1/system/status",
|
|
||||||
headers={'X-Api-Key': api_key}, timeout=10)
|
|
||||||
if resp.ok:
|
|
||||||
version = resp.json().get('version', '?')
|
|
||||||
return True, f"Connected to Prowlarr v{version}"
|
|
||||||
return False, f"Prowlarr returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Prowlarr connection error: {str(e)}"
|
|
||||||
elif service == "lidarr" or service == "lidarr_download":
|
|
||||||
url = config_manager.get('lidarr_download.url', '')
|
|
||||||
api_key = config_manager.get('lidarr_download.api_key', '')
|
|
||||||
if not url or not api_key:
|
|
||||||
return False, "Lidarr URL and API key are required."
|
|
||||||
try:
|
|
||||||
import requests as _req
|
|
||||||
resp = _req.get(f"{url.rstrip('/')}/api/v1/system/status",
|
|
||||||
headers={'X-Api-Key': api_key}, timeout=10)
|
|
||||||
if resp.ok:
|
|
||||||
version = resp.json().get('version', '?')
|
|
||||||
return True, f"Connected to Lidarr v{version}"
|
|
||||||
return False, f"Lidarr returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Lidarr connection error: {str(e)}"
|
|
||||||
elif service == "itunes":
|
|
||||||
# Public API — just confirm we can reach it with a cheap search
|
|
||||||
try:
|
|
||||||
storefront = config_manager.get('itunes.storefront', 'US') or 'US'
|
|
||||||
resp = requests.get(
|
|
||||||
'https://itunes.apple.com/search',
|
|
||||||
params={'term': 'beatles', 'limit': 1, 'country': storefront, 'media': 'music'},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if resp.ok and resp.json().get('resultCount', 0) >= 0:
|
|
||||||
return True, f"iTunes Search API reachable (storefront: {storefront})"
|
|
||||||
return False, f"iTunes returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"iTunes connection error: {str(e)}"
|
|
||||||
elif service == "deezer":
|
|
||||||
# Public API — anon search works without credentials
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
'https://api.deezer.com/search/artist',
|
|
||||||
params={'q': 'beatles', 'limit': 1},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if resp.ok and isinstance(resp.json(), dict):
|
|
||||||
return True, "Deezer Public API reachable"
|
|
||||||
return False, f"Deezer returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Deezer connection error: {str(e)}"
|
|
||||||
elif service == "discogs":
|
|
||||||
token = test_config.get('token', '') or config_manager.get('discogs.token', '')
|
|
||||||
if not token:
|
|
||||||
return False, "Missing Discogs personal token."
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
'https://api.discogs.com/database/search',
|
|
||||||
params={'q': 'beatles', 'per_page': 1},
|
|
||||||
headers={'Authorization': f'Discogs token={token}', 'User-Agent': 'SoulSync/1.0'},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
return True, "Discogs API reachable with provided token"
|
|
||||||
if resp.status_code == 401:
|
|
||||||
return False, "Discogs token rejected (HTTP 401)"
|
|
||||||
return False, f"Discogs returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Discogs connection error: {str(e)}"
|
|
||||||
elif service == "qobuz":
|
|
||||||
try:
|
|
||||||
if qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated():
|
|
||||||
return True, "Qobuz client authenticated"
|
|
||||||
return False, "Qobuz not authenticated. Provide email/password or user auth token."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Qobuz connection error: {str(e)}"
|
|
||||||
elif service == "hydrabase":
|
|
||||||
try:
|
|
||||||
if hydrabase_client and hydrabase_client.is_connected():
|
|
||||||
return True, "Hydrabase connected"
|
|
||||||
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Hydrabase connection error: {str(e)}"
|
|
||||||
elif service == "musicbrainz":
|
|
||||||
try:
|
|
||||||
from core.metadata.registry import get_musicbrainz_client
|
|
||||||
mb = get_musicbrainz_client()
|
|
||||||
results = mb.search_artists("radiohead", limit=1)
|
|
||||||
if results:
|
|
||||||
return True, "MusicBrainz reachable"
|
|
||||||
return False, "MusicBrainz returned no results — may be rate-limited or unreachable."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"MusicBrainz connection error: {str(e)}"
|
|
||||||
elif service == "soundcloud":
|
|
||||||
# Anonymous SoundCloud has no auth, so "test" really means
|
|
||||||
# "is yt-dlp installed and can it reach SoundCloud right now."
|
|
||||||
# This mirrors the /api/soundcloud/status check.
|
|
||||||
try:
|
|
||||||
from core.soundcloud_client import SoundcloudClient
|
|
||||||
sc = SoundcloudClient()
|
|
||||||
if not sc.is_available():
|
|
||||||
return False, "SoundCloud unavailable — yt-dlp not installed."
|
|
||||||
# Run a tiny live probe via asyncio so the dashboard test
|
|
||||||
# gives a meaningful pass/fail.
|
|
||||||
import asyncio
|
|
||||||
reachable = asyncio.new_event_loop().run_until_complete(sc.check_connection())
|
|
||||||
if reachable:
|
|
||||||
return True, "SoundCloud reachable (anonymous)"
|
|
||||||
return False, "SoundCloud unreachable — search probe failed. Try again."
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"SoundCloud connection error: {str(e)}"
|
|
||||||
return False, "Unknown service."
|
|
||||||
except AttributeError as e:
|
|
||||||
# This specifically catches the error you reported for Jellyfin
|
|
||||||
if "'JellyfinClient' object has no attribute 'server_info'" in str(e):
|
|
||||||
return False, "Connection failed. Please check your Jellyfin URL and API Key."
|
|
||||||
else:
|
|
||||||
return False, f"An unexpected error occurred: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False, str(e)
|
|
||||||
finally:
|
|
||||||
# 4. CRITICAL: Restore the original config
|
|
||||||
if original_config:
|
|
||||||
for key, value in original_config.items():
|
|
||||||
config_manager.set(f"{service}.{key}", value)
|
|
||||||
logger.debug(f"Restored original config for '{service}' after test.")
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""Named, switchable service-credential sets — pure logic (Phase 0 foundation).
|
|
||||||
|
|
||||||
Today every auth service (Spotify, Tidal, Deezer, Qobuz, Plex, Jellyfin,
|
|
||||||
Navidrome) holds ONE credential set in config, and clients are global singletons
|
|
||||||
built from that single slot. This module is the groundwork for letting an admin
|
|
||||||
save MULTIPLE named credential sets per service ("pills") that each profile can
|
|
||||||
switch between, without anyone but the admin creating them.
|
|
||||||
|
|
||||||
Kept PURE — service registry, payload validation, and active-set selection,
|
|
||||||
free of DB/Flask so it's unit-testable. Encrypted storage lives in MusicDatabase
|
|
||||||
(service_credentials / profile_service_credentials tables); runtime client
|
|
||||||
resolution + UI come in later phases. Nothing here changes existing behaviour;
|
|
||||||
it's dormant capability until wired.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Services that support multiple named credential sets, mapped to the payload
|
|
||||||
# keys that MUST be present for a set to be usable. Extra keys (OAuth tokens,
|
|
||||||
# redirect URIs, quality prefs) are allowed and preserved — these are only the
|
|
||||||
# minimum required to validate a set the admin is saving.
|
|
||||||
SERVICE_CREDENTIAL_SCHEMA = {
|
|
||||||
'spotify': ('client_id', 'client_secret'),
|
|
||||||
'tidal': ('access_token', 'refresh_token'),
|
|
||||||
'deezer': ('arl',),
|
|
||||||
'qobuz': ('user_auth_token',),
|
|
||||||
'plex': ('base_url', 'token'),
|
|
||||||
'jellyfin': ('base_url', 'api_key'),
|
|
||||||
'navidrome': ('base_url', 'username', 'password'),
|
|
||||||
}
|
|
||||||
|
|
||||||
SUPPORTED_SERVICES = frozenset(SERVICE_CREDENTIAL_SCHEMA)
|
|
||||||
|
|
||||||
|
|
||||||
def is_supported_service(service: str) -> bool:
|
|
||||||
"""True when the service supports named credential sets."""
|
|
||||||
return service in SERVICE_CREDENTIAL_SCHEMA
|
|
||||||
|
|
||||||
|
|
||||||
def validate_credential_payload(service: str, payload):
|
|
||||||
"""Return ``(ok, missing_keys)`` for a credential set.
|
|
||||||
|
|
||||||
Valid when every required key for the service is present and truthy. An
|
|
||||||
unknown service is invalid with no missing list (caller should reject it
|
|
||||||
as unsupported, not as "incomplete").
|
|
||||||
"""
|
|
||||||
required = SERVICE_CREDENTIAL_SCHEMA.get(service)
|
|
||||||
if required is None:
|
|
||||||
return False, []
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return False, list(required)
|
|
||||||
|
|
||||||
def _present(v):
|
|
||||||
# Whitespace-only strings count as missing — they'd otherwise save a
|
|
||||||
# blank secret that fails confusingly at the real service later.
|
|
||||||
return bool(v.strip()) if isinstance(v, str) else bool(v)
|
|
||||||
|
|
||||||
missing = [k for k in required if not _present(payload.get(k))]
|
|
||||||
return (not missing), missing
|
|
||||||
|
|
||||||
|
|
||||||
def pick_active_credential(credentials, selected_id):
|
|
||||||
"""From ``credentials`` (a list of dicts each carrying ``id``), return the
|
|
||||||
one whose id == ``selected_id``.
|
|
||||||
|
|
||||||
Returns None when there's no selection OR the selected id isn't present —
|
|
||||||
i.e. a stale pointer whose credential set was deleted. The caller then
|
|
||||||
falls back to the global/admin default, so a deleted set never breaks a
|
|
||||||
profile. Pure + stale-safe.
|
|
||||||
"""
|
|
||||||
if not selected_id:
|
|
||||||
return None
|
|
||||||
for cred in credentials or []:
|
|
||||||
if cred.get('id') == selected_id:
|
|
||||||
return cred
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'SERVICE_CREDENTIAL_SCHEMA',
|
|
||||||
'SUPPORTED_SERVICES',
|
|
||||||
'is_supported_service',
|
|
||||||
'validate_credential_payload',
|
|
||||||
'pick_active_credential',
|
|
||||||
]
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""Stall detection for the database-update job.
|
|
||||||
|
|
||||||
The DB updater keeps a single in-memory state dict whose ``status`` is set to
|
|
||||||
``running`` at start and only flipped to ``finished``/``error`` by the worker's
|
|
||||||
completion/error callbacks. If the worker thread hangs — e.g. a media-server API
|
|
||||||
call with no timeout, a DB lock — those callbacks never fire, so ``status`` stays
|
|
||||||
``running`` forever and the UI shows a frozen progress bar with no way to recover
|
|
||||||
(GitHub #859).
|
|
||||||
|
|
||||||
This module is the single, *pure* decision for "is a running job stalled?". It
|
|
||||||
takes the state dict plus the current wall-clock time and a timeout, and answers
|
|
||||||
yes/no — no DB, no globals, no clock of its own. That keeps it unit-testable and
|
|
||||||
lets the watchdog wiring in web_server.py stay a thin call. The job carries a
|
|
||||||
``last_progress_at`` epoch timestamp that the start path and every progress/phase
|
|
||||||
callback bump; staleness is simply "running, and that timestamp is older than the
|
|
||||||
timeout".
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
# 5 minutes with zero forward progress = presumed hung. A healthy scan ticks
|
|
||||||
# progress (per-artist) far more often than this even for large libraries, so
|
|
||||||
# the timeout won't false-positive a slow-but-working run.
|
|
||||||
DEFAULT_STALL_TIMEOUT_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
def is_db_update_stalled(
|
|
||||||
state: Mapping[str, Any],
|
|
||||||
now: float,
|
|
||||||
timeout_seconds: float = DEFAULT_STALL_TIMEOUT_SECONDS,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True when the job is ``running`` but has made no progress within
|
|
||||||
``timeout_seconds``.
|
|
||||||
|
|
||||||
Conservative by design — it only ever reports a stall it can prove:
|
|
||||||
- Only a ``running`` job can stall (idle/finished/error never do).
|
|
||||||
- With no usable ``last_progress_at`` timestamp we cannot judge, so we return
|
|
||||||
False rather than risk killing a job we have no clock for.
|
|
||||||
- A non-positive timeout is treated as "disabled" (never stalls).
|
|
||||||
"""
|
|
||||||
if not isinstance(state, Mapping):
|
|
||||||
return False
|
|
||||||
if state.get("status") != "running":
|
|
||||||
return False
|
|
||||||
if timeout_seconds is None or timeout_seconds <= 0:
|
|
||||||
return False
|
|
||||||
last = state.get("last_progress_at")
|
|
||||||
if not last:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
elapsed = float(now) - float(last)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return elapsed >= float(timeout_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def stalled_error_message(state: Mapping[str, Any], now: float) -> str:
|
|
||||||
"""Build a clear, human-facing message for a stalled job, including how long
|
|
||||||
it has been silent and the phase it died in."""
|
|
||||||
last = state.get("last_progress_at") if isinstance(state, Mapping) else None
|
|
||||||
phase = state.get("phase") if isinstance(state, Mapping) else None
|
|
||||||
try:
|
|
||||||
secs = int(float(now) - float(last)) if last else 0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
secs = 0
|
|
||||||
msg = "Update appears stuck — no progress"
|
|
||||||
if secs > 0:
|
|
||||||
msg += f" for {secs}s"
|
|
||||||
if phase:
|
|
||||||
msg += f" (last phase: {phase})"
|
|
||||||
msg += (". The worker may be hung on the media server. Start a new update "
|
|
||||||
"to try again, or restart SoulSync if it keeps stalling.")
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DEFAULT_STALL_TIMEOUT_SECONDS",
|
|
||||||
"is_db_update_stalled",
|
|
||||||
"stalled_error_message",
|
|
||||||
]
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Conditional PyQt6 import for backward compatibility with GUI version
|
||||||
|
try:
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
QT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
QT_AVAILABLE = False
|
||||||
|
# Define dummy classes for headless operation
|
||||||
|
class QThread:
|
||||||
|
def __init__(self):
|
||||||
|
self.callbacks = {}
|
||||||
|
def start(self):
|
||||||
|
import threading
|
||||||
|
self.thread = threading.Thread(target=self.run)
|
||||||
|
self.thread.daemon = True
|
||||||
|
self.thread.start()
|
||||||
|
def wait(self):
|
||||||
|
if hasattr(self, 'thread'):
|
||||||
|
self.thread.join()
|
||||||
|
def emit_signal(self, signal_name, *args):
|
||||||
|
if signal_name in self.callbacks:
|
||||||
|
for callback in self.callbacks[signal_name]:
|
||||||
|
try:
|
||||||
|
callback(*args)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||||
|
def connect_signal(self, signal_name, callback):
|
||||||
|
if signal_name not in self.callbacks:
|
||||||
|
self.callbacks[signal_name] = []
|
||||||
|
self.callbacks[signal_name].append(callback)
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import Optional, List, Callable
|
from typing import Optional, List, Callable
|
||||||
|
|
@ -12,19 +42,32 @@ from config.settings import config_manager
|
||||||
|
|
||||||
logger = get_logger("database_update_worker")
|
logger = get_logger("database_update_worker")
|
||||||
|
|
||||||
class DatabaseUpdateWorker:
|
class DatabaseUpdateWorker(QThread):
|
||||||
"""Worker for updating SoulSync database with media server library data."""
|
"""Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)"""
|
||||||
|
|
||||||
|
# Qt signals (only available when PyQt6 is installed)
|
||||||
|
if QT_AVAILABLE:
|
||||||
|
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
|
||||||
|
artist_processed = pyqtSignal(str, bool, str, int, int) # artist_name, success, details, albums_count, tracks_count
|
||||||
|
finished = pyqtSignal(int, int, int, int, int) # total_artists, total_albums, total_tracks, successful, failed
|
||||||
|
error = pyqtSignal(str) # error_message
|
||||||
|
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||||
|
|
||||||
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex", force_sequential: bool = False):
|
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex", force_sequential: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
# Force sequential processing for web server mode to avoid threading issues
|
# Force sequential processing for web server mode to avoid threading issues
|
||||||
self.force_sequential = force_sequential
|
self.force_sequential = force_sequential
|
||||||
self.callbacks = {
|
|
||||||
'progress_updated': [],
|
# Initialize signal callbacks for headless mode
|
||||||
'artist_processed': [],
|
if not QT_AVAILABLE:
|
||||||
'finished': [],
|
self.callbacks = {
|
||||||
'error': [],
|
'progress_updated': [],
|
||||||
'phase_changed': [],
|
'artist_processed': [],
|
||||||
}
|
'finished': [],
|
||||||
|
'error': [],
|
||||||
|
'phase_changed': []
|
||||||
|
}
|
||||||
|
|
||||||
# Support both old plex_client parameter and new media_client parameter for backward compatibility
|
# Support both old plex_client parameter and new media_client parameter for backward compatibility
|
||||||
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
|
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
|
||||||
|
|
@ -42,18 +85,6 @@ class DatabaseUpdateWorker:
|
||||||
self.full_refresh = full_refresh
|
self.full_refresh = full_refresh
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
|
|
||||||
# Track ids of rows newly INSERTED this run (not updates). The web
|
|
||||||
# layer reads this to gap-fill embedded provider IDs for the new files
|
|
||||||
# (auto-reconcile), so newly-added music contributes its
|
|
||||||
# Spotify/MusicBrainz/etc. ids without a manual backfill.
|
|
||||||
self._new_track_ids = set()
|
|
||||||
|
|
||||||
# Optional callback(worker) run as the FINAL scan phase, immediately
|
|
||||||
# before the 'finished' signal — so the auto-reconcile is inside the
|
|
||||||
# scan's running window (automations/UI treat it as a normal phase and
|
|
||||||
# wait for it). Injected by the web layer (which owns path resolution).
|
|
||||||
self.post_scan_hook = None
|
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
self.processed_artists = 0
|
self.processed_artists = 0
|
||||||
self.processed_albums = 0
|
self.processed_albums = 0
|
||||||
|
|
@ -85,35 +116,19 @@ class DatabaseUpdateWorker:
|
||||||
self.database: Optional[MusicDatabase] = None
|
self.database: Optional[MusicDatabase] = None
|
||||||
|
|
||||||
def _emit_signal(self, signal_name: str, *args):
|
def _emit_signal(self, signal_name: str, *args):
|
||||||
"""Emit a signal through the callback registry."""
|
"""Emit a signal in both Qt and headless modes"""
|
||||||
for callback in self.callbacks.get(signal_name, []):
|
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||||
try:
|
# Qt mode - use actual signal
|
||||||
callback(*args)
|
getattr(self, signal_name).emit(*args)
|
||||||
except Exception as e:
|
elif not QT_AVAILABLE:
|
||||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
# Headless mode - use callback system
|
||||||
|
self.emit_signal(signal_name, *args)
|
||||||
def _emit_finished(self, *args):
|
|
||||||
"""Run the post-scan hook (auto-reconcile) as the final phase, THEN
|
|
||||||
emit 'finished'.
|
|
||||||
|
|
||||||
Running the hook before 'finished' keeps the scan's status at
|
|
||||||
'running' through the reconcile, so every caller (automations that
|
|
||||||
poll for completion, the dashboard card, the Tools page) treats it as
|
|
||||||
a normal scan phase and waits for it — rather than seeing 'finished'
|
|
||||||
and missing the tail. Best-effort: a hook failure never blocks the
|
|
||||||
completion signal.
|
|
||||||
"""
|
|
||||||
if self.post_scan_hook:
|
|
||||||
try:
|
|
||||||
self.post_scan_hook(self)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"post-scan hook failed (non-fatal): {e}")
|
|
||||||
self._emit_signal('finished', *args)
|
|
||||||
|
|
||||||
|
|
||||||
def connect_callback(self, signal_name: str, callback: Callable):
|
def connect_callback(self, signal_name: str, callback: Callable):
|
||||||
"""Connect a callback for progress notifications."""
|
"""Connect a callback for headless mode"""
|
||||||
self.callbacks.setdefault(signal_name, []).append(callback)
|
if not QT_AVAILABLE:
|
||||||
|
self.connect_signal(signal_name, callback)
|
||||||
|
# In Qt mode, use the normal signal.connect() method
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop the database update process"""
|
"""Stop the database update process"""
|
||||||
|
|
@ -128,7 +143,7 @@ class DatabaseUpdateWorker:
|
||||||
else:
|
else:
|
||||||
freed_items = "unknown"
|
freed_items = "unknown"
|
||||||
self.media_client.clear_cache()
|
self.media_client.clear_cache()
|
||||||
logger.info(f"Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
|
logger.info(f"🧹 Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
|
||||||
|
|
||||||
|
|
@ -153,7 +168,7 @@ class DatabaseUpdateWorker:
|
||||||
# Connect Navidrome client progress to UI
|
# Connect Navidrome client progress to UI
|
||||||
if hasattr(self.media_client, 'set_progress_callback'):
|
if hasattr(self.media_client, 'set_progress_callback'):
|
||||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
||||||
logger.info("Connected Navidrome progress callback")
|
logger.info("✅ Connected Navidrome progress callback")
|
||||||
|
|
||||||
# For full refresh, get all artists
|
# For full refresh, get all artists
|
||||||
artists_to_process = self._get_all_artists()
|
artists_to_process = self._get_all_artists()
|
||||||
|
|
@ -174,10 +189,10 @@ class DatabaseUpdateWorker:
|
||||||
merge_results = self.database.merge_duplicate_artists()
|
merge_results = self.database.merge_duplicate_artists()
|
||||||
merged = merge_results.get('artists_merged', 0)
|
merged = merge_results.get('artists_merged', 0)
|
||||||
if merged > 0:
|
if merged > 0:
|
||||||
logger.info(f"Merged {merged} duplicate artists")
|
logger.info(f"🧹 Merged {merged} duplicate artists")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||||
self._emit_finished(0, 0, 0, 0, 0)
|
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
||||||
return
|
return
|
||||||
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
||||||
|
|
||||||
|
|
@ -189,7 +204,7 @@ class DatabaseUpdateWorker:
|
||||||
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
||||||
else:
|
else:
|
||||||
# Standard artist processing for Plex or full refresh
|
# Standard artist processing for Plex or full refresh
|
||||||
logger.info(f"About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
|
logger.info(f"🎯 About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
|
||||||
self._process_all_artists(artists_to_process)
|
self._process_all_artists(artists_to_process)
|
||||||
|
|
||||||
# Record full refresh completion for tracking purposes
|
# Record full refresh completion for tracking purposes
|
||||||
|
|
@ -209,7 +224,7 @@ class DatabaseUpdateWorker:
|
||||||
else:
|
else:
|
||||||
freed_items = "cache data"
|
freed_items = "cache data"
|
||||||
self.media_client.clear_cache()
|
self.media_client.clear_cache()
|
||||||
logger.info(f"Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
|
logger.info(f"🧹 Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear {self.server_type} cache: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache: {e}")
|
||||||
|
|
||||||
|
|
@ -225,7 +240,7 @@ class DatabaseUpdateWorker:
|
||||||
r_albums = removal_results.get('albums_removed', 0)
|
r_albums = removal_results.get('albums_removed', 0)
|
||||||
r_tracks = removal_results.get('tracks_removed', 0)
|
r_tracks = removal_results.get('tracks_removed', 0)
|
||||||
if r_artists > 0 or r_albums > 0:
|
if r_artists > 0 or r_albums > 0:
|
||||||
logger.info(f"Removal detection: {r_artists} artists, "
|
logger.info(f"🗑️ Removal detection: {r_artists} artists, "
|
||||||
f"{r_albums} albums, {r_tracks} tracks removed")
|
f"{r_albums} albums, {r_tracks} tracks removed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Removal detection failed (non-fatal): {e}")
|
logger.warning(f"Removal detection failed (non-fatal): {e}")
|
||||||
|
|
@ -238,9 +253,9 @@ class DatabaseUpdateWorker:
|
||||||
orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0)
|
orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0)
|
||||||
|
|
||||||
if orphaned_artists > 0 or orphaned_albums > 0:
|
if orphaned_artists > 0 or orphaned_albums > 0:
|
||||||
logger.info(f"Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed")
|
logger.info(f"🧹 Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed")
|
||||||
else:
|
else:
|
||||||
logger.debug("Cleanup complete: No orphaned records found")
|
logger.debug("🧹 Cleanup complete: No orphaned records found")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not cleanup orphaned records: {e}")
|
logger.warning(f"Could not cleanup orphaned records: {e}")
|
||||||
|
|
@ -250,7 +265,7 @@ class DatabaseUpdateWorker:
|
||||||
merge_results = self.database.merge_duplicate_artists()
|
merge_results = self.database.merge_duplicate_artists()
|
||||||
merged = merge_results.get('artists_merged', 0)
|
merged = merge_results.get('artists_merged', 0)
|
||||||
if merged > 0:
|
if merged > 0:
|
||||||
logger.info(f"Merged {merged} duplicate artists")
|
logger.info(f"🧹 Merged {merged} duplicate artists")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||||
|
|
||||||
|
|
@ -261,7 +276,7 @@ class DatabaseUpdateWorker:
|
||||||
self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0
|
self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0
|
||||||
|
|
||||||
# Emit final results
|
# Emit final results
|
||||||
self._emit_finished(
|
self._emit_signal('finished',
|
||||||
self.processed_artists,
|
self.processed_artists,
|
||||||
self.processed_albums,
|
self.processed_albums,
|
||||||
self.processed_tracks,
|
self.processed_tracks,
|
||||||
|
|
@ -362,7 +377,7 @@ class DatabaseUpdateWorker:
|
||||||
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
||||||
f"{stale_removed} stale tracks removed")
|
f"{stale_removed} stale tracks removed")
|
||||||
|
|
||||||
self._emit_finished(
|
self._emit_signal('finished',
|
||||||
self.processed_artists,
|
self.processed_artists,
|
||||||
self.processed_albums,
|
self.processed_albums,
|
||||||
self.processed_tracks,
|
self.processed_tracks,
|
||||||
|
|
@ -379,7 +394,7 @@ class DatabaseUpdateWorker:
|
||||||
total_artists = len(artists)
|
total_artists = len(artists)
|
||||||
logger.info(f"Deep scan: Processing {total_artists} artists (sequential, skip-existing mode)")
|
logger.info(f"Deep scan: Processing {total_artists} artists (sequential, skip-existing mode)")
|
||||||
|
|
||||||
for _i, artist in enumerate(artists):
|
for i, artist in enumerate(artists):
|
||||||
if self.should_stop:
|
if self.should_stop:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -422,9 +437,9 @@ class DatabaseUpdateWorker:
|
||||||
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info(f"_get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
|
logger.info(f"🎯 _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
|
||||||
artists = self.media_client.get_all_artists()
|
artists = self.media_client.get_all_artists()
|
||||||
logger.info(f"_get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
|
logger.info(f"🎯 _get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -438,14 +453,9 @@ class DatabaseUpdateWorker:
|
||||||
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Check for music library (Plex-specific check). Routes
|
# Check for music library (Plex-specific check)
|
||||||
# through ``is_fully_configured`` so all-libraries mode (in
|
if self.server_type == "plex" and not self.media_client.music_library:
|
||||||
# which ``music_library`` is None but ``_all_libraries_mode``
|
logger.error("No music library found in Plex")
|
||||||
# is True) counts as configured. Pre-fix this bailed out on
|
|
||||||
# the bare music_library None check, silently aborting the
|
|
||||||
# deep scan for any all-libraries-mode user.
|
|
||||||
if self.server_type == "plex" and not self.media_client.is_fully_configured():
|
|
||||||
logger.error("No music library configured in Plex")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if database has enough content for incremental updates (server-specific)
|
# Check if database has enough content for incremental updates (server-specific)
|
||||||
|
|
@ -571,9 +581,9 @@ class DatabaseUpdateWorker:
|
||||||
if not track_exists:
|
if not track_exists:
|
||||||
missing_tracks_count += 1
|
missing_tracks_count += 1
|
||||||
album_has_new_tracks = True
|
album_has_new_tracks = True
|
||||||
logger.debug(f"Track '{track_title}' is new - album needs processing")
|
logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Track '{track_title}' already exists")
|
logger.debug(f"✅ Track '{track_title}' already exists")
|
||||||
|
|
||||||
except Exception as track_error:
|
except Exception as track_error:
|
||||||
logger.debug(f"Error checking individual track: {track_error}")
|
logger.debug(f"Error checking individual track: {track_error}")
|
||||||
|
|
@ -585,23 +595,23 @@ class DatabaseUpdateWorker:
|
||||||
if album_has_new_tracks:
|
if album_has_new_tracks:
|
||||||
albums_with_new_content += 1
|
albums_with_new_content += 1
|
||||||
consecutive_complete_albums = 0 # Reset counter
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
logger.info(f"Album '{album_title}' has {missing_tracks_count} new tracks - needs processing")
|
logger.info(f"📀 Album '{album_title}' has {missing_tracks_count} new tracks - needs processing")
|
||||||
else:
|
else:
|
||||||
# Check if existing tracks have metadata changes (catches Plex corrections)
|
# Check if existing tracks have metadata changes (catches Plex corrections)
|
||||||
metadata_changed = self._check_for_metadata_changes(tracks)
|
metadata_changed = self._check_for_metadata_changes(tracks)
|
||||||
if metadata_changed:
|
if metadata_changed:
|
||||||
albums_with_new_content += 1
|
albums_with_new_content += 1
|
||||||
consecutive_complete_albums = 0 # Reset counter
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
logger.info(f"Album '{album_title}' has metadata changes - needs processing")
|
logger.info(f"🔄 Album '{album_title}' has metadata changes - needs processing")
|
||||||
album_has_new_tracks = True # Mark for artist processing
|
album_has_new_tracks = True # Mark for artist processing
|
||||||
else:
|
else:
|
||||||
consecutive_complete_albums += 1
|
consecutive_complete_albums += 1
|
||||||
logger.debug(f"Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})")
|
logger.debug(f"✅ Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})")
|
||||||
|
|
||||||
# Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes
|
# Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes
|
||||||
# This ensures we don't miss scattered updated content from manual corrections
|
# This ensures we don't miss scattered updated content from manual corrections
|
||||||
if consecutive_complete_albums >= 25:
|
if consecutive_complete_albums >= 25:
|
||||||
logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
||||||
stopped_early = True
|
stopped_early = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -623,7 +633,7 @@ class DatabaseUpdateWorker:
|
||||||
if artist_id not in processed_artist_ids:
|
if artist_id not in processed_artist_ids:
|
||||||
processed_artist_ids.add(artist_id)
|
processed_artist_ids.add(artist_id)
|
||||||
artists_to_process.append(album_artist)
|
artists_to_process.append(album_artist)
|
||||||
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
||||||
except Exception as artist_error:
|
except Exception as artist_error:
|
||||||
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
||||||
|
|
||||||
|
|
@ -635,11 +645,11 @@ class DatabaseUpdateWorker:
|
||||||
|
|
||||||
result_msg = f"Smart incremental scan result: {len(artists_to_process)} artists to process from {albums_with_new_content} albums with new content"
|
result_msg = f"Smart incremental scan result: {len(artists_to_process)} artists to process from {albums_with_new_content} albums with new content"
|
||||||
if stopped_early:
|
if stopped_early:
|
||||||
result_msg += " (stopped early after finding 25 consecutive complete albums)"
|
result_msg += f" (stopped early after finding 25 consecutive complete albums)"
|
||||||
else:
|
else:
|
||||||
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
|
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
|
||||||
|
|
||||||
logger.info(f"Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing")
|
logger.info(f"📊 Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing")
|
||||||
|
|
||||||
logger.info(result_msg)
|
logger.info(result_msg)
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
@ -652,7 +662,7 @@ class DatabaseUpdateWorker:
|
||||||
def _get_artists_for_navidrome_incremental_update(self) -> List:
|
def _get_artists_for_navidrome_incremental_update(self) -> List:
|
||||||
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
|
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
|
||||||
try:
|
try:
|
||||||
logger.info("Navidrome incremental: Getting recent albums and checking for new content...")
|
logger.info("🎵 Navidrome incremental: Getting recent albums and checking for new content...")
|
||||||
|
|
||||||
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
|
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
|
||||||
recent_albums = self._get_recent_albums_for_server()
|
recent_albums = self._get_recent_albums_for_server()
|
||||||
|
|
@ -710,11 +720,11 @@ class DatabaseUpdateWorker:
|
||||||
# If no new tracks found, increment consecutive complete counter
|
# If no new tracks found, increment consecutive complete counter
|
||||||
if not album_has_new_tracks:
|
if not album_has_new_tracks:
|
||||||
consecutive_complete_albums += 1
|
consecutive_complete_albums += 1
|
||||||
logger.debug(f"Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
|
logger.debug(f"✅ Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
|
||||||
|
|
||||||
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
|
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
|
||||||
if consecutive_complete_albums >= 25:
|
if consecutive_complete_albums >= 25:
|
||||||
logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as tracks_error:
|
except Exception as tracks_error:
|
||||||
|
|
@ -734,7 +744,7 @@ class DatabaseUpdateWorker:
|
||||||
if artist_id not in processed_artist_ids:
|
if artist_id not in processed_artist_ids:
|
||||||
processed_artist_ids.add(artist_id)
|
processed_artist_ids.add(artist_id)
|
||||||
artists_to_process.append(album_artist)
|
artists_to_process.append(album_artist)
|
||||||
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
||||||
except Exception as artist_error:
|
except Exception as artist_error:
|
||||||
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
||||||
|
|
||||||
|
|
@ -743,7 +753,7 @@ class DatabaseUpdateWorker:
|
||||||
consecutive_complete_albums = 0 # Reset on error
|
consecutive_complete_albums = 0 # Reset on error
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
|
logger.info(f"🎵 Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -753,7 +763,7 @@ class DatabaseUpdateWorker:
|
||||||
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
||||||
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
||||||
try:
|
try:
|
||||||
logger.info("FAST Jellyfin incremental: getting recent tracks directly...")
|
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...")
|
||||||
|
|
||||||
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
|
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
|
||||||
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
|
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
|
||||||
|
|
@ -789,14 +799,14 @@ class DatabaseUpdateWorker:
|
||||||
if not track_exists:
|
if not track_exists:
|
||||||
new_tracks.append(track)
|
new_tracks.append(track)
|
||||||
consecutive_existing_tracks = 0 # Reset counter
|
consecutive_existing_tracks = 0 # Reset counter
|
||||||
logger.debug(f"New track: {track.title}")
|
logger.debug(f"🎵 New track: {track.title}")
|
||||||
else:
|
else:
|
||||||
consecutive_existing_tracks += 1
|
consecutive_existing_tracks += 1
|
||||||
logger.debug(f"Track exists: {track.title}")
|
logger.debug(f"✅ Track exists: {track.title}")
|
||||||
|
|
||||||
# Early stopping: if we find 100 consecutive existing tracks, we're done
|
# Early stopping: if we find 100 consecutive existing tracks, we're done
|
||||||
if consecutive_existing_tracks >= 100:
|
if consecutive_existing_tracks >= 100:
|
||||||
logger.info(f"Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
|
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -823,12 +833,12 @@ class DatabaseUpdateWorker:
|
||||||
if artist_id not in processed_artists:
|
if artist_id not in processed_artists:
|
||||||
processed_artists.add(artist_id)
|
processed_artists.add(artist_id)
|
||||||
artists_to_process.append(track_artist)
|
artists_to_process.append(track_artist)
|
||||||
logger.info(f"Added artist '{track_artist.title}' (from new track '{track.title}')")
|
logger.info(f"✅ Added artist '{track_artist.title}' (from new track '{track.title}')")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
|
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
|
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
|
||||||
return artists_to_process
|
return artists_to_process
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -843,7 +853,7 @@ class DatabaseUpdateWorker:
|
||||||
logger.warning("No new tracks to process directly")
|
logger.warning("No new tracks to process directly")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
|
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
|
||||||
|
|
||||||
# Group tracks by album and artist for efficient processing
|
# Group tracks by album and artist for efficient processing
|
||||||
tracks_by_album = {}
|
tracks_by_album = {}
|
||||||
|
|
@ -911,9 +921,7 @@ class DatabaseUpdateWorker:
|
||||||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||||
if track_success:
|
if track_success:
|
||||||
total_processed_tracks += 1
|
total_processed_tracks += 1
|
||||||
if track_success == 'inserted':
|
logger.debug(f"✅ Processed new track: {track.title}")
|
||||||
self._new_track_ids.add(str(track.ratingKey))
|
|
||||||
logger.debug(f"Processed new track: {track.title}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -935,7 +943,7 @@ class DatabaseUpdateWorker:
|
||||||
self.processed_tracks += total_processed_tracks
|
self.processed_tracks += total_processed_tracks
|
||||||
self.successful_operations += total_processed_artists # Count successful artists
|
self.successful_operations += total_processed_artists # Count successful artists
|
||||||
|
|
||||||
logger.info(f"FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
|
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
delattr(self, '_jellyfin_new_tracks')
|
delattr(self, '_jellyfin_new_tracks')
|
||||||
|
|
@ -968,16 +976,10 @@ class DatabaseUpdateWorker:
|
||||||
if (db_track.title != current_title or
|
if (db_track.title != current_title or
|
||||||
db_track.artist_name != current_artist or
|
db_track.artist_name != current_artist or
|
||||||
db_track.album_title != current_album):
|
db_track.album_title != current_album):
|
||||||
logger.debug(
|
logger.debug(f"🔄 Metadata change detected for track ID {track_id}:")
|
||||||
"Metadata change detected for track %s: title=%r→%r artist=%r→%r album=%r→%r",
|
logger.debug(f" Title: '{db_track.title}' → '{current_title}'")
|
||||||
track_id,
|
logger.debug(f" Artist: '{db_track.artist_name}' → '{current_artist}'")
|
||||||
db_track.title,
|
logger.debug(f" Album: '{db_track.album_title}' → '{current_album}'")
|
||||||
current_title,
|
|
||||||
db_track.artist_name,
|
|
||||||
current_artist,
|
|
||||||
db_track.album_title,
|
|
||||||
current_album,
|
|
||||||
)
|
|
||||||
changes_detected += 1
|
changes_detected += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -985,7 +987,7 @@ class DatabaseUpdateWorker:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if changes_detected > 0:
|
if changes_detected > 0:
|
||||||
logger.info(f"Found {changes_detected} tracks with metadata changes")
|
logger.info(f"🔄 Found {changes_detected} tracks with metadata changes")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
@ -1012,7 +1014,7 @@ class DatabaseUpdateWorker:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Fetch current IDs from media server (lightweight calls)
|
# Fetch current IDs from media server (lightweight calls)
|
||||||
logger.info(f"Removal detection: fetching current IDs from {self.server_type}...")
|
logger.info(f"🔍 Removal detection: fetching current IDs from {self.server_type}...")
|
||||||
self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...")
|
self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...")
|
||||||
server_artist_ids = self.media_client.get_all_artist_ids()
|
server_artist_ids = self.media_client.get_all_artist_ids()
|
||||||
self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...")
|
self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...")
|
||||||
|
|
@ -1020,7 +1022,7 @@ class DatabaseUpdateWorker:
|
||||||
|
|
||||||
# Safety: if both come back empty, the server is unreachable
|
# Safety: if both come back empty, the server is unreachable
|
||||||
if not server_artist_ids and not server_album_ids:
|
if not server_artist_ids and not server_album_ids:
|
||||||
logger.warning("SAFETY: Server returned zero artists AND zero albums — "
|
logger.warning("🛡️ SAFETY: Server returned zero artists AND zero albums — "
|
||||||
"skipping removal detection")
|
"skipping removal detection")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1042,19 +1044,19 @@ class DatabaseUpdateWorker:
|
||||||
if check_artists and db_artist_count > 100:
|
if check_artists and db_artist_count > 100:
|
||||||
if len(server_artist_ids) < db_artist_count * 0.5:
|
if len(server_artist_ids) < db_artist_count * 0.5:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"SAFETY: Server reported {len(server_artist_ids)} artists but "
|
f"🛡️ SAFETY: Server reported {len(server_artist_ids)} artists but "
|
||||||
f"database has {db_artist_count} — skipping artist removal check")
|
f"database has {db_artist_count} — skipping artist removal check")
|
||||||
check_artists = False
|
check_artists = False
|
||||||
|
|
||||||
if check_albums and db_album_count > 100:
|
if check_albums and db_album_count > 100:
|
||||||
if len(server_album_ids) < db_album_count * 0.5:
|
if len(server_album_ids) < db_album_count * 0.5:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"SAFETY: Server reported {len(server_album_ids)} albums but "
|
f"🛡️ SAFETY: Server reported {len(server_album_ids)} albums but "
|
||||||
f"database has {db_album_count} — skipping album removal check")
|
f"database has {db_album_count} — skipping album removal check")
|
||||||
check_albums = False
|
check_albums = False
|
||||||
|
|
||||||
if not check_artists and not check_albums:
|
if not check_artists and not check_albums:
|
||||||
logger.warning("SAFETY: Both artist and album checks disabled — "
|
logger.warning("🛡️ SAFETY: Both artist and album checks disabled — "
|
||||||
"skipping removal detection")
|
"skipping removal detection")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1084,15 +1086,15 @@ class DatabaseUpdateWorker:
|
||||||
batch + [self.server_type])
|
batch + [self.server_type])
|
||||||
cascade_album_ids.update(row[0] for row in cursor.fetchall())
|
cascade_album_ids.update(row[0] for row in cursor.fetchall())
|
||||||
removed_album_ids -= cascade_album_ids
|
removed_album_ids -= cascade_album_ids
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cascade album cleanup optimization: %s", e)
|
pass # If this optimization fails, double-delete is harmless
|
||||||
|
|
||||||
if not removed_artist_ids and not removed_album_ids:
|
if not removed_artist_ids and not removed_album_ids:
|
||||||
logger.info("Removal detection: no stale content found")
|
logger.info("🔍 Removal detection: no stale content found")
|
||||||
self._emit_signal('phase_changed', "No removed content detected")
|
self._emit_signal('phase_changed', "No removed content detected")
|
||||||
return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}
|
return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}
|
||||||
|
|
||||||
logger.info(f"Removal detection: found {len(removed_artist_ids)} removed artists, "
|
logger.info(f"🗑️ Removal detection: found {len(removed_artist_ids)} removed artists, "
|
||||||
f"{len(removed_album_ids)} removed albums")
|
f"{len(removed_album_ids)} removed albums")
|
||||||
|
|
||||||
self._emit_signal('phase_changed',
|
self._emit_signal('phase_changed',
|
||||||
|
|
@ -1122,31 +1124,24 @@ class DatabaseUpdateWorker:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _get_recent_albums_plex(self) -> List:
|
def _get_recent_albums_plex(self) -> List:
|
||||||
"""Get recently added and updated albums from Plex.
|
"""Get recently added and updated albums from Plex"""
|
||||||
|
|
||||||
Routes through ``PlexClient.get_recently_added_albums`` and
|
|
||||||
``get_recently_updated_albums`` so the all-libraries mode union
|
|
||||||
works (pre-fix this reached ``self.media_client.music_library.X``
|
|
||||||
directly which crashed when music_library is None in all-
|
|
||||||
libraries mode).
|
|
||||||
"""
|
|
||||||
all_recent_content = []
|
all_recent_content = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get recently added albums (up to 400 to catch more recent content)
|
# Get recently added albums (up to 400 to catch more recent content)
|
||||||
recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype='album')
|
try:
|
||||||
if recently_added:
|
recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400)
|
||||||
all_recent_content.extend(recently_added)
|
all_recent_content.extend(recently_added)
|
||||||
logger.info(f"Found {len(recently_added)} recently added albums")
|
logger.info(f"Found {len(recently_added)} recently added albums")
|
||||||
else:
|
except:
|
||||||
# Fallback to mixed-type recents.
|
# Fallback to general recently added
|
||||||
recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype=None)
|
recently_added = self.media_client.music_library.recentlyAdded(maxresults=400)
|
||||||
all_recent_content.extend(recently_added or [])
|
all_recent_content.extend(recently_added)
|
||||||
logger.info(f"Found {len(recently_added or [])} recently added items (mixed types)")
|
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
|
||||||
|
|
||||||
# Get recently updated albums (catches metadata corrections)
|
# Get recently updated albums (catches metadata corrections)
|
||||||
try:
|
try:
|
||||||
recently_updated = self.media_client.get_recently_updated_albums(limit=400)
|
recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
|
||||||
# Remove duplicates (items that are both recently added and updated)
|
# Remove duplicates (items that are both recently added and updated)
|
||||||
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
|
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
|
||||||
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
|
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
|
||||||
|
|
@ -1224,7 +1219,7 @@ class DatabaseUpdateWorker:
|
||||||
def _process_all_artists(self, artists: List):
|
def _process_all_artists(self, artists: List):
|
||||||
"""Process all artists and their albums/tracks using thread pool"""
|
"""Process all artists and their albums/tracks using thread pool"""
|
||||||
total_artists = len(artists)
|
total_artists = len(artists)
|
||||||
logger.info(f"Processing {total_artists} artists with progress tracking")
|
logger.info(f"🎯 Processing {total_artists} artists with progress tracking")
|
||||||
|
|
||||||
def process_single_artist(artist):
|
def process_single_artist(artist):
|
||||||
"""Process a single artist and return results"""
|
"""Process a single artist and return results"""
|
||||||
|
|
@ -1245,7 +1240,7 @@ class DatabaseUpdateWorker:
|
||||||
total_artists,
|
total_artists,
|
||||||
progress_percent
|
progress_percent
|
||||||
)
|
)
|
||||||
logger.debug(f"Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
|
logger.debug(f"🔄 Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
|
||||||
|
|
||||||
# Process the artist
|
# Process the artist
|
||||||
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
||||||
|
|
@ -1266,10 +1261,10 @@ class DatabaseUpdateWorker:
|
||||||
logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
||||||
return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||||
|
|
||||||
# Process artists sequentially when requested (the web server uses this path).
|
# Process artists - use sequential processing in web server mode to avoid threading issues
|
||||||
if self.force_sequential:
|
if not QT_AVAILABLE or self.force_sequential:
|
||||||
# Sequential processing for web server mode
|
# Sequential processing for web server mode
|
||||||
for _i, artist in enumerate(artists):
|
for i, artist in enumerate(artists):
|
||||||
if self.should_stop:
|
if self.should_stop:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -1282,7 +1277,7 @@ class DatabaseUpdateWorker:
|
||||||
# Emit progress signal
|
# Emit progress signal
|
||||||
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
||||||
else:
|
else:
|
||||||
# Parallel processing for local/manual runs
|
# Process artists in parallel using ThreadPoolExecutor (Qt mode only)
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||||
# Submit all tasks
|
# Submit all tasks
|
||||||
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
||||||
|
|
@ -1377,8 +1372,6 @@ class DatabaseUpdateWorker:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
elif track_success:
|
elif track_success:
|
||||||
track_count += 1
|
track_count += 1
|
||||||
if track_success == 'inserted':
|
|
||||||
self._new_track_ids.add(track_id_str)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
|
|
||||||
|
|
@ -1416,3 +1409,71 @@ class DatabaseUpdateWorker:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in run_with_callback: {e}")
|
logger.error(f"Error in run_with_callback: {e}")
|
||||||
|
|
||||||
|
class DatabaseStatsWorker(QThread):
|
||||||
|
"""Simple worker for getting database statistics without blocking UI"""
|
||||||
|
|
||||||
|
# Qt signals (only available when PyQt6 is installed)
|
||||||
|
if QT_AVAILABLE:
|
||||||
|
stats_updated = pyqtSignal(dict) # Database statistics
|
||||||
|
|
||||||
|
def __init__(self, database_path: str = "database/music_library.db"):
|
||||||
|
super().__init__()
|
||||||
|
self.database_path = database_path
|
||||||
|
self.should_stop = False
|
||||||
|
|
||||||
|
# Initialize signal callbacks for headless mode
|
||||||
|
if not QT_AVAILABLE:
|
||||||
|
self.callbacks = {
|
||||||
|
'stats_updated': []
|
||||||
|
}
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the worker"""
|
||||||
|
self.should_stop = True
|
||||||
|
|
||||||
|
def _emit_signal(self, signal_name: str, *args):
|
||||||
|
"""Emit a signal in both Qt and headless modes"""
|
||||||
|
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||||
|
# Qt mode - use actual signal
|
||||||
|
getattr(self, signal_name).emit(*args)
|
||||||
|
elif not QT_AVAILABLE:
|
||||||
|
# Headless mode - use callback system
|
||||||
|
self.emit_signal(signal_name, *args)
|
||||||
|
|
||||||
|
def connect_callback(self, signal_name: str, callback: Callable):
|
||||||
|
"""Connect a callback for headless mode"""
|
||||||
|
if not QT_AVAILABLE:
|
||||||
|
self.connect_signal(signal_name, callback)
|
||||||
|
# In Qt mode, use the normal signal.connect() method
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Get database statistics and full info including last refresh"""
|
||||||
|
try:
|
||||||
|
if self.should_stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
database = get_database(self.database_path)
|
||||||
|
if self.should_stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get database info for active server (server-aware statistics)
|
||||||
|
info = database.get_database_info_for_server()
|
||||||
|
if not self.should_stop:
|
||||||
|
self._emit_signal('stats_updated', info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting database stats: {e}")
|
||||||
|
if not self.should_stop:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from config.settings import config_manager
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
|
||||||
|
self._emit_signal('stats_updated', {
|
||||||
|
'artists': 0,
|
||||||
|
'albums': 0,
|
||||||
|
'tracks': 0,
|
||||||
|
'database_size_mb': 0.0,
|
||||||
|
'last_update': None,
|
||||||
|
'last_full_refresh': None,
|
||||||
|
'server_source': active_server
|
||||||
|
})
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
"""SQLite integrity + safe-backup helpers.
|
|
||||||
|
|
||||||
Born out of a real incident: a WAL-mode DB got corrupted (most likely an
|
|
||||||
interrupted write during a hard restart), and because the backup routine
|
|
||||||
(a) never checked integrity and (b) rotated the oldest backup out by mtime,
|
|
||||||
every rolling backup ended up being a faithful copy of the already-corrupt
|
|
||||||
file — so when recovery was needed, all snapshots were poisoned.
|
|
||||||
|
|
||||||
This module makes that impossible:
|
|
||||||
|
|
||||||
* ``quick_check(path)`` / ``is_healthy(path)`` — fast read-only integrity probe.
|
|
||||||
* ``safe_backup(...)`` — verifies the SOURCE is healthy before copying, uses the
|
|
||||||
SQLite Online Backup API, then verifies the RESULT. A corrupt source never
|
|
||||||
produces (or keeps) a backup.
|
|
||||||
* ``prune_backups(...)`` — rotation that NEVER deletes the most recent
|
|
||||||
*verified-healthy* backup, even to honor the max-count, so a run of bad
|
|
||||||
backups can't evict your last good one.
|
|
||||||
|
|
||||||
Pure-ish: only touches sqlite3 + the filesystem paths it's given; no Flask, no
|
|
||||||
app globals. Unit-testable with real (and deliberately-corrupted) temp DBs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger("db_integrity")
|
|
||||||
|
|
||||||
|
|
||||||
def _close_quietly(conn) -> None:
|
|
||||||
"""Best-effort close; a failure to close during cleanup must not mask the
|
|
||||||
real error we're handling, but we log it rather than swallow silently."""
|
|
||||||
if conn is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
conn.close()
|
|
||||||
except Exception as e: # noqa: BLE001 — cleanup path, real error already in flight
|
|
||||||
logger.debug("db_integrity: connection close failed: %s", e)
|
|
||||||
|
|
||||||
|
|
||||||
class DBIntegrityError(Exception):
|
|
||||||
"""Raised when a database fails its integrity check."""
|
|
||||||
|
|
||||||
|
|
||||||
def quick_check(db_path: str, *, timeout: float = 30.0) -> str:
|
|
||||||
"""Run ``PRAGMA quick_check`` read-only and return its first result row.
|
|
||||||
|
|
||||||
Returns ``'ok'`` for a healthy DB, otherwise the first error line. Raises
|
|
||||||
``DBIntegrityError`` if the file can't even be opened/read (malformed
|
|
||||||
header, I/O error) — i.e. unambiguously bad.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
raise DBIntegrityError(f"Database file not found: {db_path}")
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=timeout)
|
|
||||||
row = conn.execute("PRAGMA quick_check(1)").fetchone()
|
|
||||||
return (row[0] if row else "no result")
|
|
||||||
except sqlite3.DatabaseError as e:
|
|
||||||
# malformed header / disk image malformed / disk I/O error
|
|
||||||
raise DBIntegrityError(f"{db_path}: {e}") from e
|
|
||||||
finally:
|
|
||||||
_close_quietly(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def is_healthy(db_path: str, *, timeout: float = 30.0) -> bool:
|
|
||||||
"""True iff the DB opens and ``quick_check`` reports 'ok'. Never raises."""
|
|
||||||
try:
|
|
||||||
return quick_check(db_path, timeout=timeout) == "ok"
|
|
||||||
except DBIntegrityError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def safe_backup(src_path: str, dst_path: str, *, verify_source: bool = True,
|
|
||||||
verify_result: bool = True) -> None:
|
|
||||||
"""Back up ``src_path`` to ``dst_path`` via the SQLite Online Backup API,
|
|
||||||
refusing to produce a backup from (or keep a backup of) a corrupt DB.
|
|
||||||
|
|
||||||
Raises ``DBIntegrityError`` and removes any partial ``dst_path`` when the
|
|
||||||
source is unhealthy (``verify_source``) or the produced backup fails its
|
|
||||||
own check (``verify_result``). On success ``dst_path`` is a verified-good
|
|
||||||
copy.
|
|
||||||
"""
|
|
||||||
if verify_source and not is_healthy(src_path):
|
|
||||||
# Don't immortalize corruption — surface it so the caller can alert
|
|
||||||
# and, crucially, NOT rotate out the existing good backups.
|
|
||||||
raise DBIntegrityError(
|
|
||||||
f"Refusing to back up: source database failed integrity check ({src_path})"
|
|
||||||
)
|
|
||||||
|
|
||||||
src = dst = None
|
|
||||||
try:
|
|
||||||
src = sqlite3.connect(src_path)
|
|
||||||
dst = sqlite3.connect(dst_path)
|
|
||||||
src.backup(dst)
|
|
||||||
finally:
|
|
||||||
_close_quietly(dst)
|
|
||||||
_close_quietly(src)
|
|
||||||
|
|
||||||
if verify_result and not is_healthy(dst_path):
|
|
||||||
# The copy itself came out bad — discard it rather than keep a dud.
|
|
||||||
try:
|
|
||||||
os.remove(dst_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise DBIntegrityError(
|
|
||||||
f"Backup produced a corrupt file and was discarded ({dst_path})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prune_backups(backup_paths, max_keep: int,
|
|
||||||
health_check=is_healthy) -> list:
|
|
||||||
"""Decide which backups to delete to honor ``max_keep`` WITHOUT ever
|
|
||||||
deleting the most-recent verified-healthy backup.
|
|
||||||
|
|
||||||
``backup_paths`` is an iterable of paths; order does not matter (we sort by
|
|
||||||
mtime). Returns the list of paths that SHOULD be deleted (does not delete
|
|
||||||
them — the caller does the IO, so this stays pure/testable).
|
|
||||||
|
|
||||||
Rule: oldest-first deletion until <= max_keep, but the single newest
|
|
||||||
*healthy* backup is protected and never selected for deletion. So even if
|
|
||||||
the newest few backups are corrupt, the last good snapshot survives.
|
|
||||||
"""
|
|
||||||
paths = [p for p in backup_paths]
|
|
||||||
# Newest first.
|
|
||||||
paths.sort(key=lambda p: _safe_mtime(p), reverse=True)
|
|
||||||
|
|
||||||
# Find the newest healthy backup — the one we must never drop.
|
|
||||||
protected: Optional[str] = None
|
|
||||||
for p in paths:
|
|
||||||
if health_check(p):
|
|
||||||
protected = p
|
|
||||||
break
|
|
||||||
|
|
||||||
if len(paths) <= max_keep:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Delete oldest-first beyond max_keep, but skip the protected one.
|
|
||||||
deletable = [p for p in paths if p != protected]
|
|
||||||
# oldest first among deletable
|
|
||||||
deletable.sort(key=lambda p: _safe_mtime(p))
|
|
||||||
num_to_delete = len(paths) - max_keep
|
|
||||||
return deletable[:num_to_delete]
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_mtime(path: str) -> float:
|
|
||||||
try:
|
|
||||||
return os.path.getmtime(path)
|
|
||||||
except OSError:
|
|
||||||
return 0.0
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
"""Debug info endpoint — lifted from web_server.py.
|
|
||||||
|
|
||||||
The function bodies are byte-identical to the originals. Module-level
|
|
||||||
shims for ``spotify_client`` and ``tidal_client`` (proxies that resolve
|
|
||||||
through the metadata registry / runtime client registry) plus injected
|
|
||||||
state dicts and helpers let the bodies resolve their original names
|
|
||||||
without modification.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from config.settings import config_manager
|
|
||||||
from core.metadata.registry import (
|
|
||||||
get_spotify_client,
|
|
||||||
get_primary_source,
|
|
||||||
is_hydrabase_enabled,
|
|
||||||
)
|
|
||||||
from core.metadata.status import get_spotify_status
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class _SpotifyClientProxy:
|
|
||||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
client = get_spotify_client()
|
|
||||||
if client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
return getattr(client, name)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return get_spotify_client() is not None
|
|
||||||
|
|
||||||
|
|
||||||
class _TidalClientProxy:
|
|
||||||
"""Resolves the global Tidal client lazily via an injected getter so a
|
|
||||||
Tidal re-auth that rebinds web_server.tidal_client is visible here."""
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
if _get_tidal_client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
client = _get_tidal_client()
|
|
||||||
if client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
return getattr(client, name)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
if _get_tidal_client is None:
|
|
||||||
return False
|
|
||||||
return _get_tidal_client() is not None
|
|
||||||
|
|
||||||
|
|
||||||
spotify_client = _SpotifyClientProxy()
|
|
||||||
tidal_client = _TidalClientProxy()
|
|
||||||
_get_tidal_client = None # injected via init()
|
|
||||||
|
|
||||||
|
|
||||||
# Injected at runtime via init().
|
|
||||||
SOULSYNC_VERSION = None
|
|
||||||
_DIRECT_RUN = None
|
|
||||||
_status_cache = None
|
|
||||||
qobuz_enrichment_worker = None
|
|
||||||
download_batches = None
|
|
||||||
sync_states = None
|
|
||||||
youtube_playlist_states = None
|
|
||||||
tidal_discovery_states = None
|
|
||||||
download_orchestrator = None
|
|
||||||
_log_path = None
|
|
||||||
_log_dir = None
|
|
||||||
app = None
|
|
||||||
get_database = None
|
|
||||||
|
|
||||||
|
|
||||||
def init(
|
|
||||||
soulsync_version,
|
|
||||||
direct_run,
|
|
||||||
status_cache,
|
|
||||||
qobuz_worker,
|
|
||||||
download_batches_dict,
|
|
||||||
sync_states_dict,
|
|
||||||
youtube_playlist_states_dict,
|
|
||||||
tidal_discovery_states_dict,
|
|
||||||
download_orchestrator_obj,
|
|
||||||
log_path,
|
|
||||||
log_dir,
|
|
||||||
flask_app,
|
|
||||||
get_database_fn,
|
|
||||||
tidal_client_getter,
|
|
||||||
):
|
|
||||||
"""Bind shared state/helpers from web_server."""
|
|
||||||
global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker
|
|
||||||
global download_batches, sync_states, youtube_playlist_states
|
|
||||||
global tidal_discovery_states, download_orchestrator, _log_path, _log_dir
|
|
||||||
global app, get_database, _get_tidal_client
|
|
||||||
SOULSYNC_VERSION = soulsync_version
|
|
||||||
_DIRECT_RUN = direct_run
|
|
||||||
_status_cache = status_cache
|
|
||||||
qobuz_enrichment_worker = qobuz_worker
|
|
||||||
download_batches = download_batches_dict
|
|
||||||
sync_states = sync_states_dict
|
|
||||||
youtube_playlist_states = youtube_playlist_states_dict
|
|
||||||
tidal_discovery_states = tidal_discovery_states_dict
|
|
||||||
download_orchestrator = download_orchestrator_obj
|
|
||||||
_log_path = log_path
|
|
||||||
_log_dir = log_dir
|
|
||||||
app = flask_app
|
|
||||||
get_database = get_database_fn
|
|
||||||
_get_tidal_client = tidal_client_getter
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_check(fn, default=False):
|
|
||||||
"""Safely evaluate a check function, returning default on any error."""
|
|
||||||
try:
|
|
||||||
return fn()
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def get_debug_info():
|
|
||||||
"""Collect system diagnostics for troubleshooting support requests."""
|
|
||||||
import sys
|
|
||||||
import psutil
|
|
||||||
import time
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
log_lines = request.args.get('lines', 20, type=int)
|
|
||||||
log_lines = max(10, min(log_lines, 500))
|
|
||||||
log_source = request.args.get('log', 'app')
|
|
||||||
|
|
||||||
info = {}
|
|
||||||
|
|
||||||
# App info
|
|
||||||
info['version'] = SOULSYNC_VERSION
|
|
||||||
info['os'] = f"{platform.system()} {platform.release()}"
|
|
||||||
info['python'] = sys.version.split()[0]
|
|
||||||
info['docker'] = os.path.exists('/.dockerenv')
|
|
||||||
info['runner'] = 'gunicorn' if not _DIRECT_RUN else 'direct (python web_server.py)'
|
|
||||||
|
|
||||||
# ffmpeg version
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
|
|
||||||
first_line = result.stdout.split('\n')[0] if result.stdout else ''
|
|
||||||
# e.g. "ffmpeg version 6.1.1 Copyright ..."
|
|
||||||
info['ffmpeg'] = first_line.split('Copyright')[0].replace('ffmpeg version', '').strip() if first_line else 'installed (version unknown)'
|
|
||||||
except FileNotFoundError:
|
|
||||||
info['ffmpeg'] = 'NOT INSTALLED'
|
|
||||||
except Exception:
|
|
||||||
info['ffmpeg'] = 'unknown'
|
|
||||||
|
|
||||||
# Uptime
|
|
||||||
start_time = getattr(app, 'start_time', time.time())
|
|
||||||
uptime_seconds = time.time() - start_time
|
|
||||||
info['uptime'] = str(timedelta(seconds=int(uptime_seconds)))
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
|
||||||
transfer_folder = config_manager.get('soulseek.transfer_path', './Transfer')
|
|
||||||
staging_folder = config_manager.get('import.staging_path', '')
|
|
||||||
info['paths'] = {
|
|
||||||
'download_path': download_path,
|
|
||||||
'download_path_exists': os.path.isdir(download_path) if download_path else False,
|
|
||||||
'download_path_writable': os.access(download_path, os.W_OK) if download_path and os.path.isdir(download_path) else False,
|
|
||||||
'transfer_folder': transfer_folder,
|
|
||||||
'transfer_folder_exists': os.path.isdir(transfer_folder) if transfer_folder else False,
|
|
||||||
'transfer_folder_writable': os.access(transfer_folder, os.W_OK) if transfer_folder and os.path.isdir(transfer_folder) else False,
|
|
||||||
'staging_folder': staging_folder,
|
|
||||||
'staging_folder_exists': os.path.isdir(staging_folder) if staging_folder else False,
|
|
||||||
}
|
|
||||||
# Music library paths (Settings > Library)
|
|
||||||
music_paths = config_manager.get('library.music_paths', [])
|
|
||||||
if isinstance(music_paths, list) and music_paths:
|
|
||||||
info['paths']['music_library_paths'] = []
|
|
||||||
for p in music_paths:
|
|
||||||
if p and isinstance(p, str):
|
|
||||||
info['paths']['music_library_paths'].append({
|
|
||||||
'path': p,
|
|
||||||
'exists': os.path.isdir(p),
|
|
||||||
})
|
|
||||||
# Music videos directory
|
|
||||||
music_videos_path = config_manager.get('library.music_videos_path', '')
|
|
||||||
if music_videos_path:
|
|
||||||
info['paths']['music_videos_path'] = music_videos_path
|
|
||||||
info['paths']['music_videos_path_exists'] = os.path.isdir(music_videos_path)
|
|
||||||
|
|
||||||
# Services. `_status_cache` only carries 'media_server' and 'soulseek'
|
|
||||||
# (no 'spotify' key) so anything we used to read from `spotify_cache`
|
|
||||||
# silently defaulted to the missing-value fallback — that's the
|
|
||||||
# "music_source: unknown" bug. Spotify status now comes from the
|
|
||||||
# canonical `get_spotify_status` accessor; primary metadata source
|
|
||||||
# comes from `get_primary_source` (which already accounts for the
|
|
||||||
# auth-fallback chain — Spotify drops back to Deezer when not
|
|
||||||
# authenticated).
|
|
||||||
media_server_cache = _status_cache.get('media_server', {})
|
|
||||||
soulseek_cache = _status_cache.get('soulseek', {})
|
|
||||||
spotify_status = _safe_check(lambda: get_spotify_status(spotify_client=spotify_client), default={})
|
|
||||||
if not isinstance(spotify_status, dict):
|
|
||||||
spotify_status = {}
|
|
||||||
info['services'] = {
|
|
||||||
'music_source': _safe_check(get_primary_source, default='unknown') or 'unknown',
|
|
||||||
'spotify_connected': bool(spotify_status.get('connected', False)),
|
|
||||||
'spotify_rate_limited': bool(spotify_status.get('rate_limited', False)),
|
|
||||||
'media_server_type': media_server_cache.get('type', 'none'),
|
|
||||||
'media_server_connected': media_server_cache.get('connected', False),
|
|
||||||
'soulseek_connected': soulseek_cache.get('connected', False),
|
|
||||||
'download_source': config_manager.get('download_source.mode', 'hybrid'),
|
|
||||||
'tidal_connected': _safe_check(lambda: bool(tidal_client and tidal_client.is_authenticated())),
|
|
||||||
'qobuz_connected': _safe_check(lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated())),
|
|
||||||
'hydrabase_connected': _safe_check(is_hydrabase_enabled),
|
|
||||||
# YouTube is URL-based via yt-dlp — no auth, always reachable as
|
|
||||||
# long as the binary is installed. Surfaced so the debug dump
|
|
||||||
# documents that YouTube is one of the available download sources
|
|
||||||
# rather than implying it doesn't exist.
|
|
||||||
'youtube_available': True,
|
|
||||||
}
|
|
||||||
# HiFi instance count — separate from connection status because each
|
|
||||||
# instance is its own independent endpoint with its own auth state.
|
|
||||||
info['services']['hifi_instance_count'] = _safe_check(
|
|
||||||
lambda: len(get_database().get_hifi_instances()), default=0
|
|
||||||
)
|
|
||||||
# Always-available public metadata sources (no auth, no per-user
|
|
||||||
# connection state). Listed so the debug dump reflects the full
|
|
||||||
# metadata surface SoulSync queries from, not just the auth-gated ones.
|
|
||||||
info['services']['always_available_metadata_sources'] = [
|
|
||||||
'deezer', 'itunes', 'musicbrainz',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Enrichment workers
|
|
||||||
workers = {}
|
|
||||||
worker_names = ['musicbrainz', 'audiodb', 'deezer', 'spotify', 'itunes', 'lastfm', 'genius', 'discogs', 'tidal', 'qobuz']
|
|
||||||
for name in worker_names:
|
|
||||||
paused_key = f'{name}_enrichment_paused'
|
|
||||||
workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active'
|
|
||||||
info['enrichment_workers'] = workers
|
|
||||||
|
|
||||||
# Library stats — use same method as dashboard (filters by active server)
|
|
||||||
try:
|
|
||||||
db = get_database()
|
|
||||||
lib_stats = db.get_database_info_for_server()
|
|
||||||
info['library'] = {
|
|
||||||
'artists': lib_stats.get('artists', 0),
|
|
||||||
'albums': lib_stats.get('albums', 0),
|
|
||||||
'tracks': lib_stats.get('tracks', 0),
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
info['library'] = {'artists': 0, 'albums': 0, 'tracks': 0}
|
|
||||||
|
|
||||||
# Watchlist count
|
|
||||||
try:
|
|
||||||
db = get_database()
|
|
||||||
info['watchlist_count'] = db.get_watchlist_count()
|
|
||||||
except Exception:
|
|
||||||
info['watchlist_count'] = 0
|
|
||||||
|
|
||||||
# Wishlist pending count
|
|
||||||
try:
|
|
||||||
db = get_database()
|
|
||||||
info['wishlist_count'] = db.get_wishlist_count()
|
|
||||||
except Exception:
|
|
||||||
info['wishlist_count'] = 0
|
|
||||||
|
|
||||||
# Automation count
|
|
||||||
try:
|
|
||||||
db = get_database()
|
|
||||||
automations = db.get_automations()
|
|
||||||
info['automations'] = {
|
|
||||||
'total': len(automations),
|
|
||||||
'enabled': len([a for a in automations if a.get('enabled', False)]),
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
info['automations'] = {'total': 0, 'enabled': 0}
|
|
||||||
|
|
||||||
# Active downloads & syncs (use list() snapshots to avoid RuntimeError from concurrent mutation)
|
|
||||||
try:
|
|
||||||
active_downloads = len([bid for bid, bd in list(download_batches.items()) if bd.get('phase') == 'downloading'])
|
|
||||||
except Exception:
|
|
||||||
active_downloads = 0
|
|
||||||
active_syncs = 0
|
|
||||||
try:
|
|
||||||
for _pid, ss in list(sync_states.items()):
|
|
||||||
if ss.get('status') == 'syncing':
|
|
||||||
active_syncs += 1
|
|
||||||
for _uh, st in list(youtube_playlist_states.items()):
|
|
||||||
if st.get('phase') == 'syncing':
|
|
||||||
active_syncs += 1
|
|
||||||
for _pid, st in list(tidal_discovery_states.items()):
|
|
||||||
if st.get('phase') == 'syncing':
|
|
||||||
active_syncs += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("count active syncs failed: %s", e)
|
|
||||||
info['active_downloads'] = active_downloads
|
|
||||||
info['active_syncs'] = active_syncs
|
|
||||||
|
|
||||||
# Config settings relevant to troubleshooting
|
|
||||||
source_mode = config_manager.get('download_source.mode', 'hybrid')
|
|
||||||
info['config'] = {
|
|
||||||
'source_mode': source_mode,
|
|
||||||
'quality_profile': config_manager.get('download_source.quality_profile', 'default'),
|
|
||||||
'organization_template': config_manager.get('organization.folder_template', ''),
|
|
||||||
'post_processing_enabled': config_manager.get('post_processing.enabled', True),
|
|
||||||
'acoustid_enabled': bool(config_manager.get('acoustid.api_key', '')),
|
|
||||||
'auto_scan_enabled': config_manager.get('watchlist.auto_scan', False),
|
|
||||||
'm3u_export_enabled': config_manager.get('m3u.enabled', False),
|
|
||||||
'log_level': config_manager.get('logging.level', 'INFO'),
|
|
||||||
'primary_metadata_source': config_manager.get('metadata.fallback_source', 'deezer'),
|
|
||||||
'lossy_copy_enabled': config_manager.get('post_processing.lossy_copy.enabled', False),
|
|
||||||
'lossy_copy_format': config_manager.get('post_processing.lossy_copy.format', 'mp3'),
|
|
||||||
'lossy_copy_bitrate': config_manager.get('post_processing.lossy_copy.bitrate', 320),
|
|
||||||
'allow_duplicate_tracks': config_manager.get('library.allow_duplicate_tracks', False),
|
|
||||||
'replace_lower_quality': config_manager.get('import.replace_lower_quality', False),
|
|
||||||
'auto_import_enabled': config_manager.get('import.auto_import_enabled', False),
|
|
||||||
}
|
|
||||||
# Hybrid source priority order
|
|
||||||
if source_mode == 'hybrid':
|
|
||||||
info['config']['hybrid_sources'] = config_manager.get('download_source.hybrid_order', [])
|
|
||||||
# Discogs connection status
|
|
||||||
info['services']['discogs_connected'] = bool(config_manager.get('discogs.token', ''))
|
|
||||||
|
|
||||||
# Download client init failures
|
|
||||||
info['download_client_failures'] = []
|
|
||||||
if download_orchestrator and hasattr(download_orchestrator, '_init_failures'):
|
|
||||||
info['download_client_failures'] = download_orchestrator._init_failures
|
|
||||||
elif not download_orchestrator:
|
|
||||||
info['download_client_failures'] = ['ALL (orchestrator failed to initialize)']
|
|
||||||
|
|
||||||
# API rate monitor — current calls/min, 24h totals, peaks, rate limit events
|
|
||||||
try:
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
# `get_spotify_status` is already imported at module level. A
|
|
||||||
# local re-import here would make Python treat the name as a
|
|
||||||
# function-scoped local for the WHOLE body, breaking the lambda
|
|
||||||
# at the top of get_debug_info that closes over the module-level
|
|
||||||
# binding (Python 3.12 NameError on free variables).
|
|
||||||
rates = api_call_tracker.get_all_rates()
|
|
||||||
info['api_rates'] = rates
|
|
||||||
# Rich 24h debug summary with peaks, totals, per-endpoint breakdown, events
|
|
||||||
info['api_debug_summary'] = api_call_tracker.get_debug_summary()
|
|
||||||
# Spotify rate limit details
|
|
||||||
spotify_status = get_spotify_status(spotify_client=spotify_client)
|
|
||||||
rl_info = spotify_status.get('rate_limit')
|
|
||||||
if spotify_status.get('rate_limited') and rl_info:
|
|
||||||
info['spotify_rate_limit'] = {
|
|
||||||
'active': True,
|
|
||||||
'remaining_seconds': rl_info.get('remaining_seconds', 0),
|
|
||||||
'retry_after': rl_info.get('retry_after', 0),
|
|
||||||
'endpoint': rl_info.get('endpoint', ''),
|
|
||||||
'expires_at': rl_info.get('expires_at', ''),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
info['spotify_rate_limit'] = {'active': False}
|
|
||||||
except Exception:
|
|
||||||
info['api_rates'] = {}
|
|
||||||
info['api_debug_summary'] = {}
|
|
||||||
info['spotify_rate_limit'] = {'active': False}
|
|
||||||
|
|
||||||
# Database size
|
|
||||||
db_path = os.path.join('database', 'music_library.db')
|
|
||||||
if os.path.exists(db_path):
|
|
||||||
db_size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
|
||||||
info['database_size'] = f"{db_size_mb:.1f} MB"
|
|
||||||
else:
|
|
||||||
info['database_size'] = 'not found'
|
|
||||||
|
|
||||||
# Memory & CPU
|
|
||||||
process = psutil.Process(os.getpid())
|
|
||||||
mem = process.memory_info()
|
|
||||||
info['memory_usage'] = f"{mem.rss / (1024 * 1024):.0f} MB"
|
|
||||||
info['system_memory'] = f"{psutil.virtual_memory().percent}%"
|
|
||||||
try:
|
|
||||||
info['cpu_percent'] = f"{process.cpu_percent(interval=0.1):.1f}%"
|
|
||||||
except Exception:
|
|
||||||
info['cpu_percent'] = 'unknown'
|
|
||||||
info['thread_count'] = process.num_threads()
|
|
||||||
|
|
||||||
# Log lines
|
|
||||||
log_map = {
|
|
||||||
'app': Path(_log_path),
|
|
||||||
'acoustid': _log_dir / 'acoustid.log',
|
|
||||||
'post_processing': _log_dir / 'post_processing.log',
|
|
||||||
'source_reuse': _log_dir / 'source_reuse.log',
|
|
||||||
}
|
|
||||||
log_path = log_map.get(log_source, log_map['app'])
|
|
||||||
info['log_source'] = log_source
|
|
||||||
info['log_lines_requested'] = log_lines
|
|
||||||
info['recent_logs'] = []
|
|
||||||
if os.path.exists(log_path):
|
|
||||||
try:
|
|
||||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
info['recent_logs'] = [line.rstrip() for line in lines[-log_lines:]]
|
|
||||||
except Exception:
|
|
||||||
info['recent_logs'] = ['(could not read log file)']
|
|
||||||
|
|
||||||
# Available log files
|
|
||||||
info['available_logs'] = []
|
|
||||||
logs_dir = 'logs'
|
|
||||||
if os.path.isdir(logs_dir):
|
|
||||||
for fname in sorted(os.listdir(logs_dir)):
|
|
||||||
if fname.endswith('.log'):
|
|
||||||
fpath = os.path.join(logs_dir, fname)
|
|
||||||
size_kb = os.path.getsize(fpath) / 1024
|
|
||||||
info['available_logs'].append({
|
|
||||||
'name': fname.replace('.log', ''),
|
|
||||||
'file': fname,
|
|
||||||
'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB",
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify(info)
|
|
||||||
|
|
@ -6,8 +6,7 @@ from typing import Dict, List, Optional, Any
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
|
from core.metadata_cache import get_metadata_cache
|
||||||
from core.metadata.cache import get_metadata_cache
|
|
||||||
|
|
||||||
logger = get_logger("deezer_client")
|
logger = get_logger("deezer_client")
|
||||||
|
|
||||||
|
|
@ -32,9 +31,6 @@ def rate_limited(func):
|
||||||
|
|
||||||
_last_api_call_time = time.time()
|
_last_api_call_time = time.time()
|
||||||
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
api_call_tracker.record_call('deezer')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
return result
|
return result
|
||||||
|
|
@ -46,119 +42,6 @@ def rate_limited(func):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# Pattern matches Deezer's CDN cover/picture URL: a numeric width-x-height
|
|
||||||
# segment in the path (e.g. ``/1000x1000-000000-80-0-0.jpg``). Captures
|
|
||||||
# both halves so the replacement can use a single dimension and preserve
|
|
||||||
# the rest of the path verbatim.
|
|
||||||
_DEEZER_CDN_SIZE_PATTERN = re.compile(r'/(\d+)x(\d+)-')
|
|
||||||
|
|
||||||
# Maximum size Deezer's CDN serves before returning 403. Verified
|
|
||||||
# empirically against multiple albums — 1900 works reliably, 2000+
|
|
||||||
# returns Forbidden. CDN serves the source-native size when it's
|
|
||||||
# smaller than requested, so asking for 1900 is safe even on albums
|
|
||||||
# whose source upload was lower-res (no upscaling, just same bytes).
|
|
||||||
_DEEZER_MAX_COVER_SIZE = 1900
|
|
||||||
|
|
||||||
|
|
||||||
def _upgrade_deezer_cover_url(url: str, target_size: int = _DEEZER_MAX_COVER_SIZE) -> str:
|
|
||||||
"""Rewrite a Deezer CDN cover/picture URL to request a larger size.
|
|
||||||
|
|
||||||
Deezer's API returns ``cover_xl`` / ``picture_xl`` URLs at
|
|
||||||
1000×1000, but the underlying CDN serves up to 1900×1900 by
|
|
||||||
rewriting the size segment in the URL path. This helper does the
|
|
||||||
rewrite — same idea as ``_upgrade_spotify_image_url`` in
|
|
||||||
``spotify_client`` and the ``mzstatic.com`` size-replacement in
|
|
||||||
``download_cover_art``.
|
|
||||||
|
|
||||||
Defensive on every input shape:
|
|
||||||
- Empty / None URL → returned as-is
|
|
||||||
- Non-Deezer URL (no ``dzcdn`` host, no size segment) → returned as-is
|
|
||||||
- Already at or above target size → returned as-is (no point rewriting)
|
|
||||||
|
|
||||||
The CDN returns the source-native image bytes when source < target,
|
|
||||||
so asking for 1900 on an album whose source was uploaded at 600
|
|
||||||
just returns the 600-pixel image — no upscaling, no failure.
|
|
||||||
"""
|
|
||||||
if not url or 'dzcdn' not in url:
|
|
||||||
return url
|
|
||||||
match = _DEEZER_CDN_SIZE_PATTERN.search(url)
|
|
||||||
if not match:
|
|
||||||
return url
|
|
||||||
current = int(match.group(1))
|
|
||||||
if current >= target_size:
|
|
||||||
return url
|
|
||||||
return _DEEZER_CDN_SIZE_PATTERN.sub(f'/{target_size}x{target_size}-', url, count=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
|
|
||||||
"""Distinguish a full `/track/<id>` cache hit from partial album-tracks data.
|
|
||||||
|
|
||||||
Three Deezer endpoints feed the per-track cache:
|
|
||||||
- `/track/<id>` — full record, includes both `track_position` AND
|
|
||||||
`contributors` (the multi-artist list the contributors-upgrade
|
|
||||||
path reads).
|
|
||||||
- `/album/<id>/tracks` — partial; includes `track_position` but
|
|
||||||
omits `contributors`.
|
|
||||||
- `/search/track` — minimal; lacks `track_position`.
|
|
||||||
|
|
||||||
Pre-fix `get_track_details` only checked `track_position`, so
|
|
||||||
partial album-tracks payloads were treated as full hits and the
|
|
||||||
contributors-upgrade silently fell back to single-artist tagging
|
|
||||||
whenever an album had been fetched before its individual tracks
|
|
||||||
were post-processed (issue #588).
|
|
||||||
|
|
||||||
`contributors` key presence is the load-bearing distinction —
|
|
||||||
`[]` is a valid value for genuinely single-artist tracks fetched
|
|
||||||
via the per-track endpoint, so test for key membership not
|
|
||||||
truthiness.
|
|
||||||
"""
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return False
|
|
||||||
return 'track_position' in payload and 'contributors' in payload
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_album_track_positions(session, base_url, album_ids, cache=None, sleep_s=0.2):
|
|
||||||
"""Build ``{str(track_id): track_position}`` for a set of Deezer album ids.
|
|
||||||
|
|
||||||
Deezer PLAYLIST and SEARCH track objects (and even the album object's embedded
|
|
||||||
``tracks.data``) omit ``track_position`` — only ``/album/<id>/tracks`` and
|
|
||||||
``/track/<id>`` carry it. So numbering playlist tracks by their playlist index
|
|
||||||
silently poisons the real album track number, which then rides onto the
|
|
||||||
downloaded file's tag. This resolves the authoritative position per album
|
|
||||||
(cache-first, best-effort — a failed album just isn't in the map)."""
|
|
||||||
import time as _time
|
|
||||||
positions: Dict[str, int] = {}
|
|
||||||
for aid in album_ids:
|
|
||||||
aid = str(aid)
|
|
||||||
at_list = None
|
|
||||||
if cache:
|
|
||||||
try:
|
|
||||||
ct = cache.get_entity('deezer', 'album_tracks', aid)
|
|
||||||
if ct and ct.get('data'):
|
|
||||||
at_list = ct['data']
|
|
||||||
except Exception: # noqa: BLE001 - cache is best-effort
|
|
||||||
at_list = None
|
|
||||||
if at_list is None:
|
|
||||||
try:
|
|
||||||
if sleep_s:
|
|
||||||
_time.sleep(sleep_s) # respect Deezer rate limits
|
|
||||||
r = session.get(f"{base_url}/album/{aid}/tracks", params={'limit': 500}, timeout=10)
|
|
||||||
if getattr(r, 'ok', False):
|
|
||||||
at_list = (r.json() or {}).get('data', [])
|
|
||||||
if cache and at_list is not None:
|
|
||||||
try:
|
|
||||||
cache.store_entity('deezer', 'album_tracks', aid, {'data': at_list})
|
|
||||||
except Exception as _cache_err: # noqa: BLE001
|
|
||||||
logger.debug("album_tracks cache store failed for %s: %s", aid, _cache_err)
|
|
||||||
except Exception: # noqa: BLE001 - never let metadata resolution break the fetch
|
|
||||||
at_list = None
|
|
||||||
for at in (at_list or []):
|
|
||||||
tp = at.get('track_position')
|
|
||||||
if at.get('id') and tp:
|
|
||||||
positions[str(at['id'])] = tp
|
|
||||||
return positions
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -186,21 +69,9 @@ class Track:
|
||||||
if isinstance(album_data, dict):
|
if isinstance(album_data, dict):
|
||||||
album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')
|
album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')
|
||||||
|
|
||||||
# Get artist name(s) — use contributors for multi-artist tracks (feat. collabs)
|
# Get artist name
|
||||||
artist_data = track_data.get('artist', {})
|
artist_data = track_data.get('artist', {})
|
||||||
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
|
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
|
||||||
contributors = track_data.get('contributors', [])
|
|
||||||
if isinstance(contributors, list) and len(contributors) > 1:
|
|
||||||
artist_names = []
|
|
||||||
for c in contributors:
|
|
||||||
if isinstance(c, dict) and c.get('name'):
|
|
||||||
artist_names.append(c['name'])
|
|
||||||
if artist_names:
|
|
||||||
all_artists = artist_names
|
|
||||||
else:
|
|
||||||
all_artists = [artist_name]
|
|
||||||
else:
|
|
||||||
all_artists = [artist_name]
|
|
||||||
|
|
||||||
# Get album name
|
# Get album name
|
||||||
album_name = ''
|
album_name = ''
|
||||||
|
|
@ -228,7 +99,7 @@ class Track:
|
||||||
return cls(
|
return cls(
|
||||||
id=str(track_data.get('id', '')),
|
id=str(track_data.get('id', '')),
|
||||||
name=track_data.get('title', ''),
|
name=track_data.get('title', ''),
|
||||||
artists=all_artists,
|
artists=[artist_name],
|
||||||
album=album_name,
|
album=album_name,
|
||||||
duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds
|
duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds
|
||||||
popularity=track_data.get('rank', 0),
|
popularity=track_data.get('rank', 0),
|
||||||
|
|
@ -282,7 +153,6 @@ class Album:
|
||||||
album_type: str
|
album_type: str
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
explicit: Optional[bool] = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
|
def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
|
||||||
|
|
@ -314,8 +184,7 @@ class Album:
|
||||||
total_tracks=album_data.get('nb_tracks', 0),
|
total_tracks=album_data.get('nb_tracks', 0),
|
||||||
album_type=album_type,
|
album_type=album_type,
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
external_urls=external_urls if external_urls else None,
|
external_urls=external_urls if external_urls else None
|
||||||
explicit=bool(album_data.get('explicit_lyrics', False)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -351,40 +220,20 @@ class DeezerClient:
|
||||||
'User-Agent': 'SoulSync/1.0',
|
'User-Agent': 'SoulSync/1.0',
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
})
|
})
|
||||||
self._access_token = None
|
logger.info("Deezer client initialized")
|
||||||
self._load_token()
|
|
||||||
logger.info("Deezer client initialized" + (" (authenticated)" if self._access_token else " (public)"))
|
|
||||||
|
|
||||||
def _load_token(self):
|
|
||||||
"""Load OAuth access token from config if available."""
|
|
||||||
try:
|
|
||||||
from config.settings import config_manager
|
|
||||||
self._access_token = config_manager.get('deezer.access_token', None)
|
|
||||||
except Exception:
|
|
||||||
self._access_token = None
|
|
||||||
|
|
||||||
def is_user_authenticated(self) -> bool:
|
|
||||||
"""Check if we have a Deezer OAuth user token (for favorites, playlists, etc.)"""
|
|
||||||
return bool(self._access_token)
|
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
"""Deezer public API requires no authentication — always available"""
|
"""Deezer public API requires no authentication — always available"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def reload_config(self):
|
def reload_config(self):
|
||||||
"""Reload configuration — refresh OAuth token from config."""
|
"""Reload configuration (no-op for Deezer since no auth required)"""
|
||||||
self._load_token()
|
pass
|
||||||
|
|
||||||
def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]:
|
def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]:
|
||||||
"""Generic GET request to Deezer API with error handling.
|
"""Generic GET request to Deezer API with error handling"""
|
||||||
Includes OAuth access_token when available for user-level endpoints."""
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
||||||
if params is None:
|
|
||||||
params = {}
|
|
||||||
# Include access token for authenticated requests
|
|
||||||
if self._access_token and 'access_token' not in params:
|
|
||||||
params['access_token'] = self._access_token
|
|
||||||
response = self.session.get(url, params=params, timeout=timeout)
|
response = self.session.get(url, params=params, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
|
@ -414,136 +263,39 @@ class DeezerClient:
|
||||||
# can serve as a drop-in fallback metadata source in SpotifyClient.
|
# can serve as a drop-in fallback metadata source in SpotifyClient.
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_tracks(
|
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||||
self,
|
"""Search for tracks — returns Track dataclass list (metadata source interface)"""
|
||||||
query: str = '',
|
|
||||||
limit: int = 20,
|
|
||||||
*,
|
|
||||||
track: Optional[str] = None,
|
|
||||||
artist: Optional[str] = None,
|
|
||||||
album: Optional[str] = None,
|
|
||||||
) -> List[Track]:
|
|
||||||
"""Search for tracks — returns Track dataclass list (metadata source interface).
|
|
||||||
|
|
||||||
Two call modes:
|
|
||||||
|
|
||||||
1. **Free-text** (`query='Foreigner Dirty White Boy'`) — legacy
|
|
||||||
shape, passes the string straight to Deezer's `q` param.
|
|
||||||
Same behaviour as before, kept for backward compat.
|
|
||||||
|
|
||||||
2. **Field-scoped** (`track='Dirty White Boy', artist='Foreigner'`) —
|
|
||||||
builds Deezer's advanced search syntax (`track:"X" artist:"Y"`).
|
|
||||||
Massively tighter relevance than the free-text path because
|
|
||||||
the API matches each term in the right field instead of
|
|
||||||
anywhere across title / lyrics / artist / album / contributors.
|
|
||||||
Without this, the Deezer ranking buries the canonical track
|
|
||||||
under karaoke / cover / "originally performed by" variants
|
|
||||||
— see issue #534.
|
|
||||||
|
|
||||||
Field-scoped form is used whenever ``track`` or ``artist`` is
|
|
||||||
provided. ``query`` is ignored in that case (the field params
|
|
||||||
are authoritative). When both are missing, falls through to
|
|
||||||
``query``. The cache key is the constructed query string in
|
|
||||||
either case so the two paths share entries naturally.
|
|
||||||
"""
|
|
||||||
# Build the actual API query — advanced syntax when callers pass
|
|
||||||
# field hints, raw query otherwise.
|
|
||||||
used_advanced = bool(track or artist or album)
|
|
||||||
if used_advanced:
|
|
||||||
api_query = self._build_advanced_query(track=track, artist=artist, album=album)
|
|
||||||
else:
|
|
||||||
api_query = query
|
|
||||||
|
|
||||||
if not api_query:
|
|
||||||
return []
|
|
||||||
|
|
||||||
tracks = self._search_tracks_with_query(api_query, limit)
|
|
||||||
|
|
||||||
# Safety net: Deezer's advanced syntax is `artist:"X"`-style
|
|
||||||
# substring match, but in practice it's brittle on artist name
|
|
||||||
# variants ("Foreigner [US]", "The Foreigner", etc.) and on
|
|
||||||
# tracks indexed under non-canonical title spellings. When the
|
|
||||||
# advanced query returns nothing, fall back to a free-text join
|
|
||||||
# so the user sees the prior (less-relevant but non-empty) result
|
|
||||||
# set rather than "No matches" — same behaviour as pre-fix for
|
|
||||||
# this edge case. Caller-side rerank still tightens the result.
|
|
||||||
if not tracks and used_advanced:
|
|
||||||
fallback_parts = [p for p in (track, artist, album) if p]
|
|
||||||
fallback_query = ' '.join(fallback_parts)
|
|
||||||
if fallback_query and fallback_query != api_query:
|
|
||||||
logger.debug(
|
|
||||||
"[Deezer] Advanced query returned 0 results, falling back "
|
|
||||||
"to free-text: %r → %r", api_query, fallback_query,
|
|
||||||
)
|
|
||||||
tracks = self._search_tracks_with_query(fallback_query, limit)
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def _search_tracks_with_query(self, api_query: str, limit: int) -> List[Track]:
|
|
||||||
"""Cache-aware single API call. Pulled out so the
|
|
||||||
``search_tracks`` orchestration can call this twice (advanced
|
|
||||||
query → free-text fallback) without duplicating the cache +
|
|
||||||
parse + store dance."""
|
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cached_results = cache.get_search_results('deezer', 'track', api_query, limit)
|
cached_results = cache.get_search_results('deezer', 'track', query, limit)
|
||||||
if cached_results is not None:
|
if cached_results is not None:
|
||||||
tracks = []
|
tracks = []
|
||||||
for raw in cached_results:
|
for raw in cached_results:
|
||||||
try:
|
try:
|
||||||
tracks.append(Track.from_deezer_track(raw))
|
tracks.append(Track.from_deezer_track(raw))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("Track.from_deezer_track cache parse: %s", e)
|
pass
|
||||||
if tracks:
|
if tracks:
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
data = self._api_get('search/track', {'q': api_query, 'limit': min(limit, 100)})
|
data = self._api_get('search/track', {'q': query, 'limit': min(limit, 100)})
|
||||||
if not data or 'data' not in data:
|
if not data or 'data' not in data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
raw_items = []
|
raw_items = []
|
||||||
for track_data in data['data']:
|
for track_data in data['data']:
|
||||||
track_obj = Track.from_deezer_track(track_data)
|
track = Track.from_deezer_track(track_data)
|
||||||
tracks.append(track_obj)
|
tracks.append(track)
|
||||||
raw_items.append(track_data)
|
raw_items.append(track_data)
|
||||||
|
|
||||||
entries = [(str(td.get('id', '')), td) for td in raw_items if td.get('id')]
|
entries = [(str(td.get('id', '')), td) for td in raw_items if td.get('id')]
|
||||||
if entries:
|
if entries:
|
||||||
cache.store_entities_bulk('deezer', 'track', entries)
|
cache.store_entities_bulk('deezer', 'track', entries)
|
||||||
cache.store_search_results('deezer', 'track', api_query, limit,
|
cache.store_search_results('deezer', 'track', query, limit,
|
||||||
[str(td.get('id', '')) for td in raw_items if td.get('id')])
|
[str(td.get('id', '')) for td in raw_items if td.get('id')])
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_advanced_query(
|
|
||||||
*,
|
|
||||||
track: Optional[str] = None,
|
|
||||||
artist: Optional[str] = None,
|
|
||||||
album: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Compose Deezer's advanced search syntax from field hints.
|
|
||||||
|
|
||||||
Per Deezer's docs:
|
|
||||||
https://developers.deezer.com/api/search
|
|
||||||
|
|
||||||
q=track:"X" artist:"Y" album:"Z"
|
|
||||||
|
|
||||||
Quotes around each value preserve multi-word phrases. Empty
|
|
||||||
fields are skipped. Embedded double-quotes get stripped (no
|
|
||||||
escape mechanism in Deezer's syntax) — rare in practice, but
|
|
||||||
a search for `O"Hara` would otherwise produce a malformed
|
|
||||||
query.
|
|
||||||
"""
|
|
||||||
parts = []
|
|
||||||
if track:
|
|
||||||
parts.append(f'track:"{track.replace(chr(34), "")}"')
|
|
||||||
if artist:
|
|
||||||
parts.append(f'artist:"{artist.replace(chr(34), "")}"')
|
|
||||||
if album:
|
|
||||||
parts.append(f'album:"{album.replace(chr(34), "")}"')
|
|
||||||
return ' '.join(parts)
|
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||||
"""Search for artists — returns Artist dataclass list (metadata source interface)"""
|
"""Search for artists — returns Artist dataclass list (metadata source interface)"""
|
||||||
|
|
@ -554,8 +306,8 @@ class DeezerClient:
|
||||||
for raw in cached_results:
|
for raw in cached_results:
|
||||||
try:
|
try:
|
||||||
artists.append(Artist.from_deezer_artist(raw))
|
artists.append(Artist.from_deezer_artist(raw))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("Artist.from_deezer_artist cache parse: %s", e)
|
pass
|
||||||
if artists:
|
if artists:
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
|
|
@ -588,8 +340,8 @@ class DeezerClient:
|
||||||
for raw in cached_results:
|
for raw in cached_results:
|
||||||
try:
|
try:
|
||||||
albums.append(Album.from_deezer_album(raw))
|
albums.append(Album.from_deezer_album(raw))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("Album.from_deezer_album cache parse: %s", e)
|
pass
|
||||||
if albums:
|
if albums:
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
|
|
@ -616,9 +368,12 @@ class DeezerClient:
|
||||||
"""Get detailed track info — returns Spotify-compatible dict (metadata source interface)"""
|
"""Get detailed track info — returns Spotify-compatible dict (metadata source interface)"""
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cached = cache.get_entity('deezer', 'track', str(track_id))
|
cached = cache.get_entity('deezer', 'track', str(track_id))
|
||||||
if cached and cached.get('title') and _is_full_track_payload(cached):
|
if cached and cached.get('title'):
|
||||||
return self._build_enhanced_track(cached)
|
# Search results are cached with minimal data (no release_date, track_position).
|
||||||
# Otherwise fall through to fetch full data from API
|
# Only use cache if it has fields that the /track/{id} endpoint provides.
|
||||||
|
if 'release_date' in cached or 'track_position' in cached or 'isrc' in cached:
|
||||||
|
return self._build_enhanced_track(cached)
|
||||||
|
# Otherwise fall through to fetch full data from API
|
||||||
|
|
||||||
data = self._api_get(f'track/{track_id}')
|
data = self._api_get(f'track/{track_id}')
|
||||||
if not data:
|
if not data:
|
||||||
|
|
@ -636,15 +391,6 @@ class DeezerClient:
|
||||||
album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else ''
|
album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else ''
|
||||||
album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else ''
|
album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else ''
|
||||||
|
|
||||||
# Use contributors for multi-artist tracks
|
|
||||||
contributors = track_data.get('contributors', [])
|
|
||||||
if isinstance(contributors, list) and len(contributors) > 1:
|
|
||||||
all_artists = [c['name'] for c in contributors if isinstance(c, dict) and c.get('name')]
|
|
||||||
if not all_artists:
|
|
||||||
all_artists = [artist_name]
|
|
||||||
else:
|
|
||||||
all_artists = [artist_name]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': str(track_data.get('id', '')),
|
'id': str(track_data.get('id', '')),
|
||||||
'name': track_data.get('title', ''),
|
'name': track_data.get('title', ''),
|
||||||
|
|
@ -652,7 +398,7 @@ class DeezerClient:
|
||||||
'disc_number': track_data.get('disk_number', 1),
|
'disc_number': track_data.get('disk_number', 1),
|
||||||
'duration_ms': track_data.get('duration', 0) * 1000,
|
'duration_ms': track_data.get('duration', 0) * 1000,
|
||||||
'explicit': track_data.get('explicit_lyrics', False),
|
'explicit': track_data.get('explicit_lyrics', False),
|
||||||
'artists': all_artists,
|
'artists': [artist_name],
|
||||||
'primary_artist': artist_name,
|
'primary_artist': artist_name,
|
||||||
'album': {
|
'album': {
|
||||||
'id': album_id,
|
'id': album_id,
|
||||||
|
|
@ -807,74 +553,6 @@ class DeezerClient:
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
||||||
"""Return the artist's top tracks in Spotify-compatible dict format.
|
|
||||||
|
|
||||||
Wraps Deezer's `/artist/{id}/top?limit=N`. Returns dicts with the same
|
|
||||||
shape Spotify's `artist_top_tracks` produces — id, name, artists, album
|
|
||||||
(with album_type / total_tracks / release_date / images), duration_ms,
|
|
||||||
track_number, disc_number — so callers don't need to branch on source.
|
|
||||||
"""
|
|
||||||
if not artist_id:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
limit = max(1, min(int(limit or 10), 100))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
limit = 10
|
|
||||||
|
|
||||||
data = self._api_get(f'artist/{artist_id}/top', {'limit': limit})
|
|
||||||
if not data or 'data' not in data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
tracks = []
|
|
||||||
for track_data in data['data']:
|
|
||||||
if not isinstance(track_data, dict):
|
|
||||||
continue
|
|
||||||
artist_data = track_data.get('artist') or {}
|
|
||||||
album_data = track_data.get('album') or {}
|
|
||||||
|
|
||||||
# Build images list from any cover sizes Deezer returned for the album
|
|
||||||
images = []
|
|
||||||
if isinstance(album_data, dict):
|
|
||||||
for size_key, dim in [('cover_xl', 1000), ('cover_big', 500),
|
|
||||||
('cover_medium', 250), ('cover_small', 56)]:
|
|
||||||
if album_data.get(size_key):
|
|
||||||
images.append({'url': album_data[size_key], 'height': dim, 'width': dim})
|
|
||||||
|
|
||||||
# Deezer `/artist/{id}/top` results don't include record_type on the
|
|
||||||
# nested album object; we don't have a track-count to infer from
|
|
||||||
# either. Default 'album' so the path-builder template variable
|
|
||||||
# always has something to substitute (existing behavior elsewhere).
|
|
||||||
album_payload = {
|
|
||||||
'id': str(album_data.get('id', '')) if isinstance(album_data, dict) else '',
|
|
||||||
'name': album_data.get('title', '') if isinstance(album_data, dict) else '',
|
|
||||||
'album_type': 'album',
|
|
||||||
'images': images,
|
|
||||||
'release_date': '',
|
|
||||||
'total_tracks': 0,
|
|
||||||
'artists': [{'name': artist_data.get('name', '')}] if isinstance(artist_data, dict) else [],
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks.append({
|
|
||||||
'id': str(track_data.get('id', '')),
|
|
||||||
'name': track_data.get('title', ''),
|
|
||||||
'artists': [{
|
|
||||||
'id': str(artist_data.get('id', '')) if isinstance(artist_data, dict) else '',
|
|
||||||
'name': artist_data.get('name', '') if isinstance(artist_data, dict) else '',
|
|
||||||
}],
|
|
||||||
'album': album_payload,
|
|
||||||
'duration_ms': (track_data.get('duration') or 0) * 1000, # Deezer is seconds
|
|
||||||
'popularity': track_data.get('rank', 0),
|
|
||||||
'preview_url': track_data.get('preview'),
|
|
||||||
'external_urls': {'deezer': track_data['link']} if track_data.get('link') else {},
|
|
||||||
'track_number': track_data.get('track_position'),
|
|
||||||
'disc_number': track_data.get('disk_number', 1),
|
|
||||||
'explicit': bool(track_data.get('explicit_lyrics', False)),
|
|
||||||
'_source': 'deezer',
|
|
||||||
})
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get full artist details — returns Spotify-compatible dict (metadata source interface).
|
"""Get full artist details — returns Spotify-compatible dict (metadata source interface).
|
||||||
|
|
||||||
|
|
@ -911,80 +589,31 @@ class DeezerClient:
|
||||||
'_raw_data': artist_data
|
'_raw_data': artist_data
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_artist_albums_list(self, artist_id: str, album_type: str = 'album,single', limit: int = 200) -> List[Album]:
|
def get_artist_albums_list(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||||
"""Get albums by artist ID — returns Album dataclass list (metadata source interface).
|
"""Get albums by artist ID — returns Album dataclass list (metadata source interface).
|
||||||
|
|
||||||
Matches iTunesClient.get_artist_albums() interface.
|
Matches iTunesClient.get_artist_albums() interface."""
|
||||||
Paginates through all results up to the requested limit."""
|
data = self._api_get(f'artist/{artist_id}/albums', {'limit': min(limit, 100)})
|
||||||
cache = get_metadata_cache()
|
if not data or 'data' not in data:
|
||||||
cached_items = get_cached_artist_album_items(cache, 'deezer', artist_id, album_type=album_type, limit=limit)
|
return []
|
||||||
if cached_items:
|
|
||||||
try:
|
|
||||||
requested_types = [t.strip() for t in album_type.split(',')]
|
|
||||||
cached_albums = []
|
|
||||||
for album_data in cached_items:
|
|
||||||
album = Album.from_deezer_album(album_data)
|
|
||||||
if album_type != 'album,single':
|
|
||||||
if album.album_type not in requested_types:
|
|
||||||
if not (album.album_type == 'ep' and 'single' in requested_types):
|
|
||||||
continue
|
|
||||||
cached_albums.append(album)
|
|
||||||
return cached_albums[:limit]
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Deezer artist albums cache reuse failed: %s", e)
|
|
||||||
|
|
||||||
albums = []
|
albums = []
|
||||||
all_raw = []
|
|
||||||
requested_types = [t.strip() for t in album_type.split(',')]
|
requested_types = [t.strip() for t in album_type.split(',')]
|
||||||
offset = 0
|
|
||||||
page_size = 100 # Deezer API max per request
|
|
||||||
complete = True # cleared if pagination breaks on a transient/malformed error
|
|
||||||
|
|
||||||
while offset < limit:
|
for album_data in data['data']:
|
||||||
fetch_limit = min(page_size, limit - offset)
|
album = Album.from_deezer_album(album_data)
|
||||||
data = self._api_get(f'artist/{artist_id}/albums', {'limit': fetch_limit, 'index': offset})
|
|
||||||
if not data or 'data' not in data:
|
|
||||||
# Malformed/transient response mid-pagination — what we have is a
|
|
||||||
# PARTIAL discography. Don't cache it as the full list (mirrors the
|
|
||||||
# Spotify truncated-fetch guard). #853 follow-up.
|
|
||||||
complete = False
|
|
||||||
break
|
|
||||||
if len(data['data']) == 0:
|
|
||||||
break # No more albums — a clean end of pagination.
|
|
||||||
|
|
||||||
for album_data in data['data']:
|
if album_type != 'album,single':
|
||||||
all_raw.append(album_data)
|
if album.album_type not in requested_types:
|
||||||
album = Album.from_deezer_album(album_data)
|
if not (album.album_type == 'ep' and 'single' in requested_types):
|
||||||
|
continue
|
||||||
|
|
||||||
if album_type != 'album,single':
|
albums.append(album)
|
||||||
if album.album_type not in requested_types:
|
|
||||||
if not (album.album_type == 'ep' and 'single' in requested_types):
|
|
||||||
continue
|
|
||||||
|
|
||||||
albums.append(album)
|
cache = get_metadata_cache()
|
||||||
|
entries = [(str(ad.get('id', '')), ad) for ad in data['data'] if ad.get('id')]
|
||||||
if len(data['data']) < fetch_limit:
|
|
||||||
break # Last page
|
|
||||||
offset += len(data['data'])
|
|
||||||
|
|
||||||
# Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album.
|
|
||||||
# Inject it so cached album entities have artist_name for discover page display.
|
|
||||||
artist_stub = None
|
|
||||||
if albums and albums[0].artists:
|
|
||||||
artist_stub = {'id': int(artist_id) if artist_id.isdigit() else 0, 'name': albums[0].artists[0]}
|
|
||||||
entries = []
|
|
||||||
for ad in all_raw:
|
|
||||||
if ad.get('id'):
|
|
||||||
if artist_stub and not ad.get('artist'):
|
|
||||||
ad['artist'] = artist_stub
|
|
||||||
entries.append((str(ad['id']), ad))
|
|
||||||
if entries:
|
if entries:
|
||||||
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
|
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
|
||||||
# Only cache the artist→album-LIST when pagination finished cleanly; a
|
|
||||||
# partial list would otherwise serve an incomplete discography until TTL.
|
|
||||||
# (Individual album entities above are complete, so they cache regardless.)
|
|
||||||
if complete:
|
|
||||||
store_artist_album_items(cache, 'deezer', artist_id, all_raw, album_type=album_type, limit=limit)
|
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||||
return albums[:limit]
|
return albums[:limit]
|
||||||
|
|
@ -1003,88 +632,6 @@ class DeezerClient:
|
||||||
return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
|
return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ==================== User Methods (require OAuth) ====================
|
|
||||||
|
|
||||||
@rate_limited
|
|
||||||
def get_user_favorite_artists(self, limit: int = 200) -> list:
|
|
||||||
"""Fetch user's favorite artists from Deezer. Requires OAuth access token.
|
|
||||||
Returns list of dicts with deezer_id, name, image_url."""
|
|
||||||
if not self._access_token:
|
|
||||||
logger.debug("Deezer not user-authenticated — cannot fetch favorites")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
artists = []
|
|
||||||
index = 0
|
|
||||||
while len(artists) < limit:
|
|
||||||
data = self._api_get('user/me/artists', params={
|
|
||||||
'limit': min(100, limit - len(artists)),
|
|
||||||
'index': index
|
|
||||||
})
|
|
||||||
if not data or 'data' not in data:
|
|
||||||
break
|
|
||||||
items = data['data']
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
for a in items:
|
|
||||||
artists.append({
|
|
||||||
'deezer_id': str(a.get('id', '')),
|
|
||||||
'name': a.get('name', ''),
|
|
||||||
'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''),
|
|
||||||
})
|
|
||||||
if not data.get('next'):
|
|
||||||
break
|
|
||||||
index += len(items)
|
|
||||||
time.sleep(0.3) # Extra breathing room
|
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(artists)} favorite artists from Deezer")
|
|
||||||
return artists
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching Deezer favorite artists: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
@rate_limited
|
|
||||||
def get_user_favorite_albums(self, limit: int = 200) -> list:
|
|
||||||
"""Fetch user's favorite albums from Deezer. Requires OAuth access token.
|
|
||||||
Returns list of dicts with deezer_id, album_name, artist_name, image_url, release_date, total_tracks."""
|
|
||||||
if not self._access_token:
|
|
||||||
logger.debug("Deezer not user-authenticated — cannot fetch favorite albums")
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
albums = []
|
|
||||||
index = 0
|
|
||||||
while len(albums) < limit:
|
|
||||||
data = self._api_get('user/me/albums', params={
|
|
||||||
'limit': min(100, limit - len(albums)),
|
|
||||||
'index': index
|
|
||||||
})
|
|
||||||
if not data or 'data' not in data:
|
|
||||||
break
|
|
||||||
items = data['data']
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
for a in items:
|
|
||||||
artist_name = ''
|
|
||||||
if isinstance(a.get('artist'), dict):
|
|
||||||
artist_name = a['artist'].get('name', '')
|
|
||||||
albums.append({
|
|
||||||
'deezer_id': str(a.get('id', '')),
|
|
||||||
'album_name': a.get('title', ''),
|
|
||||||
'artist_name': artist_name,
|
|
||||||
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
|
|
||||||
'release_date': a.get('release_date', ''),
|
|
||||||
'total_tracks': a.get('nb_tracks', 0),
|
|
||||||
})
|
|
||||||
if not data.get('next'):
|
|
||||||
break
|
|
||||||
index += len(items)
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(albums)} favorite albums from Deezer")
|
|
||||||
return albums
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching Deezer favorite albums: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ==================== Stub Methods (match iTunesClient interface) ====================
|
# ==================== Stub Methods (match iTunesClient interface) ====================
|
||||||
|
|
||||||
def get_user_playlists(self) -> List[Playlist]:
|
def get_user_playlists(self) -> List[Playlist]:
|
||||||
|
|
@ -1146,8 +693,8 @@ class DeezerClient:
|
||||||
try:
|
try:
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cache.store_entity('deezer', 'artist', str(result.get('id', '')), result)
|
cache.store_entity('deezer', 'artist', str(result.get('id', '')), result)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache store_entity artist search: %s", e)
|
pass
|
||||||
logger.debug(f"Found artist for query: {artist_name}")
|
logger.debug(f"Found artist for query: {artist_name}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -1191,8 +738,8 @@ class DeezerClient:
|
||||||
try:
|
try:
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cache.store_entity('deezer', 'album', str(result.get('id', '')), result)
|
cache.store_entity('deezer', 'album', str(result.get('id', '')), result)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache store_entity album search: %s", e)
|
pass
|
||||||
logger.debug(f"Found album for query: {artist_name} - {album_title}")
|
logger.debug(f"Found album for query: {artist_name} - {album_title}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -1236,8 +783,8 @@ class DeezerClient:
|
||||||
try:
|
try:
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cache.store_entity('deezer', 'track', str(result.get('id', '')), result)
|
cache.store_entity('deezer', 'track', str(result.get('id', '')), result)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache store_entity track search: %s", e)
|
pass
|
||||||
logger.debug(f"Found track for query: {artist_name} - {track_title}")
|
logger.debug(f"Found track for query: {artist_name} - {track_title}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -1269,8 +816,8 @@ class DeezerClient:
|
||||||
# Cache hit with full details (has label = was a get_album response, not just search)
|
# Cache hit with full details (has label = was a get_album response, not just search)
|
||||||
logger.debug(f"Cache hit for album {album_id}")
|
logger.debug(f"Cache hit for album {album_id}")
|
||||||
return cached
|
return cached
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache get_entity album: %s", e)
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
|
|
@ -1288,8 +835,8 @@ class DeezerClient:
|
||||||
try:
|
try:
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cache.store_entity('deezer', 'album', str(album_id), data)
|
cache.store_entity('deezer', 'album', str(album_id), data)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache store_entity album full: %s", e)
|
pass
|
||||||
logger.debug(f"Got full album details for ID: {album_id}")
|
logger.debug(f"Got full album details for ID: {album_id}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -1317,8 +864,8 @@ class DeezerClient:
|
||||||
if cached and cached.get('bpm'):
|
if cached and cached.get('bpm'):
|
||||||
logger.debug(f"Cache hit for track {track_id}")
|
logger.debug(f"Cache hit for track {track_id}")
|
||||||
return cached
|
return cached
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache get_entity track: %s", e)
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
|
|
@ -1336,8 +883,8 @@ class DeezerClient:
|
||||||
try:
|
try:
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
cache.store_entity('deezer', 'track', str(track_id), data)
|
cache.store_entity('deezer', 'track', str(track_id), data)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("cache store_entity track full: %s", e)
|
pass
|
||||||
logger.debug(f"Got full track details for ID: {track_id}")
|
logger.debug(f"Got full track details for ID: {track_id}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -1399,16 +946,6 @@ class DeezerClient:
|
||||||
|
|
||||||
raw_tracks.extend(page_tracks)
|
raw_tracks.extend(page_tracks)
|
||||||
|
|
||||||
# Real album track positions — playlist tracks don't carry track_position,
|
|
||||||
# so numbering by playlist index would poison the downloaded file's tag.
|
|
||||||
album_ids = {str(t.get('album', {}).get('id')) for t in raw_tracks if t.get('album', {}).get('id')}
|
|
||||||
try:
|
|
||||||
from core.metadata.cache import get_metadata_cache
|
|
||||||
_cache = get_metadata_cache()
|
|
||||||
except Exception:
|
|
||||||
_cache = None
|
|
||||||
track_positions = resolve_album_track_positions(self.session, self.BASE_URL, album_ids, _cache)
|
|
||||||
|
|
||||||
# Normalize tracks
|
# Normalize tracks
|
||||||
tracks: List[Dict[str, Any]] = []
|
tracks: List[Dict[str, Any]] = []
|
||||||
for i, t in enumerate(raw_tracks, start=1):
|
for i, t in enumerate(raw_tracks, start=1):
|
||||||
|
|
@ -1420,8 +957,7 @@ class DeezerClient:
|
||||||
'artists': [artist_name],
|
'artists': [artist_name],
|
||||||
'album': t.get('album', {}).get('title', ''),
|
'album': t.get('album', {}).get('title', ''),
|
||||||
'duration_ms': t.get('duration', 0) * 1000,
|
'duration_ms': t.get('duration', 0) * 1000,
|
||||||
# REAL album position; the playlist index is a last resort only.
|
'track_number': i,
|
||||||
'track_number': track_positions.get(str(t.get('id'))) or i,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -1429,7 +965,7 @@ class DeezerClient:
|
||||||
'name': data.get('title', ''),
|
'name': data.get('title', ''),
|
||||||
'description': data.get('description', ''),
|
'description': data.get('description', ''),
|
||||||
'track_count': total_tracks,
|
'track_count': total_tracks,
|
||||||
'image_url': data.get('picture_xl') or data.get('picture_big') or data.get('picture_medium', ''),
|
'image_url': data.get('picture_medium', ''),
|
||||||
'owner': data.get('creator', {}).get('name', ''),
|
'owner': data.get('creator', {}).get('name', ''),
|
||||||
'tracks': tracks,
|
'tracks': tracks,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult
|
||||||
from core.quality.source_map import quality_from_deezer, quality_tier_for_source
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("deezer_download")
|
logger = get_logger("deezer_download")
|
||||||
|
|
@ -73,17 +72,14 @@ def _decrypt_chunk(chunk: bytes, key: bytes) -> bytes:
|
||||||
cipher = Cipher(algorithms.Blowfish(key), modes.CBC(iv))
|
cipher = Cipher(algorithms.Blowfish(key), modes.CBC(iv))
|
||||||
decryptor = cipher.decryptor()
|
decryptor = cipher.decryptor()
|
||||||
return decryptor.update(chunk) + decryptor.finalize()
|
return decryptor.update(chunk) + decryptor.finalize()
|
||||||
except ImportError as exc:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Deezer downloads require pycryptodome or cryptography package. "
|
"Deezer downloads require pycryptodome or cryptography package. "
|
||||||
"Install with: pip install pycryptodome"
|
"Install with: pip install pycryptodome"
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
|
|
||||||
from core.download_plugins.base import DownloadSourcePlugin
|
class DeezerDownloadClient:
|
||||||
|
|
||||||
|
|
||||||
class DeezerDownloadClient(DownloadSourcePlugin):
|
|
||||||
"""Deezer download client using ARL token authentication."""
|
"""Deezer download client using ARL token authentication."""
|
||||||
|
|
||||||
def __init__(self, download_path: str = None):
|
def __init__(self, download_path: str = None):
|
||||||
|
|
@ -93,14 +89,11 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
if download_path is None:
|
if download_path is None:
|
||||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||||
self.download_path = Path(download_path)
|
self.download_path = Path(download_path)
|
||||||
try:
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not verify download path {self.download_path}: {e}")
|
|
||||||
|
|
||||||
# Engine reference is populated by set_engine() at registration
|
# Download tracking (same pattern as Tidal/Qobuz/HiFi)
|
||||||
# time. None until orchestrator wires the registry.
|
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||||
self._engine = None
|
self._download_lock = threading.Lock()
|
||||||
|
|
||||||
# Shutdown check callback (set by web_server)
|
# Shutdown check callback (set by web_server)
|
||||||
self.shutdown_check = None
|
self.shutdown_check = None
|
||||||
|
|
@ -121,27 +114,17 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
self._license_token = None
|
self._license_token = None
|
||||||
self._user_data = None
|
self._user_data = None
|
||||||
self._authenticated = False
|
self._authenticated = False
|
||||||
self._pending_arl: Optional[str] = None
|
|
||||||
|
|
||||||
# Quality preference
|
# Quality preference
|
||||||
self._quality = quality_tier_for_source('deezer', default='flac')
|
self._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||||
|
|
||||||
# Try to authenticate on init if ARL is configured
|
# Try to authenticate on init if ARL is configured
|
||||||
arl = config_manager.get('deezer_download.arl', '')
|
arl = config_manager.get('deezer_download.arl', '')
|
||||||
if arl:
|
if arl:
|
||||||
from core.boot_phase import is_boot_phase
|
self._authenticate(arl)
|
||||||
if is_boot_phase():
|
|
||||||
self._pending_arl = arl
|
|
||||||
logger.debug("Deezer ARL present — authentication deferred until after boot")
|
|
||||||
else:
|
|
||||||
self._authenticate(arl)
|
|
||||||
|
|
||||||
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
||||||
|
|
||||||
def set_engine(self, engine):
|
|
||||||
"""Engine callback — wires the central thread worker + state store."""
|
|
||||||
self._engine = engine
|
|
||||||
|
|
||||||
# ─── Authentication ──────────────────────────────────────────
|
# ─── Authentication ──────────────────────────────────────────
|
||||||
|
|
||||||
def _authenticate(self, arl: str) -> bool:
|
def _authenticate(self, arl: str) -> bool:
|
||||||
|
|
@ -233,66 +216,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
return self._authenticated
|
return self._authenticated
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
if self._pending_arl and not self._authenticated:
|
|
||||||
from core.boot_phase import is_boot_phase
|
|
||||||
if not is_boot_phase():
|
|
||||||
self._authenticate(self._pending_arl)
|
|
||||||
self._pending_arl = None
|
|
||||||
return self._authenticated
|
return self._authenticated
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
return await loop.run_in_executor(None, self.is_available)
|
return await loop.run_in_executor(None, self.is_available)
|
||||||
|
|
||||||
# ─── Playlist export (#945) ──────────────────────────────────
|
|
||||||
#
|
|
||||||
# UNOFFICIAL: rides the private gw-light gateway with the ARL session already used
|
|
||||||
# for downloads. Deezer shut their public developer API, so this is the only write
|
|
||||||
# path — and it's fragile by nature (breaks when Deezer changes internals).
|
|
||||||
|
|
||||||
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
|
|
||||||
public=False, description=""):
|
|
||||||
"""Create a Deezer playlist (or append to an existing one) from a mirrored
|
|
||||||
playlist's tracks. ``track_ids`` are stored ``deezer_id`` values per library track.
|
|
||||||
``existing_id`` set → add to that playlist (idempotent re-export reuses the stored
|
|
||||||
target); unset → create a new one. Returns
|
|
||||||
``{success, playlist_id, url, added, error}``."""
|
|
||||||
if not self._authenticated:
|
|
||||||
return {"success": False, "error": "Deezer is not connected (ARL)"}
|
|
||||||
song_ids = [str(t) for t in (track_ids or []) if t]
|
|
||||||
if not song_ids:
|
|
||||||
return {"success": False, "error": "No matching Deezer tracks to export"}
|
|
||||||
try:
|
|
||||||
songs = [[sid, i] for i, sid in enumerate(song_ids)]
|
|
||||||
if existing_id:
|
|
||||||
res = self._gw_call("playlist.addSongs",
|
|
||||||
{"playlist_id": int(existing_id), "songs": songs})
|
|
||||||
if res is None:
|
|
||||||
return {"success": False, "error": "Deezer rejected the playlist update"}
|
|
||||||
playlist_id = existing_id
|
|
||||||
else:
|
|
||||||
res = self._gw_call("playlist.create", {
|
|
||||||
"title": name, "description": description,
|
|
||||||
"is_public": bool(public), "songs": songs,
|
|
||||||
})
|
|
||||||
if res is None:
|
|
||||||
return {"success": False, "error": "Deezer rejected the playlist create"}
|
|
||||||
# gw 'playlist.create' returns the new playlist id (int) as `results`.
|
|
||||||
if isinstance(res, dict):
|
|
||||||
playlist_id = res.get("PLAYLIST_ID") or res.get("id")
|
|
||||||
else:
|
|
||||||
playlist_id = res
|
|
||||||
if not playlist_id:
|
|
||||||
return {"success": False, "error": "Deezer did not return a playlist id"}
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"playlist_id": str(playlist_id),
|
|
||||||
"url": f"https://www.deezer.com/playlist/{playlist_id}",
|
|
||||||
"added": len(song_ids),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
||||||
def reconnect(self, arl: str = None) -> bool:
|
def reconnect(self, arl: str = None) -> bool:
|
||||||
"""Re-authenticate with a new or existing ARL."""
|
"""Re-authenticate with a new or existing ARL."""
|
||||||
if arl is None:
|
if arl is None:
|
||||||
|
|
@ -307,262 +236,6 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
labels = {'flac': 'FLAC (Lossless)', 'mp3_320': 'MP3 320kbps', 'mp3_128': 'MP3 128kbps'}
|
labels = {'flac': 'FLAC (Lossless)', 'mp3_320': 'MP3 320kbps', 'mp3_128': 'MP3 128kbps'}
|
||||||
return labels.get(self._quality, 'MP3 320kbps')
|
return labels.get(self._quality, 'MP3 320kbps')
|
||||||
|
|
||||||
# ─── User Playlists (ARL-authenticated) ─────────────────────
|
|
||||||
|
|
||||||
def get_user_playlists(self) -> list:
|
|
||||||
"""Fetch the authenticated user's playlists via Deezer public API with ARL cookies."""
|
|
||||||
if not self._authenticated or not self._user_data:
|
|
||||||
return []
|
|
||||||
user_id = self._user_data.get('USER_ID')
|
|
||||||
if not user_id:
|
|
||||||
return []
|
|
||||||
|
|
||||||
playlists = []
|
|
||||||
index = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
resp = self._session.get(
|
|
||||||
f'https://api.deezer.com/user/{user_id}/playlists',
|
|
||||||
params={'index': index, 'limit': 100},
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if 'error' in data:
|
|
||||||
logger.warning(f"Deezer playlists error: {data['error']}")
|
|
||||||
break
|
|
||||||
items = data.get('data', [])
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
for p in items:
|
|
||||||
playlists.append({
|
|
||||||
'id': str(p.get('id', '')),
|
|
||||||
'name': p.get('title', ''),
|
|
||||||
'track_count': p.get('nb_tracks', 0),
|
|
||||||
'image_url': p.get('picture_medium', ''),
|
|
||||||
'owner': p.get('creator', {}).get('name', ''),
|
|
||||||
'description': p.get('description', ''),
|
|
||||||
})
|
|
||||||
if not data.get('next'):
|
|
||||||
break
|
|
||||||
index += len(items)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching user playlists at index {index}: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(f"Fetched {len(playlists)} user playlists from Deezer")
|
|
||||||
return playlists
|
|
||||||
|
|
||||||
def get_user_favorite_artists(self, limit: int = 200) -> list:
|
|
||||||
"""Fetch the authenticated user's favorite artists via public API with ARL cookies."""
|
|
||||||
if not self._authenticated or not self._user_data:
|
|
||||||
return []
|
|
||||||
user_id = self._user_data.get('USER_ID')
|
|
||||||
if not user_id:
|
|
||||||
return []
|
|
||||||
|
|
||||||
artists = []
|
|
||||||
index = 0
|
|
||||||
while len(artists) < limit:
|
|
||||||
try:
|
|
||||||
resp = self._session.get(
|
|
||||||
f'https://api.deezer.com/user/{user_id}/artists',
|
|
||||||
params={'index': index, 'limit': min(100, limit - len(artists))},
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if 'error' in data:
|
|
||||||
logger.warning(f"Deezer artists error: {data['error']}")
|
|
||||||
break
|
|
||||||
items = data.get('data', [])
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
for a in items:
|
|
||||||
artists.append({
|
|
||||||
'deezer_id': str(a.get('id', '')),
|
|
||||||
'name': a.get('name', ''),
|
|
||||||
'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''),
|
|
||||||
})
|
|
||||||
if not data.get('next'):
|
|
||||||
break
|
|
||||||
index += len(items)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching favorite artists at index {index}: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
|
|
||||||
return artists
|
|
||||||
|
|
||||||
def get_user_favorite_albums(self, limit: int = 200) -> list:
|
|
||||||
"""Fetch the authenticated user's favorite albums via public API with ARL cookies."""
|
|
||||||
if not self._authenticated or not self._user_data:
|
|
||||||
return []
|
|
||||||
user_id = self._user_data.get('USER_ID')
|
|
||||||
if not user_id:
|
|
||||||
return []
|
|
||||||
|
|
||||||
albums = []
|
|
||||||
index = 0
|
|
||||||
while len(albums) < limit:
|
|
||||||
try:
|
|
||||||
resp = self._session.get(
|
|
||||||
f'https://api.deezer.com/user/{user_id}/albums',
|
|
||||||
params={'index': index, 'limit': min(100, limit - len(albums))},
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if 'error' in data:
|
|
||||||
logger.warning(f"Deezer albums error: {data['error']}")
|
|
||||||
break
|
|
||||||
items = data.get('data', [])
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
for a in items:
|
|
||||||
artist_name = ''
|
|
||||||
if isinstance(a.get('artist'), dict):
|
|
||||||
artist_name = a['artist'].get('name', '')
|
|
||||||
albums.append({
|
|
||||||
'deezer_id': str(a.get('id', '')),
|
|
||||||
'album_name': a.get('title', ''),
|
|
||||||
'artist_name': artist_name,
|
|
||||||
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
|
|
||||||
'release_date': a.get('release_date', ''),
|
|
||||||
'total_tracks': a.get('nb_tracks', 0),
|
|
||||||
})
|
|
||||||
if not data.get('next'):
|
|
||||||
break
|
|
||||||
index += len(items)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching favorite albums at index {index}: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(f"Fetched {len(albums)} favorite albums from Deezer (ARL)")
|
|
||||||
return albums
|
|
||||||
|
|
||||||
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
|
|
||||||
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
|
|
||||||
try:
|
|
||||||
resp = self._session.get(
|
|
||||||
f'https://api.deezer.com/playlist/{playlist_id}',
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
if 'error' in data:
|
|
||||||
logger.error(f"Deezer playlist error: {data['error']}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
total_tracks = data.get('nb_tracks', 0)
|
|
||||||
raw_tracks = data.get('tracks', {}).get('data', [])
|
|
||||||
|
|
||||||
# Paginate if needed
|
|
||||||
while len(raw_tracks) < total_tracks:
|
|
||||||
idx = len(raw_tracks)
|
|
||||||
page_resp = self._session.get(
|
|
||||||
f'https://api.deezer.com/playlist/{playlist_id}/tracks',
|
|
||||||
params={'index': idx, 'limit': 400},
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
page_resp.raise_for_status()
|
|
||||||
page_data = page_resp.json()
|
|
||||||
if 'error' in page_data:
|
|
||||||
break
|
|
||||||
page_tracks = page_data.get('data', [])
|
|
||||||
if not page_tracks:
|
|
||||||
break
|
|
||||||
raw_tracks.extend(page_tracks)
|
|
||||||
|
|
||||||
# Batch-fetch release dates for unique albums (cache-first)
|
|
||||||
album_ids = set()
|
|
||||||
for t in raw_tracks:
|
|
||||||
aid = t.get('album', {}).get('id')
|
|
||||||
if aid:
|
|
||||||
album_ids.add(str(aid))
|
|
||||||
album_release_dates = {}
|
|
||||||
# Deezer PLAYLIST tracks do NOT carry `track_position` (only `/track/<id>`
|
|
||||||
# and `/album/<id>/tracks` do), so numbering them by their playlist index
|
|
||||||
# poisons the real album track number — which then rides into the wishlist
|
|
||||||
# and onto the downloaded file's tag (e.g. 'Apologize' tagged track 1 instead
|
|
||||||
# of 16). Resolve the REAL position from each album's track list (cache-first).
|
|
||||||
track_positions: Dict[str, int] = {} # str(track_id) -> album track_position
|
|
||||||
try:
|
|
||||||
from core.metadata.cache import get_metadata_cache
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
except Exception:
|
|
||||||
cache = None
|
|
||||||
for aid in album_ids:
|
|
||||||
# Check metadata cache first
|
|
||||||
if cache:
|
|
||||||
try:
|
|
||||||
cached = cache.get_entity('deezer', 'album', aid)
|
|
||||||
if cached and cached.get('release_date'):
|
|
||||||
album_release_dates[aid] = cached['release_date']
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("cache get_entity album release_date: %s", e)
|
|
||||||
# Cache miss — fetch from API
|
|
||||||
if aid not in album_release_dates:
|
|
||||||
try:
|
|
||||||
time.sleep(0.3) # Respect rate limits
|
|
||||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
|
||||||
if a_resp.ok:
|
|
||||||
a_data = a_resp.json()
|
|
||||||
album_release_dates[aid] = a_data.get('release_date', '')
|
|
||||||
# Store in metadata cache for future use
|
|
||||||
if cache:
|
|
||||||
try:
|
|
||||||
cache.store_entity('deezer', 'album', aid, a_data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("cache store_entity album release_date: %s", e)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
|
||||||
# Real album track positions (separate endpoint — playlist tracks AND the
|
|
||||||
# album object's embedded tracks both omit track_position). Cache-first.
|
|
||||||
try:
|
|
||||||
from core.deezer_client import resolve_album_track_positions
|
|
||||||
track_positions = resolve_album_track_positions(
|
|
||||||
self._session, 'https://api.deezer.com', album_ids, cache)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("resolve deezer album track positions: %s", e)
|
|
||||||
|
|
||||||
tracks = []
|
|
||||||
for i, t in enumerate(raw_tracks, start=1):
|
|
||||||
artist_name = t.get('artist', {}).get('name', 'Unknown Artist')
|
|
||||||
album_data = t.get('album', {})
|
|
||||||
album_cover = album_data.get('cover_medium') or album_data.get('cover_small') or ''
|
|
||||||
album_id = str(album_data.get('id', ''))
|
|
||||||
tracks.append({
|
|
||||||
'id': str(t.get('id', '')),
|
|
||||||
'name': t.get('title', ''),
|
|
||||||
'artists': [{'name': artist_name}],
|
|
||||||
'album': {
|
|
||||||
'name': album_data.get('title', ''),
|
|
||||||
'images': [{'url': album_cover}] if album_cover else [],
|
|
||||||
'release_date': album_release_dates.get(album_id, ''),
|
|
||||||
'album_type': 'album',
|
|
||||||
'total_tracks': total_tracks,
|
|
||||||
'id': album_id,
|
|
||||||
},
|
|
||||||
'duration_ms': t.get('duration', 0) * 1000,
|
|
||||||
# REAL album position (resolved above); the playlist index is a last
|
|
||||||
# resort only when the album lookup failed, never the default.
|
|
||||||
'track_number': track_positions.get(str(t.get('id'))) or t.get('track_position') or i,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': str(data.get('id', '')),
|
|
||||||
'name': data.get('title', ''),
|
|
||||||
'description': data.get('description', ''),
|
|
||||||
'track_count': total_tracks,
|
|
||||||
'image_url': data.get('picture_medium', ''),
|
|
||||||
'owner': data.get('creator', {}).get('name', ''),
|
|
||||||
'tracks': tracks,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching playlist {playlist_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ─── Track Info ──────────────────────────────────────────────
|
# ─── Track Info ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_track_data(self, track_id: str) -> Optional[dict]:
|
def _get_track_data(self, track_id: str) -> Optional[dict]:
|
||||||
|
|
@ -661,7 +334,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
bitrate = 128
|
bitrate = 128
|
||||||
quality = 'mp3'
|
quality = 'mp3'
|
||||||
|
|
||||||
tr = TrackResult(
|
results.append(TrackResult(
|
||||||
username='deezer_dl',
|
username='deezer_dl',
|
||||||
filename=f"{track_id}||{artist} - {title}",
|
filename=f"{track_id}||{artist} - {title}",
|
||||||
size=est_size,
|
size=est_size,
|
||||||
|
|
@ -675,10 +348,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
title=title,
|
title=title,
|
||||||
album=album,
|
album=album,
|
||||||
track_number=item.get('track_position'),
|
track_number=item.get('track_position'),
|
||||||
)
|
))
|
||||||
# Stamp CD-quality FLAC (16/44.1) so lossless ranks correctly.
|
|
||||||
tr.set_quality(quality_from_deezer(self._quality))
|
|
||||||
results.append(tr)
|
|
||||||
|
|
||||||
logger.info(f"Deezer search for '{query}' returned {len(results)} results")
|
logger.info(f"Deezer search for '{query}' returned {len(results)} results")
|
||||||
return results, []
|
return results, []
|
||||||
|
|
@ -695,67 +365,87 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
if not self._authenticated:
|
if not self._authenticated:
|
||||||
logger.error("Deezer not authenticated — cannot download")
|
logger.error("Deezer not authenticated — cannot download")
|
||||||
return None
|
return None
|
||||||
if self._engine is None:
|
|
||||||
# Raise rather than return None so the orchestrator's
|
|
||||||
# download_with_fallback surfaces a real warning + tries
|
|
||||||
# the next source. Returning None silently dropped the
|
|
||||||
# download with no user feedback (per JohnBaumb).
|
|
||||||
raise RuntimeError("Deezer client has no engine reference — cannot dispatch download")
|
|
||||||
|
|
||||||
# Parse filename: "track_id||display_name"
|
# Parse filename: "track_id||display_name"
|
||||||
parts = filename.split('||', 1)
|
parts = filename.split('||', 1)
|
||||||
track_id = parts[0]
|
track_id = parts[0]
|
||||||
display_name = parts[1] if len(parts) > 1 else f"Track {track_id}"
|
display_name = parts[1] if len(parts) > 1 else f"Track {track_id}"
|
||||||
|
|
||||||
return self._engine.worker.dispatch(
|
download_id = str(uuid.uuid4())
|
||||||
source_name='deezer',
|
|
||||||
target_id=track_id,
|
with self._download_lock:
|
||||||
display_name=display_name,
|
self.active_downloads[download_id] = {
|
||||||
original_filename=filename,
|
'id': download_id,
|
||||||
impl_callable=self._download_sync,
|
|
||||||
extra_record_fields={
|
|
||||||
'track_id': track_id,
|
'track_id': track_id,
|
||||||
'display_name': display_name,
|
'display_name': display_name,
|
||||||
|
'filename': filename,
|
||||||
|
'username': 'deezer_dl',
|
||||||
|
'state': 'Initializing',
|
||||||
|
'progress': 0.0,
|
||||||
'size': file_size,
|
'size': file_size,
|
||||||
|
'transferred': 0,
|
||||||
|
'speed': 0,
|
||||||
|
'file_path': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
},
|
}
|
||||||
# Legacy username slot — frontend status indicators key off
|
|
||||||
# ``deezer_dl``, not the canonical ``deezer``.
|
thread = threading.Thread(
|
||||||
username_override='deezer_dl',
|
target=self._download_thread_worker,
|
||||||
# Diagnostic thread name for multi-thread debugging.
|
args=(download_id, track_id, display_name),
|
||||||
thread_name=f'deezer-dl-{track_id}',
|
daemon=True,
|
||||||
|
name=f'deezer-dl-{track_id}'
|
||||||
)
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def _set_error(self, download_id: str, message: str) -> None:
|
logger.info(f"Started Deezer download {download_id}: {display_name}")
|
||||||
"""Helper: set the engine record's `error` slot. No-op if
|
return download_id
|
||||||
engine isn't wired or record was already removed."""
|
|
||||||
if self._engine is None:
|
|
||||||
return
|
|
||||||
self._engine.update_record('deezer', download_id, {'error': message})
|
|
||||||
|
|
||||||
def _is_cancelled(self, download_id: str) -> bool:
|
def _download_thread_worker(self, download_id: str, track_id: str, display_name: str):
|
||||||
if self._engine is None:
|
"""Background worker for a single download."""
|
||||||
return False
|
try:
|
||||||
record = self._engine.get_record('deezer', download_id)
|
result_path = self._download_sync(download_id, track_id, display_name)
|
||||||
return record is not None and record.get('state') == 'Cancelled'
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
dl = self.active_downloads[download_id]
|
||||||
|
if dl['state'] == 'Cancelled':
|
||||||
|
return
|
||||||
|
if result_path:
|
||||||
|
dl['state'] = 'Completed, Succeeded'
|
||||||
|
dl['progress'] = 100.0
|
||||||
|
dl['file_path'] = result_path
|
||||||
|
logger.info(f"Deezer download {download_id} completed: {result_path}")
|
||||||
|
else:
|
||||||
|
dl['state'] = 'Errored'
|
||||||
|
logger.error(f"Deezer download {download_id} failed: {dl.get('error', 'unknown')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Deezer download thread error: {e}")
|
||||||
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['state'] = 'Errored'
|
||||||
|
self.active_downloads[download_id]['error'] = str(e)
|
||||||
|
|
||||||
def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]:
|
def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]:
|
||||||
"""Synchronous download: get URL, download, decrypt, save."""
|
"""Synchronous download: get URL, download, decrypt, save."""
|
||||||
# Check for shutdown
|
# Check for shutdown
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
if self._engine is not None:
|
with self._download_lock:
|
||||||
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['state'] = 'Aborted'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get track data from private API
|
# Get track data from private API
|
||||||
track_data = self._get_track_data(track_id)
|
track_data = self._get_track_data(track_id)
|
||||||
if not track_data:
|
if not track_data:
|
||||||
self._set_error(download_id, 'Failed to get track data')
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['error'] = 'Failed to get track data'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
track_token = track_data.get('TRACK_TOKEN', '')
|
track_token = track_data.get('TRACK_TOKEN', '')
|
||||||
if not track_token:
|
if not track_token:
|
||||||
self._set_error(download_id, 'No track token available')
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['error'] = 'No track token available'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Determine quality and get media URL with fallback
|
# Determine quality and get media URL with fallback
|
||||||
|
|
@ -765,6 +455,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
if allow_fallback:
|
if allow_fallback:
|
||||||
quality_order = _QUALITY_ORDER.copy()
|
quality_order = _QUALITY_ORDER.copy()
|
||||||
|
# Start from user's preferred quality
|
||||||
try:
|
try:
|
||||||
pref_idx = quality_order.index(self._quality)
|
pref_idx = quality_order.index(self._quality)
|
||||||
quality_order = quality_order[pref_idx:] + quality_order[:pref_idx]
|
quality_order = quality_order[pref_idx:] + quality_order[:pref_idx]
|
||||||
|
|
@ -781,16 +472,27 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_url:
|
if not media_url:
|
||||||
self._set_error(download_id, 'No media URL available (may require higher subscription tier)')
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['error'] = 'No media URL available (may require higher subscription tier)'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if actual_quality != self._quality:
|
if actual_quality != self._quality:
|
||||||
logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}")
|
logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}")
|
||||||
|
|
||||||
|
# Determine file extension
|
||||||
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
||||||
|
|
||||||
|
# Sanitize filename
|
||||||
safe_name = self._sanitize_filename(display_name)
|
safe_name = self._sanitize_filename(display_name)
|
||||||
out_path = str(self.download_path / f"{safe_name}{ext}")
|
out_path = str(self.download_path / f"{safe_name}{ext}")
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
dl = self.active_downloads[download_id]
|
||||||
|
dl['state'] = 'InProgress, Downloading'
|
||||||
|
|
||||||
# Download and decrypt
|
# Download and decrypt
|
||||||
try:
|
try:
|
||||||
bf_key = _get_blowfish_key(track_id)
|
bf_key = _get_blowfish_key(track_id)
|
||||||
|
|
@ -798,8 +500,9 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
total_size = int(resp.headers.get('content-length', 0))
|
total_size = int(resp.headers.get('content-length', 0))
|
||||||
if self._engine is not None:
|
with self._download_lock:
|
||||||
self._engine.update_record('deezer', download_id, {'size': total_size})
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['size'] = total_size
|
||||||
|
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
chunk_index = 0
|
chunk_index = 0
|
||||||
|
|
@ -812,20 +515,23 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
# Check for cancellation/shutdown
|
# Check for cancellation/shutdown
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
if self._engine is not None:
|
with self._download_lock:
|
||||||
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['state'] = 'Aborted'
|
||||||
try:
|
try:
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self._is_cancelled(download_id):
|
with self._download_lock:
|
||||||
try:
|
if download_id in self.active_downloads:
|
||||||
os.remove(out_path)
|
if self.active_downloads[download_id]['state'] == 'Cancelled':
|
||||||
except OSError:
|
try:
|
||||||
pass
|
os.remove(out_path)
|
||||||
return None
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
# Decrypt every 3rd chunk (Deezer's encryption pattern)
|
# Decrypt every 3rd chunk (Deezer's encryption pattern)
|
||||||
if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE:
|
if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE:
|
||||||
|
|
@ -842,12 +548,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||||
progress = (downloaded / total_size * 100) if total_size > 0 else 0
|
progress = (downloaded / total_size * 100) if total_size > 0 else 0
|
||||||
|
|
||||||
if self._engine is not None:
|
with self._download_lock:
|
||||||
self._engine.update_record('deezer', download_id, {
|
if download_id in self.active_downloads:
|
||||||
'transferred': downloaded,
|
dl = self.active_downloads[download_id]
|
||||||
'progress': min(progress, 99.9),
|
dl['transferred'] = downloaded
|
||||||
'speed': speed,
|
dl['progress'] = min(progress, 99.9)
|
||||||
})
|
dl['speed'] = speed
|
||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
file_size = os.path.getsize(out_path)
|
file_size = os.path.getsize(out_path)
|
||||||
|
|
@ -857,7 +563,9 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._set_error(download_id, f'File too small ({file_size} bytes)')
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['error'] = f'File too small ({file_size} bytes)'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})")
|
logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})")
|
||||||
|
|
@ -869,58 +577,59 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._set_error(download_id, str(e))
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['error'] = str(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ─── Download Status ─────────────────────────────────────────
|
# ─── Download Status ─────────────────────────────────────────
|
||||||
|
|
||||||
def _record_to_status(self, record: dict) -> DownloadStatus:
|
|
||||||
return DownloadStatus(
|
|
||||||
id=record['id'],
|
|
||||||
filename=record['filename'],
|
|
||||||
username=record['username'],
|
|
||||||
state=record['state'],
|
|
||||||
progress=record['progress'],
|
|
||||||
size=record.get('size', 0),
|
|
||||||
transferred=record.get('transferred', 0),
|
|
||||||
speed=record.get('speed', 0),
|
|
||||||
file_path=record.get('file_path'),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
if self._engine is None:
|
"""Return all active downloads."""
|
||||||
return []
|
with self._download_lock:
|
||||||
return [
|
return [self._to_status(dl) for dl in self.active_downloads.values()]
|
||||||
self._record_to_status(record)
|
|
||||||
for record in self._engine.iter_records_for_source('deezer')
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||||
if self._engine is None:
|
"""Get status of a specific download."""
|
||||||
return None
|
with self._download_lock:
|
||||||
record = self._engine.get_record('deezer', download_id)
|
dl = self.active_downloads.get(download_id)
|
||||||
return self._record_to_status(record) if record is not None else None
|
return self._to_status(dl) if dl else None
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str, username: str = None,
|
async def cancel_download(self, download_id: str, username: str = None,
|
||||||
remove: bool = False) -> bool:
|
remove: bool = False) -> bool:
|
||||||
if self._engine is None:
|
"""Cancel a download."""
|
||||||
return False
|
with self._download_lock:
|
||||||
if self._engine.get_record('deezer', download_id) is None:
|
dl = self.active_downloads.get(download_id)
|
||||||
return False
|
if not dl:
|
||||||
self._engine.update_record('deezer', download_id, {'state': 'Cancelled'})
|
return False
|
||||||
if remove:
|
dl['state'] = 'Cancelled'
|
||||||
self._engine.remove_record('deezer', download_id)
|
if remove:
|
||||||
|
del self.active_downloads[download_id]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def clear_all_completed_downloads(self) -> bool:
|
async def clear_all_completed_downloads(self) -> bool:
|
||||||
if self._engine is None:
|
"""Remove all terminal downloads."""
|
||||||
return True
|
terminal_states = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
with self._download_lock:
|
||||||
for record in list(self._engine.iter_records_for_source('deezer')):
|
to_remove = [k for k, v in self.active_downloads.items() if v['state'] in terminal_states]
|
||||||
if record.get('state') in terminal:
|
for k in to_remove:
|
||||||
self._engine.remove_record('deezer', record['id'])
|
del self.active_downloads[k]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _to_status(self, dl: dict) -> DownloadStatus:
|
||||||
|
"""Convert internal dict to DownloadStatus."""
|
||||||
|
return DownloadStatus(
|
||||||
|
id=dl['id'],
|
||||||
|
filename=dl['filename'],
|
||||||
|
username=dl['username'],
|
||||||
|
state=dl['state'],
|
||||||
|
progress=dl['progress'],
|
||||||
|
size=dl['size'],
|
||||||
|
transferred=dl['transferred'],
|
||||||
|
speed=dl['speed'],
|
||||||
|
file_path=dl.get('file_path'),
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Utilities ───────────────────────────────────────────────
|
# ─── Utilities ───────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,6 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.deezer_client import DeezerClient
|
from core.deezer_client import DeezerClient
|
||||||
from core.worker_utils import (
|
|
||||||
accept_artist_match,
|
|
||||||
artist_name_matches,
|
|
||||||
interruptible_sleep,
|
|
||||||
owned_album_titles,
|
|
||||||
pick_artist_by_catalog,
|
|
||||||
release_titles,
|
|
||||||
set_album_api_track_count,
|
|
||||||
)
|
|
||||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
|
||||||
|
|
||||||
logger = get_logger("deezer_worker")
|
logger = get_logger("deezer_worker")
|
||||||
|
|
||||||
|
|
@ -34,7 +24,6 @@ class DeezerWorker:
|
||||||
self.paused = False
|
self.paused = False
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
# Current item being processed (for UI tooltip)
|
# Current item being processed (for UI tooltip)
|
||||||
self.current_item = None
|
self.current_item = None
|
||||||
|
|
@ -63,7 +52,6 @@ class DeezerWorker:
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
self._stop_event.clear()
|
|
||||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
logger.info("Deezer background worker started")
|
logger.info("Deezer background worker started")
|
||||||
|
|
@ -76,10 +64,9 @@ class DeezerWorker:
|
||||||
logger.info("Stopping Deezer worker...")
|
logger.info("Stopping Deezer worker...")
|
||||||
self.should_stop = True
|
self.should_stop = True
|
||||||
self.running = False
|
self.running = False
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
if self.thread:
|
if self.thread:
|
||||||
self.thread.join(timeout=1)
|
self.thread.join(timeout=5)
|
||||||
|
|
||||||
logger.info("Deezer worker stopped")
|
logger.info("Deezer worker stopped")
|
||||||
|
|
||||||
|
|
@ -128,7 +115,7 @@ class DeezerWorker:
|
||||||
while not self.should_stop:
|
while not self.should_stop:
|
||||||
try:
|
try:
|
||||||
if self.paused:
|
if self.paused:
|
||||||
interruptible_sleep(self._stop_event, 1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.current_item = None
|
self.current_item = None
|
||||||
|
|
@ -137,7 +124,7 @@ class DeezerWorker:
|
||||||
|
|
||||||
if not item:
|
if not item:
|
||||||
logger.debug("No pending items, sleeping...")
|
logger.debug("No pending items, sleeping...")
|
||||||
interruptible_sleep(self._stop_event, 10)
|
time.sleep(10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.current_item = item
|
self.current_item = item
|
||||||
|
|
@ -149,18 +136,18 @@ class DeezerWorker:
|
||||||
itype = item.get('type', '')
|
itype = item.get('type', '')
|
||||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||||
# Can't mark status without an ID — just skip
|
# Can't mark status without an ID — just skip
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.debug("null id table resolve failed: %s", e)
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
self._process_item(item)
|
self._process_item(item)
|
||||||
|
|
||||||
interruptible_sleep(self._stop_event, 2)
|
time.sleep(2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in worker loop: {e}")
|
logger.error(f"Error in worker loop: {e}")
|
||||||
interruptible_sleep(self._stop_event, 5)
|
time.sleep(5)
|
||||||
|
|
||||||
logger.info("Deezer worker thread finished")
|
logger.info("Deezer worker thread finished")
|
||||||
|
|
||||||
|
|
@ -171,16 +158,6 @@ class DeezerWorker:
|
||||||
conn = self.db._get_connection()
|
conn = self.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Pinned-group override (Manage Enrichment Workers): process one
|
|
||||||
# entity type first, then fall through to the normal chain. Unset or
|
|
||||||
# exhausted ⇒ default artist→album→track order, unchanged.
|
|
||||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
|
||||||
_prio = read_enrichment_priority('deezer')
|
|
||||||
if _prio:
|
|
||||||
_pi = priority_pending_item(cursor, 'deezer', _prio)
|
|
||||||
if _pi:
|
|
||||||
return _pi
|
|
||||||
|
|
||||||
# Priority 1: Unattempted artists
|
# Priority 1: Unattempted artists
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
|
|
@ -286,19 +263,10 @@ class DeezerWorker:
|
||||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||||
return similarity >= self.name_similarity_threshold
|
return similarity >= self.name_similarity_threshold
|
||||||
|
|
||||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id,
|
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||||
result_artist_name: Optional[str] = None) -> bool:
|
|
||||||
"""Verify that the result's artist ID matches the parent artist's stored Deezer ID.
|
"""Verify that the result's artist ID matches the parent artist's stored Deezer ID.
|
||||||
|
|
||||||
If mismatched, the album/track search is more specific (uses artist+title),
|
If mismatched, the album/track search is more specific (uses artist+title),
|
||||||
so we trust it and correct the parent artist's deezer_id — BUT only when
|
so we trust it and correct the parent artist's deezer_id."""
|
||||||
the result's artist *name* actually matches our parent artist. Without
|
|
||||||
that guard, a collaboration or compilation track (e.g. a track our
|
|
||||||
library credits to Jorja Smith that lives on Kendrick Lamar's curated
|
|
||||||
"Black Panther" album) would search up to an album whose Deezer primary
|
|
||||||
artist is someone else (Kendrick), and we'd stamp that wrong Deezer ID
|
|
||||||
onto our artist — corrupting it (and causing duplicate ids shared across
|
|
||||||
unrelated artists)."""
|
|
||||||
parent_deezer_id = item.get('artist_deezer_id')
|
parent_deezer_id = item.get('artist_deezer_id')
|
||||||
if not parent_deezer_id:
|
if not parent_deezer_id:
|
||||||
return True
|
return True
|
||||||
|
|
@ -307,20 +275,6 @@ class DeezerWorker:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_deezer_id):
|
if str(result_artist_id) != str(parent_deezer_id):
|
||||||
# Guard: only correct when the album/track's primary artist is the
|
|
||||||
# SAME artist by name. A mismatch means it's a collab/compilation,
|
|
||||||
# not a stale-id correction.
|
|
||||||
parent_name = item.get('artist') or ''
|
|
||||||
if (result_artist_name and parent_name
|
|
||||||
and not self._name_matches(parent_name, result_artist_name)):
|
|
||||||
logger.info(
|
|
||||||
f"Skipping artist-ID correction from {item['type']} "
|
|
||||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
|
||||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
|
||||||
f"correction)"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}"
|
f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}"
|
||||||
|
|
@ -409,66 +363,27 @@ class DeezerWorker:
|
||||||
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
|
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Multi-candidate search (was single search_artist) so same-name artists
|
result = self.client.search_artist(artist_name)
|
||||||
# can be disambiguated: gate by name, then pick the one whose catalog
|
|
||||||
# overlaps the albums this library owns.
|
|
||||||
results = self.client.search_artists(artist_name, limit=5)
|
|
||||||
gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))]
|
|
||||||
chosen, _overlap = pick_artist_by_catalog(
|
|
||||||
gated,
|
|
||||||
owned_album_titles(self.db, artist_id),
|
|
||||||
lambda a: release_titles(self.client.get_artist_albums_list(a.id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
# search_artists returns lean Artist objects; fetch the full dict (same
|
|
||||||
# shape the old search_artist returned) for storage.
|
|
||||||
result = self.client.get_artist_info(chosen.id) if chosen else None
|
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('name', '')
|
result_name = result.get('name', '')
|
||||||
ok, reason = accept_artist_match(
|
if self._name_matches(artist_name, result_name):
|
||||||
self.db, 'deezer_id', result.get('id'), artist_id,
|
|
||||||
artist_name, result_name,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
self._update_artist(artist_id, result)
|
self._update_artist(artist_id, result)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"Artist '{artist_name}' not matched: {reason}")
|
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
logger.debug(f"No match for artist '{artist_name}'")
|
logger.debug(f"No match for artist '{artist_name}'")
|
||||||
|
|
||||||
def _refresh_album_via_stored_id(self, album_id, stored_id, full_album_dict):
|
|
||||||
"""Issue #501 callback. Stored ID exists → fetched full Deezer
|
|
||||||
album payload. Use it as both args to ``_update_album`` (search-
|
|
||||||
result and full-data shapes overlap on the fields we need —
|
|
||||||
artist verification skipped since manual match presumably
|
|
||||||
already vetted)."""
|
|
||||||
self._update_album(album_id, full_album_dict, full_album_dict)
|
|
||||||
|
|
||||||
def _refresh_track_via_stored_id(self, track_id, stored_id, full_track_dict):
|
|
||||||
"""Issue #501 callback for tracks — same pattern as albums."""
|
|
||||||
self._update_track(track_id, full_track_dict, full_track_dict)
|
|
||||||
|
|
||||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||||
"""Process an album: search Deezer, verify, fetch full details, store metadata"""
|
"""Process an album: search Deezer, verify, fetch full details, store metadata"""
|
||||||
# Issue #501: honor manual matches. Pre-fix this method just
|
existing_id = self._get_existing_id('album', album_id)
|
||||||
# SKIPPED when a stored ID was present (preserved the ID but
|
if existing_id:
|
||||||
# never refreshed metadata). Now it goes through the full
|
logger.debug(f"Preserving existing Deezer ID for album '{album_name}': {existing_id}")
|
||||||
# refresh path via the stored ID, picking up label / genres /
|
|
||||||
# explicit updates without ever overwriting the manual match.
|
|
||||||
if honor_stored_match(
|
|
||||||
db=self.db, entity_table='albums', entity_id=album_id,
|
|
||||||
id_column='deezer_id',
|
|
||||||
client_fetch_fn=self.client.get_album_raw,
|
|
||||||
on_match_fn=self._refresh_album_via_stored_id,
|
|
||||||
log_prefix='Deezer',
|
|
||||||
):
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
return
|
return
|
||||||
|
|
||||||
result = self.client.search_album(artist_name, album_name)
|
result = self.client.search_album(artist_name, album_name)
|
||||||
|
|
@ -478,8 +393,7 @@ class DeezerWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
result_artist_id = result_artist.get('id') if result_artist else None
|
||||||
result_artist_name = result_artist.get('name') if result_artist else None
|
self._verify_artist_id(item, result_artist_id)
|
||||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
|
||||||
|
|
||||||
# Fetch full album details for label, genres, explicit
|
# Fetch full album details for label, genres, explicit
|
||||||
deezer_album_id = result.get('id')
|
deezer_album_id = result.get('id')
|
||||||
|
|
@ -512,15 +426,9 @@ class DeezerWorker:
|
||||||
|
|
||||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||||
"""Process a track: search Deezer, verify, fetch full details for BPM, store metadata"""
|
"""Process a track: search Deezer, verify, fetch full details for BPM, store metadata"""
|
||||||
# Issue #501: honor manual matches (see _process_album).
|
existing_id = self._get_existing_id('track', track_id)
|
||||||
if honor_stored_match(
|
if existing_id:
|
||||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
logger.debug(f"Preserving existing Deezer ID for track '{track_name}': {existing_id}")
|
||||||
id_column='deezer_id',
|
|
||||||
client_fetch_fn=self.client.get_track_raw,
|
|
||||||
on_match_fn=self._refresh_track_via_stored_id,
|
|
||||||
log_prefix='Deezer',
|
|
||||||
):
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
return
|
return
|
||||||
|
|
||||||
result = self.client.search_track(artist_name, track_name)
|
result = self.client.search_track(artist_name, track_name)
|
||||||
|
|
@ -530,8 +438,7 @@ class DeezerWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
result_artist_id = result_artist.get('id') if result_artist else None
|
||||||
result_artist_name = result_artist.get('name') if result_artist else None
|
self._verify_artist_id(item, result_artist_id)
|
||||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
|
||||||
|
|
||||||
# Fetch full track details for BPM
|
# Fetch full track details for BPM
|
||||||
deezer_track_id = result.get('id')
|
deezer_track_id = result.get('id')
|
||||||
|
|
@ -658,27 +565,12 @@ class DeezerWorker:
|
||||||
genres_data = full_data.get('genres', {}).get('data', [])
|
genres_data = full_data.get('genres', {}).get('data', [])
|
||||||
if genres_data:
|
if genres_data:
|
||||||
genre_names = [g.get('name') for g in genres_data if g.get('name')]
|
genre_names = [g.get('name') for g in genres_data if g.get('name')]
|
||||||
if genre_names:
|
|
||||||
from core.genre_filter import filter_genres
|
|
||||||
from config.settings import config_manager as _cfg
|
|
||||||
genre_names = filter_genres(genre_names, _cfg)
|
|
||||||
if genre_names:
|
if genre_names:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE albums SET genres = ?
|
UPDATE albums SET genres = ?
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||||
""", (json.dumps(genre_names), album_id))
|
""", (json.dumps(genre_names), album_id))
|
||||||
|
|
||||||
# Cache the authoritative expected track count for the Album
|
|
||||||
# Completeness repair job. Deezer's field is `nb_tracks`; prefer
|
|
||||||
# full_data over search_data so we pick up the richer count when
|
|
||||||
# the full album lookup ran. Helper handles the int conversion
|
|
||||||
# and skip-on-missing semantics.
|
|
||||||
set_album_api_track_count(
|
|
||||||
cursor,
|
|
||||||
album_id,
|
|
||||||
(full_data.get('nb_tracks') if full_data else None) or search_data.get('nb_tracks'),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
"""On-demand memory-growth diagnostic (issue #802: ~0.7 MiB/s RSS growth).
|
|
||||||
|
|
||||||
Wraps ``tracemalloc`` so a user seeing runaway memory can capture WHERE the
|
|
||||||
allocations come from instead of us guessing:
|
|
||||||
|
|
||||||
1. start_tracking() — begins tracing + stores a baseline snapshot
|
|
||||||
2. ...reproduce the growth for a few minutes...
|
|
||||||
3. report() — top allocation sites, with the DELTA since baseline
|
|
||||||
(the delta is the leak; absolute sizes are mostly
|
|
||||||
startup noise)
|
|
||||||
4. stop_tracking() — ends tracing, frees trace memory
|
|
||||||
|
|
||||||
Opt-in by design: tracemalloc costs CPU and memory while active (it shadows
|
|
||||||
every allocation), so it must never run by default. The Flask endpoints that
|
|
||||||
expose this live in web_server (GET /api/debug/memory/...) so a user can drive
|
|
||||||
the whole flow from a browser.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import tracemalloc
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("diagnostics.memory")
|
|
||||||
|
|
||||||
_baseline: Optional[tracemalloc.Snapshot] = None
|
|
||||||
_started_at: Optional[float] = None
|
|
||||||
|
|
||||||
# Allocation-site traces this deep give useful "who called it" context without
|
|
||||||
# pathological overhead.
|
|
||||||
_TRACE_FRAMES = 15
|
|
||||||
|
|
||||||
|
|
||||||
def is_tracking() -> bool:
|
|
||||||
return tracemalloc.is_tracing()
|
|
||||||
|
|
||||||
|
|
||||||
def start_tracking() -> Dict[str, Any]:
|
|
||||||
"""Begin tracing and store the baseline snapshot. Idempotent."""
|
|
||||||
global _baseline, _started_at
|
|
||||||
if tracemalloc.is_tracing():
|
|
||||||
return {"tracking": True, "already_running": True, "started_at": _started_at}
|
|
||||||
tracemalloc.start(_TRACE_FRAMES)
|
|
||||||
_baseline = tracemalloc.take_snapshot()
|
|
||||||
_started_at = time.time()
|
|
||||||
logger.info("Memory tracking started (tracemalloc, %d frames)", _TRACE_FRAMES)
|
|
||||||
return {"tracking": True, "already_running": False, "started_at": _started_at}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_tracking() -> Dict[str, Any]:
|
|
||||||
"""End tracing and free the trace bookkeeping."""
|
|
||||||
global _baseline, _started_at
|
|
||||||
was = tracemalloc.is_tracing()
|
|
||||||
if was:
|
|
||||||
tracemalloc.stop()
|
|
||||||
logger.info("Memory tracking stopped")
|
|
||||||
_baseline = None
|
|
||||||
_started_at = None
|
|
||||||
return {"tracking": False, "was_tracking": was}
|
|
||||||
|
|
||||||
|
|
||||||
def _rss_mb() -> Optional[float]:
|
|
||||||
"""Process RSS in MiB, best-effort (psutil, then /proc fallback)."""
|
|
||||||
try:
|
|
||||||
import psutil
|
|
||||||
return round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 1)
|
|
||||||
except Exception: # noqa: S110 — RSS is optional context; fall through to /proc
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
with open("/proc/self/status", encoding="utf-8") as fh:
|
|
||||||
for line in fh:
|
|
||||||
if line.startswith("VmRSS:"):
|
|
||||||
return round(int(line.split()[1]) / 1024, 1)
|
|
||||||
except Exception: # noqa: S110 — no /proc on this platform; RSS stays None
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_stat(stat: Any) -> Dict[str, Any]:
|
|
||||||
"""Project one tracemalloc StatisticDiff/Statistic into a plain dict.
|
|
||||||
Duck-typed (reads size/count/size_diff/count_diff/traceback) so it's
|
|
||||||
unit-testable without real snapshots."""
|
|
||||||
tb = getattr(stat, "traceback", None)
|
|
||||||
frames: List[str] = []
|
|
||||||
if tb:
|
|
||||||
# Most-recent-call-last reads naturally top-down in a report.
|
|
||||||
for frame in list(tb)[-3:]:
|
|
||||||
frames.append(f"{frame.filename}:{frame.lineno}")
|
|
||||||
return {
|
|
||||||
"location": frames[-1] if frames else "?",
|
|
||||||
"trace": frames,
|
|
||||||
"size_mb": round(getattr(stat, "size", 0) / (1024 * 1024), 3),
|
|
||||||
"size_diff_mb": round(getattr(stat, "size_diff", 0) / (1024 * 1024), 3),
|
|
||||||
"count": getattr(stat, "count", 0),
|
|
||||||
"count_diff": getattr(stat, "count_diff", 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def report(top: int = 25) -> Dict[str, Any]:
|
|
||||||
"""Current snapshot vs the start_tracking() baseline: the top allocation
|
|
||||||
sites by GROWTH (size_diff). Includes traced totals + process RSS so the
|
|
||||||
user can see how much of the real growth tracemalloc accounts for."""
|
|
||||||
if not tracemalloc.is_tracing():
|
|
||||||
return {
|
|
||||||
"tracking": False,
|
|
||||||
"rss_mb": _rss_mb(),
|
|
||||||
"hint": "Start with /api/debug/memory/start, reproduce the growth "
|
|
||||||
"for a few minutes, then call this again.",
|
|
||||||
}
|
|
||||||
snapshot = tracemalloc.take_snapshot()
|
|
||||||
# Filter the tracer's own bookkeeping out of the picture.
|
|
||||||
snapshot = snapshot.filter_traces((
|
|
||||||
tracemalloc.Filter(False, tracemalloc.__file__),
|
|
||||||
tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
|
|
||||||
))
|
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
|
||||||
|
|
||||||
if _baseline is not None:
|
|
||||||
stats = snapshot.compare_to(_baseline, "traceback")
|
|
||||||
stats.sort(key=lambda s: s.size_diff, reverse=True)
|
|
||||||
else:
|
|
||||||
stats = snapshot.statistics("traceback")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tracking": True,
|
|
||||||
"started_at": _started_at,
|
|
||||||
"elapsed_seconds": round(time.time() - _started_at, 1) if _started_at else None,
|
|
||||||
"traced_current_mb": round(current / (1024 * 1024), 1),
|
|
||||||
"traced_peak_mb": round(peak / (1024 * 1024), 1),
|
|
||||||
"rss_mb": _rss_mb(),
|
|
||||||
"top_growth": [format_stat(s) for s in stats[:top]],
|
|
||||||
}
|
|
||||||
|
|
@ -1,983 +0,0 @@
|
||||||
"""
|
|
||||||
Discogs API client for music metadata enrichment.
|
|
||||||
|
|
||||||
Follows the same pattern as iTunesClient/DeezerClient — returns data
|
|
||||||
via the shared Track/Artist/Album dataclasses so all sources are interchangeable.
|
|
||||||
|
|
||||||
Rate limits: 25 req/min unauthenticated, 60 req/min with personal token.
|
|
||||||
API docs: https://www.discogs.com/developers
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import requests
|
|
||||||
from core.metadata.artist_album_cache import get_cached_artist_album_payload, store_artist_album_items
|
|
||||||
from core.metadata.cache import get_metadata_cache
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import wraps
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("discogs_client")
|
|
||||||
|
|
||||||
# Global rate limiting
|
|
||||||
_last_api_call_time = 0
|
|
||||||
_api_call_lock = threading.Lock()
|
|
||||||
MIN_API_INTERVAL = 2.5 # 25 req/min unauth = 1 call per 2.4s, padded to 2.5s
|
|
||||||
MIN_API_INTERVAL_AUTH = 1.0 # 60 req/min auth = 1 call per 1.0s
|
|
||||||
|
|
||||||
_is_authenticated = False
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limited(func):
|
|
||||||
"""Decorator to enforce rate limiting on Discogs API calls."""
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
global _last_api_call_time
|
|
||||||
|
|
||||||
interval = MIN_API_INTERVAL_AUTH if _is_authenticated else MIN_API_INTERVAL
|
|
||||||
|
|
||||||
with _api_call_lock:
|
|
||||||
current_time = time.time()
|
|
||||||
time_since_last_call = current_time - _last_api_call_time
|
|
||||||
|
|
||||||
if time_since_last_call < interval:
|
|
||||||
sleep_time = interval - time_since_last_call
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
|
|
||||||
_last_api_call_time = time.time()
|
|
||||||
|
|
||||||
from core.api_call_tracker import api_call_tracker
|
|
||||||
api_call_tracker.record_call('discogs')
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
if "429" in str(e):
|
|
||||||
logger.warning(f"Discogs rate limit hit, backing off: {e}")
|
|
||||||
time.sleep(30)
|
|
||||||
raise e
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
# Discogs disambiguates duplicate artist names two ways: numeric `(N)`
|
|
||||||
# suffix on the older convention (e.g. "Bullet (2)") and a trailing `*`
|
|
||||||
# on the newer convention (e.g. "John Smith*"). Both are presentation-
|
|
||||||
# only — the underlying canonical name is what users expect to see in
|
|
||||||
# search results, on cards, and especially in import paths (otherwise
|
|
||||||
# library folders end up named `Foo*` on disk). Strip both at every
|
|
||||||
# point a Discogs payload becomes a name string.
|
|
||||||
_DISCOGS_DISAMBIG_RE = re.compile(r'(?:\s*\(\d+\))?\s*\*+\s*$|\s*\(\d+\)\s*$')
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_discogs_artist_name(name: Optional[str]) -> str:
|
|
||||||
"""Strip Discogs disambiguation suffixes — both `(N)` and trailing
|
|
||||||
`*` — from an artist name. Returns '' for None / empty input."""
|
|
||||||
if not name:
|
|
||||||
return ''
|
|
||||||
return _DISCOGS_DISAMBIG_RE.sub('', name).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Discogs album ID typing -------------------------------------------------
|
|
||||||
# Discogs has two album object types — masters (/masters/{id}) and releases
|
|
||||||
# (/releases/{id}) — whose numeric IDs share one space, so release N and master
|
|
||||||
# N are DIFFERENT albums. A bare numeric ID is therefore ambiguous. We tag the
|
|
||||||
# type into the ID string ('m12345' / 'r12345') at the point we parse it, so the
|
|
||||||
# correct endpoint can be chosen later without guessing. (Artist IDs are a single
|
|
||||||
# namespace and stay untagged.)
|
|
||||||
|
|
||||||
def _discogs_album_kind(data: Dict[str, Any]) -> str:
|
|
||||||
"""Classify a Discogs album payload as 'master' or 'release'.
|
|
||||||
|
|
||||||
Search results and artist-discography items carry an explicit ``type``;
|
|
||||||
full detail responses don't, but only master detail has ``main_release``."""
|
|
||||||
t = (data.get('type') or '').lower()
|
|
||||||
if t in ('master', 'release'):
|
|
||||||
return t
|
|
||||||
return 'master' if 'main_release' in data else 'release'
|
|
||||||
|
|
||||||
|
|
||||||
def _tag_discogs_album_id(raw_id: Any, kind: str) -> str:
|
|
||||||
"""``'12345'`` + ``'master'`` -> ``'m12345'``; empty input -> ``''``."""
|
|
||||||
s = str(raw_id or '').strip()
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
return f"{'m' if kind == 'master' else 'r'}{s}"
|
|
||||||
|
|
||||||
|
|
||||||
def _discogs_album_endpoints(album_id: Any) -> List[str]:
|
|
||||||
"""Map a (possibly tagged) album ID to the API path(s) to try, in order.
|
|
||||||
|
|
||||||
``'m12345'`` -> ``['/masters/12345']``
|
|
||||||
``'r12345'`` -> ``['/releases/12345']``
|
|
||||||
``'12345'`` (legacy untagged) -> ``['/releases/12345', '/masters/12345']``
|
|
||||||
|
|
||||||
Legacy bare IDs are tried release-first because stored IDs originate
|
|
||||||
overwhelmingly from search / manual-match / collection sync (all releases);
|
|
||||||
this also self-heals pre-fix bad matches. Returns ``[]`` for unusable input."""
|
|
||||||
s = str(album_id or '').strip()
|
|
||||||
if len(s) > 1 and s[0] in ('m', 'r') and s[1:].isdigit():
|
|
||||||
return [f"/{'masters' if s[0] == 'm' else 'releases'}/{s[1:]}"]
|
|
||||||
if s.isdigit():
|
|
||||||
return [f'/releases/{s}', f'/masters/{s}']
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# --- Shared dataclasses (same shape as iTunes/Deezer/Spotify) ---
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Track:
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
artists: List[str]
|
|
||||||
album: str
|
|
||||||
duration_ms: int
|
|
||||||
popularity: int
|
|
||||||
preview_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
release_date: Optional[str] = None
|
|
||||||
track_number: Optional[int] = None
|
|
||||||
disc_number: Optional[int] = None
|
|
||||||
album_type: Optional[str] = None
|
|
||||||
total_tracks: Optional[int] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_discogs_track(cls, track_data: Dict[str, Any], release_data: Dict[str, Any] = None) -> 'Track':
|
|
||||||
"""Build Track from Discogs release tracklist entry + parent release."""
|
|
||||||
release = release_data or {}
|
|
||||||
|
|
||||||
# Parse position (e.g., "A1", "B2", "1", "2-3")
|
|
||||||
position = track_data.get('position', '')
|
|
||||||
track_number = None
|
|
||||||
disc_number = 1
|
|
||||||
if position:
|
|
||||||
# Handle "1-3" (disc-track) format
|
|
||||||
if '-' in position and position.replace('-', '').isdigit():
|
|
||||||
parts = position.split('-')
|
|
||||||
disc_number = int(parts[0])
|
|
||||||
track_number = int(parts[1])
|
|
||||||
elif position.isdigit():
|
|
||||||
track_number = int(position)
|
|
||||||
else:
|
|
||||||
# Vinyl side notation: A1 → disc 1 track 1, B2 → disc 1 track 6, etc.
|
|
||||||
try:
|
|
||||||
track_number = int(''.join(c for c in position if c.isdigit()) or '0') or None
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Duration string "5:23" → ms
|
|
||||||
duration_ms = 0
|
|
||||||
dur_str = track_data.get('duration', '')
|
|
||||||
if dur_str and ':' in dur_str:
|
|
||||||
parts = dur_str.split(':')
|
|
||||||
try:
|
|
||||||
duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Artists from track-level or release-level
|
|
||||||
track_artists = []
|
|
||||||
if track_data.get('artists'):
|
|
||||||
track_artists = [_clean_discogs_artist_name(a.get('name', '')) for a in track_data['artists'] if a.get('name')]
|
|
||||||
if not track_artists and release.get('artists'):
|
|
||||||
track_artists = [_clean_discogs_artist_name(a.get('name', '')) for a in release['artists'] if a.get('name')]
|
|
||||||
track_artists = [a for a in track_artists if a]
|
|
||||||
if not track_artists:
|
|
||||||
track_artists = ['Unknown Artist']
|
|
||||||
|
|
||||||
# Image from release
|
|
||||||
image_url = None
|
|
||||||
images = release.get('images', [])
|
|
||||||
if images:
|
|
||||||
# Prefer 'primary' type, fall back to first
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
|
|
||||||
# Album type
|
|
||||||
total_tracks = len(release.get('tracklist', []))
|
|
||||||
formats = release.get('formats', [{}])
|
|
||||||
format_name = formats[0].get('name', '') if formats else ''
|
|
||||||
|
|
||||||
external_urls = {}
|
|
||||||
if release.get('uri'):
|
|
||||||
external_urls['discogs'] = f"https://www.discogs.com{release['uri']}" if release['uri'].startswith('/') else release['uri']
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
id=str(release.get('id', '')) + f'_t{track_number or 0}',
|
|
||||||
name=track_data.get('title', ''),
|
|
||||||
artists=track_artists,
|
|
||||||
album=release.get('title', ''),
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
popularity=release.get('community', {}).get('have', 0),
|
|
||||||
external_urls=external_urls if external_urls else None,
|
|
||||||
image_url=image_url,
|
|
||||||
release_date=str(release.get('year', '')) if release.get('year') else None,
|
|
||||||
track_number=track_number,
|
|
||||||
disc_number=disc_number,
|
|
||||||
album_type='album',
|
|
||||||
total_tracks=total_tracks,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Artist:
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
popularity: int
|
|
||||||
genres: List[str]
|
|
||||||
followers: int
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_discogs_artist(cls, artist_data: Dict[str, Any]) -> 'Artist':
|
|
||||||
# Images — prefer primary
|
|
||||||
image_url = None
|
|
||||||
images = artist_data.get('images', [])
|
|
||||||
if images:
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
# Search results use 'thumb' or 'cover_image'
|
|
||||||
if not image_url:
|
|
||||||
image_url = artist_data.get('cover_image') or artist_data.get('thumb')
|
|
||||||
if image_url and 'spacer.gif' in image_url:
|
|
||||||
image_url = None
|
|
||||||
|
|
||||||
external_urls = {}
|
|
||||||
if artist_data.get('uri'):
|
|
||||||
uri = artist_data['uri']
|
|
||||||
external_urls['discogs'] = f"https://www.discogs.com{uri}" if uri.startswith('/') else uri
|
|
||||||
elif artist_data.get('resource_url'):
|
|
||||||
external_urls['discogs_api'] = artist_data['resource_url']
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
id=str(artist_data.get('id', '')),
|
|
||||||
name=_clean_discogs_artist_name(
|
|
||||||
artist_data.get('name', artist_data.get('title', ''))
|
|
||||||
),
|
|
||||||
popularity=0,
|
|
||||||
genres=[],
|
|
||||||
followers=0,
|
|
||||||
image_url=image_url,
|
|
||||||
external_urls=external_urls if external_urls else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Album:
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
artists: List[str]
|
|
||||||
release_date: str
|
|
||||||
total_tracks: int
|
|
||||||
album_type: str
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_discogs_release(cls, release_data: Dict[str, Any]) -> 'Album':
|
|
||||||
# Artists — search results put "Artist - Title" in the title field
|
|
||||||
artists = []
|
|
||||||
title = release_data.get('title', '')
|
|
||||||
if release_data.get('artists'):
|
|
||||||
artists = [_clean_discogs_artist_name(a.get('name', '')) for a in release_data['artists'] if a.get('name')]
|
|
||||||
elif release_data.get('artist'):
|
|
||||||
artists = [_clean_discogs_artist_name(release_data['artist'])]
|
|
||||||
elif ' - ' in title:
|
|
||||||
# Search results: "Radiohead - OK Computer" → artist="Radiohead", title="OK Computer"
|
|
||||||
parts = title.split(' - ', 1)
|
|
||||||
artists = [_clean_discogs_artist_name(parts[0])]
|
|
||||||
title = parts[1].strip()
|
|
||||||
artists = [a for a in artists if a]
|
|
||||||
if not artists:
|
|
||||||
artists = ['Unknown Artist']
|
|
||||||
|
|
||||||
# Image
|
|
||||||
image_url = None
|
|
||||||
images = release_data.get('images', [])
|
|
||||||
if images:
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
if not image_url:
|
|
||||||
image_url = release_data.get('cover_image') or release_data.get('thumb')
|
|
||||||
if image_url and 'spacer.gif' in image_url:
|
|
||||||
image_url = None
|
|
||||||
|
|
||||||
# Track count
|
|
||||||
tracklist = release_data.get('tracklist', [])
|
|
||||||
total_tracks = len(tracklist) if tracklist else (release_data.get('format_quantity', 0) or 0)
|
|
||||||
|
|
||||||
# Album type from formats array (full release detail) or format string (search/artist releases)
|
|
||||||
formats = release_data.get('formats', [])
|
|
||||||
format_name = formats[0].get('name', '').lower() if formats else ''
|
|
||||||
descriptions = [d.lower() for d in formats[0].get('descriptions', [])] if formats else []
|
|
||||||
|
|
||||||
# Also check the 'format' field from search/artist release endpoints
|
|
||||||
# Can be a string "Vinyl, LP, Album" or a list ["Vinyl", "LP", "Album"]
|
|
||||||
raw_format = release_data.get('format') or ''
|
|
||||||
if isinstance(raw_format, list):
|
|
||||||
format_str = ', '.join(raw_format).lower()
|
|
||||||
else:
|
|
||||||
format_str = str(raw_format).lower()
|
|
||||||
|
|
||||||
if 'single' in descriptions or 'single' in format_name or 'single' in format_str:
|
|
||||||
album_type = 'single'
|
|
||||||
elif 'ep' in descriptions or ', ep' in format_str or format_str.endswith('ep'):
|
|
||||||
album_type = 'ep'
|
|
||||||
elif 'compilation' in descriptions or 'compilation' in format_str or 'compilation' in (release_data.get('type', '') or '').lower():
|
|
||||||
album_type = 'compilation'
|
|
||||||
elif 'lp' in descriptions or 'lp' in format_str or 'album' in descriptions or 'album' in format_str:
|
|
||||||
album_type = 'album'
|
|
||||||
elif total_tracks <= 3 and total_tracks > 0:
|
|
||||||
album_type = 'single'
|
|
||||||
elif total_tracks <= 6 and total_tracks > 0:
|
|
||||||
album_type = 'ep'
|
|
||||||
else:
|
|
||||||
album_type = 'album'
|
|
||||||
|
|
||||||
# Year
|
|
||||||
year = release_data.get('year', '')
|
|
||||||
release_date = str(year) if year and year != 0 else ''
|
|
||||||
|
|
||||||
external_urls = {}
|
|
||||||
if release_data.get('uri'):
|
|
||||||
uri = release_data['uri']
|
|
||||||
external_urls['discogs'] = f"https://www.discogs.com{uri}" if uri.startswith('/') else uri
|
|
||||||
elif release_data.get('resource_url'):
|
|
||||||
external_urls['discogs_api'] = release_data['resource_url']
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
id=_tag_discogs_album_id(release_data.get('id', ''), _discogs_album_kind(release_data)),
|
|
||||||
name=title,
|
|
||||||
artists=artists,
|
|
||||||
release_date=release_date,
|
|
||||||
total_tracks=total_tracks,
|
|
||||||
album_type=album_type,
|
|
||||||
image_url=image_url,
|
|
||||||
external_urls=external_urls if external_urls else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DiscogsClient:
|
|
||||||
"""
|
|
||||||
Discogs API client for music metadata.
|
|
||||||
|
|
||||||
Full parity with iTunesClient/DeezerClient — same method signatures,
|
|
||||||
same return types (Track, Artist, Album dataclasses).
|
|
||||||
|
|
||||||
Rate limit: 25 req/min unauthenticated, 60 req/min with personal token.
|
|
||||||
"""
|
|
||||||
|
|
||||||
BASE_URL = "https://api.discogs.com"
|
|
||||||
|
|
||||||
def __init__(self, token: str = None):
|
|
||||||
global _is_authenticated
|
|
||||||
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update({
|
|
||||||
'User-Agent': 'SoulSync/2.2 +https://github.com/Nezreka/SoulSync',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Load token from config or parameter
|
|
||||||
self.token = token
|
|
||||||
if not self.token:
|
|
||||||
try:
|
|
||||||
from config.settings import config_manager
|
|
||||||
self.token = config_manager.get('discogs.token', '')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("load discogs.token from config: %s", e)
|
|
||||||
|
|
||||||
if self.token:
|
|
||||||
self.session.headers['Authorization'] = f'Discogs token={self.token}'
|
|
||||||
_is_authenticated = True
|
|
||||||
logger.info("Discogs client initialized (authenticated — 60 req/min)")
|
|
||||||
else:
|
|
||||||
_is_authenticated = False
|
|
||||||
logger.info("Discogs client initialized (unauthenticated — 25 req/min)")
|
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
return bool(self.token)
|
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
|
||||||
return True # Works without auth
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_name(name: str) -> str:
|
|
||||||
"""Normalize a name for comparison — lowercase, strip parentheticals and punctuation."""
|
|
||||||
name = name.lower().strip()
|
|
||||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
|
||||||
name = re.sub(r'[^\w\s]', '', name)
|
|
||||||
name = re.sub(r'\s+', ' ', name).strip()
|
|
||||||
return name
|
|
||||||
|
|
||||||
# --- Core API Methods ---
|
|
||||||
|
|
||||||
@rate_limited
|
|
||||||
def _api_get(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
|
||||||
"""Make a GET request to the Discogs API."""
|
|
||||||
try:
|
|
||||||
url = f"{self.BASE_URL}{endpoint}" if endpoint.startswith('/') else endpoint
|
|
||||||
resp = self.session.get(url, params=params, timeout=15)
|
|
||||||
|
|
||||||
if resp.status_code == 429:
|
|
||||||
logger.warning("Discogs rate limit hit")
|
|
||||||
time.sleep(30)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.debug(f"Discogs API {endpoint} returned {resp.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Discogs API error ({endpoint}): {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# --- User Collection (powers Your Albums Discogs source) ---
|
|
||||||
|
|
||||||
def get_authenticated_username(self) -> Optional[str]:
|
|
||||||
"""Resolve the username for the configured personal token.
|
|
||||||
|
|
||||||
Discogs's `/oauth/identity` endpoint returns the user's
|
|
||||||
username when called with a valid token. Cached on the
|
|
||||||
instance so subsequent calls don't re-hit the API.
|
|
||||||
"""
|
|
||||||
if hasattr(self, '_cached_username'):
|
|
||||||
return self._cached_username
|
|
||||||
if not self.is_authenticated():
|
|
||||||
self._cached_username = None
|
|
||||||
return None
|
|
||||||
data = self._api_get('/oauth/identity')
|
|
||||||
username = data.get('username') if data else None
|
|
||||||
self._cached_username = username
|
|
||||||
return username
|
|
||||||
|
|
||||||
def get_user_collection(self, username: Optional[str] = None,
|
|
||||||
folder_id: int = 0,
|
|
||||||
per_page: int = 100,
|
|
||||||
max_pages: int = 50) -> List[Dict[str, Any]]:
|
|
||||||
"""Fetch a Discogs user's collection (folder 0 = "All").
|
|
||||||
|
|
||||||
Returns a list of normalized release dicts ready for
|
|
||||||
``database.upsert_liked_album``:
|
|
||||||
{
|
|
||||||
'album_name': str,
|
|
||||||
'artist_name': str,
|
|
||||||
'release_id': int, # Discogs release id
|
|
||||||
'image_url': str | None,
|
|
||||||
'release_date': str, # 'YYYY' (Discogs only stores year)
|
|
||||||
'total_tracks': int,
|
|
||||||
}
|
|
||||||
|
|
||||||
Pagination caps at ``max_pages`` to bound runtime — at 100/page
|
|
||||||
that's 5000 releases, more than enough for typical collections.
|
|
||||||
Authenticated calls only (Discogs collection is private).
|
|
||||||
"""
|
|
||||||
if not self.is_authenticated():
|
|
||||||
logger.warning("Discogs collection fetch attempted without token")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if not username:
|
|
||||||
username = self.get_authenticated_username()
|
|
||||||
if not username:
|
|
||||||
logger.warning("Could not resolve Discogs username for token")
|
|
||||||
return []
|
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
page = 1
|
|
||||||
while page <= max_pages:
|
|
||||||
data = self._api_get(
|
|
||||||
f'/users/{username}/collection/folders/{folder_id}/releases',
|
|
||||||
{'page': page, 'per_page': per_page, 'sort': 'added', 'sort_order': 'desc'},
|
|
||||||
)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
releases = data.get('releases', []) or []
|
|
||||||
if not releases:
|
|
||||||
break
|
|
||||||
|
|
||||||
for entry in releases:
|
|
||||||
info = entry.get('basic_information') or {}
|
|
||||||
release_id = entry.get('id') or info.get('id')
|
|
||||||
if not release_id:
|
|
||||||
continue
|
|
||||||
title = info.get('title') or ''
|
|
||||||
# Discogs `artists` is a list of {name, id, ...}; first is primary.
|
|
||||||
artists = info.get('artists') or []
|
|
||||||
artist_name = ''
|
|
||||||
if artists and isinstance(artists[0], dict):
|
|
||||||
artist_name = (artists[0].get('name') or '').strip()
|
|
||||||
# Strip Discogs disambiguation suffixes — both `(N)` and `*`.
|
|
||||||
artist_name = _clean_discogs_artist_name(artist_name)
|
|
||||||
if not title or not artist_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Image URLs: cover_image is the primary, also has thumb.
|
|
||||||
image_url = (info.get('cover_image')
|
|
||||||
or info.get('thumb')
|
|
||||||
or '')
|
|
||||||
|
|
||||||
year = info.get('year')
|
|
||||||
release_date = str(year) if year and year > 0 else ''
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
'album_name': title.strip(),
|
|
||||||
'artist_name': artist_name,
|
|
||||||
'release_id': int(release_id),
|
|
||||||
'image_url': image_url or None,
|
|
||||||
'release_date': release_date,
|
|
||||||
'total_tracks': 0, # Not in basic_information; populated via get_release if needed
|
|
||||||
})
|
|
||||||
|
|
||||||
pagination = data.get('pagination') or {}
|
|
||||||
if page >= int(pagination.get('pages') or 1):
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
logger.info(f"Discogs collection: fetched {len(results)} releases for {username}")
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_release(self, release_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Fetch full Discogs release detail including tracklist.
|
|
||||||
|
|
||||||
Returns the raw API response so callers can render rich
|
|
||||||
Discogs context (year, format, label, country, tracklist).
|
|
||||||
"""
|
|
||||||
if not release_id:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
release_id = int(release_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return self._api_get(f'/releases/{release_id}')
|
|
||||||
|
|
||||||
# --- Search Methods (same signatures as iTunes/Deezer) ---
|
|
||||||
|
|
||||||
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
|
|
||||||
"""Search for artists on Discogs."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached_results = cache.get_search_results('discogs', 'artist', query, limit)
|
|
||||||
if cached_results is not None:
|
|
||||||
artists = []
|
|
||||||
for raw in cached_results:
|
|
||||||
try:
|
|
||||||
artists.append(Artist.from_discogs_artist(raw))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Artist.from_discogs_artist cache parse: %s", e)
|
|
||||||
if artists:
|
|
||||||
return artists
|
|
||||||
|
|
||||||
data = self._api_get('/database/search', {
|
|
||||||
'q': query, 'type': 'artist', 'per_page': min(limit, 50),
|
|
||||||
})
|
|
||||||
if not data or not data.get('results'):
|
|
||||||
return []
|
|
||||||
|
|
||||||
artists = []
|
|
||||||
raw_items = []
|
|
||||||
for item in data['results'][:limit]:
|
|
||||||
try:
|
|
||||||
artists.append(Artist.from_discogs_artist(item))
|
|
||||||
raw_items.append(item)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error parsing Discogs artist: {e}")
|
|
||||||
|
|
||||||
if raw_items:
|
|
||||||
entries = [(str(r.get('id', '')), r) for r in raw_items if r.get('id')]
|
|
||||||
if entries:
|
|
||||||
cache.store_entities_bulk('discogs', 'artist', entries)
|
|
||||||
cache.store_search_results('discogs', 'artist', query, limit,
|
|
||||||
[str(r.get('id', '')) for r in raw_items if r.get('id')])
|
|
||||||
return artists
|
|
||||||
|
|
||||||
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
|
|
||||||
"""Search for releases/albums on Discogs."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached_results = cache.get_search_results('discogs', 'album', query, limit)
|
|
||||||
if cached_results is not None:
|
|
||||||
albums = []
|
|
||||||
for raw in cached_results:
|
|
||||||
try:
|
|
||||||
albums.append(Album.from_discogs_release(raw))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Album.from_discogs_release cache parse: %s", e)
|
|
||||||
if albums:
|
|
||||||
return albums
|
|
||||||
|
|
||||||
data = self._api_get('/database/search', {
|
|
||||||
'q': query, 'type': 'release', 'per_page': min(limit, 50),
|
|
||||||
})
|
|
||||||
if not data or not data.get('results'):
|
|
||||||
return []
|
|
||||||
|
|
||||||
albums = []
|
|
||||||
raw_items = []
|
|
||||||
seen_titles = set()
|
|
||||||
for item in data['results'][:limit * 2]:
|
|
||||||
try:
|
|
||||||
album = Album.from_discogs_release(item)
|
|
||||||
dedup_key = f"{album.name.lower()}|{album.artists[0].lower() if album.artists else ''}"
|
|
||||||
if dedup_key in seen_titles:
|
|
||||||
continue
|
|
||||||
seen_titles.add(dedup_key)
|
|
||||||
albums.append(album)
|
|
||||||
raw_items.append(item)
|
|
||||||
if len(albums) >= limit:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error parsing Discogs release: {e}")
|
|
||||||
|
|
||||||
if raw_items:
|
|
||||||
entries = [(str(r.get('id', '')), r) for r in raw_items if r.get('id')]
|
|
||||||
if entries:
|
|
||||||
cache.store_entities_bulk('discogs', 'album', entries, skip_if_exists=True)
|
|
||||||
cache.store_search_results('discogs', 'album', query, limit,
|
|
||||||
[str(r.get('id', '')) for r in raw_items if r.get('id')])
|
|
||||||
return albums
|
|
||||||
|
|
||||||
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
|
|
||||||
"""Search for tracks on Discogs.
|
|
||||||
Discogs doesn't have a track-level search API — returns empty list.
|
|
||||||
Track data is available via get_album() tracklists instead."""
|
|
||||||
# Discogs has no track search endpoint. Artists and albums are the
|
|
||||||
# searchable entities. Individual tracks come from release tracklists.
|
|
||||||
return []
|
|
||||||
|
|
||||||
# --- Lookup Methods ---
|
|
||||||
|
|
||||||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get artist details by Discogs ID."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached = cache.get_entity('discogs', 'artist', artist_id)
|
|
||||||
if cached and cached.get('name'):
|
|
||||||
# Rebuild normalized result from cached raw data
|
|
||||||
data = cached
|
|
||||||
else:
|
|
||||||
data = self._api_get(f'/artists/{artist_id}')
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
cache.store_entity('discogs', 'artist', artist_id, data)
|
|
||||||
|
|
||||||
artist = Artist.from_discogs_artist(data)
|
|
||||||
|
|
||||||
# Get profile/bio
|
|
||||||
profile = data.get('profile', '')
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'id': artist.id,
|
|
||||||
'name': artist.name,
|
|
||||||
'image_url': artist.image_url,
|
|
||||||
'genres': [],
|
|
||||||
'popularity': 0,
|
|
||||||
'followers': 0,
|
|
||||||
'bio': profile,
|
|
||||||
'external_urls': artist.external_urls,
|
|
||||||
'images': [{'url': artist.image_url}] if artist.image_url else [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_album(self, release_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get release/album details by Discogs ID. Tries master first, falls back to release."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached = cache.get_entity('discogs', 'album', release_id)
|
|
||||||
if cached and cached.get('title'):
|
|
||||||
data = cached
|
|
||||||
else:
|
|
||||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
|
||||||
data = None
|
|
||||||
for path in _discogs_album_endpoints(release_id):
|
|
||||||
data = self._api_get(path)
|
|
||||||
if data and data.get('title'):
|
|
||||||
break
|
|
||||||
data = None
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
cache.store_entity('discogs', 'album', release_id, data)
|
|
||||||
|
|
||||||
album = Album.from_discogs_release(data)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'id': album.id,
|
|
||||||
'name': album.name,
|
|
||||||
'artist': album.artists[0] if album.artists else '',
|
|
||||||
'artists': album.artists,
|
|
||||||
'release_date': album.release_date,
|
|
||||||
'total_tracks': album.total_tracks,
|
|
||||||
'album_type': album.album_type,
|
|
||||||
'image_url': album.image_url,
|
|
||||||
'images': [{'url': album.image_url}] if album.image_url else [],
|
|
||||||
'external_urls': album.external_urls,
|
|
||||||
'genres': data.get('genres', []),
|
|
||||||
'styles': data.get('styles', []),
|
|
||||||
'label': data.get('labels', [{}])[0].get('name', '') if data.get('labels') else '',
|
|
||||||
'catalog_number': data.get('labels', [{}])[0].get('catno', '') if data.get('labels') else '',
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_tracks and data.get('tracklist'):
|
|
||||||
result['tracks'] = {
|
|
||||||
'items': [self._tracklist_to_spotify_format(t, data) for t in data['tracklist']
|
|
||||||
if t.get('type_', '') == 'track' or not t.get('type_')]
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
|
||||||
"""Get releases by an artist. Prefers master releases, filters features."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached_payload = get_cached_artist_album_payload(cache, 'discogs', artist_id, album_type=album_type, limit=limit)
|
|
||||||
releases = cached_payload.get('_releases') if cached_payload else None
|
|
||||||
artist_name = ''
|
|
||||||
if cached_payload:
|
|
||||||
artist_name = str(cached_payload.get('artist_name') or '').lower()
|
|
||||||
|
|
||||||
if not isinstance(releases, list) or not releases:
|
|
||||||
# First get the artist name for feature filtering. Strip Discogs
|
|
||||||
# disambiguation suffix so feature-vs-primary matching below
|
|
||||||
# compares against the canonical name, not "Beyoncé*".
|
|
||||||
artist_data = self._api_get(f'/artists/{artist_id}')
|
|
||||||
artist_name = _clean_discogs_artist_name(
|
|
||||||
artist_data.get('name', '') if artist_data else ''
|
|
||||||
).lower()
|
|
||||||
|
|
||||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
|
||||||
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
|
|
||||||
})
|
|
||||||
if not data or not data.get('releases'):
|
|
||||||
return []
|
|
||||||
releases = data.get('releases') or []
|
|
||||||
store_artist_album_items(
|
|
||||||
cache,
|
|
||||||
'discogs',
|
|
||||||
artist_id,
|
|
||||||
releases,
|
|
||||||
album_type=album_type,
|
|
||||||
limit=limit,
|
|
||||||
items_field='_releases',
|
|
||||||
extra_fields={'artist_name': artist_name},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Separate masters from individual releases — prefer masters (canonical versions)
|
|
||||||
masters = []
|
|
||||||
releases_no_master = []
|
|
||||||
master_titles = set()
|
|
||||||
|
|
||||||
for item in releases:
|
|
||||||
# Skip non-main roles
|
|
||||||
role = item.get('role', 'Main').lower()
|
|
||||||
if role not in ('main', ''):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Filter out features — only include releases where this artist is the PRIMARY artist
|
|
||||||
# "Beyoncé Feat. Kendrick Lamar" → primary is Beyoncé, skip
|
|
||||||
# "Kendrick Lamar Feat. Rihanna" → primary is Kendrick, keep
|
|
||||||
release_artist = item.get('artist', '')
|
|
||||||
if artist_name and release_artist:
|
|
||||||
# Get the primary artist (before any Feat./Ft./&)
|
|
||||||
primary = re.split(r'\s+(?:feat\.?|ft\.?|featuring)\s+', release_artist, flags=re.IGNORECASE)[0]
|
|
||||||
primary = re.split(r'\s*[&,]\s*', primary)[0].strip()
|
|
||||||
# Check if our artist is the primary
|
|
||||||
if self._normalize_name(primary) != self._normalize_name(artist_name):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item.get('type') == 'master':
|
|
||||||
masters.append(item)
|
|
||||||
master_titles.add(item.get('title', '').lower())
|
|
||||||
else:
|
|
||||||
releases_no_master.append(item)
|
|
||||||
|
|
||||||
# Use masters first, then add releases that don't have a master
|
|
||||||
ordered = masters + [r for r in releases_no_master if r.get('title', '').lower() not in master_titles]
|
|
||||||
|
|
||||||
albums = []
|
|
||||||
seen_titles = set()
|
|
||||||
allowed_types = set(album_type.split(','))
|
|
||||||
|
|
||||||
for item in ordered:
|
|
||||||
try:
|
|
||||||
album = Album.from_discogs_release(item)
|
|
||||||
|
|
||||||
# Use thumb from release list as image
|
|
||||||
thumb = item.get('thumb') or item.get('cover_image') or ''
|
|
||||||
if thumb and 'spacer.gif' not in thumb and not album.image_url:
|
|
||||||
album = Album(id=album.id, name=album.name, artists=album.artists,
|
|
||||||
release_date=album.release_date, total_tracks=album.total_tracks,
|
|
||||||
album_type=album.album_type, image_url=thumb,
|
|
||||||
external_urls=album.external_urls)
|
|
||||||
|
|
||||||
# Deduplicate by normalized title (but keep deluxe/special editions as separate)
|
|
||||||
dedup_key = album.name.lower().strip()
|
|
||||||
if dedup_key in seen_titles:
|
|
||||||
continue
|
|
||||||
seen_titles.add(dedup_key)
|
|
||||||
|
|
||||||
# Filter by requested type
|
|
||||||
if album.album_type in allowed_types:
|
|
||||||
albums.append(album)
|
|
||||||
|
|
||||||
if len(albums) >= limit:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error parsing Discogs artist release: {e}")
|
|
||||||
|
|
||||||
return albums
|
|
||||||
|
|
||||||
def get_album_tracks(self, release_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get album tracks by Discogs release or master ID. Returns Spotify-compatible format."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cache_key = f"{release_id}_tracks"
|
|
||||||
cached = cache.get_entity('discogs', 'album', cache_key)
|
|
||||||
if cached:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
|
||||||
data = None
|
|
||||||
for path in _discogs_album_endpoints(release_id):
|
|
||||||
data = self._api_get(path)
|
|
||||||
if data and data.get('tracklist'):
|
|
||||||
break
|
|
||||||
data = None
|
|
||||||
if not data or not data.get('tracklist'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get album image
|
|
||||||
image_url = None
|
|
||||||
images = data.get('images', [])
|
|
||||||
if images:
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
|
|
||||||
album_info = {
|
|
||||||
'id': str(release_id),
|
|
||||||
'name': data.get('title', ''),
|
|
||||||
'images': [{'url': image_url, 'height': 600, 'width': 600}] if image_url else [],
|
|
||||||
'release_date': str(data.get('year', '')) if data.get('year') else '',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get artists
|
|
||||||
artists_list = []
|
|
||||||
if data.get('artists'):
|
|
||||||
artists_list = [
|
|
||||||
{'name': _clean_discogs_artist_name(a.get('name', ''))}
|
|
||||||
for a in data['artists']
|
|
||||||
if a.get('name') and _clean_discogs_artist_name(a.get('name', ''))
|
|
||||||
]
|
|
||||||
if not artists_list:
|
|
||||||
artists_list = [{'name': 'Unknown Artist'}]
|
|
||||||
|
|
||||||
tracks = []
|
|
||||||
track_num = 0
|
|
||||||
disc_num = 1
|
|
||||||
for t in data['tracklist']:
|
|
||||||
if t.get('type_') == 'heading':
|
|
||||||
disc_num += 1
|
|
||||||
continue
|
|
||||||
if t.get('type_', '') not in ('track', ''):
|
|
||||||
continue
|
|
||||||
|
|
||||||
track_num += 1
|
|
||||||
|
|
||||||
# Parse duration
|
|
||||||
duration_ms = 0
|
|
||||||
dur_str = t.get('duration', '')
|
|
||||||
if dur_str and ':' in dur_str:
|
|
||||||
parts = dur_str.split(':')
|
|
||||||
try:
|
|
||||||
duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Per-track artists or fall back to release artists
|
|
||||||
track_artists = artists_list
|
|
||||||
if t.get('artists'):
|
|
||||||
track_artists = [
|
|
||||||
{'name': _clean_discogs_artist_name(a.get('name', ''))}
|
|
||||||
for a in t['artists']
|
|
||||||
if a.get('name') and _clean_discogs_artist_name(a.get('name', ''))
|
|
||||||
]
|
|
||||||
|
|
||||||
tracks.append({
|
|
||||||
'id': f"{release_id}_t{track_num}",
|
|
||||||
'name': t.get('title', ''),
|
|
||||||
'artists': track_artists,
|
|
||||||
'album': album_info,
|
|
||||||
'duration_ms': duration_ms,
|
|
||||||
'track_number': track_num,
|
|
||||||
'disc_number': disc_num if disc_num > 1 else 1,
|
|
||||||
'explicit': False,
|
|
||||||
'uri': f"discogs:track:{release_id}_{track_num}",
|
|
||||||
'external_urls': {},
|
|
||||||
'_source': 'discogs',
|
|
||||||
})
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'items': tracks,
|
|
||||||
'total': len(tracks),
|
|
||||||
'limit': len(tracks),
|
|
||||||
'next': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.store_entity('discogs', 'album', cache_key, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _fetch_and_cache_artist(self, artist_id: str) -> Optional[Dict]:
|
|
||||||
"""Fetch raw artist data with cache. Used by enrichment worker."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached = cache.get_entity('discogs', 'artist', str(artist_id))
|
|
||||||
if cached and cached.get('name'):
|
|
||||||
return cached
|
|
||||||
data = self._api_get(f'/artists/{artist_id}')
|
|
||||||
if data:
|
|
||||||
cache.store_entity('discogs', 'artist', str(artist_id), data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _fetch_and_cache_album(self, release_id: str) -> Optional[Dict]:
|
|
||||||
"""Fetch raw album/release data with cache. Used by enrichment worker."""
|
|
||||||
cache = get_metadata_cache()
|
|
||||||
cached = cache.get_entity('discogs', 'album', str(release_id))
|
|
||||||
if cached and cached.get('title'):
|
|
||||||
return cached
|
|
||||||
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
|
|
||||||
data = None
|
|
||||||
for path in _discogs_album_endpoints(release_id):
|
|
||||||
data = self._api_get(path)
|
|
||||||
if data and data.get('title'):
|
|
||||||
break
|
|
||||||
data = None
|
|
||||||
if data:
|
|
||||||
cache.store_entity('discogs', 'album', str(release_id), data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
|
||||||
"""Get artist image by fetching their first album's cover art.
|
|
||||||
Used as fallback when artist has no direct image."""
|
|
||||||
data = self._api_get(f'/artists/{artist_id}/releases', {
|
|
||||||
'sort': 'year', 'sort_order': 'desc', 'per_page': 5,
|
|
||||||
})
|
|
||||||
if not data or not data.get('releases'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
for release in data['releases']:
|
|
||||||
thumb = release.get('thumb')
|
|
||||||
if thumb and 'spacer.gif' not in thumb:
|
|
||||||
return thumb
|
|
||||||
return None
|
|
||||||
|
|
||||||
# --- Helpers ---
|
|
||||||
|
|
||||||
def _tracklist_to_spotify_format(self, track_data: Dict, release_data: Dict) -> Dict:
|
|
||||||
"""Convert a Discogs tracklist entry to Spotify-compatible track dict."""
|
|
||||||
t = Track.from_discogs_track(track_data, release_data)
|
|
||||||
return {
|
|
||||||
'id': t.id,
|
|
||||||
'name': t.name,
|
|
||||||
'artists': [{'name': a} for a in t.artists],
|
|
||||||
'track_number': t.track_number,
|
|
||||||
'disc_number': t.disc_number,
|
|
||||||
'duration_ms': t.duration_ms,
|
|
||||||
}
|
|
||||||
|
|
@ -1,532 +0,0 @@
|
||||||
"""
|
|
||||||
Discogs background enrichment worker.
|
|
||||||
|
|
||||||
Enriches library artists and albums with Discogs metadata:
|
|
||||||
- Artists: discogs_id, bio, members, genres, styles, URLs, images
|
|
||||||
- Albums: discogs_id, genres, styles, label, catalog number, country, community rating
|
|
||||||
|
|
||||||
Follows the exact same pattern as AudioDBWorker.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
from database.music_database import MusicDatabase
|
|
||||||
from core.discogs_client import DiscogsClient, _discogs_album_kind, _tag_discogs_album_id
|
|
||||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
|
||||||
|
|
||||||
logger = get_logger("discogs_worker")
|
|
||||||
|
|
||||||
|
|
||||||
def count_discogs_real_tracks(tracklist) -> int:
|
|
||||||
"""Count actual songs in a Discogs tracklist response.
|
|
||||||
|
|
||||||
Discogs tracklists interleave real tracks with section headings
|
|
||||||
(``type_=='heading'``), index markers (``type_=='index'``),
|
|
||||||
and sub-tracks (``type_=='sub_track'``) that aren't themselves
|
|
||||||
songs. We count anything that's explicitly typed as ``'track'`` OR
|
|
||||||
has an empty/missing ``type_`` field — matching exactly what
|
|
||||||
:meth:`core.discogs_client.DiscogsClient.get_album_tracks` itself
|
|
||||||
treats as a real track (`type_ in ('track', '')`). Counting any
|
|
||||||
narrower set silently disagrees with the repair job's fallback
|
|
||||||
`_get_expected_total` path, which calls `get_album_tracks_for_source`
|
|
||||||
under the hood and therefore uses the client's count.
|
|
||||||
|
|
||||||
Reported by kettui on PR #374 — original filter only counted
|
|
||||||
``type_=='track'`` and undercounted releases where the discogs
|
|
||||||
response left ``type_`` empty for some real tracks.
|
|
||||||
"""
|
|
||||||
if not tracklist:
|
|
||||||
return 0
|
|
||||||
return sum(1 for t in tracklist if (t.get('type_') or '') in ('track', ''))
|
|
||||||
|
|
||||||
|
|
||||||
class DiscogsWorker:
|
|
||||||
"""Background worker for enriching library artists and albums with Discogs metadata."""
|
|
||||||
|
|
||||||
def __init__(self, database: MusicDatabase):
|
|
||||||
self.db = database
|
|
||||||
self.client = DiscogsClient()
|
|
||||||
|
|
||||||
# Worker state
|
|
||||||
self.running = False
|
|
||||||
self.paused = False
|
|
||||||
self.should_stop = False
|
|
||||||
self.thread = None
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
# Current item being processed (for UI tooltip)
|
|
||||||
self.current_item = None
|
|
||||||
|
|
||||||
# Statistics
|
|
||||||
self.stats = {
|
|
||||||
'matched': 0,
|
|
||||||
'not_found': 0,
|
|
||||||
'pending': 0,
|
|
||||||
'errors': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Retry configuration
|
|
||||||
self.retry_days = 30
|
|
||||||
|
|
||||||
# Name matching threshold
|
|
||||||
self.name_similarity_threshold = 0.80
|
|
||||||
|
|
||||||
logger.info(f"Discogs background worker initialized (authenticated: {self.client.is_authenticated()})")
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start the background worker."""
|
|
||||||
if self.running:
|
|
||||||
logger.warning("Discogs worker already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
self.should_stop = False
|
|
||||||
self._stop_event.clear()
|
|
||||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
logger.info("Discogs background worker started")
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the background worker."""
|
|
||||||
if not self.running:
|
|
||||||
return
|
|
||||||
logger.info("Stopping Discogs worker...")
|
|
||||||
self.should_stop = True
|
|
||||||
self.running = False
|
|
||||||
self._stop_event.set()
|
|
||||||
if self.thread:
|
|
||||||
self.thread.join(timeout=1)
|
|
||||||
logger.info("Discogs worker stopped")
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
"""Pause the worker."""
|
|
||||||
if not self.running:
|
|
||||||
return
|
|
||||||
self.paused = True
|
|
||||||
logger.info("Discogs worker paused")
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
"""Resume the worker."""
|
|
||||||
if not self.running:
|
|
||||||
return
|
|
||||||
self.paused = False
|
|
||||||
logger.info("Discogs worker resumed")
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
|
||||||
"""Get current statistics."""
|
|
||||||
self.stats['pending'] = self._count_pending_items()
|
|
||||||
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
|
||||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'enabled': True,
|
|
||||||
'running': is_actually_running and not self.paused,
|
|
||||||
'paused': self.paused,
|
|
||||||
'idle': is_idle,
|
|
||||||
'current_item': self.current_item,
|
|
||||||
'stats': self.stats.copy(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
"""Main worker loop."""
|
|
||||||
logger.info("Discogs worker thread started")
|
|
||||||
|
|
||||||
while not self.should_stop:
|
|
||||||
try:
|
|
||||||
if self.paused:
|
|
||||||
interruptible_sleep(self._stop_event, 1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.current_item = None
|
|
||||||
item = self._get_next_item()
|
|
||||||
|
|
||||||
if not item:
|
|
||||||
interruptible_sleep(self._stop_event, 10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.current_item = item.get('name', '')
|
|
||||||
|
|
||||||
# Guard: skip items with None/NULL IDs
|
|
||||||
item_id = item.get('id')
|
|
||||||
if item_id is None:
|
|
||||||
logger.warning(f"Skipping {item.get('type', 'unknown')} with NULL id")
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._process_item(item)
|
|
||||||
interruptible_sleep(self._stop_event, 2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in Discogs worker loop: {e}")
|
|
||||||
interruptible_sleep(self._stop_event, 5)
|
|
||||||
|
|
||||||
logger.info("Discogs worker thread finished")
|
|
||||||
|
|
||||||
def _get_next_item(self) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get next item to process (artists → albums → retries)."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Pinned-group override (Manage Enrichment Workers): process one
|
|
||||||
# entity type first, then fall through to the normal chain. Discogs
|
|
||||||
# has no track endpoint, so only artist/album are honored.
|
|
||||||
from core.worker_utils import read_enrichment_priority, priority_pending_item
|
|
||||||
_prio = read_enrichment_priority('discogs')
|
|
||||||
if _prio in ('artist', 'album'):
|
|
||||||
_pi = priority_pending_item(cursor, 'discogs', _prio)
|
|
||||||
if _pi:
|
|
||||||
return _pi
|
|
||||||
|
|
||||||
# Priority 1: Unattempted artists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, name FROM artists
|
|
||||||
WHERE discogs_match_status IS NULL AND id IS NOT NULL
|
|
||||||
ORDER BY id ASC LIMIT 1
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
|
||||||
|
|
||||||
# Priority 2: Unattempted albums
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT a.id, a.title, ar.name AS artist_name, ar.discogs_id AS artist_discogs_id
|
|
||||||
FROM albums a
|
|
||||||
JOIN artists ar ON a.artist_id = ar.id
|
|
||||||
WHERE a.discogs_match_status IS NULL AND a.id IS NOT NULL
|
|
||||||
ORDER BY a.id ASC LIMIT 1
|
|
||||||
""")
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_discogs_id': row[3]}
|
|
||||||
|
|
||||||
# Priority 3: Retry 'not_found' artists after retry_days
|
|
||||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id, name FROM artists
|
|
||||||
WHERE discogs_match_status = 'not_found' AND discogs_last_attempted < ?
|
|
||||||
ORDER BY discogs_last_attempted ASC LIMIT 1
|
|
||||||
""", (not_found_cutoff,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
|
||||||
|
|
||||||
# Priority 4: Retry 'not_found' albums
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT a.id, a.title, ar.name AS artist_name, ar.discogs_id AS artist_discogs_id
|
|
||||||
FROM albums a
|
|
||||||
JOIN artists ar ON a.artist_id = ar.id
|
|
||||||
WHERE a.discogs_match_status = 'not_found' AND a.discogs_last_attempted < ?
|
|
||||||
ORDER BY a.discogs_last_attempted ASC LIMIT 1
|
|
||||||
""", (not_found_cutoff,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_discogs_id': row[3]}
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting next Discogs item: {e}")
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _count_pending_items(self) -> int:
|
|
||||||
"""Count items still needing Discogs enrichment."""
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM artists WHERE discogs_match_status IS NULL AND id IS NOT NULL")
|
|
||||||
artists = cursor.fetchone()[0]
|
|
||||||
cursor.execute("SELECT COUNT(*) FROM albums WHERE discogs_match_status IS NULL AND id IS NOT NULL")
|
|
||||||
albums = cursor.fetchone()[0]
|
|
||||||
conn.close()
|
|
||||||
return artists + albums
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _normalize_name(self, name: str) -> str:
|
|
||||||
"""Normalize name for comparison."""
|
|
||||||
name = name.lower().strip()
|
|
||||||
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
|
|
||||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
|
||||||
name = re.sub(r'[^\w\s]', '', name)
|
|
||||||
name = re.sub(r'\s+', ' ', name).strip()
|
|
||||||
return name
|
|
||||||
|
|
||||||
def _name_matches(self, query_name: str, result_name: str) -> bool:
|
|
||||||
"""Check if Discogs result name matches our query with fuzzy matching."""
|
|
||||||
norm_query = self._normalize_name(query_name)
|
|
||||||
norm_result = self._normalize_name(result_name)
|
|
||||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
|
||||||
return similarity >= self.name_similarity_threshold
|
|
||||||
|
|
||||||
def _process_item(self, item: Dict[str, Any]):
|
|
||||||
"""Process a single artist or album."""
|
|
||||||
try:
|
|
||||||
item_type = item['type']
|
|
||||||
item_id = item['id']
|
|
||||||
item_name = item['name']
|
|
||||||
|
|
||||||
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
|
|
||||||
|
|
||||||
# Check for existing discogs_id (manual match) — use direct lookup
|
|
||||||
existing_id = self._get_existing_id(item_type, item_id)
|
|
||||||
if existing_id:
|
|
||||||
try:
|
|
||||||
if item_type == 'artist':
|
|
||||||
data = self.client._fetch_and_cache_artist(existing_id)
|
|
||||||
if data:
|
|
||||||
self._update_artist(item_id, data)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Enriched artist '{item_name}' from existing Discogs ID: {existing_id}")
|
|
||||||
return
|
|
||||||
elif item_type == 'album':
|
|
||||||
data = self.client._fetch_and_cache_album(existing_id)
|
|
||||||
if data:
|
|
||||||
self._update_album(item_id, data)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Enriched album '{item_name}' from existing Discogs ID: {existing_id}")
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Direct Discogs lookup failed for ID {existing_id}: {e}")
|
|
||||||
return # Preserve manual match, don't search
|
|
||||||
|
|
||||||
if item_type == 'artist':
|
|
||||||
self._search_and_match_artist(item_id, item_name)
|
|
||||||
elif item_type == 'album':
|
|
||||||
self._search_and_match_album(item_id, item_name, item.get('artist', ''), item.get('artist_discogs_id'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {item.get('type')} #{item.get('id')}: {e}")
|
|
||||||
self.stats['errors'] += 1
|
|
||||||
try:
|
|
||||||
self._mark_status(item['type'], item['id'], 'error')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("mark item status error failed: %s", e)
|
|
||||||
|
|
||||||
def _get_existing_id(self, entity_type: str, entity_id) -> Optional[str]:
|
|
||||||
"""Check if entity already has a discogs_id."""
|
|
||||||
table = 'artists' if entity_type == 'artist' else 'albums'
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(f"SELECT discogs_id FROM {table} WHERE id = ?", (entity_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
return row[0] if row and row[0] else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _search_and_match_artist(self, artist_id, artist_name: str):
|
|
||||||
"""Search Discogs for an artist and store metadata if matched."""
|
|
||||||
results = self.client.search_artists(artist_name, limit=5)
|
|
||||||
if not results:
|
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
# Find best match by name similarity (skipping ids already claimed by
|
|
||||||
# a differently-named artist, so we don't create a shared/duplicate id).
|
|
||||||
for result in results:
|
|
||||||
ok, reason = accept_artist_match(
|
|
||||||
self.db, 'discogs_id', result.id, artist_id, artist_name, result.name,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
# Fetch full artist detail (uses cache)
|
|
||||||
data = self.client._fetch_and_cache_artist(result.id)
|
|
||||||
if data:
|
|
||||||
self._update_artist(artist_id, data)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Discogs ID: {result.id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"No confident match for artist '{artist_name}'")
|
|
||||||
|
|
||||||
def _search_and_match_album(self, album_id, album_name: str, artist_name: str, artist_discogs_id: str = None):
|
|
||||||
"""Search Discogs for an album and store metadata if matched."""
|
|
||||||
# Search with artist + album for better precision
|
|
||||||
query = f"{artist_name} {album_name}" if artist_name else album_name
|
|
||||||
results = self.client.search_albums(query, limit=5)
|
|
||||||
if not results:
|
|
||||||
self._mark_status('album', album_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
if self._name_matches(album_name, result.name):
|
|
||||||
# Fetch full release detail (uses cache)
|
|
||||||
data = self.client._fetch_and_cache_album(result.id)
|
|
||||||
if data:
|
|
||||||
self._update_album(album_id, data)
|
|
||||||
self.stats['matched'] += 1
|
|
||||||
logger.info(f"Matched album '{album_name}' -> Discogs ID: {result.id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._mark_status('album', album_id, 'not_found')
|
|
||||||
self.stats['not_found'] += 1
|
|
||||||
logger.debug(f"No confident match for album '{album_name}'")
|
|
||||||
|
|
||||||
def _update_artist(self, artist_id, data: Dict[str, Any]):
|
|
||||||
"""Store Discogs metadata for an artist."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
discogs_id = str(data.get('id', ''))
|
|
||||||
bio = data.get('profile', '')
|
|
||||||
members = json.dumps([m.get('name', '') for m in data.get('members', [])]) if data.get('members') else None
|
|
||||||
urls = json.dumps(data.get('urls', [])) if data.get('urls') else None
|
|
||||||
|
|
||||||
# Get image
|
|
||||||
image_url = None
|
|
||||||
images = data.get('images', [])
|
|
||||||
if images:
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET
|
|
||||||
discogs_id = ?,
|
|
||||||
discogs_match_status = 'matched',
|
|
||||||
discogs_last_attempted = CURRENT_TIMESTAMP,
|
|
||||||
discogs_bio = ?,
|
|
||||||
discogs_members = ?,
|
|
||||||
discogs_urls = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (discogs_id, bio, members, urls, artist_id))
|
|
||||||
|
|
||||||
# Backfill summary/bio if empty (AudioDB backfill)
|
|
||||||
if bio:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET summary = ?
|
|
||||||
WHERE id = ? AND (summary IS NULL OR summary = '')
|
|
||||||
""", (bio, artist_id))
|
|
||||||
|
|
||||||
# Backfill thumb_url if empty
|
|
||||||
if image_url:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE artists SET thumb_url = ?
|
|
||||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
|
||||||
""", (image_url, artist_id))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating artist #{artist_id} with Discogs data: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _update_album(self, album_id, data: Dict[str, Any]):
|
|
||||||
"""Store Discogs metadata for an album."""
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Tag the ID with its Discogs type so later re-fetches hit the right
|
|
||||||
# endpoint (master vs release share one numeric space).
|
|
||||||
discogs_id = _tag_discogs_album_id(data.get('id', ''), _discogs_album_kind(data))
|
|
||||||
genres = json.dumps(data.get('genres', []))
|
|
||||||
styles = json.dumps(data.get('styles', []))
|
|
||||||
labels = data.get('labels', [])
|
|
||||||
label = labels[0].get('name', '') if labels else ''
|
|
||||||
catno = labels[0].get('catno', '') if labels else ''
|
|
||||||
country = data.get('country', '')
|
|
||||||
|
|
||||||
# Community rating
|
|
||||||
community = data.get('community', {})
|
|
||||||
rating = community.get('rating', {})
|
|
||||||
rating_avg = rating.get('average', 0)
|
|
||||||
rating_count = rating.get('count', 0)
|
|
||||||
|
|
||||||
# Image
|
|
||||||
image_url = None
|
|
||||||
images = data.get('images', [])
|
|
||||||
if images:
|
|
||||||
primary = next((img for img in images if img.get('type') == 'primary'), None)
|
|
||||||
image_url = (primary or images[0]).get('uri')
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET
|
|
||||||
discogs_id = ?,
|
|
||||||
discogs_match_status = 'matched',
|
|
||||||
discogs_last_attempted = CURRENT_TIMESTAMP,
|
|
||||||
discogs_genres = ?,
|
|
||||||
discogs_styles = ?,
|
|
||||||
discogs_label = ?,
|
|
||||||
discogs_catno = ?,
|
|
||||||
discogs_country = ?,
|
|
||||||
discogs_rating = ?,
|
|
||||||
discogs_rating_count = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (discogs_id, genres, styles, label, catno, country, rating_avg, rating_count, album_id))
|
|
||||||
|
|
||||||
# Backfill genres if empty
|
|
||||||
if data.get('genres'):
|
|
||||||
from core.genre_filter import filter_genres
|
|
||||||
from config.settings import config_manager as _cfg
|
|
||||||
_filtered = filter_genres(data.get('genres', []), _cfg)
|
|
||||||
if _filtered:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET genres = ?
|
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
|
||||||
""", (json.dumps(_filtered), album_id))
|
|
||||||
|
|
||||||
# Backfill thumb_url if empty
|
|
||||||
if image_url:
|
|
||||||
cursor.execute("""
|
|
||||||
UPDATE albums SET thumb_url = ?
|
|
||||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
|
||||||
""", (image_url, album_id))
|
|
||||||
|
|
||||||
# Cache the authoritative expected track count for the Album
|
|
||||||
# Completeness repair job. See `count_discogs_real_tracks`
|
|
||||||
# for why we accept both `type_ == 'track'` and empty `type_`
|
|
||||||
# (kettui's PR #374 review — narrower filter undercounted).
|
|
||||||
set_album_api_track_count(
|
|
||||||
cursor,
|
|
||||||
album_id,
|
|
||||||
count_discogs_real_tracks(data.get('tracklist')),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating album #{album_id} with Discogs data: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _mark_status(self, entity_type: str, entity_id, status: str):
|
|
||||||
"""Mark entity's Discogs match status."""
|
|
||||||
table = {'artist': 'artists', 'album': 'albums'}.get(entity_type)
|
|
||||||
if not table:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(f"""
|
|
||||||
UPDATE {table} SET
|
|
||||||
discogs_match_status = ?,
|
|
||||||
discogs_last_attempted = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
""", (status, entity_id))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
"""Background worker for Beatport chart discovery.
|
|
||||||
|
|
||||||
`run_beatport_discovery_worker(url_hash, deps)` is the function the
|
|
||||||
beatport discovery start-endpoint submits to its executor to match each
|
|
||||||
Beatport chart track against Spotify (preferred) or iTunes (fallback):
|
|
||||||
|
|
||||||
1. Pause enrichment workers (release shared resources).
|
|
||||||
2. For each Beatport track:
|
|
||||||
- Cancellation gate (state['phase'] != 'discovering').
|
|
||||||
- Clean Beatport text (artist/title) of common annotations.
|
|
||||||
- Discovery cache lookup; cache hit short-circuits the search and
|
|
||||||
normalizes cached artists from ['str'] → [{'name': 'str'}].
|
|
||||||
- matching_engine search-query generation, with high min_confidence
|
|
||||||
(0.9) to avoid bad matches.
|
|
||||||
- Strategy 1: scored candidates from initial Spotify/iTunes searches.
|
|
||||||
- Strategy 4: extended search with limit=50 if no high-confidence
|
|
||||||
match found.
|
|
||||||
- On Spotify match: format artists as [{'name': str}] objects, pull
|
|
||||||
full album object from raw cache when available.
|
|
||||||
- On iTunes match: format with image_url-derived album.images entry.
|
|
||||||
- Save matched result to discovery cache when confidence >= 0.75.
|
|
||||||
- On miss: Wing It stub stored as 'wing-it' status (success ticked).
|
|
||||||
3. After all tracks: phase='discovered', activity feed entry, sync
|
|
||||||
discovery results back to mirrored playlist via
|
|
||||||
`_sync_discovery_results_to_mirrored`.
|
|
||||||
4. On error: state['phase']='fresh' + status='error'.
|
|
||||||
5. Finally: resume enrichment workers.
|
|
||||||
|
|
||||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
|
||||||
and iTunes clients, matching engine, multiple discovery helpers, state
|
|
||||||
dict, mirrored sync) all injected via `BeatportDiscoveryDeps`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BeatportDiscoveryDeps:
|
|
||||||
"""Bundle of cross-cutting deps the Beatport discovery worker needs."""
|
|
||||||
beatport_chart_states: dict
|
|
||||||
spotify_client: Any
|
|
||||||
matching_engine: Any
|
|
||||||
pause_enrichment_workers: Callable[[str], dict]
|
|
||||||
resume_enrichment_workers: Callable[[dict, str], None]
|
|
||||||
get_active_discovery_source: Callable[[], str]
|
|
||||||
get_metadata_fallback_client: Callable[[], Any]
|
|
||||||
clean_beatport_text: Callable[[str], str]
|
|
||||||
get_discovery_cache_key: Callable
|
|
||||||
get_database: Callable[[], Any]
|
|
||||||
validate_discovery_cache_artist: Callable
|
|
||||||
spotify_rate_limited: Callable[[], bool]
|
|
||||||
discovery_score_candidates: Callable
|
|
||||||
get_metadata_cache: Callable[[], Any]
|
|
||||||
build_discovery_wing_it_stub: Callable
|
|
||||||
add_activity_item: Callable
|
|
||||||
sync_discovery_results_to_mirrored: Callable
|
|
||||||
|
|
||||||
|
|
||||||
def run_beatport_discovery_worker(url_hash, deps: BeatportDiscoveryDeps):
|
|
||||||
"""Background worker for Beatport discovery process (Spotify preferred, iTunes fallback)"""
|
|
||||||
_ew_state = {}
|
|
||||||
try:
|
|
||||||
_ew_state = deps.pause_enrichment_workers('Beatport discovery')
|
|
||||||
state = deps.beatport_chart_states[url_hash]
|
|
||||||
chart = state['chart']
|
|
||||||
tracks = chart['tracks']
|
|
||||||
|
|
||||||
# Determine which provider to use
|
|
||||||
discovery_source = deps.get_active_discovery_source()
|
|
||||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
|
||||||
|
|
||||||
# Initialize fallback client if needed
|
|
||||||
itunes_client_instance = None
|
|
||||||
if not use_spotify:
|
|
||||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
|
||||||
|
|
||||||
logger.info(f"Starting {discovery_source.upper()} discovery for {len(tracks)} Beatport tracks...")
|
|
||||||
|
|
||||||
# Store discovery source in state for frontend
|
|
||||||
state['discovery_source'] = discovery_source
|
|
||||||
|
|
||||||
# Process each track for discovery
|
|
||||||
for i, track in enumerate(tracks):
|
|
||||||
try:
|
|
||||||
# Check for cancellation
|
|
||||||
if state.get('phase') != 'discovering':
|
|
||||||
logger.warning(f"Beatport discovery cancelled (phase changed to '{state.get('phase')}')")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
state['discovery_progress'] = int((i / len(tracks)) * 100)
|
|
||||||
|
|
||||||
# Get track info from Beatport data (frontend sends 'name' and 'artists' fields)
|
|
||||||
track_title = deps.clean_beatport_text(track.get('name', 'Unknown Title'))
|
|
||||||
track_artists = track.get('artists', ['Unknown Artist'])
|
|
||||||
# Handle artists - could be a list or string
|
|
||||||
if isinstance(track_artists, list):
|
|
||||||
if len(track_artists) > 0 and isinstance(track_artists[0], str):
|
|
||||||
# Handle case like ["CID,Taylr Renee"] - split on comma and clean
|
|
||||||
track_artist = deps.clean_beatport_text(track_artists[0].split(',')[0].strip())
|
|
||||||
else:
|
|
||||||
track_artist = deps.clean_beatport_text(track_artists[0] if track_artists else 'Unknown Artist')
|
|
||||||
else:
|
|
||||||
track_artist = deps.clean_beatport_text(str(track_artists))
|
|
||||||
|
|
||||||
logger.debug(f"Searching {discovery_source.upper()} for: '{track_artist}' - '{track_title}'")
|
|
||||||
|
|
||||||
# Check discovery cache first
|
|
||||||
cache_key = deps.get_discovery_cache_key(track_title, track_artist)
|
|
||||||
try:
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
|
||||||
if cached_match and deps.validate_discovery_cache_artist(track_artist, cached_match):
|
|
||||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_artist} - {track_title}")
|
|
||||||
# Convert artists from ['str'] to [{'name': 'str'}] for Beatport frontend format
|
|
||||||
beatport_artists = cached_match.get('artists', [])
|
|
||||||
if beatport_artists and isinstance(beatport_artists[0], str):
|
|
||||||
cached_match['artists'] = [{'name': a} for a in beatport_artists]
|
|
||||||
result_entry = {
|
|
||||||
'index': i,
|
|
||||||
'beatport_track': {
|
|
||||||
'title': track_title,
|
|
||||||
'artist': track_artist
|
|
||||||
},
|
|
||||||
'status': 'found',
|
|
||||||
'status_class': 'found',
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'spotify_data': cached_match
|
|
||||||
}
|
|
||||||
state['spotify_matches'] += 1
|
|
||||||
state['discovery_results'].append(result_entry)
|
|
||||||
continue
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache lookup error: {cache_err}")
|
|
||||||
|
|
||||||
# Use matching engine for track matching
|
|
||||||
found_track = None
|
|
||||||
best_confidence = 0.0
|
|
||||||
best_raw_track = None
|
|
||||||
min_confidence = 0.9 # Higher threshold for Beatport to avoid bad matches
|
|
||||||
|
|
||||||
# Generate search queries using matching engine (with fallback)
|
|
||||||
try:
|
|
||||||
temp_track = type('TempTrack', (), {
|
|
||||||
'name': track_title,
|
|
||||||
'artists': [track_artist],
|
|
||||||
'album': None
|
|
||||||
})()
|
|
||||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
|
||||||
logger.debug(f"Generated {len(search_queries)} search queries using matching engine")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Matching engine failed for Beatport, falling back to basic queries: {e}")
|
|
||||||
if use_spotify:
|
|
||||||
search_queries = [
|
|
||||||
f"{track_artist} {track_title}",
|
|
||||||
f'artist:"{track_artist}" track:"{track_title}"',
|
|
||||||
f'"{track_artist}" "{track_title}"'
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
search_queries = [
|
|
||||||
f"{track_artist} {track_title}",
|
|
||||||
f"{track_title} {track_artist}",
|
|
||||||
track_title
|
|
||||||
]
|
|
||||||
|
|
||||||
for query_idx, search_query in enumerate(search_queries):
|
|
||||||
try:
|
|
||||||
logger.debug(f"Query {query_idx + 1}/{len(search_queries)}: {search_query} ({discovery_source.upper()})")
|
|
||||||
|
|
||||||
search_results = None
|
|
||||||
|
|
||||||
if use_spotify and not deps.spotify_rate_limited():
|
|
||||||
search_results = deps.spotify_client.search_tracks(search_query, limit=10)
|
|
||||||
else:
|
|
||||||
search_results = itunes_client_instance.search_tracks(search_query, limit=10)
|
|
||||||
|
|
||||||
if not search_results:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Score all results using the matching engine
|
|
||||||
match, confidence, match_idx = deps.discovery_score_candidates(
|
|
||||||
track_title, track_artist, 0, search_results
|
|
||||||
)
|
|
||||||
|
|
||||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
|
||||||
best_confidence = confidence
|
|
||||||
found_track = match
|
|
||||||
if use_spotify and match.id:
|
|
||||||
_cache = deps.get_metadata_cache()
|
|
||||||
best_raw_track = _cache.get_entity('spotify', 'track', match.id)
|
|
||||||
else:
|
|
||||||
best_raw_track = None
|
|
||||||
logger.debug(f"New best Beatport match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
if best_confidence >= 0.9:
|
|
||||||
logger.debug(f"High confidence match found ({best_confidence:.3f}), stopping search")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error in {discovery_source.upper()} search for query '{search_query}': {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Strategy 4: Extended search with higher limit (last resort)
|
|
||||||
if not found_track:
|
|
||||||
logger.debug("Beatport Strategy 4: Extended search with limit=50")
|
|
||||||
query = f"{track_artist} {track_title}"
|
|
||||||
if use_spotify:
|
|
||||||
extended_results = deps.spotify_client.search_tracks(query, limit=50)
|
|
||||||
else:
|
|
||||||
extended_results = itunes_client_instance.search_tracks(query, limit=50)
|
|
||||||
if extended_results:
|
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
|
||||||
track_title, track_artist, 0, extended_results
|
|
||||||
)
|
|
||||||
if match and confidence >= min_confidence:
|
|
||||||
found_track = match
|
|
||||||
best_confidence = confidence
|
|
||||||
logger.debug(f"Strategy 4 Beatport match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
if found_track:
|
|
||||||
logger.info(f"Final Beatport match: {found_track.artists[0]} - {found_track.name} (confidence: {best_confidence:.3f})")
|
|
||||||
else:
|
|
||||||
logger.warning(f"No suitable match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})")
|
|
||||||
|
|
||||||
# Create result entry
|
|
||||||
result_entry = {
|
|
||||||
'index': i, # Add index for frontend table row identification
|
|
||||||
'beatport_track': {
|
|
||||||
'title': track_title,
|
|
||||||
'artist': track_artist
|
|
||||||
},
|
|
||||||
'status': 'found' if found_track else 'not_found',
|
|
||||||
'status_class': 'found' if found_track else 'not-found',
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'confidence': best_confidence
|
|
||||||
}
|
|
||||||
|
|
||||||
if found_track:
|
|
||||||
if use_spotify:
|
|
||||||
# SPOTIFY result formatting
|
|
||||||
# Debug: show available attributes
|
|
||||||
logger.debug(f"Spotify track attributes: {dir(found_track)}")
|
|
||||||
|
|
||||||
# Format artists correctly for frontend compatibility
|
|
||||||
formatted_artists = []
|
|
||||||
if isinstance(found_track.artists, list):
|
|
||||||
# If it's already a list of strings, convert to objects with 'name' property
|
|
||||||
for artist in found_track.artists:
|
|
||||||
if isinstance(artist, str):
|
|
||||||
formatted_artists.append({'name': artist})
|
|
||||||
else:
|
|
||||||
# If it's already an object, use as-is
|
|
||||||
formatted_artists.append(artist)
|
|
||||||
else:
|
|
||||||
# Single artist case
|
|
||||||
formatted_artists = [{'name': str(found_track.artists)}]
|
|
||||||
|
|
||||||
# Use full album object from raw Spotify data if available
|
|
||||||
album_data = best_raw_track.get('album', {}) if best_raw_track else {}
|
|
||||||
if not album_data:
|
|
||||||
# Fallback to string album name
|
|
||||||
album_data = {'name': found_track.album, 'album_type': 'album', 'release_date': getattr(found_track, 'release_date', '') or '', 'images': []}
|
|
||||||
|
|
||||||
result_entry['spotify_data'] = {
|
|
||||||
'name': found_track.name,
|
|
||||||
'artists': formatted_artists, # Now formatted as list of objects with 'name' property
|
|
||||||
'album': album_data, # Full album object with images
|
|
||||||
'id': found_track.id,
|
|
||||||
'source': 'spotify'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# ITUNES result formatting
|
|
||||||
# Note: iTunes Track dataclass has 'artists' (list) and 'image_url', not 'artist' and 'artwork_url'
|
|
||||||
result_artists = found_track.artists if hasattr(found_track, 'artists') else []
|
|
||||||
result_artist = result_artists[0] if result_artists else 'Unknown'
|
|
||||||
result_name = found_track.name if hasattr(found_track, 'name') else 'Unknown'
|
|
||||||
album_name = found_track.album if hasattr(found_track, 'album') else 'Unknown Album'
|
|
||||||
image_url = found_track.image_url if hasattr(found_track, 'image_url') else ''
|
|
||||||
track_id = found_track.id if hasattr(found_track, 'id') else ''
|
|
||||||
|
|
||||||
# Format artists as list of objects for frontend compatibility
|
|
||||||
formatted_artists = [{'name': result_artist}]
|
|
||||||
|
|
||||||
# Build album data with artwork
|
|
||||||
album_data = {
|
|
||||||
'name': album_name,
|
|
||||||
'album_type': 'album',
|
|
||||||
'release_date': getattr(found_track, 'release_date', '') or '',
|
|
||||||
'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else []
|
|
||||||
}
|
|
||||||
|
|
||||||
result_entry['spotify_data'] = { # Use same key for frontend compatibility
|
|
||||||
'name': result_name,
|
|
||||||
'artists': formatted_artists,
|
|
||||||
'album': album_data,
|
|
||||||
'id': track_id,
|
|
||||||
'source': discovery_source
|
|
||||||
}
|
|
||||||
|
|
||||||
state['spotify_matches'] += 1
|
|
||||||
|
|
||||||
# Save to discovery cache (normalize artists from [{name:str}] to [str] for canonical format)
|
|
||||||
if best_confidence >= 0.75:
|
|
||||||
try:
|
|
||||||
cache_data = dict(result_entry['spotify_data'])
|
|
||||||
cache_artists = cache_data.get('artists', [])
|
|
||||||
if cache_artists and isinstance(cache_artists[0], dict):
|
|
||||||
cache_data['artists'] = [a.get('name', '') for a in cache_artists]
|
|
||||||
# Extract image URL for discovery pool display
|
|
||||||
if 'image_url' not in cache_data:
|
|
||||||
_bp_album = cache_data.get('album', {})
|
|
||||||
_bp_images = _bp_album.get('images', []) if isinstance(_bp_album, dict) else []
|
|
||||||
cache_data['image_url'] = _bp_images[0].get('url', '') if _bp_images else ''
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cache_db.save_discovery_cache_match(
|
|
||||||
cache_key[0], cache_key[1], discovery_source, best_confidence,
|
|
||||||
cache_data, track_title, track_artist
|
|
||||||
)
|
|
||||||
logger.debug(f"CACHE SAVED: {track_artist} - {track_title} (confidence: {best_confidence:.3f})")
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache save error: {cache_err}")
|
|
||||||
|
|
||||||
# Auto Wing It fallback for unmatched tracks
|
|
||||||
if result_entry.get('status_class') == 'not-found':
|
|
||||||
bp_t = result_entry.get('beatport_track', {})
|
|
||||||
stub = deps.build_discovery_wing_it_stub(
|
|
||||||
bp_t.get('title', ''),
|
|
||||||
bp_t.get('artist', ''),
|
|
||||||
)
|
|
||||||
result_entry['status'] = 'found'
|
|
||||||
result_entry['status_class'] = 'wing-it'
|
|
||||||
result_entry['spotify_data'] = stub
|
|
||||||
result_entry['match_data'] = stub
|
|
||||||
result_entry['wing_it_fallback'] = True
|
|
||||||
result_entry['confidence'] = 0
|
|
||||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
|
||||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
|
||||||
|
|
||||||
state['discovery_results'].append(result_entry)
|
|
||||||
|
|
||||||
# Small delay to avoid rate limiting
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing Beatport track {i}: {e}")
|
|
||||||
# Add error result
|
|
||||||
state['discovery_results'].append({
|
|
||||||
'index': i, # Add index for frontend table row identification
|
|
||||||
'beatport_track': {
|
|
||||||
'title': track.get('name', 'Unknown'), # Changed from 'title' to 'name' to match track structure
|
|
||||||
'artist': track.get('artists', ['Unknown'])[0] if isinstance(track.get('artists'), list) else 'Unknown'
|
|
||||||
},
|
|
||||||
'status': 'error',
|
|
||||||
'status_class': 'error', # Add status class for CSS styling
|
|
||||||
'error': str(e),
|
|
||||||
'discovery_source': discovery_source
|
|
||||||
})
|
|
||||||
|
|
||||||
# Mark discovery as complete
|
|
||||||
state['discovery_progress'] = 100
|
|
||||||
state['phase'] = 'discovered'
|
|
||||||
state['status'] = 'discovered'
|
|
||||||
|
|
||||||
# Add activity for completion
|
|
||||||
chart_name = chart.get('name', 'Unknown Chart')
|
|
||||||
source_label = discovery_source.upper()
|
|
||||||
deps.add_activity_item("", f"Beatport Discovery Complete ({source_label})",
|
|
||||||
f"'{chart_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
|
|
||||||
|
|
||||||
logger.info(f"Beatport discovery complete ({source_label}): {state['spotify_matches']}/{len(tracks)} tracks found")
|
|
||||||
|
|
||||||
# Sync discovery results back to mirrored playlist
|
|
||||||
deps.sync_discovery_results_to_mirrored('beatport', url_hash, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in Beatport discovery worker: {e}")
|
|
||||||
if url_hash in deps.beatport_chart_states:
|
|
||||||
deps.beatport_chart_states[url_hash]['status'] = 'error'
|
|
||||||
deps.beatport_chart_states[url_hash]['phase'] = 'fresh'
|
|
||||||
finally:
|
|
||||||
deps.resume_enrichment_workers(_ew_state, 'Beatport discovery')
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
"""Background worker for Deezer playlist discovery.
|
|
||||||
|
|
||||||
`run_deezer_discovery_worker(playlist_id, deps)` is the function the
|
|
||||||
Deezer discovery start-endpoint submits to the deezer discovery executor
|
|
||||||
to match each Deezer playlist track against Spotify (preferred) or iTunes
|
|
||||||
(fallback):
|
|
||||||
|
|
||||||
1. Pause enrichment workers (release shared resources).
|
|
||||||
2. For each Deezer track:
|
|
||||||
- Cancellation gate (state['cancelled']).
|
|
||||||
- Discovery cache lookup; cache hit short-circuits the search.
|
|
||||||
- SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
|
|
||||||
(shared search helper, returns tuple for Spotify or dict for iTunes).
|
|
||||||
- On match: build `match_data` (Spotify path preserves track_number /
|
|
||||||
disc_number from raw API data, image extracted from album images).
|
|
||||||
- Save to discovery cache.
|
|
||||||
- On miss: Wing It stub created from raw Deezer track data.
|
|
||||||
3. After all tracks: phase='discovered', activity feed entry.
|
|
||||||
4. Sync discovery results back to mirrored playlist via
|
|
||||||
`_sync_discovery_results_to_mirrored`.
|
|
||||||
5. On error: state['phase']='error' + status with error string.
|
|
||||||
6. Finally: resume enrichment workers.
|
|
||||||
|
|
||||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify and
|
|
||||||
iTunes clients, multiple metadata helpers, state dict, mirrored sync,
|
|
||||||
shared tidal search helper) all injected via `DeezerDiscoveryDeps`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import types
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeezerDiscoveryDeps:
|
|
||||||
"""Bundle of cross-cutting deps the Deezer discovery worker needs."""
|
|
||||||
deezer_discovery_states: dict
|
|
||||||
spotify_client: Any
|
|
||||||
pause_enrichment_workers: Callable[[str], dict]
|
|
||||||
resume_enrichment_workers: Callable[[dict, str], None]
|
|
||||||
get_active_discovery_source: Callable[[], str]
|
|
||||||
get_metadata_fallback_client: Callable[[], Any]
|
|
||||||
get_discovery_cache_key: Callable
|
|
||||||
get_database: Callable[[], Any]
|
|
||||||
validate_discovery_cache_artist: Callable
|
|
||||||
search_spotify_for_tidal_track: Callable
|
|
||||||
build_discovery_wing_it_stub: Callable
|
|
||||||
add_activity_item: Callable
|
|
||||||
sync_discovery_results_to_mirrored: Callable
|
|
||||||
|
|
||||||
|
|
||||||
def run_deezer_discovery_worker(playlist_id, deps: DeezerDiscoveryDeps):
|
|
||||||
"""Background worker for Deezer discovery process (Spotify preferred, iTunes fallback)"""
|
|
||||||
_ew_state = {}
|
|
||||||
try:
|
|
||||||
_ew_state = deps.pause_enrichment_workers('Deezer discovery')
|
|
||||||
state = deps.deezer_discovery_states[playlist_id]
|
|
||||||
playlist = state['playlist']
|
|
||||||
|
|
||||||
# Determine which provider to use
|
|
||||||
discovery_source = deps.get_active_discovery_source()
|
|
||||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
|
||||||
|
|
||||||
# Initialize fallback client if needed
|
|
||||||
itunes_client_instance = None
|
|
||||||
if not use_spotify:
|
|
||||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
|
||||||
|
|
||||||
logger.info(f"Starting Deezer discovery for: {playlist['name']} (using {discovery_source.upper()})")
|
|
||||||
|
|
||||||
# Store discovery source in state for frontend
|
|
||||||
state['discovery_source'] = discovery_source
|
|
||||||
|
|
||||||
successful_discoveries = 0
|
|
||||||
tracks = playlist['tracks']
|
|
||||||
|
|
||||||
for i, deezer_track in enumerate(tracks):
|
|
||||||
if state.get('cancelled', False):
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
track_name = deezer_track['name']
|
|
||||||
track_artists = deezer_track['artists']
|
|
||||||
track_id = deezer_track['id']
|
|
||||||
track_album = deezer_track.get('album', '')
|
|
||||||
track_duration_ms = deezer_track.get('duration_ms', 0)
|
|
||||||
|
|
||||||
logger.info(f"[{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}")
|
|
||||||
|
|
||||||
# Check discovery cache first
|
|
||||||
cache_key = deps.get_discovery_cache_key(track_name, track_artists[0] if track_artists else '')
|
|
||||||
try:
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
|
||||||
if cached_match and deps.validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match):
|
|
||||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}")
|
|
||||||
# Extract display-friendly artist string from cached match
|
|
||||||
cached_artists = cached_match.get('artists', [])
|
|
||||||
if cached_artists:
|
|
||||||
cached_artist_str = ', '.join(
|
|
||||||
a if isinstance(a, str) else a.get('name', '') for a in cached_artists
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cached_artist_str = ''
|
|
||||||
cached_album = cached_match.get('album', '')
|
|
||||||
if isinstance(cached_album, dict):
|
|
||||||
cached_album = cached_album.get('name', '')
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'deezer_track': {
|
|
||||||
'id': track_id,
|
|
||||||
'name': track_name,
|
|
||||||
'artists': track_artists or [],
|
|
||||||
'album': track_album,
|
|
||||||
'duration_ms': track_duration_ms,
|
|
||||||
},
|
|
||||||
'spotify_data': cached_match,
|
|
||||||
'match_data': cached_match,
|
|
||||||
'status': 'Found',
|
|
||||||
'status_class': 'found',
|
|
||||||
'spotify_track': cached_match.get('name', ''),
|
|
||||||
'spotify_artist': cached_artist_str,
|
|
||||||
'spotify_album': cached_album,
|
|
||||||
'spotify_id': cached_match.get('id', ''),
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'index': i
|
|
||||||
}
|
|
||||||
successful_discoveries += 1
|
|
||||||
state['spotify_matches'] = successful_discoveries
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
|
||||||
continue
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache lookup error: {cache_err}")
|
|
||||||
|
|
||||||
# Create a SimpleNamespace duck-type object for _search_spotify_for_tidal_track
|
|
||||||
track_ns = types.SimpleNamespace(
|
|
||||||
id=track_id,
|
|
||||||
name=track_name,
|
|
||||||
artists=track_artists,
|
|
||||||
album=track_album,
|
|
||||||
duration_ms=track_duration_ms
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the search function with appropriate provider
|
|
||||||
track_result = deps.search_spotify_for_tidal_track(
|
|
||||||
track_ns,
|
|
||||||
use_spotify=use_spotify,
|
|
||||||
itunes_client=itunes_client_instance
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create result entry
|
|
||||||
result = {
|
|
||||||
'deezer_track': {
|
|
||||||
'id': track_id,
|
|
||||||
'name': track_name,
|
|
||||||
'artists': track_artists or [],
|
|
||||||
'album': track_album,
|
|
||||||
'duration_ms': track_duration_ms,
|
|
||||||
},
|
|
||||||
'spotify_data': None,
|
|
||||||
'match_data': None,
|
|
||||||
'status': 'Not Found',
|
|
||||||
'status_class': 'not-found',
|
|
||||||
'spotify_track': '',
|
|
||||||
'spotify_artist': '',
|
|
||||||
'spotify_album': '',
|
|
||||||
'discovery_source': discovery_source
|
|
||||||
}
|
|
||||||
|
|
||||||
match_confidence = 0.0
|
|
||||||
|
|
||||||
if use_spotify and isinstance(track_result, tuple):
|
|
||||||
# Spotify: Function returns (Track, raw_data, confidence)
|
|
||||||
track_obj, raw_track_data, match_confidence = track_result
|
|
||||||
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
|
|
||||||
# Ensure album has a name — fall back to track_obj.album if raw_data was missing
|
|
||||||
if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album:
|
|
||||||
album_obj['name'] = track_obj.album
|
|
||||||
elif not album_obj and track_obj.album:
|
|
||||||
album_obj = {'name': track_obj.album}
|
|
||||||
# Ensure release_date is present (raw Spotify data has it, but fallback may not)
|
|
||||||
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
|
|
||||||
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
|
|
||||||
# Extract image URL from album data or track object
|
|
||||||
_album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else []
|
|
||||||
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
|
|
||||||
|
|
||||||
match_data = {
|
|
||||||
'id': track_obj.id,
|
|
||||||
'name': track_obj.name,
|
|
||||||
'artists': track_obj.artists,
|
|
||||||
'album': album_obj,
|
|
||||||
'duration_ms': track_obj.duration_ms,
|
|
||||||
'external_urls': track_obj.external_urls,
|
|
||||||
'image_url': _image_url,
|
|
||||||
'source': 'spotify'
|
|
||||||
}
|
|
||||||
# Preserve track_number/disc_number from raw Spotify API data
|
|
||||||
if raw_track_data and raw_track_data.get('track_number'):
|
|
||||||
match_data['track_number'] = raw_track_data['track_number']
|
|
||||||
if raw_track_data and raw_track_data.get('disc_number'):
|
|
||||||
match_data['disc_number'] = raw_track_data['disc_number']
|
|
||||||
result['spotify_data'] = match_data
|
|
||||||
result['match_data'] = match_data
|
|
||||||
result['status'] = 'Found'
|
|
||||||
result['status_class'] = 'found'
|
|
||||||
result['spotify_track'] = track_obj.name
|
|
||||||
result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists)
|
|
||||||
result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj)
|
|
||||||
result['spotify_id'] = track_obj.id
|
|
||||||
result['confidence'] = match_confidence
|
|
||||||
successful_discoveries += 1
|
|
||||||
state['spotify_matches'] = successful_discoveries
|
|
||||||
|
|
||||||
elif not use_spotify and track_result and isinstance(track_result, dict):
|
|
||||||
# Fallback: Function returns a dict with track data (includes 'confidence' key)
|
|
||||||
match_confidence = track_result.pop('confidence', 0.80)
|
|
||||||
match_data = track_result
|
|
||||||
match_data['source'] = discovery_source
|
|
||||||
# Extract image URL from album images
|
|
||||||
_fb_album = match_data.get('album', {})
|
|
||||||
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
|
|
||||||
if _fb_images and 'image_url' not in match_data:
|
|
||||||
match_data['image_url'] = _fb_images[0].get('url', '')
|
|
||||||
result['spotify_data'] = match_data
|
|
||||||
result['match_data'] = match_data
|
|
||||||
result['status'] = 'Found'
|
|
||||||
result['status_class'] = 'found'
|
|
||||||
result['spotify_track'] = match_data.get('name', '')
|
|
||||||
itunes_artists = match_data.get('artists', [])
|
|
||||||
result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else ''
|
|
||||||
result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '')
|
|
||||||
result['spotify_id'] = match_data.get('id', '')
|
|
||||||
result['confidence'] = match_confidence
|
|
||||||
successful_discoveries += 1
|
|
||||||
state['spotify_matches'] = successful_discoveries
|
|
||||||
|
|
||||||
# Save to discovery cache if match found
|
|
||||||
if result['status_class'] == 'found' and result.get('match_data'):
|
|
||||||
try:
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cache_db.save_discovery_cache_match(
|
|
||||||
cache_key[0], cache_key[1], discovery_source, match_confidence,
|
|
||||||
result['match_data'], track_name,
|
|
||||||
track_artists[0] if track_artists else ''
|
|
||||||
)
|
|
||||||
logger.info(f"CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})")
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache save error: {cache_err}")
|
|
||||||
|
|
||||||
# Auto Wing It fallback for unmatched tracks
|
|
||||||
if result['status_class'] == 'not-found':
|
|
||||||
deezer_t = result.get('deezer_track', {})
|
|
||||||
stub = deps.build_discovery_wing_it_stub(
|
|
||||||
deezer_t.get('name', ''),
|
|
||||||
', '.join(deezer_t.get('artists', [])),
|
|
||||||
deezer_t.get('duration_ms', 0)
|
|
||||||
)
|
|
||||||
result['status'] = 'Wing It'
|
|
||||||
result['status_class'] = 'wing-it'
|
|
||||||
result['spotify_data'] = stub
|
|
||||||
result['match_data'] = stub
|
|
||||||
result['spotify_track'] = deezer_t.get('name', '')
|
|
||||||
result['spotify_artist'] = ', '.join(deezer_t.get('artists', []))
|
|
||||||
result['wing_it_fallback'] = True
|
|
||||||
result['confidence'] = 0
|
|
||||||
successful_discoveries += 1
|
|
||||||
state['spotify_matches'] = successful_discoveries
|
|
||||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
|
||||||
|
|
||||||
result['index'] = i
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
|
||||||
|
|
||||||
# Add delay between requests
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing track {i+1}: {e}")
|
|
||||||
# Add error result
|
|
||||||
result = {
|
|
||||||
'deezer_track': {
|
|
||||||
'name': deezer_track.get('name', 'Unknown'),
|
|
||||||
'artists': deezer_track.get('artists', []),
|
|
||||||
},
|
|
||||||
'spotify_data': None,
|
|
||||||
'match_data': None,
|
|
||||||
'status': 'Error',
|
|
||||||
'status_class': 'error',
|
|
||||||
'spotify_track': '',
|
|
||||||
'spotify_artist': '',
|
|
||||||
'spotify_album': '',
|
|
||||||
'error': str(e),
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'index': i
|
|
||||||
}
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
state['phase'] = 'discovered'
|
|
||||||
state['status'] = 'discovered'
|
|
||||||
state['discovery_progress'] = 100
|
|
||||||
|
|
||||||
# Add activity for discovery completion
|
|
||||||
source_label = discovery_source.upper()
|
|
||||||
deps.add_activity_item("", f"Deezer Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
|
|
||||||
|
|
||||||
logger.info(f"Deezer discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
|
|
||||||
|
|
||||||
# Sync discovery results back to mirrored playlist
|
|
||||||
deps.sync_discovery_results_to_mirrored('deezer', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in Deezer discovery worker: {e}")
|
|
||||||
if playlist_id in deps.deezer_discovery_states:
|
|
||||||
deps.deezer_discovery_states[playlist_id]['phase'] = 'error'
|
|
||||||
deps.deezer_discovery_states[playlist_id]['status'] = f'error: {str(e)}'
|
|
||||||
finally:
|
|
||||||
deps.resume_enrichment_workers(_ew_state, 'Deezer discovery')
|
|
||||||
|
|
@ -1,767 +0,0 @@
|
||||||
"""Generic, source-agnostic helpers for the playlist-discovery route layer.
|
|
||||||
|
|
||||||
The discovery/sync endpoints in ``web_server.py`` were copy-pasted once per
|
|
||||||
source (Tidal, Deezer, Qobuz, Spotify-public, iTunes-link, YouTube,
|
|
||||||
ListenBrainz, Beatport). The per-source copies differ only by a source label
|
|
||||||
string and which ``<source>_discovery_states`` global they read. This module
|
|
||||||
lifts the source-agnostic pieces into importable, unit-testable helpers so the
|
|
||||||
route functions become thin wrappers — exactly preserving behavior (1:1).
|
|
||||||
|
|
||||||
Each helper is lifted verbatim from its web_server.py counterpart; any
|
|
||||||
per-source quirk that genuinely differs (e.g. Beatport's distinct result
|
|
||||||
shape) is intentionally NOT routed through here and stays in its own function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Tuple
|
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("discovery.endpoints")
|
|
||||||
|
|
||||||
|
|
||||||
def convert_results_to_spotify_tracks(
|
|
||||||
discovery_results: List[Dict[str, Any]],
|
|
||||||
source_label: str,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Convert a source's discovery results into the Spotify-track dicts the
|
|
||||||
sync pipeline expects.
|
|
||||||
|
|
||||||
Lifted verbatim from the per-source ``convert_<source>_results_to_spotify_tracks``
|
|
||||||
functions (and the already-generic ``_convert_link_results_to_spotify_tracks``),
|
|
||||||
which were byte-identical apart from the ``source_label`` used in the log
|
|
||||||
line. Two input shapes are supported, matching the originals exactly:
|
|
||||||
|
|
||||||
- ``spotify_data`` (manual-fix shape): copied through, preserving optional
|
|
||||||
``track_number`` / ``disc_number``.
|
|
||||||
- ``spotify_track`` + ``status_class == 'found'`` (auto-discovery shape):
|
|
||||||
rebuilt from the flat ``spotify_*`` fields.
|
|
||||||
|
|
||||||
Any result matching neither shape is skipped, identical to the originals.
|
|
||||||
|
|
||||||
NOTE: Beatport deliberately does NOT use this — its converter coerces
|
|
||||||
artist objects to strings and emits a different track shape (``source``
|
|
||||||
field, album dict), so it keeps its own implementation.
|
|
||||||
"""
|
|
||||||
spotify_tracks: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for result in discovery_results:
|
|
||||||
# Support both data formats: spotify_data (manual fixes) and individual
|
|
||||||
# fields (automatic discovery).
|
|
||||||
if result.get('spotify_data'):
|
|
||||||
spotify_data = result['spotify_data']
|
|
||||||
track = {
|
|
||||||
'id': spotify_data['id'],
|
|
||||||
'name': spotify_data['name'],
|
|
||||||
'artists': spotify_data['artists'],
|
|
||||||
'album': spotify_data['album'],
|
|
||||||
'duration_ms': spotify_data.get('duration_ms', 0),
|
|
||||||
}
|
|
||||||
if spotify_data.get('track_number'):
|
|
||||||
track['track_number'] = spotify_data['track_number']
|
|
||||||
if spotify_data.get('disc_number'):
|
|
||||||
track['disc_number'] = spotify_data['disc_number']
|
|
||||||
spotify_tracks.append(track)
|
|
||||||
elif result.get('spotify_track') and result.get('status_class') == 'found':
|
|
||||||
spotify_tracks.append({
|
|
||||||
'id': result.get('spotify_id', 'unknown'),
|
|
||||||
'name': result.get('spotify_track', 'Unknown Track'),
|
|
||||||
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
|
|
||||||
'album': result.get('spotify_album', 'Unknown Album'),
|
|
||||||
'duration_ms': 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Converted {len(spotify_tracks)} {source_label} matches to Spotify tracks for sync")
|
|
||||||
return spotify_tracks
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_sync(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
not_found_message: str,
|
|
||||||
sync_lock: Any,
|
|
||||||
sync_states: Dict[str, Any],
|
|
||||||
active_sync_workers: Dict[str, Any],
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Cancel an in-progress sync for one discovery playlist.
|
|
||||||
|
|
||||||
1:1 lift of the byte-identical ``cancel_<source>_sync`` bodies (Tidal,
|
|
||||||
Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz). The
|
|
||||||
caller passes the already-resolved state key (ListenBrainz transforms it
|
|
||||||
via ``_lb_state_key`` first), the source ``label``, the exact 404 message
|
|
||||||
(iTunes-Link uses "iTunes Link not found", not "... playlist not found"),
|
|
||||||
and the shared sync infrastructure (so this stays free of web_server
|
|
||||||
globals / Flask).
|
|
||||||
|
|
||||||
Returns ``(payload_dict, status_code)``; the caller wraps in ``jsonify``.
|
|
||||||
|
|
||||||
Beatport is NOT routed here — it cancels a stored ``sync_future`` and
|
|
||||||
returns a different payload.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
# Idempotent: the live discovery state is gone (a restart wiped the
|
|
||||||
# in-memory state, or it was already cancelled). Cancelling a sync
|
|
||||||
# that isn't running is a no-op SUCCESS, not a 404 — otherwise a
|
|
||||||
# mirrored playlist (e.g. a ListenBrainz weekly) whose state vanished
|
|
||||||
# is permanently wedged with "playlist not found" and can never be
|
|
||||||
# re-synced or dismissed (#702).
|
|
||||||
return {"success": True, "message": f"No active {label} sync to cancel"}, 200
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
sync_playlist_id = state.get('sync_playlist_id')
|
|
||||||
|
|
||||||
if sync_playlist_id:
|
|
||||||
with sync_lock:
|
|
||||||
sync_states[sync_playlist_id] = {"status": "cancelled"}
|
|
||||||
if sync_playlist_id in active_sync_workers:
|
|
||||||
del active_sync_workers[sync_playlist_id]
|
|
||||||
|
|
||||||
state['phase'] = 'discovered'
|
|
||||||
state['sync_playlist_id'] = None
|
|
||||||
state['sync_progress'] = {}
|
|
||||||
|
|
||||||
return {"success": True, "message": f"{label} sync cancelled"}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error cancelling {label} sync: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def delete_playlist_state(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
not_found_message: str,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Delete a discovery playlist's state entry, cancelling any active
|
|
||||||
discovery first.
|
|
||||||
|
|
||||||
1:1 lift of the byte-identical ``delete_<source>_playlist`` bodies
|
|
||||||
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
|
|
||||||
|
|
||||||
The iTunes-Link / YouTube / ListenBrainz / Beatport deletes intentionally
|
|
||||||
keep their own bodies — they differ in success message, info-log wording,
|
|
||||||
name extraction, and/or key transform.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
if 'discovery_future' in state and state['discovery_future']:
|
|
||||||
state['discovery_future'].cancel()
|
|
||||||
del states[key]
|
|
||||||
|
|
||||||
logger.info(f"Deleted {label} playlist state: {key}")
|
|
||||||
return {"success": True, "message": "Playlist deleted"}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting {label} playlist: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
# --- playlist-name accessors -------------------------------------------------
|
|
||||||
# The per-source sync-status handlers read the display name three different
|
|
||||||
# ways. Each is reproduced verbatim so the 1:1 behavior (including which ones
|
|
||||||
# raise vs. fall back to 'Unknown Playlist') is preserved.
|
|
||||||
|
|
||||||
def playlist_name_attr_or_unknown(state: Dict[str, Any]) -> str:
|
|
||||||
"""Tidal: playlist is an object — use ``.name`` or 'Unknown Playlist'."""
|
|
||||||
pl = state.get('playlist')
|
|
||||||
return pl.name if pl and hasattr(pl, 'name') else 'Unknown Playlist'
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_name_strict(state: Dict[str, Any]) -> str:
|
|
||||||
"""Deezer / Qobuz / Spotify-Public / iTunes-Link: strict dict access —
|
|
||||||
raises (→ 500) if 'playlist' is missing, exactly like the originals."""
|
|
||||||
return state['playlist']['name']
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_name_safe(state: Dict[str, Any]) -> str:
|
|
||||||
"""YouTube / ListenBrainz: safe dict access, defaulting to 'Unknown
|
|
||||||
Playlist'."""
|
|
||||||
return state.get('playlist', {}).get('name', 'Unknown Playlist')
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_name_obj(state: Dict[str, Any]) -> str:
|
|
||||||
"""Tidal start-sync: playlist is an object — strict ``.name`` (raises if
|
|
||||||
absent, exactly like the original)."""
|
|
||||||
return state['playlist'].name
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_image_obj(state: Dict[str, Any]) -> str:
|
|
||||||
"""Tidal: ``getattr(playlist, 'image_url', '')`` (object attribute)."""
|
|
||||||
return getattr(state['playlist'], 'image_url', '')
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_image_dict(state: Dict[str, Any]) -> str:
|
|
||||||
"""Deezer/Qobuz/Spotify-Public/YouTube: ``playlist.get('image_url', '')``
|
|
||||||
(dict access)."""
|
|
||||||
return state['playlist'].get('image_url', '')
|
|
||||||
|
|
||||||
|
|
||||||
def get_sync_status(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
not_found_message: str,
|
|
||||||
error_label: str,
|
|
||||||
activity_subject: str,
|
|
||||||
playlist_name_getter,
|
|
||||||
sync_lock: Any,
|
|
||||||
sync_states: Dict[str, Any],
|
|
||||||
add_activity_item,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Report sync status for one discovery playlist, posting an activity-feed
|
|
||||||
item when the sync finishes or errors.
|
|
||||||
|
|
||||||
1:1 lift of the ``get_<source>_sync_status`` bodies (Tidal, Deezer, Qobuz,
|
|
||||||
Spotify-Public, iTunes-Link, YouTube, ListenBrainz). Per-source variation
|
|
||||||
is captured by the parameters:
|
|
||||||
|
|
||||||
- ``not_found_message`` — the 404 string (iTunes-Link drops "playlist").
|
|
||||||
- ``error_label`` — used in the except log ("Error getting <X> sync status").
|
|
||||||
- ``activity_subject`` — the activity-feed prefix; note Spotify-Public uses
|
|
||||||
"Spotify Link playlist" while its error_label is "Spotify Public".
|
|
||||||
- ``playlist_name_getter`` — one of the accessors above (attr/strict/safe);
|
|
||||||
the strict one can raise, matching the originals (→ 500). The state's
|
|
||||||
phase/sync_progress are mutated BEFORE the name is read, so a raising
|
|
||||||
getter leaves the same partial mutation the original did.
|
|
||||||
|
|
||||||
Beatport is NOT routed here — it returns a different payload (``status``
|
|
||||||
not ``sync_status``, includes ``sync_id``, no lock, ``chart`` key).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
sync_playlist_id = state.get('sync_playlist_id')
|
|
||||||
|
|
||||||
if not sync_playlist_id:
|
|
||||||
return {"error": "No sync in progress"}, 404
|
|
||||||
|
|
||||||
with sync_lock:
|
|
||||||
sync_state = sync_states.get(sync_playlist_id, {})
|
|
||||||
|
|
||||||
response = {
|
|
||||||
'phase': state['phase'],
|
|
||||||
'sync_status': sync_state.get('status', 'unknown'),
|
|
||||||
'progress': sync_state.get('progress', {}),
|
|
||||||
'complete': sync_state.get('status') == 'finished',
|
|
||||||
'error': sync_state.get('error'),
|
|
||||||
}
|
|
||||||
|
|
||||||
if sync_state.get('status') == 'finished':
|
|
||||||
state['phase'] = 'sync_complete'
|
|
||||||
state['sync_progress'] = sync_state.get('progress', {})
|
|
||||||
playlist_name = playlist_name_getter(state)
|
|
||||||
add_activity_item("", "Sync Complete", f"{activity_subject} '{playlist_name}' synced successfully", "Now")
|
|
||||||
elif sync_state.get('status') == 'error':
|
|
||||||
state['phase'] = 'discovered'
|
|
||||||
playlist_name = playlist_name_getter(state)
|
|
||||||
add_activity_item("", "Sync Failed", f"{activity_subject} '{playlist_name}' sync failed", "Now")
|
|
||||||
|
|
||||||
return response, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting {error_label} sync status: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def get_discovery_status(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
not_found_message: str,
|
|
||||||
error_label: str,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Report real-time discovery progress/results for one playlist.
|
|
||||||
|
|
||||||
1:1 lift of the byte-identical ``get_<source>_discovery_status`` bodies.
|
|
||||||
Unlike sync-status, this shape is identical for ALL eight sources —
|
|
||||||
Beatport included — so it folds in too. Only the 404 message
|
|
||||||
(".../discovery not found" vs ".../playlist not found" vs "Beatport chart
|
|
||||||
not found") and the except-log label vary, both passed in. The caller
|
|
||||||
resolves the key (ListenBrainz via ``_lb_state_key``).
|
|
||||||
|
|
||||||
Returns ``(payload, status_code)``.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'phase': state['phase'],
|
|
||||||
'status': state['status'],
|
|
||||||
'progress': state['discovery_progress'],
|
|
||||||
'spotify_matches': state['spotify_matches'],
|
|
||||||
'spotify_total': state['spotify_total'],
|
|
||||||
'results': state['discovery_results'],
|
|
||||||
'complete': state['phase'] == 'discovered',
|
|
||||||
}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting {error_label} discovery status: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def reset_playlist(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
not_found_message: str,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Reset a discovery playlist back to the 'fresh' phase, clearing all
|
|
||||||
discovery/sync data while preserving the original playlist payload.
|
|
||||||
|
|
||||||
1:1 lift of the byte-identical ``reset_<source>_playlist`` bodies
|
|
||||||
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
|
|
||||||
|
|
||||||
NOT folded in (genuinely divergent): YouTube (status -> 'parsed', no
|
|
||||||
download_process_id, logs the playlist name, "reset to fresh state"),
|
|
||||||
ListenBrainz (status -> 'cached', logs playlist title, returns
|
|
||||||
{"phase": "fresh"}), iTunes-Link (uses state.update, no info log, distinct
|
|
||||||
message). Those keep their own bodies.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
if 'discovery_future' in state and state['discovery_future']:
|
|
||||||
state['discovery_future'].cancel()
|
|
||||||
|
|
||||||
state['phase'] = 'fresh'
|
|
||||||
state['status'] = 'fresh'
|
|
||||||
state['discovery_results'] = []
|
|
||||||
state['discovery_progress'] = 0
|
|
||||||
state['spotify_matches'] = 0
|
|
||||||
state['sync_playlist_id'] = None
|
|
||||||
state['converted_spotify_playlist_id'] = None
|
|
||||||
state['download_process_id'] = None
|
|
||||||
state['sync_progress'] = {}
|
|
||||||
state['discovery_future'] = None
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
|
|
||||||
logger.info(f"Reset {label} playlist to fresh: {key}")
|
|
||||||
return {"success": True, "message": "Playlist reset to fresh phase"}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error resetting {label} playlist: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def get_playlist_states(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
*,
|
|
||||||
error_label: str,
|
|
||||||
info_log_label: str = None,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Return all stored discovery states for a source as a list for frontend
|
|
||||||
card hydration (``{"states": [...]}``).
|
|
||||||
|
|
||||||
1:1 lift of the ``get_<source>_playlist_states`` bodies (Tidal, Deezer,
|
|
||||||
Qobuz, Spotify-Public, iTunes-Link), which build the same per-entry dict.
|
|
||||||
iTunes-Link is the only one without the "Returning N ..." info log, so
|
|
||||||
``info_log_label`` is optional (pass None to suppress it, as iTunes did).
|
|
||||||
|
|
||||||
NOT folded in: the YouTube/ListenBrainz ``get_all_*_playlists`` endpoints —
|
|
||||||
they return ``{"playlists": [...]}`` (different key + fields: url/created_at,
|
|
||||||
no discovery_results) and filter mirrored/profile-scoped entries.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = []
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
for key, state in states.items():
|
|
||||||
state['last_accessed'] = current_time
|
|
||||||
result.append({
|
|
||||||
'playlist_id': key,
|
|
||||||
'phase': state['phase'],
|
|
||||||
'status': state['status'],
|
|
||||||
'discovery_progress': state['discovery_progress'],
|
|
||||||
'spotify_matches': state['spotify_matches'],
|
|
||||||
'spotify_total': state['spotify_total'],
|
|
||||||
'discovery_results': state['discovery_results'],
|
|
||||||
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
|
|
||||||
'download_process_id': state.get('download_process_id'),
|
|
||||||
'last_accessed': state['last_accessed'],
|
|
||||||
})
|
|
||||||
|
|
||||||
if info_log_label:
|
|
||||||
logger.info(f"Returning {len(result)} stored {info_log_label} playlist states for hydration")
|
|
||||||
return {"states": result}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting {error_label} playlist states: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def save_bubble_snapshot(
|
|
||||||
get_json,
|
|
||||||
*,
|
|
||||||
payload_key: str,
|
|
||||||
no_data_error: str,
|
|
||||||
snapshot_kind: str,
|
|
||||||
success_noun: str,
|
|
||||||
log_subject: str,
|
|
||||||
log_noun: str,
|
|
||||||
get_database,
|
|
||||||
get_current_profile_id,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Persist a bubble/download snapshot for cross-refresh hydration.
|
|
||||||
|
|
||||||
1:1 lift of the four structurally-identical snapshot endpoints
|
|
||||||
(discover_downloads, artist_bubbles, search_bubbles, beatport_bubbles),
|
|
||||||
which differ only by:
|
|
||||||
|
|
||||||
- ``payload_key`` ('downloads' for discover, 'bubbles' for the rest) and
|
|
||||||
its ``no_data_error`` message.
|
|
||||||
- ``snapshot_kind`` — the db.save_bubble_snapshot category.
|
|
||||||
- ``success_noun`` — fills "Snapshot saved with N <noun>".
|
|
||||||
- ``log_subject`` / ``log_noun`` — the info ("Saved <subject>: N <noun>")
|
|
||||||
and except ("Error saving <subject>") log lines.
|
|
||||||
|
|
||||||
Returns ``(payload, status_code)``. ``get_json`` is invoked inside the try
|
|
||||||
like the original ``request.json``.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
data = get_json()
|
|
||||||
if not data or payload_key not in data:
|
|
||||||
return {'success': False, 'error': no_data_error}, 400
|
|
||||||
|
|
||||||
items = data[payload_key]
|
|
||||||
|
|
||||||
db = get_database()
|
|
||||||
db.save_bubble_snapshot(snapshot_kind, items, profile_id=get_current_profile_id())
|
|
||||||
|
|
||||||
count = len(items)
|
|
||||||
logger.info(f"Saved {log_subject}: {count} {log_noun}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'message': f'Snapshot saved with {count} {success_noun}',
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving {log_subject}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return {'success': False, 'error': str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def update_playlist_phase(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
get_json,
|
|
||||||
*,
|
|
||||||
not_found_message: str,
|
|
||||||
error_label: str,
|
|
||||||
valid_phases: List[str],
|
|
||||||
apply_extra_fields: bool,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Update a discovery playlist's phase (used when the modal closes, e.g. to
|
|
||||||
reset download_complete -> discovered).
|
|
||||||
|
|
||||||
1:1 lift of the ``update_<source>_playlist_phase`` bodies for the five
|
|
||||||
sources with the identical validation + full-message response (Tidal,
|
|
||||||
Deezer, Qobuz, Spotify-Public, YouTube). Per-source params:
|
|
||||||
|
|
||||||
- ``valid_phases`` — YouTube's list additionally includes 'parsed'.
|
|
||||||
- ``apply_extra_fields`` — Deezer/Qobuz/Spotify-Public also persist
|
|
||||||
download_process_id / converted_spotify_playlist_id from the body;
|
|
||||||
Tidal/YouTube do NOT (so pass False to keep them 1:1).
|
|
||||||
- ``not_found_message`` / ``error_label``; ``get_json`` invoked inside the
|
|
||||||
try like the original ``request.get_json()``.
|
|
||||||
|
|
||||||
Returns ``(payload, status_code)``.
|
|
||||||
|
|
||||||
NOT folded in: iTunes-Link — it uses ``data.get('phase')`` (no separate
|
|
||||||
"Phase not provided" 400) and returns a no-message payload.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
data = get_json()
|
|
||||||
if not data or 'phase' not in data:
|
|
||||||
return {"error": "Phase not provided"}, 400
|
|
||||||
|
|
||||||
new_phase = data['phase']
|
|
||||||
if new_phase not in valid_phases:
|
|
||||||
return {"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}, 400
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
old_phase = state.get('phase', 'unknown')
|
|
||||||
state['phase'] = new_phase
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
|
|
||||||
if apply_extra_fields:
|
|
||||||
if 'download_process_id' in data:
|
|
||||||
state['download_process_id'] = data['download_process_id']
|
|
||||||
if 'converted_spotify_playlist_id' in data:
|
|
||||||
state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id']
|
|
||||||
|
|
||||||
logger.info(f"Updated {error_label} playlist {key} phase: {old_phase} → {new_phase}")
|
|
||||||
return {"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating {error_label} playlist phase: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def first_artist_str_or_obj(original_track: Dict[str, Any]) -> str:
|
|
||||||
"""Tidal: first artist from an artists list that may hold strings OR
|
|
||||||
objects ({'name': ...}); '' when empty."""
|
|
||||||
artists = original_track.get('artists', [])
|
|
||||||
if artists:
|
|
||||||
return artists[0] if isinstance(artists[0], str) else artists[0].get('name', '')
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
def first_artist_plain(original_track: Dict[str, Any]) -> str:
|
|
||||||
"""Deezer/Qobuz/Spotify-Public: first artist assuming a list of strings;
|
|
||||||
'' when empty."""
|
|
||||||
artists = original_track.get('artists', [])
|
|
||||||
return artists[0] if artists else ''
|
|
||||||
|
|
||||||
|
|
||||||
def update_discovery_match(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
get_json,
|
|
||||||
*,
|
|
||||||
source_log_label: str,
|
|
||||||
error_label: str,
|
|
||||||
original_track_key: str,
|
|
||||||
original_artist_getter,
|
|
||||||
join_artist_names,
|
|
||||||
extract_artist_name,
|
|
||||||
build_fix_modal_spotify_data,
|
|
||||||
get_discovery_cache_key,
|
|
||||||
get_database,
|
|
||||||
get_active_discovery_source,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Apply a manually-selected Spotify track to a discovery result (the
|
|
||||||
fix-modal flow) and persist it to the discovery cache.
|
|
||||||
|
|
||||||
1:1 lift of the ``update_<source>_discovery_match`` bodies for the four
|
|
||||||
sources with the identical structure (Tidal, Deezer, Qobuz, Spotify-Public).
|
|
||||||
Per-source pieces are params:
|
|
||||||
|
|
||||||
- ``source_log_label`` (lowercase, e.g. "tidal") for the "Manual match
|
|
||||||
updated: ..." line; ``error_label`` for the except log.
|
|
||||||
- ``original_track_key`` — the raw-source track key on the result
|
|
||||||
('tidal_track', 'deezer_track', ...).
|
|
||||||
- ``original_artist_getter`` — Tidal handles string-or-object artists
|
|
||||||
(``first_artist_str_or_obj``); the rest assume strings
|
|
||||||
(``first_artist_plain``).
|
|
||||||
- the web_server helpers (join/extract artist, build_fix_modal_spotify_data,
|
|
||||||
cache-key, get_database, active-discovery-source) are injected so this
|
|
||||||
stays free of those globals.
|
|
||||||
- ``get_json`` is called INSIDE the try (like the original's
|
|
||||||
``request.get_json()``) so a malformed body yields the same 500.
|
|
||||||
|
|
||||||
Returns ``(payload, status_code)``.
|
|
||||||
|
|
||||||
NOT folded in: iTunes-Link (saves spotify_data directly via a different
|
|
||||||
cache signature), YouTube (multi-key original_track fallback), ListenBrainz
|
|
||||||
(entirely different unmatch-capable structure, no cache write), Beatport.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = get_json()
|
|
||||||
identifier = data.get('identifier')
|
|
||||||
track_index = data.get('track_index')
|
|
||||||
spotify_track = data.get('spotify_track')
|
|
||||||
|
|
||||||
if not identifier or track_index is None or not spotify_track:
|
|
||||||
return {'error': 'Missing required fields'}, 400
|
|
||||||
|
|
||||||
state = states.get(identifier)
|
|
||||||
result = None
|
|
||||||
if state:
|
|
||||||
if track_index >= len(state['discovery_results']):
|
|
||||||
return {'error': 'Invalid track index'}, 400
|
|
||||||
|
|
||||||
result = state['discovery_results'][track_index]
|
|
||||||
old_status = result.get('status')
|
|
||||||
|
|
||||||
result['status'] = 'Found'
|
|
||||||
result['status_class'] = 'found'
|
|
||||||
result['spotify_track'] = spotify_track['name']
|
|
||||||
result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists'])
|
|
||||||
result['spotify_album'] = spotify_track['album']
|
|
||||||
result['spotify_id'] = spotify_track['id']
|
|
||||||
|
|
||||||
duration_ms = spotify_track.get('duration_ms', 0)
|
|
||||||
if duration_ms:
|
|
||||||
minutes = duration_ms // 60000
|
|
||||||
seconds = (duration_ms % 60000) // 1000
|
|
||||||
result['duration'] = f"{minutes}:{seconds:02d}"
|
|
||||||
else:
|
|
||||||
result['duration'] = '0:00'
|
|
||||||
|
|
||||||
result['spotify_data'] = build_fix_modal_spotify_data(spotify_track)
|
|
||||||
result['wing_it_fallback'] = False
|
|
||||||
result['manual_match'] = True
|
|
||||||
|
|
||||||
if old_status != 'found' and old_status != 'Found':
|
|
||||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
|
||||||
|
|
||||||
logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}")
|
|
||||||
logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
|
||||||
|
|
||||||
original_track = result.get(original_track_key, {})
|
|
||||||
original_name = original_track.get('name', spotify_track['name'])
|
|
||||||
original_artist = original_artist_getter(original_track)
|
|
||||||
else:
|
|
||||||
# #843: the in-memory discovery state can be gone — a server restart,
|
|
||||||
# or an imported playlist that wasn't discovered in THIS process —
|
|
||||||
# while the card is still shown from persisted data. The DURABLE part
|
|
||||||
# of a manual fix (writing the match to the discovery cache so future
|
|
||||||
# syncs resolve it) doesn't need the in-memory state, only the original
|
|
||||||
# track's name + artist, which the client now sends. Fall back to those
|
|
||||||
# instead of 404ing the fix into uselessness.
|
|
||||||
original_name = (data.get('original_name') or '').strip()
|
|
||||||
original_artist = (data.get('original_artist') or '').strip()
|
|
||||||
if not original_name and not original_artist:
|
|
||||||
return {'error': 'Discovery state not found'}, 404
|
|
||||||
if not original_name:
|
|
||||||
original_name = spotify_track['name']
|
|
||||||
# Key the cache by the FIRST artist — every in-memory + sync path uses
|
|
||||||
# artists[0], but the client may send a joined "A, B, C" string. Without
|
|
||||||
# this, a multi-artist track would save under a key the sync never looks
|
|
||||||
# up (full string ≠ first artist), so the fix would silently never apply.
|
|
||||||
if original_artist:
|
|
||||||
original_artist = original_artist.split(',')[0].strip()
|
|
||||||
logger.info(
|
|
||||||
f"Manual match (no in-memory state) → discovery cache: "
|
|
||||||
f"{source_log_label} - {identifier} - '{original_name}' by '{original_artist}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cache_key = get_discovery_cache_key(original_name, original_artist)
|
|
||||||
artists_list = spotify_track['artists']
|
|
||||||
if isinstance(artists_list, list):
|
|
||||||
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
|
|
||||||
image_url = spotify_track.get('image_url') or ''
|
|
||||||
album_raw = spotify_track.get('album', '')
|
|
||||||
if isinstance(album_raw, dict):
|
|
||||||
album_obj = dict(album_raw)
|
|
||||||
if image_url and not album_obj.get('image_url'):
|
|
||||||
album_obj['image_url'] = image_url
|
|
||||||
if image_url and not album_obj.get('images'):
|
|
||||||
album_obj['images'] = [{'url': image_url}]
|
|
||||||
else:
|
|
||||||
album_obj = {'name': album_raw or ''}
|
|
||||||
if image_url:
|
|
||||||
album_obj['image_url'] = image_url
|
|
||||||
album_obj['images'] = [{'url': image_url}]
|
|
||||||
|
|
||||||
matched_data = {
|
|
||||||
'id': spotify_track['id'],
|
|
||||||
'name': spotify_track['name'],
|
|
||||||
'artists': artists_list,
|
|
||||||
'album': album_obj,
|
|
||||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
|
||||||
'image_url': image_url,
|
|
||||||
'source': 'spotify',
|
|
||||||
}
|
|
||||||
cache_db = get_database()
|
|
||||||
cache_db.save_discovery_cache_match(
|
|
||||||
cache_key[0], cache_key[1], get_active_discovery_source(), 1.0, matched_data,
|
|
||||||
original_name, original_artist
|
|
||||||
)
|
|
||||||
logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}")
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Error saving manual fix to discovery cache: {cache_err}")
|
|
||||||
|
|
||||||
return {'success': True, 'result': result}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating {error_label} discovery match: {e}")
|
|
||||||
return {'error': str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
def start_sync(
|
|
||||||
states: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
sync_id_prefix: str,
|
|
||||||
not_found_message: str,
|
|
||||||
not_ready_message: str,
|
|
||||||
convert_fn,
|
|
||||||
playlist_name_getter,
|
|
||||||
playlist_image_getter,
|
|
||||||
activity_label: str,
|
|
||||||
error_label: str,
|
|
||||||
sync_lock: Any,
|
|
||||||
sync_states: Dict[str, Any],
|
|
||||||
active_sync_workers: Dict[str, Any],
|
|
||||||
submit_sync_task,
|
|
||||||
add_activity_item,
|
|
||||||
) -> Tuple[Dict[str, Any], int]:
|
|
||||||
"""Kick off a playlist sync from a source's discovered Spotify matches.
|
|
||||||
|
|
||||||
1:1 lift of the ``start_<source>_sync`` bodies for the five sources with
|
|
||||||
the identical flow (Tidal, Deezer, Qobuz, Spotify-Public, YouTube). The
|
|
||||||
per-source pieces are parameters:
|
|
||||||
|
|
||||||
- ``sync_id_prefix`` — the ``f"{prefix}_{key}"`` sync id.
|
|
||||||
- ``convert_fn`` — the source's discovery->spotify-tracks converter.
|
|
||||||
- ``playlist_name_getter`` / ``playlist_image_getter`` — Tidal reads an
|
|
||||||
object (``.name`` / ``getattr``), the rest read a dict; lifted as the
|
|
||||||
``playlist_name_obj``/``playlist_image_obj`` vs ``playlist_name_strict``/
|
|
||||||
``playlist_image_dict`` accessors.
|
|
||||||
- ``activity_label`` vs ``error_label`` — these DIFFER for Spotify-Public:
|
|
||||||
activity says "Spotify Link Sync Started" while logs say "Spotify Public".
|
|
||||||
- ``submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks,
|
|
||||||
playlist_image_url) -> Future`` — wraps sync_executor/_run_sync_task/
|
|
||||||
get_current_profile_id so this stays free of those globals.
|
|
||||||
|
|
||||||
Returns ``(payload, status_code)``.
|
|
||||||
|
|
||||||
NOT folded in: iTunes-Link (no final info log), ListenBrainz (submits the
|
|
||||||
task without an image arg), Beatport (extra debug logging, 'chart' key).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if key not in states:
|
|
||||||
return {"error": not_found_message}, 404
|
|
||||||
|
|
||||||
state = states[key]
|
|
||||||
state['last_accessed'] = time.time()
|
|
||||||
|
|
||||||
if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']:
|
|
||||||
return {"error": not_ready_message}, 400
|
|
||||||
|
|
||||||
spotify_tracks = convert_fn(state['discovery_results'])
|
|
||||||
if not spotify_tracks:
|
|
||||||
return {"error": "No Spotify matches found for sync"}, 400
|
|
||||||
|
|
||||||
sync_playlist_id = f"{sync_id_prefix}_{key}"
|
|
||||||
playlist_name = playlist_name_getter(state)
|
|
||||||
|
|
||||||
add_activity_item("", f"{activity_label} Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
|
|
||||||
|
|
||||||
state['phase'] = 'syncing'
|
|
||||||
state['sync_playlist_id'] = sync_playlist_id
|
|
||||||
state['sync_progress'] = {}
|
|
||||||
|
|
||||||
with sync_lock:
|
|
||||||
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
|
|
||||||
|
|
||||||
playlist_image_url = playlist_image_getter(state)
|
|
||||||
future = submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url)
|
|
||||||
active_sync_workers[sync_playlist_id] = future
|
|
||||||
|
|
||||||
logger.info(f"Started {error_label} sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
|
|
||||||
return {"success": True, "sync_playlist_id": sync_playlist_id}, 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error starting {error_label} sync: {e}")
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
"""Discover Hero endpoint — lifted from web_server.py.
|
|
||||||
|
|
||||||
The function body is byte-identical to the original. The
|
|
||||||
``spotify_client`` proxy + helper shims let the body resolve its
|
|
||||||
original names; the more complex ``_get_metadata_fallback_client``
|
|
||||||
is injected via init() because it composes multiple registry helpers
|
|
||||||
that web_server.py wires together.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from flask import g, jsonify
|
|
||||||
|
|
||||||
from database.music_database import get_database
|
|
||||||
from core.metadata.registry import get_primary_source, get_spotify_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_profile_id() -> int:
|
|
||||||
"""Mirror of web_server.get_current_profile_id — uses Flask g.
|
|
||||||
|
|
||||||
Catches RuntimeError too because reading `g` outside a request
|
|
||||||
context raises that (not AttributeError) — happens when this is
|
|
||||||
called from background threads (sync, automation, scanners)."""
|
|
||||||
try:
|
|
||||||
return g.profile_id
|
|
||||||
except (AttributeError, RuntimeError):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _get_active_discovery_source():
|
|
||||||
"""Mirror of web_server._get_active_discovery_source — delegates to registry."""
|
|
||||||
return get_primary_source()
|
|
||||||
|
|
||||||
|
|
||||||
class _SpotifyClientProxy:
|
|
||||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
client = get_spotify_client()
|
|
||||||
if client is None:
|
|
||||||
raise AttributeError(name)
|
|
||||||
return getattr(client, name)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return get_spotify_client() is not None
|
|
||||||
|
|
||||||
|
|
||||||
spotify_client = _SpotifyClientProxy()
|
|
||||||
|
|
||||||
|
|
||||||
# Injected at runtime via init().
|
|
||||||
_get_metadata_fallback_client = None
|
|
||||||
|
|
||||||
|
|
||||||
def init(get_metadata_fallback_client_fn):
|
|
||||||
"""Bind web_server's _get_metadata_fallback_client helper."""
|
|
||||||
global _get_metadata_fallback_client
|
|
||||||
_get_metadata_fallback_client = get_metadata_fallback_client_fn
|
|
||||||
|
|
||||||
|
|
||||||
def get_discover_hero():
|
|
||||||
"""Get featured similar artists for hero slideshow"""
|
|
||||||
try:
|
|
||||||
database = get_database()
|
|
||||||
|
|
||||||
# Determine active source
|
|
||||||
active_source = _get_active_discovery_source()
|
|
||||||
logger.info(f"Discover hero using source: {active_source}")
|
|
||||||
|
|
||||||
# Import fallback client for non-Spotify lookups
|
|
||||||
itunes_client = _get_metadata_fallback_client()
|
|
||||||
|
|
||||||
# Get top similar artists (excluding watchlist, cycled by last_featured)
|
|
||||||
# Fetch more than needed since strict source filtering may drop many
|
|
||||||
pid = get_current_profile_id()
|
|
||||||
logger.info(f"[Discover Hero] Profile ID: {pid}, Active source: {active_source}")
|
|
||||||
similar_artists = database.get_top_similar_artists(limit=200, profile_id=pid, require_source=active_source)
|
|
||||||
|
|
||||||
# FALLBACK: If no similar artists exist, use watchlist artists for Hero section
|
|
||||||
if not similar_artists:
|
|
||||||
logger.warning("[Discover Hero] No similar artists found, falling back to watchlist artists")
|
|
||||||
watchlist_artists = database.get_watchlist_artists(profile_id=pid)
|
|
||||||
|
|
||||||
if not watchlist_artists:
|
|
||||||
return jsonify({"success": True, "artists": [], "source": active_source})
|
|
||||||
|
|
||||||
# Convert watchlist artists to hero format
|
|
||||||
import random
|
|
||||||
shuffled_watchlist = list(watchlist_artists)
|
|
||||||
random.shuffle(shuffled_watchlist)
|
|
||||||
|
|
||||||
hero_artists = []
|
|
||||||
for artist in shuffled_watchlist[:10]:
|
|
||||||
if active_source == 'spotify':
|
|
||||||
artist_id = artist.spotify_artist_id
|
|
||||||
elif active_source == 'deezer':
|
|
||||||
artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id
|
|
||||||
elif active_source == 'musicbrainz':
|
|
||||||
artist_id = getattr(artist, 'musicbrainz_artist_id', None) or artist.itunes_artist_id
|
|
||||||
else:
|
|
||||||
artist_id = artist.itunes_artist_id
|
|
||||||
if not artist_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
artist_data = {
|
|
||||||
"spotify_artist_id": artist.spotify_artist_id,
|
|
||||||
"itunes_artist_id": artist.itunes_artist_id,
|
|
||||||
"artist_id": artist_id,
|
|
||||||
"artist_name": artist.artist_name,
|
|
||||||
"occurrence_count": 1,
|
|
||||||
"similarity_rank": 1,
|
|
||||||
"source": active_source,
|
|
||||||
"is_watchlist": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use cached image from watchlist — no API call needed
|
|
||||||
if hasattr(artist, 'image_url') and artist.image_url:
|
|
||||||
artist_data['image_url'] = artist.image_url
|
|
||||||
|
|
||||||
hero_artists.append(artist_data)
|
|
||||||
|
|
||||||
logger.warning(f"[Discover Hero] Returning {len(hero_artists)} watchlist artists as fallback")
|
|
||||||
return jsonify({"success": True, "artists": hero_artists, "source": active_source, "fallback": "watchlist"})
|
|
||||||
|
|
||||||
# Artists are already filtered by source in SQL — no post-filter needed
|
|
||||||
valid_artists = list(similar_artists)
|
|
||||||
|
|
||||||
# FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly
|
|
||||||
if active_source in ('itunes', 'deezer', 'musicbrainz') and not valid_artists:
|
|
||||||
logger.warning(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists")
|
|
||||||
resolved_count = 0
|
|
||||||
for artist in similar_artists:
|
|
||||||
existing_id = getattr(artist, f'similar_artist_{active_source}_id', None) or (artist.similar_artist_itunes_id if active_source == 'itunes' else None)
|
|
||||||
if existing_id:
|
|
||||||
valid_artists.append(artist)
|
|
||||||
continue
|
|
||||||
# Try to resolve ID by name
|
|
||||||
try:
|
|
||||||
resolve_client = itunes_client
|
|
||||||
if active_source == 'musicbrainz':
|
|
||||||
from core.metadata.registry import get_musicbrainz_client
|
|
||||||
resolve_client = get_musicbrainz_client()
|
|
||||||
search_results = resolve_client.search_artists(artist.similar_artist_name, limit=1)
|
|
||||||
if search_results and len(search_results) > 0:
|
|
||||||
resolved_id = search_results[0].id
|
|
||||||
# Cache the resolved ID for future use
|
|
||||||
if active_source == 'deezer':
|
|
||||||
database.update_similar_artist_deezer_id(artist.id, resolved_id)
|
|
||||||
artist.similar_artist_deezer_id = resolved_id
|
|
||||||
elif active_source == 'musicbrainz':
|
|
||||||
database.update_similar_artist_musicbrainz_id(artist.id, resolved_id)
|
|
||||||
artist.similar_artist_musicbrainz_id = resolved_id
|
|
||||||
else:
|
|
||||||
database.update_similar_artist_itunes_id(artist.id, resolved_id)
|
|
||||||
artist.similar_artist_itunes_id = resolved_id
|
|
||||||
valid_artists.append(artist)
|
|
||||||
resolved_count += 1
|
|
||||||
logger.info(f" [Resolved] {artist.similar_artist_name} -> {active_source} ID: {resolved_id}")
|
|
||||||
except Exception as resolve_err:
|
|
||||||
logger.error(f" [Failed] Could not resolve {active_source} ID for {artist.similar_artist_name}: {resolve_err}")
|
|
||||||
# Stop after 10 successful resolutions to avoid rate limiting
|
|
||||||
if len(valid_artists) >= 10:
|
|
||||||
break
|
|
||||||
logger.warning(f"[{active_source} Fallback] Resolved {resolved_count} artists with IDs")
|
|
||||||
|
|
||||||
logger.info(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}")
|
|
||||||
|
|
||||||
# Filter out blacklisted artists
|
|
||||||
blacklisted = database.get_discovery_blacklist_names()
|
|
||||||
if blacklisted:
|
|
||||||
valid_artists = [a for a in valid_artists if a.similar_artist_name.lower() not in blacklisted]
|
|
||||||
|
|
||||||
# Take top 10 (already ordered by least-recently-featured, then quality)
|
|
||||||
similar_artists = valid_artists[:10]
|
|
||||||
|
|
||||||
# Convert to JSON format — use cached metadata, only fetch from API if missing
|
|
||||||
hero_artists = []
|
|
||||||
for artist in similar_artists:
|
|
||||||
# Use the ID for the active source, falling back to the other if needed
|
|
||||||
if active_source == 'spotify':
|
|
||||||
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
|
|
||||||
elif active_source == 'deezer':
|
|
||||||
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
|
||||||
elif active_source == 'musicbrainz':
|
|
||||||
artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
|
||||||
else:
|
|
||||||
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
|
||||||
|
|
||||||
artist_data = {
|
|
||||||
"spotify_artist_id": artist.similar_artist_spotify_id,
|
|
||||||
"itunes_artist_id": artist.similar_artist_itunes_id,
|
|
||||||
"musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None),
|
|
||||||
"artist_id": artist_id,
|
|
||||||
"artist_name": artist.similar_artist_name,
|
|
||||||
"occurrence_count": artist.occurrence_count,
|
|
||||||
"similarity_rank": artist.similarity_rank,
|
|
||||||
"source": active_source
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use cached metadata if available
|
|
||||||
if artist.image_url:
|
|
||||||
artist_data['image_url'] = artist.image_url
|
|
||||||
artist_data['genres'] = artist.genres or []
|
|
||||||
artist_data['popularity'] = artist.popularity or 0
|
|
||||||
else:
|
|
||||||
# No cached metadata — fetch from API and cache for next time
|
|
||||||
try:
|
|
||||||
if active_source == 'spotify' and artist.similar_artist_spotify_id:
|
|
||||||
if spotify_client and spotify_client.is_authenticated():
|
|
||||||
sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id)
|
|
||||||
if sp_artist and sp_artist.get('images'):
|
|
||||||
artist_data['artist_name'] = sp_artist.get('name', artist.similar_artist_name)
|
|
||||||
artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None
|
|
||||||
artist_data['genres'] = sp_artist.get('genres', [])
|
|
||||||
artist_data['popularity'] = sp_artist.get('popularity', 0)
|
|
||||||
# Cache it
|
|
||||||
database.update_similar_artist_metadata(
|
|
||||||
artist.id, artist_data.get('image_url'),
|
|
||||||
artist_data.get('genres'), artist_data.get('popularity')
|
|
||||||
)
|
|
||||||
elif active_source in ('itunes', 'deezer', 'musicbrainz'):
|
|
||||||
if active_source == 'deezer':
|
|
||||||
fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
|
|
||||||
fetch_client = itunes_client
|
|
||||||
elif active_source == 'musicbrainz':
|
|
||||||
fb_artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None)
|
|
||||||
from core.metadata.registry import get_musicbrainz_client
|
|
||||||
fetch_client = get_musicbrainz_client()
|
|
||||||
else:
|
|
||||||
fb_artist_id = artist.similar_artist_itunes_id
|
|
||||||
fetch_client = itunes_client
|
|
||||||
if fb_artist_id:
|
|
||||||
fb_artist_data = fetch_client.get_artist(fb_artist_id)
|
|
||||||
if fb_artist_data:
|
|
||||||
artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name)
|
|
||||||
artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None
|
|
||||||
artist_data['genres'] = fb_artist_data.get('genres', [])
|
|
||||||
artist_data['popularity'] = fb_artist_data.get('popularity', 0)
|
|
||||||
# Cache it
|
|
||||||
database.update_similar_artist_metadata(
|
|
||||||
artist.id, artist_data.get('image_url'),
|
|
||||||
artist_data.get('genres'), artist_data.get('popularity')
|
|
||||||
)
|
|
||||||
except Exception as img_err:
|
|
||||||
logger.error(f"Could not fetch artist image: {img_err}")
|
|
||||||
|
|
||||||
hero_artists.append(artist_data)
|
|
||||||
|
|
||||||
# Mark these artists as featured so they cycle to the back of the queue
|
|
||||||
featured_names = [a["artist_name"] for a in hero_artists]
|
|
||||||
database.mark_artists_featured(featured_names)
|
|
||||||
|
|
||||||
return jsonify({"success": True, "artists": hero_artists, "source": active_source})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting discover hero: {e}")
|
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
"""Background worker for ListenBrainz playlist discovery.
|
|
||||||
|
|
||||||
`run_listenbrainz_discovery_worker(state_key, deps)` is the function
|
|
||||||
the listenbrainz discovery start-endpoint submits to its executor to
|
|
||||||
match each ListenBrainz playlist track against Spotify (preferred) or
|
|
||||||
iTunes (fallback):
|
|
||||||
|
|
||||||
1. Pause enrichment workers (release shared resources).
|
|
||||||
2. For each ListenBrainz track:
|
|
||||||
- Cancellation gate (state['phase'] != 'discovering').
|
|
||||||
- Discovery cache lookup; cache hit short-circuits the search.
|
|
||||||
- Strategy 1: matching_engine search queries with confidence scoring.
|
|
||||||
- Strategy 2: swapped artist/title query.
|
|
||||||
- Strategy 3: album-based query (if album_name available).
|
|
||||||
- Strategy 4: extended search with limit=50.
|
|
||||||
- On match → save to discovery cache.
|
|
||||||
- On miss → build Wing It stub from raw source data.
|
|
||||||
3. After loop: phase='discovered', activity feed entry.
|
|
||||||
4. On error → state['status']='error', phase='fresh'.
|
|
||||||
5. Finally: resume enrichment workers.
|
|
||||||
|
|
||||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
|
||||||
and iTunes clients, matching engine, multiple metadata helpers, state
|
|
||||||
dict, database access) all injected via `ListenbrainzDiscoveryDeps`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ListenbrainzDiscoveryDeps:
|
|
||||||
"""Bundle of cross-cutting deps the ListenBrainz discovery worker needs."""
|
|
||||||
listenbrainz_playlist_states: dict
|
|
||||||
spotify_client: Any
|
|
||||||
matching_engine: Any
|
|
||||||
pause_enrichment_workers: Callable[[str], dict]
|
|
||||||
resume_enrichment_workers: Callable[[dict, str], None]
|
|
||||||
get_active_discovery_source: Callable[[], str]
|
|
||||||
get_metadata_fallback_client: Callable[[], Any]
|
|
||||||
get_discovery_cache_key: Callable
|
|
||||||
get_database: Callable[[], Any]
|
|
||||||
validate_discovery_cache_artist: Callable
|
|
||||||
extract_artist_name: Callable
|
|
||||||
spotify_rate_limited: Callable[[], bool]
|
|
||||||
discovery_score_candidates: Callable
|
|
||||||
get_metadata_cache: Callable[[], Any]
|
|
||||||
build_discovery_wing_it_stub: Callable
|
|
||||||
add_activity_item: Callable
|
|
||||||
|
|
||||||
|
|
||||||
def run_listenbrainz_discovery_worker(state_key, deps: ListenbrainzDiscoveryDeps):
|
|
||||||
"""Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)"""
|
|
||||||
playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key
|
|
||||||
_ew_state = {}
|
|
||||||
try:
|
|
||||||
_ew_state = deps.pause_enrichment_workers('ListenBrainz discovery')
|
|
||||||
state = deps.listenbrainz_playlist_states[state_key]
|
|
||||||
playlist = state['playlist']
|
|
||||||
tracks = playlist['tracks']
|
|
||||||
|
|
||||||
# Determine which provider to use (Spotify preferred, iTunes fallback)
|
|
||||||
discovery_source = deps.get_active_discovery_source()
|
|
||||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
|
||||||
|
|
||||||
# Get fallback client
|
|
||||||
itunes_client = deps.get_metadata_fallback_client()
|
|
||||||
|
|
||||||
logger.info(f"Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...")
|
|
||||||
|
|
||||||
# Store the discovery source in state
|
|
||||||
state['discovery_source'] = discovery_source
|
|
||||||
|
|
||||||
# Process each track for discovery
|
|
||||||
for i, track in enumerate(tracks):
|
|
||||||
try:
|
|
||||||
# Check for cancellation
|
|
||||||
if state.get('phase') != 'discovering':
|
|
||||||
logger.warning(f"ListenBrainz discovery cancelled (phase changed to '{state.get('phase')}')")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
state['discovery_progress'] = int((i / len(tracks)) * 100)
|
|
||||||
|
|
||||||
# Get cleaned track data from ListenBrainz
|
|
||||||
cleaned_title = track['track_name']
|
|
||||||
cleaned_artist = track['artist_name']
|
|
||||||
album_name = track.get('album_name', '')
|
|
||||||
duration_ms = track.get('duration_ms', 0)
|
|
||||||
|
|
||||||
logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'")
|
|
||||||
|
|
||||||
# Check discovery cache first
|
|
||||||
cache_key = deps.get_discovery_cache_key(cleaned_title, cleaned_artist)
|
|
||||||
try:
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
|
||||||
if cached_match and deps.validate_discovery_cache_artist(cleaned_artist, cached_match):
|
|
||||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}")
|
|
||||||
result = {
|
|
||||||
'index': i,
|
|
||||||
'lb_track': cleaned_title,
|
|
||||||
'lb_artist': cleaned_artist,
|
|
||||||
'status': 'Found',
|
|
||||||
'status_class': 'found',
|
|
||||||
'spotify_track': cached_match.get('name', ''),
|
|
||||||
'spotify_artist': deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '',
|
|
||||||
'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''),
|
|
||||||
'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00',
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'matched_data': cached_match,
|
|
||||||
'spotify_data': cached_match
|
|
||||||
}
|
|
||||||
state['spotify_matches'] += 1
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
continue
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache lookup error: {cache_err}")
|
|
||||||
|
|
||||||
# Try multiple search strategies using matching engine
|
|
||||||
matched_track = None
|
|
||||||
best_confidence = 0.0
|
|
||||||
best_raw_track = None
|
|
||||||
min_confidence = 0.9
|
|
||||||
source_duration = duration_ms or 0
|
|
||||||
|
|
||||||
# Strategy 1: Use matching_engine search queries
|
|
||||||
try:
|
|
||||||
temp_track = type('TempTrack', (), {
|
|
||||||
'name': cleaned_title,
|
|
||||||
'artists': [cleaned_artist],
|
|
||||||
'album': album_name if album_name else None
|
|
||||||
})()
|
|
||||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
|
||||||
logger.info(f"Generated {len(search_queries)} search queries for ListenBrainz track")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Matching engine failed for ListenBrainz, falling back to basic query: {e}")
|
|
||||||
search_queries = [f"{cleaned_artist} {cleaned_title}", cleaned_title]
|
|
||||||
|
|
||||||
for query_idx, search_query in enumerate(search_queries):
|
|
||||||
try:
|
|
||||||
logger.debug(f"ListenBrainz query {query_idx + 1}/{len(search_queries)}: {search_query}")
|
|
||||||
|
|
||||||
search_results = None
|
|
||||||
|
|
||||||
if use_spotify and not deps.spotify_rate_limited():
|
|
||||||
search_results = deps.spotify_client.search_tracks(search_query, limit=10)
|
|
||||||
else:
|
|
||||||
search_results = itunes_client.search_tracks(search_query, limit=10)
|
|
||||||
|
|
||||||
if not search_results:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Score all results using the matching engine
|
|
||||||
match, confidence, match_idx = deps.discovery_score_candidates(
|
|
||||||
cleaned_title, cleaned_artist, source_duration, search_results
|
|
||||||
)
|
|
||||||
|
|
||||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
|
||||||
best_confidence = confidence
|
|
||||||
matched_track = match
|
|
||||||
if use_spotify and match.id:
|
|
||||||
_cache = deps.get_metadata_cache()
|
|
||||||
best_raw_track = _cache.get_entity('spotify', 'track', match.id)
|
|
||||||
else:
|
|
||||||
best_raw_track = None
|
|
||||||
logger.info(f"New best ListenBrainz match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
if best_confidence >= 0.9:
|
|
||||||
logger.info(f"High confidence ListenBrainz match found ({best_confidence:.3f}), stopping search")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error in ListenBrainz search for query '{search_query}': {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if matched_track:
|
|
||||||
logger.info(f"Strategy 1 ListenBrainz match: {matched_track.artists[0]} - {matched_track.name} (confidence: {best_confidence:.3f})")
|
|
||||||
|
|
||||||
# Strategy 2: Swapped search (if first failed) - score results properly
|
|
||||||
if not matched_track:
|
|
||||||
logger.info("ListenBrainz Strategy 2: Trying swapped search (artist/title reversed)")
|
|
||||||
if use_spotify:
|
|
||||||
query = f"artist:{cleaned_title} track:{cleaned_artist}"
|
|
||||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
|
||||||
else:
|
|
||||||
query = f"{cleaned_title} {cleaned_artist}"
|
|
||||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
|
||||||
if fallback_results:
|
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
|
||||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
|
||||||
)
|
|
||||||
if match and confidence >= min_confidence:
|
|
||||||
matched_track = match
|
|
||||||
best_confidence = confidence
|
|
||||||
logger.info(f"Strategy 2 ListenBrainz match (swapped): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
# Strategy 3: Album-based search (if still failed and we have album name) - score results properly
|
|
||||||
if not matched_track and album_name:
|
|
||||||
logger.info(f"ListenBrainz Strategy 3: Trying album-based search: '{cleaned_artist} {album_name} {cleaned_title}'")
|
|
||||||
if use_spotify:
|
|
||||||
query = f"artist:{cleaned_artist} album:{album_name} track:{cleaned_title}"
|
|
||||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
|
||||||
else:
|
|
||||||
query = f"{cleaned_artist} {album_name} {cleaned_title}"
|
|
||||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
|
||||||
if fallback_results:
|
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
|
||||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
|
||||||
)
|
|
||||||
if match and confidence >= min_confidence:
|
|
||||||
matched_track = match
|
|
||||||
best_confidence = confidence
|
|
||||||
logger.info(f"Strategy 3 ListenBrainz match (album): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
# Strategy 4: Extended search with higher limit (last resort)
|
|
||||||
if not matched_track:
|
|
||||||
logger.info("ListenBrainz Strategy 4: Extended search with limit=50")
|
|
||||||
query = f"{cleaned_artist} {cleaned_title}"
|
|
||||||
if use_spotify:
|
|
||||||
extended_results = deps.spotify_client.search_tracks(query, limit=50)
|
|
||||||
else:
|
|
||||||
extended_results = itunes_client.search_tracks(query, limit=50)
|
|
||||||
if extended_results:
|
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
|
||||||
cleaned_title, cleaned_artist, source_duration, extended_results
|
|
||||||
)
|
|
||||||
if match and confidence >= min_confidence:
|
|
||||||
matched_track = match
|
|
||||||
best_confidence = confidence
|
|
||||||
logger.info(f"Strategy 4 ListenBrainz match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
|
||||||
|
|
||||||
# Create result entry
|
|
||||||
result = {
|
|
||||||
'index': i,
|
|
||||||
'lb_track': cleaned_title,
|
|
||||||
'lb_artist': cleaned_artist,
|
|
||||||
'status': 'Found' if matched_track else 'Not Found',
|
|
||||||
'status_class': 'found' if matched_track else 'not-found',
|
|
||||||
'spotify_track': matched_track.name if matched_track else '',
|
|
||||||
'spotify_artist': deps.extract_artist_name(matched_track.artists[0]) if matched_track else '',
|
|
||||||
'spotify_album': matched_track.album if matched_track else '',
|
|
||||||
'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00',
|
|
||||||
'discovery_source': discovery_source,
|
|
||||||
'confidence': best_confidence
|
|
||||||
}
|
|
||||||
|
|
||||||
if matched_track:
|
|
||||||
state['spotify_matches'] += 1
|
|
||||||
|
|
||||||
# Build album data based on provider
|
|
||||||
if use_spotify and best_raw_track:
|
|
||||||
album_data = best_raw_track.get('album', {})
|
|
||||||
else:
|
|
||||||
album_data = {
|
|
||||||
'name': matched_track.album,
|
|
||||||
'album_type': 'album',
|
|
||||||
'release_date': getattr(matched_track, 'release_date', '') or '',
|
|
||||||
'images': [{'url': matched_track.image_url}] if hasattr(matched_track, 'image_url') and matched_track.image_url else []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract image URL for discovery pool display
|
|
||||||
_yt_album_images = album_data.get('images', [])
|
|
||||||
_yt_image_url = _yt_album_images[0].get('url', '') if _yt_album_images else (getattr(matched_track, 'image_url', '') or '')
|
|
||||||
|
|
||||||
result['matched_data'] = {
|
|
||||||
'id': matched_track.id,
|
|
||||||
'name': matched_track.name,
|
|
||||||
'artists': matched_track.artists,
|
|
||||||
'album': album_data,
|
|
||||||
'duration_ms': matched_track.duration_ms,
|
|
||||||
'image_url': _yt_image_url,
|
|
||||||
'source': discovery_source
|
|
||||||
}
|
|
||||||
result['spotify_data'] = result['matched_data']
|
|
||||||
|
|
||||||
# Save to discovery cache (only high-confidence matches)
|
|
||||||
if best_confidence >= 0.7:
|
|
||||||
try:
|
|
||||||
cache_db = deps.get_database()
|
|
||||||
cache_db.save_discovery_cache_match(
|
|
||||||
cache_key[0], cache_key[1], discovery_source, best_confidence,
|
|
||||||
result['matched_data'], cleaned_title, cleaned_artist
|
|
||||||
)
|
|
||||||
logger.info(f"CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})")
|
|
||||||
except Exception as cache_err:
|
|
||||||
logger.error(f"Cache save error: {cache_err}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Auto Wing It fallback — build stub from raw source data
|
|
||||||
stub = deps.build_discovery_wing_it_stub(cleaned_title, cleaned_artist, duration_ms)
|
|
||||||
result['status'] = 'Wing It'
|
|
||||||
result['status_class'] = 'wing-it'
|
|
||||||
result['spotify_track'] = cleaned_title
|
|
||||||
result['spotify_artist'] = cleaned_artist
|
|
||||||
result['spotify_album'] = ''
|
|
||||||
result['matched_data'] = stub
|
|
||||||
result['spotify_data'] = stub
|
|
||||||
result['wing_it_fallback'] = True
|
|
||||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
|
||||||
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
|
|
||||||
logger.info(f" {'' if matched_track else ''} Track {i+1}/{len(tracks)}: {result['status']}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing track {i}: {e}")
|
|
||||||
result = {
|
|
||||||
'index': i,
|
|
||||||
'lb_track': track['track_name'],
|
|
||||||
'lb_artist': track['artist_name'],
|
|
||||||
'status': 'Error',
|
|
||||||
'status_class': 'error',
|
|
||||||
'spotify_track': '',
|
|
||||||
'spotify_artist': '',
|
|
||||||
'spotify_album': '',
|
|
||||||
'duration': '0:00'
|
|
||||||
}
|
|
||||||
state['discovery_results'].append(result)
|
|
||||||
|
|
||||||
# Complete discovery
|
|
||||||
state['phase'] = 'discovered'
|
|
||||||
state['status'] = 'complete'
|
|
||||||
state['discovery_progress'] = 100
|
|
||||||
|
|
||||||
playlist_name = playlist.get('name') or playlist.get('title') or 'Unknown Playlist'
|
|
||||||
source_label = discovery_source.upper()
|
|
||||||
deps.add_activity_item("", f"ListenBrainz Discovery Complete ({source_label})", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
|
|
||||||
|
|
||||||
logger.info(f"ListenBrainz discovery complete ({discovery_source}): {state['spotify_matches']}/{len(tracks)} tracks matched")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in ListenBrainz discovery worker: {e}")
|
|
||||||
state['status'] = 'error'
|
|
||||||
state['phase'] = 'fresh'
|
|
||||||
finally:
|
|
||||||
deps.resume_enrichment_workers(_ew_state, 'ListenBrainz discovery')
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
"""Listening-driven recommendation core (#913).
|
|
||||||
|
|
||||||
PURE, side-effect-free ranking that turns "the artists you listen to most" plus
|
|
||||||
"who's similar to each" into:
|
|
||||||
|
|
||||||
1. a consensus-ranked list of artists you'd probably love but don't own, and
|
|
||||||
2. an aggregated candidate-track list for a generated playlist.
|
|
||||||
|
|
||||||
No DB / network / config here. The caller (the watchlist scanner) supplies the
|
|
||||||
seeds (top-played artists), the ``similar_artists`` rows per seed, and the
|
|
||||||
owned-artist set, then fetches top tracks for the winners. Keeping the decision
|
|
||||||
logic in one pure place makes it fully unit-testable without the live stack and
|
|
||||||
keeps the scan wiring thin — and additive, so it can't disturb existing flows.
|
|
||||||
|
|
||||||
Scoring rationale (the "best in class" bit): a recommended artist's score is
|
|
||||||
``Σ over the seeds that recommend it of (seed_weight × similarity)``. That single
|
|
||||||
sum rewards all three signals at once — **consensus** (an artist endorsed by many
|
|
||||||
of your seeds accumulates more terms), your **play weight** (heavier seeds push
|
|
||||||
harder), and **similarity strength** — instead of a flat "appears in N lists".
|
|
||||||
``seed_count`` is exposed separately for display ("because you like A, B, C") and
|
|
||||||
as the adventurousness dial's lever (``min_seed_count``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Dict, List, Optional, Sequence, Set
|
|
||||||
|
|
||||||
|
|
||||||
def _norm(name: object) -> str:
|
|
||||||
return str(name or "").strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _positive_float(value: object, default: float = 1.0) -> float:
|
|
||||||
try:
|
|
||||||
f = float(value) # type: ignore[arg-type]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
return f if f > 0 else default
|
|
||||||
|
|
||||||
|
|
||||||
def _get(row: object, attr: str):
|
|
||||||
"""Read a field from a dataclass row or a dict row."""
|
|
||||||
if isinstance(row, dict):
|
|
||||||
return row.get(attr)
|
|
||||||
return getattr(row, attr, None)
|
|
||||||
|
|
||||||
|
|
||||||
def choose_mix_fetch_source(active_source: object, active_can_fetch: bool) -> str:
|
|
||||||
"""Pick which source to fetch the "Listening Mix" top tracks from.
|
|
||||||
|
|
||||||
The mix is a list of (artist, title) pairs acquired via Soulseek, so the fetch source need
|
|
||||||
NOT match the user's active metadata source. Use the active source when it can fetch top
|
|
||||||
tracks itself (Spotify/Deezer); otherwise fall back to Deezer, whose public ``artist/{id}/top``
|
|
||||||
needs no auth and is available to every user — so iTunes / Discogs / MusicBrainz users still
|
|
||||||
get a full mix without switching sources. Pure.
|
|
||||||
"""
|
|
||||||
if str(active_source or "").lower() in ("spotify", "deezer") and active_can_fetch:
|
|
||||||
return str(active_source).lower()
|
|
||||||
return "deezer"
|
|
||||||
|
|
||||||
|
|
||||||
def names_match(a: object, b: object) -> bool:
|
|
||||||
"""Strict artist-name equality after stripping case + non-alphanumerics.
|
|
||||||
|
|
||||||
Used to verify a name-search result before fetching that artist's top tracks, so the
|
|
||||||
"Listening Mix" can never pull the WRONG artist's songs (e.g. a same-name act). Exact
|
|
||||||
alphanumeric match: "Tyler, The Creator" == "Tyler The Creator", but "Drake" != "Drake Bell".
|
|
||||||
Pure.
|
|
||||||
"""
|
|
||||||
def _alnum(x: object) -> str:
|
|
||||||
return "".join(ch for ch in str(x or "").lower() if ch.isalnum())
|
|
||||||
na, nb = _alnum(a), _alnum(b)
|
|
||||||
return bool(na) and na == nb
|
|
||||||
|
|
||||||
|
|
||||||
def similarity_from_rank(rank: object, max_rank: int = 10) -> float:
|
|
||||||
"""Turn a stored ``similarity_rank`` (1 = most similar … 10 = least) into a 0–1 weight.
|
|
||||||
|
|
||||||
SoulSync stores each ``(seed → similar)`` edge with a 1–10 rank (``1`` is the closest
|
|
||||||
match). The ranker multiplies this into the score so a seed's *closest* matches count
|
|
||||||
for more than its long-tail ones. Linear decay over the documented range: rank 1 → 1.0,
|
|
||||||
rank 5 → 0.6, rank 10 → 0.1, with a 0.1 floor so a far match still contributes. A
|
|
||||||
missing/garbage rank falls back to 1.0 (treat as "no rank info, full weight"). Pure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
r = int(rank)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 1.0
|
|
||||||
floor = round(1.0 / max_rank, 4)
|
|
||||||
if r <= 1:
|
|
||||||
return 1.0
|
|
||||||
if r >= max_rank:
|
|
||||||
return floor
|
|
||||||
return round((max_rank - r + 1) / max_rank, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def build_recency_weighted_seeds(
|
|
||||||
top_artists: Sequence[dict],
|
|
||||||
recent_play_counts: Optional[Dict[str, float]] = None,
|
|
||||||
*,
|
|
||||||
recency_factor: float = 1.5,
|
|
||||||
) -> List[dict]:
|
|
||||||
"""Blend lifetime + recent play counts into seed weights — "what you're into NOW".
|
|
||||||
|
|
||||||
``weight = lifetime_plays + recency_factor × recent_plays``. An artist you've played a
|
|
||||||
lot *recently* outranks one you played a lot years ago, so the recommendations track
|
|
||||||
your current taste instead of your all-time history. ``recency_factor`` is the dial
|
|
||||||
(0 = pure lifetime). Returns ``[{'name', 'weight'}]`` for :func:`rank_recommended_artists`.
|
|
||||||
Pure — the caller supplies both play-count maps from the listening history.
|
|
||||||
"""
|
|
||||||
recent = {_norm(k): _positive_float(v, 0.0) for k, v in (recent_play_counts or {}).items()}
|
|
||||||
out: List[dict] = []
|
|
||||||
for a in top_artists or ():
|
|
||||||
name = str(a.get("name") or "").strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
lifetime = _positive_float(a.get("play_count", a.get("weight", 1.0)))
|
|
||||||
boost = recency_factor * recent.get(_norm(name), 0.0)
|
|
||||||
out.append({"name": name, "weight": lifetime + boost})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def group_similars_by_seed(
|
|
||||||
seeds: Sequence[dict],
|
|
||||||
similar_rows: Sequence,
|
|
||||||
id_to_name: Dict[str, str],
|
|
||||||
*,
|
|
||||||
source_id_attr: str = "source_artist_id",
|
|
||||||
similar_name_attr: str = "similar_artist_name",
|
|
||||||
rank_attr: Optional[str] = None,
|
|
||||||
) -> Dict[str, List[dict]]:
|
|
||||||
"""Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name', 'score'?}]}``.
|
|
||||||
|
|
||||||
The stored rows key the similar artist by the SEED's source id (``source_artist_id``),
|
|
||||||
not its name, so :func:`rank_recommended_artists` can't consume them directly. This
|
|
||||||
resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id:
|
|
||||||
artist_name}`` for the library, built by the caller) and keeps only rows that resolve
|
|
||||||
to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure — no I/O.
|
|
||||||
|
|
||||||
``id_to_name`` MUST be keyed by whatever id the edges actually store — for SoulSync that
|
|
||||||
is the artist's SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), NOT the internal row id.
|
|
||||||
When ``rank_attr`` is given, each row's rank is converted via :func:`similarity_from_rank`
|
|
||||||
and carried as ``score`` so closer matches weigh more; without it every similar comes out
|
|
||||||
score-less (the ranker then treats similarity as 1.0 — original behavior).
|
|
||||||
"""
|
|
||||||
seed_names = {_norm(s.get("name")) for s in seeds}
|
|
||||||
seed_names.discard("")
|
|
||||||
id_to_norm = {str(k): _norm(v) for k, v in (id_to_name or {}).items()}
|
|
||||||
|
|
||||||
out: Dict[str, List[dict]] = {}
|
|
||||||
for row in similar_rows or ():
|
|
||||||
seed_name = id_to_norm.get(str(_get(row, source_id_attr) or ""), "")
|
|
||||||
if not seed_name or seed_name not in seed_names:
|
|
||||||
continue
|
|
||||||
sim_name = str(_get(row, similar_name_attr) or "").strip()
|
|
||||||
if not sim_name:
|
|
||||||
continue
|
|
||||||
entry = {"name": sim_name}
|
|
||||||
if rank_attr is not None:
|
|
||||||
entry["score"] = similarity_from_rank(_get(row, rank_attr))
|
|
||||||
out.setdefault(seed_name, []).append(entry)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RecommendedArtist:
|
|
||||||
"""One artist recommended from your listening, with the why."""
|
|
||||||
name: str # display name (first-seen casing)
|
|
||||||
score: float # Σ seed_weight × similarity
|
|
||||||
seed_count: int # distinct seeds endorsing it (consensus)
|
|
||||||
seeds: List[str] = field(default_factory=list) # display names of those seeds
|
|
||||||
|
|
||||||
|
|
||||||
def rank_recommended_artists(
|
|
||||||
seeds: Sequence[dict],
|
|
||||||
similars_by_seed: Dict[str, Sequence[dict]],
|
|
||||||
owned_artist_names: Optional[Set[str]] = None,
|
|
||||||
*,
|
|
||||||
limit: int = 30,
|
|
||||||
min_seed_count: int = 1,
|
|
||||||
) -> List[RecommendedArtist]:
|
|
||||||
"""Rank artists similar to your most-played by consensus + play weight + similarity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seeds: ``[{'name': str, 'weight': float}]`` — your top-played artists.
|
|
||||||
``weight`` (play count or any positive number) defaults to 1.0.
|
|
||||||
similars_by_seed: ``{seed_name_lower: [{'name': str, 'score': float}]}`` — the
|
|
||||||
similar-artist rows for each seed. ``score`` is optional (defaults 1.0).
|
|
||||||
owned_artist_names: lowercased names already in the library — excluded so the
|
|
||||||
result is artists you DON'T have. The seeds themselves are always excluded.
|
|
||||||
limit: max results.
|
|
||||||
min_seed_count: drop recommendations endorsed by fewer than N seeds — the
|
|
||||||
adventurousness dial's "Safer" end raises this for higher-confidence picks.
|
|
||||||
|
|
||||||
Returns up to ``limit`` :class:`RecommendedArtist`, highest score first.
|
|
||||||
"""
|
|
||||||
owned = {_norm(a) for a in (owned_artist_names or set())}
|
|
||||||
seed_norms = {_norm(s.get("name")) for s in seeds}
|
|
||||||
seed_norms.discard("")
|
|
||||||
exclude = owned | seed_norms
|
|
||||||
|
|
||||||
acc: Dict[str, dict] = {}
|
|
||||||
for seed in seeds:
|
|
||||||
s_name = _norm(seed.get("name"))
|
|
||||||
if not s_name:
|
|
||||||
continue
|
|
||||||
s_display = str(seed.get("name") or "").strip()
|
|
||||||
weight = _positive_float(seed.get("weight", 1.0))
|
|
||||||
for sim in similars_by_seed.get(s_name, ()) or ():
|
|
||||||
a_norm = _norm(sim.get("name"))
|
|
||||||
if not a_norm or a_norm in exclude:
|
|
||||||
continue
|
|
||||||
sim_score = _positive_float(sim.get("score", 1.0))
|
|
||||||
row = acc.setdefault(
|
|
||||||
a_norm, {"name": str(sim.get("name") or "").strip(), "score": 0.0, "seeds": {}}
|
|
||||||
)
|
|
||||||
row["score"] += weight * sim_score
|
|
||||||
row["seeds"].setdefault(s_name, s_display) # one seed counts once
|
|
||||||
|
|
||||||
out: List[RecommendedArtist] = []
|
|
||||||
floor = max(1, int(min_seed_count))
|
|
||||||
for row in acc.values():
|
|
||||||
seed_count = len(row["seeds"])
|
|
||||||
if seed_count < floor:
|
|
||||||
continue
|
|
||||||
out.append(RecommendedArtist(
|
|
||||||
name=row["name"],
|
|
||||||
score=round(row["score"], 6),
|
|
||||||
seed_count=seed_count,
|
|
||||||
seeds=list(row["seeds"].values()),
|
|
||||||
))
|
|
||||||
out.sort(key=lambda r: (-r.score, -r.seed_count, r.name.lower()))
|
|
||||||
return out[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_candidate_tracks(
|
|
||||||
recommended_artists: Sequence[RecommendedArtist],
|
|
||||||
top_tracks_by_artist: Dict[str, Sequence[dict]],
|
|
||||||
owned_track_keys: Optional[Set] = None,
|
|
||||||
*,
|
|
||||||
per_artist: int = 3,
|
|
||||||
limit: int = 50,
|
|
||||||
exclude_owned: bool = True,
|
|
||||||
) -> List[dict]:
|
|
||||||
"""Build the candidate track list for the generated playlist.
|
|
||||||
|
|
||||||
Takes the top ``per_artist`` tracks from each recommended artist **in artist-rank
|
|
||||||
order**, dedups by ``(artist, title)``, optionally drops owned tracks (the
|
|
||||||
"discovery" flavor) and caps at ``limit``. Each returned track dict is the source
|
|
||||||
track plus ``_seed_artist`` (which recommended artist it came from).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recommended_artists: ranked output of :func:`rank_recommended_artists`.
|
|
||||||
top_tracks_by_artist: ``{artist_name_lower: [track_dict, ...]}`` — fetched by
|
|
||||||
the caller (Last.fm / source top tracks), NOT limited to a curated pool.
|
|
||||||
owned_track_keys: set of ``(artist_lower, title_lower)`` already in the library.
|
|
||||||
exclude_owned: drop tracks in ``owned_track_keys`` (discovery flavor). Set False
|
|
||||||
for a "replay" playlist of tracks you already own.
|
|
||||||
"""
|
|
||||||
owned = owned_track_keys or set()
|
|
||||||
seen: Set = set()
|
|
||||||
out: List[dict] = []
|
|
||||||
for art in recommended_artists:
|
|
||||||
tracks = top_tracks_by_artist.get(_norm(art.name), ()) or ()
|
|
||||||
taken = 0
|
|
||||||
for t in tracks:
|
|
||||||
if taken >= per_artist:
|
|
||||||
break
|
|
||||||
title = str(t.get("name") or t.get("title") or "").strip()
|
|
||||||
if not title:
|
|
||||||
continue
|
|
||||||
key = (_norm(art.name), _norm(title))
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
if exclude_owned and key in owned:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
out.append({**t, "_seed_artist": art.name})
|
|
||||||
taken += 1
|
|
||||||
if len(out) >= limit:
|
|
||||||
break
|
|
||||||
return out[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def to_mix_track(track: object, source: str) -> Optional[dict]:
|
|
||||||
"""Shape one source "top tracks" API dict into the flat dict the Discover compact
|
|
||||||
playlist row renders + syncs (the "Listening Mix" #913 playlist).
|
|
||||||
|
|
||||||
Spotify's ``artist_top_tracks`` and Deezer's ``get_artist_top_tracks`` both return the
|
|
||||||
same Spotify-shape object (``id, name, artists[], album{name,images[]}, duration_ms``).
|
|
||||||
This flattens that into the renderer's field names (``track_name/artist_name/album_name/
|
|
||||||
album_cover_url/duration_ms``), keeps the original under ``track_data_json`` for sync, and
|
|
||||||
sets the source-specific id field. Returns None for anything without a usable id/title so
|
|
||||||
the caller can filter. A ``name`` key is kept so :func:`aggregate_candidate_tracks` can
|
|
||||||
dedup by title. Pure — no I/O.
|
|
||||||
"""
|
|
||||||
if not isinstance(track, dict):
|
|
||||||
return None
|
|
||||||
tid = track.get("id")
|
|
||||||
name = str(track.get("name") or "").strip()
|
|
||||||
if not tid or not name:
|
|
||||||
return None
|
|
||||||
artists = track.get("artists") or []
|
|
||||||
artist_name = ""
|
|
||||||
if artists and isinstance(artists[0], dict):
|
|
||||||
artist_name = str(artists[0].get("name") or "").strip()
|
|
||||||
album = track.get("album") if isinstance(track.get("album"), dict) else {}
|
|
||||||
album_name = str(album.get("name") or "").strip()
|
|
||||||
images = album.get("images") or []
|
|
||||||
cover = images[0].get("url") if images and isinstance(images[0], dict) else None
|
|
||||||
out = {
|
|
||||||
"track_id": str(tid),
|
|
||||||
"name": name, # for aggregate_candidate_tracks dedup
|
|
||||||
"track_name": name, # for the renderer
|
|
||||||
"artist_name": artist_name,
|
|
||||||
"album_name": album_name,
|
|
||||||
"album_cover_url": cover,
|
|
||||||
"duration_ms": track.get("duration_ms") or 0,
|
|
||||||
"track_data_json": track, # full payload for sync/download
|
|
||||||
"source": source,
|
|
||||||
}
|
|
||||||
id_field = {"spotify": "spotify_track_id", "deezer": "deezer_track_id",
|
|
||||||
"itunes": "itunes_track_id"}.get(source)
|
|
||||||
if id_field:
|
|
||||||
out[id_field] = str(tid)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"RecommendedArtist",
|
|
||||||
"choose_mix_fetch_source",
|
|
||||||
"names_match",
|
|
||||||
"similarity_from_rank",
|
|
||||||
"build_recency_weighted_seeds",
|
|
||||||
"to_mix_track",
|
|
||||||
"group_similars_by_seed",
|
|
||||||
"rank_recommended_artists",
|
|
||||||
"aggregate_candidate_tracks",
|
|
||||||
]
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue