Compare commits

..

No commits in common. "08e739a768403e347a6796acff3cfa2dca8d7a08" and "83f782d125b2c3801e783b4c44b295b8510ee7de" have entirely different histories.

9 changed files with 192 additions and 33 deletions

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# Copy to `.env` and fill in, then `source .env` before running.
# Get these from https://developer.spotify.com/dashboard (your app's settings).
# Register `http://127.0.0.1:8888` as a Redirect URI in that app.
export SPOTIFY_CLIENT_ID=your_client_id_here
export SPOTIFY_CLIENT_SECRET=your_client_secret_here
# Optional overrides:
# export MUSIC_LIBRARY_PATH=~/Music
# export WORK_DIR=./work_dir/new

2
.gitignore vendored
View file

@ -2,6 +2,8 @@ prefetched*
__pycache__ __pycache__
.idea .idea
secret secret
.env
.venv
work_dir work_dir
old old
tmp tmp

14
Env.py
View file

@ -1,7 +1,13 @@
import os
from pathlib import Path from pathlib import Path
local_library_path = Path("/home/auser/Music/") # Local config. Paths can be overridden via environment variables.
work_dir = Path("./work_dir/new") local_library_path = Path(os.environ.get("MUSIC_LIBRARY_PATH", "~/Music")).expanduser()
work_dir = Path(os.environ.get("WORK_DIR", "./work_dir/new"))
client_id = '3b4db45bb66b45e9a2532989c8034332' # Spotify app credentials. NEVER hardcode these here — set them in the
client_secret = "ed03dce79ec049e59b1bf20967aba418" # environment (see .env.example) so they don't end up in version control.
# export SPOTIFY_CLIENT_ID=...
# export SPOTIFY_CLIENT_SECRET=...
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")

View file

@ -26,7 +26,7 @@ class Flow:
itunes_library : Library = None itunes_library : Library = None
@staticmethod @staticmethod
def fetch_spotify(self): def fetch_spotify():
fetch_all(client_id, client_secret, work_dir) fetch_all(client_id, client_secret, work_dir)
@staticmethod @staticmethod

103
README.md
View file

@ -1,3 +1,100 @@
# spotifyFetcher # MusicIndexer
loads all data through web api<br>
client secret - 4ced4e4575094b749834a60996680c6c Pull your Spotify library and listening stats via the Spotify Web API, index a
local music library (audio tags + iTunes XML), and match the two so you can see
which tracks you own, what's missing, and your top artists/tracks/playlists — in
a Streamlit GUI.
## Features
- **Spotify backend** — OAuth (Authorization Code) login, then fetch the user
profile, liked tracks, all playlists and their tracks, long-term top tracks
and top artists, and playlist/profile cover art. Resilient paginated fetching
(follows `next`, retries dropped connections, honours `429` rate limits) with
progress bars.
- **Local backend** — index a folder of audio files via `mutagen` tags.
- **iTunes backend** — parse an iTunes Library XML export.
- **Matching** — map local tracks to Spotify tracks (fuzzy matcher; ISRC/MBID
matching planned).
- **Stats & lyrics** — generate listening stats and fetch lyrics.
- **GUI** — browse libraries as artist → album → track tables.
## Setup
Requires Python 3.13+.
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### Credentials
Create an app at https://developer.spotify.com/dashboard and add
`http://127.0.0.1:8888` as a **Redirect URI**. Credentials are read from the
environment — never commit them.
```bash
cp .env.example .env
# edit .env with your client id/secret
source .env
```
`Env.py` reads `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` (plus optional
`MUSIC_LIBRARY_PATH` and `WORK_DIR`) from the environment.
## Usage
Edit `main.py` to uncomment the steps you want, then run:
```bash
python main.py
```
The flow steps (see `Flow.py`) include:
| Step | What it does |
|------|--------------|
| `fetch_spotify()` | OAuth login + download all Spotify data into `work_dir/spotify/` |
| `fetch_local()` | index local audio files into `work_dir/local/` |
| `parse_spotify_library()` / `parse_itunes_library()` / `parse_local_library()` | build normalized libraries |
| `load_libraries()` / `save_libraries()` | (de)serialize libraries to `work_dir` |
| `map_local_to_spotify()` | match local tracks against the Spotify library |
| `fetch_and_save_lyrics()` | fetch lyrics for tracks |
| `log_stats()` | generate stats |
On first `fetch_spotify()` a browser opens for the Spotify login; a local server
on `127.0.0.1:8888` catches the redirect and exchanges the code for a token.
### GUI
```bash
./gui.sh # runs: .venv/bin/streamlit run Gui.py
```
## Layout
```
main.py entry point (CLI flow)
Gui.py / gui.sh Streamlit GUI
Flow.py orchestrates fetch / parse / map / stats
Env.py config (reads credentials from the environment)
src/
Library.py data model (Library / Artist / Album / Track)
LibrarySerializer.py load/save libraries
backends/
spotify/ Web API client, OAuth, parser
local/ local-file indexer, parser, playlist generator
itunes/ iTunes XML parser
navidrome/ Navidrome backend
mappers/ local↔Spotify matching (fuzzy / search)
lyrics/ lyrics fetching
work_dir/ fetched data and serialized libraries (gitignored)
```
## Notes
- Data is cached as JSON under `work_dir/` (gitignored); re-running reuses it.
- The deprecated Spotify endpoints (audio-features, recommendations, etc.) are
not used — only currently-supported endpoints.

6
gui.sh
View file

@ -1 +1,5 @@
streamlit run Gui.py #!/usr/bin/env bash
# Run from the script's directory using the project virtualenv,
# so it works regardless of whether the venv is activated / on PATH.
cd "$(dirname "$0")"
exec .venv/bin/streamlit run Gui.py "$@"

12
main.py
View file

@ -17,17 +17,17 @@ def test1():
def main(): def main():
flow = Flow.Flow() flow = Flow.Flow()
# flow.fetch_spotify() flow.fetch_spotify()
# flow.fetch_local() # flow.fetch_local()
flow.parse_spotify_library() flow.parse_spotify_library()
flow.parse_itunes_library() #flow.parse_itunes_library()
flow.parse_local_library() #flow.parse_local_library()
flow.load_libraries() #flow.load_libraries()
flow.map_local_to_spotify() #flow.map_local_to_spotify()
flow.save_libraries() #flow.save_libraries()
# flow.log_stats() # flow.log_stats()

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
streamlit==1.58.0
streamlit-aggrid==1.2.1.post2
pandas==3.0.3
Pillow==12.2.0
mutagen==1.47.0
rapidfuzz==3.14.5
requests==2.34.2
tqdm==4.68.2
xmltodict==1.0.4

View file

@ -1,7 +1,10 @@
import requests import requests
import urllib.parse import urllib.parse
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
from tqdm import tqdm
from src.backends.spotify.SpotifyAuthenticator import authenticate from src.backends.spotify.SpotifyAuthenticator import authenticate
@ -9,6 +12,31 @@ from src.helpers import *
FETCH_STEP = 50 FETCH_STEP = 50
token = '' token = ''
REQUEST_TIMEOUT = 30
def _build_session():
"""Session that retries transient failures (dropped connections, 5xx) and
honours Spotify's 429 Retry-After, so a single reset mid-fetch doesn't
abort the whole run."""
session = requests.Session()
retry = Retry(
total=6,
connect=6,
read=6,
backoff_factor=1.5, # sleeps ~0, 1.5, 3, 6, 12, 24s between attempts
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}),
respect_retry_after_header=True,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
_session = _build_session()
def fetch(endpoint, method='GET', body=None): def fetch(endpoint, method='GET', body=None):
@ -17,19 +45,23 @@ def fetch(endpoint, method='GET', body=None):
'Authorization': f'Bearer {token}', 'Authorization': f'Bearer {token}',
} }
response = requests.request(method, url, headers=headers, json=body) response = _session.request(method, url, headers=headers, json=body, timeout=REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def fetch_paginated(endpoint): def fetch_paginated(endpoint, desc="Fetching", leave=True):
"""Collect every item of a paging object by following its `next` field, """Collect every item of a paging object by following its `next` field,
which is Spotify's recommended way to page (more robust than tracking which is Spotify's recommended way to page (more robust than tracking
offset/total manually).""" offset/total manually). Shows a tqdm progress bar driven by the paging
object's `total`."""
items = [] items = []
page = fetch(endpoint) page = fetch(endpoint)
with tqdm(total=page.get('total'), desc=desc, unit="item", leave=leave) as pbar:
while True: while True:
items += page.get('items', []) batch = page.get('items', [])
items += batch
pbar.update(len(batch))
next_url = page.get('next') next_url = page.get('next')
if not next_url: if not next_url:
break break
@ -43,31 +75,31 @@ def find_song(song_pattern):
def fetch_playlists(user_id): def fetch_playlists(user_id):
print("Fetching playlists") return fetch_paginated(f"v1/me/playlists?limit={FETCH_STEP}", desc="Fetching playlists")
return fetch_paginated(f"v1/me/playlists?limit={FETCH_STEP}")
def fetch_playlist_items(playlist_id): def fetch_playlist_items(playlist_id):
return fetch_paginated(f"v1/playlists/{playlist_id}/tracks?limit={FETCH_STEP}") return fetch_paginated(
f"v1/playlists/{playlist_id}/tracks?limit={FETCH_STEP}",
desc="Fetching playlist tracks",
leave=False,
)
def fetch_tracks(user_id): def fetch_tracks(user_id):
print("Fetching tracks") tracks = fetch_paginated(f"v1/me/tracks?limit={FETCH_STEP}&market=ES", desc="Fetching liked tracks")
tracks = fetch_paginated(f"v1/me/tracks?limit={FETCH_STEP}&market=ES")
save_data(tracks, "tracks") save_data(tracks, "tracks")
return tracks return tracks
def fetch_tracks_top(): def fetch_tracks_top():
print("Fetching top tracks") tracks = fetch_paginated(f"v1/me/top/tracks?limit={FETCH_STEP}&time_range=long_term", desc="Fetching top tracks")
tracks = fetch_paginated(f"v1/me/top/tracks?limit={FETCH_STEP}&time_range=long_term")
save_data(tracks, "top_tracks") save_data(tracks, "top_tracks")
return tracks return tracks
def fetch_artists_top(): def fetch_artists_top():
print("Fetching top artists") artists = fetch_paginated(f"v1/me/top/artists?limit={FETCH_STEP}&time_range=long_term", desc="Fetching top artists")
artists = fetch_paginated(f"v1/me/top/artists?limit={FETCH_STEP}&time_range=long_term")
save_data(artists, "top_artists") save_data(artists, "top_artists")
return artists return artists