Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Шурупов Илья Викторович
cbb127115c Auto-load .env; make --spotify-full force the cached path too
The live fetch was being skipped because creds never reached the process:
Env.py only read os.environ, and .env uses `export ...`, so without a prior
`source .env` the run fell to the cached branch — which then skipped on an
unchanged cache signature ("Spotify cache unchanged — skipping").

- Env.py now auto-loads a project-root .env (setdefault, so an exported value
  still wins; tolerates `export ` and quotes).
- --spotify-full now also passes force=True to ingest_spotify, so the cached
  fallback re-ingests instead of skipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:48:03 +03:00
Шурупов Илья Викторович
48bf84bcb7 Track Spotify origin (liked/playlist/top); make top tracks opt-in; clean Ctrl+C
- Tag each Spotify track with its origin (liked / from_playlist / from_top,
  combinable) so genuinely-saved tracks are distinguishable from ones only
  surfaced by the top-tracks endpoint.
- Top tracks are no longer added by default (they inflated "missing" with
  unowned listening stats). --include-top pulls them as full objects so they
  carry an ISRC for real ID matching.
- On --spotify-full, prune Spotify rows that are neither liked nor in a
  playlist (top-only / orphaned), cleaning an existing DB in one pass.
- Cached-ingest fallback honours include_top and prunes too.
- In-place SQLite migration adds the new columns (no DB rebuild needed).
- Ctrl+C exits cleanly (130) with a message instead of a traceback;
  incremental progress is already committed, so re-run to continue.
- Surface origin breakdown in the fetch log, match summary, and GUI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:42:44 +03:00
Шурупов Илья Викторович
6920b634c4 Incremental live Spotify fetch (snapshot/watermark)
The live fetch no longer re-pulls everything each run:
- playlists: re-fetch a playlist's tracks only when its snapshot_id changed
  (new/deleted playlists handled); state in spotify_playlist table.
- liked songs: page newest-first, stop at the newest added_at already seen
  (watermark in ingest_state).
- cover art: download only when the file is missing or the URL changed.

New src/ingest/spotify_fetch.py drives this against the low-level
SpotifyWebAPI client and upserts straight to the DB; fetch uses it when creds
are set (falls back to the cached-JSON ingest otherwise). `fetch --spotify-full`
forces a complete resync. Shared upsert helper extracted into spotify_raw.

Verified with a mocked API: unchanged re-run does 0 playlist-track fetches and
one liked page; a snapshot change re-fetches exactly that playlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:26:09 +03:00
Шурупов Илья Викторович
ca1ca74244 Incremental Spotify ingest + --no-enrich
- spotify_raw: skip re-ingesting when the cache files are unchanged (signature
  in ingest_state), matching the iTunes/local incremental behaviour — so a
  re-run with no changes touches nothing.
- fetch --no-enrich: skip MusicBrainz enrichment and rely on MBIDs already on
  tracks (e.g. local Picard tags).

Verified on the real library: 1st run ~9s, unchanged re-run ~0.3s with all
three sources skipping; with enrichment off, 1164 Spotify tracks still match
locally by ISRC/MBID from tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 02:04:54 +03:00
Шурупов Илья Викторович
03b98d9a1d Real backends + fetch/display workflows; config paths
Wire the incremental pipeline to real sources and expose two workflows.

Config (Env.py): three explicit paths — LOCAL_LIBRARY_PATH (scan source),
ITUNES_XML_PATH (manual export), DATA_DIR (storage root: DB + Spotify cache).

Real, incremental ingesters:
- local_scan: walks the library, skips files whose mtime/size are unchanged,
  prunes deleted ones, and reads embedded ISRC + recording MBID from tags
  (tagged files get real IDs with no network), via tags.py.
- itunes_xml: parse the iTunes Library.xml as a plist; skip if mtime unchanged.
- spotify_raw: ingest raw Spotify JSON (ISRC from external_ids); live fetch
  wrapper for when credentials are set.

Workflows (src/workflows.py + main.py):
- `fetch`  — pull + scan + parse + enrich + match, with readable step logs.
  First run is slow (full scan + MB lookups); later runs only touch changes
  (verified: ~71s first, ~0.6s second).
- `display` — launch the GUI.

Also: enrich uses ISRC-first again (local MBIDs now come from tags), fix
--enrich-limit 0, quiet musicbrainzngs XML warnings, ignore /data/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:46:56 +03:00
Шурупов Илья Викторович
0e64d439c4 DB-backed incremental redesign: find tracks missing locally
Replace the JSON-file pipeline with a SQLite/SQLAlchemy store (Postgres via
DATABASE_URL) and an incremental, ID-based matcher whose goal is "which
Spotify/iTunes tracks am I missing locally".

- src/db: models (Track/MbCache/FileState/IngestState) + engine/session.
- src/ingest: upsert prefetched spotify/itunes/local (work_dir/latest-1) by
  (source, source_id); pulls ISRC from raw Spotify; preserves enrichment.
- src/enrich: MusicBrainz recording-MBID resolver. Resolves via NORMALIZED
  text search (so the same song across sources lands on one MBID + one cached
  lookup), ISRC as exact fallback. Only touches unresolved tracks, caches
  positive AND negative results, rate-limited and resumable.
- src/match: normalization + cross-source matcher with tiers ISRC > MBID >
  normalized text; reports present/missing and the match method.
- main.py: CLI (ingest / enrich / match). Gui.py: missing-tracks viewer.
- README + requirements (SQLAlchemy, musicbrainzngs); ignore *.db.

Incremental everywhere: re-running updates only what changed; enrich continues
where it stopped. Live Spotify/local backends are left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:25:21 +03:00
388 changed files with 7442114 additions and 287 deletions

View file

@ -1,9 +1,18 @@
# Copy to `.env` and fill in, then `source .env` before running. # Copy to `.env`, 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. # --- the three things you point at your own data ---------------------------
# Folder of audio files to scan (tags incl. ISRC / MusicBrainz id are read).
export LOCAL_LIBRARY_PATH=~/Music
# iTunes/Music "Library.xml" you export manually (File > Library > Export Library…).
export ITUNES_XML_PATH=~/Music/iTunes\ Library.xml
# Storage root: the database and cached Spotify responses live here.
export DATA_DIR=./data
# --- Spotify credentials (only needed for live fetching) -------------------
# https://developer.spotify.com/dashboard — register http://127.0.0.1:8888 as a Redirect URI.
export SPOTIFY_CLIENT_ID=your_client_id_here export SPOTIFY_CLIENT_ID=your_client_id_here
export SPOTIFY_CLIENT_SECRET=your_client_secret_here export SPOTIFY_CLIENT_SECRET=your_client_secret_here
# Optional overrides: # --- optional --------------------------------------------------------------
# export MUSIC_LIBRARY_PATH=~/Music # export MB_CONTACT=you@example.com # MusicBrainz User-Agent contact
# export WORK_DIR=./work_dir/new # export DATABASE_URL=postgresql+psycopg://u:p@localhost/mi # use Postgres instead of SQLite

2
.gitignore vendored
View file

@ -4,6 +4,8 @@ __pycache__
secret secret
.env .env
.venv .venv
*.db
/data/
work_dir work_dir
old old
tmp tmp

63
Env.py
View file

@ -1,13 +1,62 @@
"""Configuration. The three things you point at your own data:
LOCAL_LIBRARY_PATH folder of audio files to scan
ITUNES_XML_PATH iTunes/Music "Library.xml" you exported manually
DATA_DIR storage root: the database + cached fetches live here
Everything can be overridden via environment variables (see .env.example).
"""
import os import os
from pathlib import Path from pathlib import Path
# Local config. Paths can be overridden via environment variables.
local_library_path = Path(os.environ.get("MUSIC_LIBRARY_PATH", "~/Music")).expanduser()
work_dir = Path(os.environ.get("WORK_DIR", "./work_dir/new"))
# Spotify app credentials. NEVER hardcode these here — set them in the def _load_dotenv():
# environment (see .env.example) so they don't end up in version control. """Load a project-root .env into os.environ if present, so `python main.py`
# export SPOTIFY_CLIENT_ID=... works without remembering to `source .env`. Already-set environment
# export SPOTIFY_CLIENT_SECRET=... variables win (setdefault), so an explicit `source .env`/export still
overrides. Supports an optional leading `export ` and quoted values."""
path = Path(__file__).with_name(".env")
if not path.exists():
return
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):]
key, sep, value = line.partition("=")
if not sep:
continue
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
_load_dotenv()
def _path(env, default):
return Path(os.environ.get(env, default)).expanduser()
# --- where YOUR data is read from / written to -------------------------------
# Local music library to scan (tags incl. ISRC/MusicBrainz id are read).
local_library_path = _path("LOCAL_LIBRARY_PATH", "~/Music")
# iTunes / Apple Music library export. In the Music app:
# File -> Library -> Export Library… (produces an XML/plist file).
itunes_xml_path = _path("ITUNES_XML_PATH", "~/Music/iTunes Library.xml")
# Storage root — the database and cached Spotify responses go here.
data_dir = _path("DATA_DIR", "./data")
# --- derived storage locations -----------------------------------------------
spotify_cache_dir = data_dir / "spotify" # raw Spotify API responses
database_url = os.environ.get(
"DATABASE_URL", f"sqlite:///{(data_dir / 'musicindexer.db').as_posix()}"
)
# --- Spotify app credentials (only needed for live fetching) -----------------
client_id = os.environ.get("SPOTIFY_CLIENT_ID") client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET") client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
# Contact string sent to MusicBrainz in the User-Agent.
mb_contact = os.environ.get("MB_CONTACT", "music-indexer@example.com")

266
Gui.py
View file

@ -1,208 +1,96 @@
import time """Streamlit GUI: what's in your Spotify / iTunes libraries that you do NOT
have locally, decided by real ID matching (recording MBID / ISRC) with a
normalized-text fallback.
import streamlit as st Reads only the database (run `python main.py ingest` and `enrich` first).
from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode """
import pandas as pd import pandas as pd
import streamlit as st
from st_aggrid import AgGrid, GridOptionsBuilder
import Flow from src.db.database import init_db
from src.match.matcher import classify_all, summary
from src.Library import * st.set_page_config(layout="wide", page_title="MusicIndexer — Missing tracks")
def entity_to_row(entity: Entity):
return {
"id": entity.id,
"mbid": entity.mbid,
"Title": entity.title,
"PLay Count": entity.play_count,
"Resolved": int(entity.resolved_percentage * 100),
"AutoScore": entity.auto_score,
"Added": entity.date_added.strftime("%Y-%m-%d") if entity.date_added else None,
"Played": entity.date_last_play.strftime("%Y-%m-%d") if entity.date_last_play else None,
"Released": entity.date_released.strftime("%Y-%m-%d") if entity.date_released else None,
"LocalId": str(entity.local_id) if entity.local_id else None,
"Path": str(entity.local_path) if entity.local_path else None,
}
def track_to_row(track: Track):
return entity_to_row(track) | {
"Album": track.album.title if track.album else "",
"Artists": ", ".join(a.title if a.title else "no_arist_name" for a in track.artists),
"Lyrics": track.has_lyrics,
}
def artist_to_row(artist: Artist):
return entity_to_row(artist) | {
}
def album_to_row(album: Album):
return entity_to_row(album) | {
}
class LibraryView: @st.cache_data(ttl=30)
def __init__(self): def load_data():
self.lib : Library = None init_db()
self.libs : list[Library] = None missing, present = classify_all()
self.view = None return missing, present, summary()
self.lyrics_only = False
self.query = None
def add_libraries(self, libs : list[Library]):
self.libs = libs
def render(self): def grid(df, key):
st.set_page_config(layout="wide") gb = GridOptionsBuilder.from_dataframe(df)
gb.configure_default_column(filter=True, sortable=True, resizable=True)
gb.configure_pagination(paginationAutoPageSize=False, paginationPageSize=50)
AgGrid(df, gridOptions=gb.build(), height=620, fit_columns_on_grid_load=True, key=key)
with st.sidebar:
lib = st.selectbox( def main():
"Library", st.title("🎧 Tracks you're missing locally")
options=[lib.name for lib in self.libs],
if st.sidebar.button("🔄 Refresh from DB"):
load_data.clear()
missing, present, summ = load_data()
# --- summary metrics ---
for source in ("spotify", "itunes"):
s = summ.get(source, {})
if not s:
continue
st.subheader(source.capitalize())
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric("Total", s["total"])
c2.metric("Missing locally", s["missing"])
c3.metric("Have (ID)", s["present_mbid"])
c4.metric("Have (text)", s["present_text"])
coverage = (s["has_mbid"] / s["total"] * 100) if s["total"] else 0
c5.metric("MBID coverage", f"{coverage:.0f}%")
if source == "spotify":
st.caption(
f"Origins — liked: {s.get('liked', 0)} · "
f"in a playlist: {s.get('from_playlist', 0)} · "
f"top tracks: {s.get('from_top', 0)}"
) )
for iter in self.libs: local = summ.get("local", {})
if iter.name == lib: st.caption(
self.lib = iter f"Local library: {local.get('total', 0)} tracks, "
f"{local.get('has_mbid', 0)} with a resolved MBID. "
"Have (ID) = certain ISRC/MBID match; Have (text) = normalized "
"artist/title match (run `fetch`/`enrich` to upgrade text matches to ID)."
)
st.write(f"Showing library '{self.lib.name}'") st.divider()
self.view = st.radio( # --- controls ---
"View", view = st.sidebar.radio("View", ["Missing", "Present", "All"])
["Tree"] sources = st.sidebar.multiselect("Sources", ["spotify", "itunes"],
) default=["spotify", "itunes"])
if self.view == "Tree": if view == "Missing":
self.render_artists_albums_tracks() rows = [dict(r, status="missing") for r in missing]
# elif self.view == "Tracks": elif view == "Present":
# self.render_tracks() rows = [dict(r, status="present") for r in present]
# elif self.view == "Playlists": else:
# self.render_playlists() rows = ([dict(r, status="missing") for r in missing]
+ [dict(r, status="present") for r in present])
def track_visible( rows = [r for r in rows if r["source"] in sources]
self,
track: Track,
artist: Artist | None,
album: Album | None,
):
if self.lyrics_only and not track.has_lyrics:
return False
if self.query and self.query.lower() not in track.title.lower(): if not rows:
return False st.info("Nothing to show — run `python main.py ingest` (and `enrich`).")
return
if artist and all(a.id != artist.id for a in track.artists): df = pd.DataFrame(rows)[
return False ["status", "source", "origin", "artist", "title", "album", "matched_by",
"isrc", "mbid", "play_count", "spotify_id"]
if album and (not track.album or track.album.id != album.id): ]
return False st.write(f"**{len(df)}** tracks")
grid(df, key=f"{view}-{'-'.join(sorted(sources))}")
return True
def render_artists_albums_tracks(self):
# --- ARTISTS TABLE ---
st.subheader("Artists")
artist_rows = [artist_to_row(a) for a in self.lib.artists]
df_artists = pd.DataFrame(artist_rows)
gb_artists = GridOptionsBuilder.from_dataframe(df_artists)
gb_artists.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_artists.configure_column("Title", filter="agTextColumnFilter")
gb_artists.configure_column("AutoScore", sort="desc")
grid_options_artists = gb_artists.build()
grid_response_artists = AgGrid(
df_artists,
gridOptions=grid_options_artists,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
selected_rows_df = grid_response_artists.get("selected_rows")
selected_artist_titles = selected_rows_df["Title"].tolist() if selected_rows_df is not None and not selected_rows_df.empty else []
selected_artists = [a for a in self.lib.artists if a.title in selected_artist_titles]
# --- ALBUMS TABLE ---
st.subheader("Albums")
if selected_artists:
albums_list = []
for artist in selected_artists:
albums_list.extend(artist.albums)
albums_list = list({al.id: al for al in albums_list}.values())
else:
albums_list = self.lib.albums
album_rows = [album_to_row(a) for a in albums_list]
df_albums = pd.DataFrame(album_rows)
gb_albums = GridOptionsBuilder.from_dataframe(df_albums)
gb_albums.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_albums.configure_column("Title", filter="agTextColumnFilter")
gb_albums.configure_column("AutoScore", sort="desc")
grid_options_albums = gb_albums.build()
grid_response_albums = AgGrid(
df_albums,
gridOptions=grid_options_albums,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
selected_rows_df = grid_response_albums.get("selected_rows")
selected_album_titles = selected_rows_df["Title"].tolist() if selected_rows_df is not None and not selected_rows_df.empty else []
selected_albums = [al for al in albums_list if al.title in selected_album_titles]
# --- TRACKS TABLE ---
st.subheader("Tracks")
if selected_albums:
tracks_list = []
for album in selected_albums:
tracks_list.extend(album.tracks)
elif selected_artists:
tracks_list = []
for artist in selected_artists:
tracks_list.extend([t for t in self.lib.tracks if artist in t.artists])
else:
tracks_list = self.lib.tracks
track_rows = [track_to_row(t) for t in tracks_list]
df_tracks = pd.DataFrame(track_rows)
gb_tracks = GridOptionsBuilder.from_dataframe(df_tracks)
gb_tracks.configure_selection(selection_mode="multiple", use_checkbox=True)
gb_tracks.configure_column("Title", filter="agTextColumnFilter")
gb_tracks.configure_column("AutoScore", sort="desc")
grid_options_tracks = gb_tracks.build()
AgGrid(
df_tracks,
gridOptions=grid_options_tracks,
update_mode=GridUpdateMode.SELECTION_CHANGED,
allow_unsafe_jscode=True,
enable_enterprise_modules=False,
fit_columns_on_grid_load=True,
)
def run(): main()
flow = Flow.Flow()
# flow.fetch_spotify()
# flow.fetch_local()
# flow.parse_spotify_library()
# flow.parse_local_library()
flow.load_libraries()
# flow.map_local_to_spotify()
# flow.log_stats()
gui = LibraryView()
gui.add_libraries([flow.spotify_library, flow.itunes_library, flow.local_library])
gui.render()
run()

155
README.md
View file

@ -5,19 +5,38 @@ 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 which tracks you own, what's missing, and your top artists/tracks/playlists — in
a Streamlit GUI. a Streamlit GUI.
## Features It is **database-backed and incremental**: data lives in a DB (SQLite by
default, Postgres optional) and every step UPSERTs, so re-running only updates
what changed and nothing already fetched is lost. The headline use case is
seeing **which Spotify/iTunes tracks you don't have locally**, decided by real
recording identifiers (ISRC / MusicBrainz MBID) with a normalized-text fallback.
- **Spotify backend** — OAuth (Authorization Code) login, then fetch the user ## Pipeline
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 ingest -> enrich (MBIDs) -> match -> GUI
progress bars. ```
- **Local backend** — index a folder of audio files via `mutagen` tags.
- **iTunes backend** — parse an iTunes Library XML export. | Step | Command | Incremental rule |
- **Matching** — map local tracks to Spotify tracks (fuzzy matcher; ISRC/MBID |------|---------|------------------|
matching planned). | **ingest** | `python main.py ingest` | upsert tracks by `(source, source_id)`; never drops enrichment |
- **Stats & lyrics** — generate listening stats and fetch lyrics. | **enrich** | `python main.py enrich` | query MusicBrainz only for tracks with no MBID yet; every lookup (incl. "not found") cached; rate-limited & resumable |
- **GUI** — browse libraries as artist → album → track tables. | **match** | `python main.py match` | classify each Spotify/iTunes track: present locally (ISRC > MBID > text) or missing |
| **GUI** | `./gui.sh` | browse missing/present with the match method shown |
### How matching works
Each track is resolved to a MusicBrainz **recording MBID** by searching with the
*normalized* artist+title, so the same song from different sources lands on the
same MBID (and shares one cached lookup). A Spotify/iTunes track counts as
"have" if its ISRC, its MBID, or its normalized artist|title matches a local
track — otherwise it's **missing**. The GUI labels each match `isrc`/`mbid`
(ID-certain) or `text` (fuzzy).
> Note: the local library here has no ISRC/MBID tags, so before `enrich` runs,
> matching is normalized-text only (still useful). `enrich` upgrades matches to
> ID-certain. Tagging local files with Picard/AcoustID (ISRC) would make the ID
> tiers exact end-to-end.
## Setup ## Setup
@ -29,72 +48,94 @@ source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### Credentials ### Configure (`.env`)
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 ```bash
cp .env.example .env cp .env.example .env # then edit — it's auto-loaded (no `source` needed)
# edit .env with your client id/secret
source .env
``` ```
`Env.py` reads `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` (plus optional The project root `.env` is read automatically on startup; any variable already
`MUSIC_LIBRARY_PATH` and `WORK_DIR`) from the environment. exported in your shell still takes precedence.
## Usage Three things you point at your own data:
Edit `main.py` to uncomment the steps you want, then run: | Variable | What |
|----------|------|
| `LOCAL_LIBRARY_PATH` | folder of audio files to scan (ISRC/MusicBrainz tags are read) |
| `ITUNES_XML_PATH` | the `Library.xml` you export from Music (File → Library → Export Library…) |
| `DATA_DIR` | storage root — the database and cached Spotify responses live here |
`SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` are only needed for live Spotify
fetching (register `http://127.0.0.1:8888` as a Redirect URI in your
[Spotify app](https://developer.spotify.com/dashboard)). Set
`DATABASE_URL=postgresql+psycopg://…` to use Postgres instead of SQLite — no
code change.
## Two workflows
```bash ```bash
python main.py python main.py fetch # do all the work, incrementally, and report
python main.py display # open the GUI to see what you're missing
``` ```
The flow steps (see `Flow.py`) include: **`fetch`** pulls Spotify, scans the local library, parses the iTunes export,
resolves MBIDs, and matches — logging progress at each step. The **first run is
slow** (full scan + MusicBrainz lookups at ~1 req/s); **later runs are quick**
because each source only touches what changed:
| Step | What it does | - **Spotify** — playlists are re-fetched only when their `snapshot_id` changes;
|------|--------------| liked songs are paged newest-first and stop at the newest one already stored;
| `fetch_spotify()` | OAuth login + download all Spotify data into `work_dir/spotify/` | cover art downloads only when missing or its URL changed (`--spotify-full`
| `fetch_local()` | index local audio files into `work_dir/local/` | forces a complete resync).
| `parse_spotify_library()` / `parse_itunes_library()` / `parse_local_library()` | build normalized libraries | - Your library is **liked songs + playlist tracks**. The *top-tracks* endpoint
| `load_libraries()` / `save_libraries()` | (de)serialize libraries to `work_dir` | is **not** pulled by default (those are just listening stats, not saved
| `map_local_to_spotify()` | match local tracks against the Spotify library | music, and would inflate "missing"). Add `--include-top` to pull them too —
| `fetch_and_save_lyrics()` | fetch lyrics for tracks | they're fetched as full Spotify objects, so they carry an ISRC for real ID
| `log_stats()` | generate stats | matching like everything else.
- Each Spotify track records its **origin** (`liked` / `playlist` / `top`,
combinable). On a `--spotify-full` run, rows that are *only* there because
the top endpoint listed them (neither liked nor in a playlist) are pruned —
run `python main.py fetch --spotify-full` once to clean up an existing DB.
- **Local** — only files whose mtime/size changed; deleted files are pruned.
- **iTunes** — skipped entirely if the export's mtime is unchanged.
- **MBID enrichment** — only still-unresolved tracks.
On first `fetch_spotify()` a browser opens for the Spotify login; a local server Useful flags/helpers:
on `127.0.0.1:8888` catches the redirect and exchanges the code for a token.
### GUI
```bash ```bash
./gui.sh # runs: .venv/bin/streamlit run Gui.py python main.py fetch --enrich-limit 500 # cap MBID lookups this run
python main.py enrich # resume MBID resolution only
python main.py match # print the present/missing summary
``` ```
> ISRC/MBID matching works as soon as `fetch` runs (local tags + Spotify ISRC).
> MusicBrainz enrichment fills in the rest (iTunes, untagged local files); it's
> cached and resumable, so you can stop/continue any time.
## Layout ## Layout
``` ```
main.py entry point (CLI flow) main.py CLI: fetch / display / enrich / match
Gui.py / gui.sh Streamlit GUI Gui.py / gui.sh Streamlit GUI (missing-tracks viewer)
Flow.py orchestrates fetch / parse / map / stats Env.py config: LOCAL_LIBRARY_PATH / ITUNES_XML_PATH / DATA_DIR
Env.py config (reads credentials from the environment)
src/ src/
Library.py data model (Library / Artist / Album / Track) workflows.py run_fetch (the whole pipeline) + run_display
LibrarySerializer.py load/save libraries db/ SQLAlchemy models + engine/session
backends/ ingest/
spotify/ Web API client, OAuth, parser spotify_raw.py live fetch + ingest raw Spotify JSON (ISRC)
local/ local-file indexer, parser, playlist generator local_scan.py incremental file scan (reads ISRC/MBID tags)
itunes/ iTunes XML parser itunes_xml.py parse the iTunes Library.xml (plist)
navidrome/ Navidrome backend tags.py read ISRC / recording MBID / duration from a file
mappers/ local↔Spotify matching (fuzzy / search) enrich/ MusicBrainz MBID resolver (cached, resumable)
lyrics/ lyrics fetching match/ normalization + cross-source matcher
work_dir/ fetched data and serialized libraries (gitignored) backends/ lower-level Spotify Web API client / OAuth
$DATA_DIR/ database + cached Spotify responses (gitignored)
``` ```
## Notes ## Notes
- Data is cached as JSON under `work_dir/` (gitignored); re-running reuses it. - The DB is the source of truth; everything is incremental and re-runnable.
- The deprecated Spotify endpoints (audio-features, recommendations, etc.) are - Local matching is real-ID where files are tagged (ISRC/MusicBrainz, e.g. via
not used — only currently-supported endpoints. Picard); untagged files fall back to MusicBrainz text lookup, then text.
- Only currently-supported Spotify endpoints are used (no deprecated
audio-features/recommendations/etc.).

113
main.py
View file

@ -1,38 +1,95 @@
import Flow """MusicIndexer CLI. Two main workflows plus granular helpers.
python main.py fetch # do all the work: pull + scan + parse + enrich + match
python main.py display # open the GUI to see what you're missing
`fetch` is incremental and DB-backed (see Env.py): the first run is slow
(initial scan + MusicBrainz lookups), later runs only touch what changed.
Helpers: `enrich` (resume MBID resolution), `match` (print the summary).
"""
import argparse
import logging
import sys
def test1(): def _setup_logging():
flow = Flow.Flow() logging.basicConfig(level=logging.INFO, format="%(message)s")
# flow.fetch_local()
flow.parse_local_library()
flow.load_libraries()
# flow.map_local_to_spotify() def cmd_fetch(args):
# flow.log_stats() _setup_logging()
from src.workflows import run_fetch
# flow.fetch_and_save_lyrics() run_fetch(enrich_limit=args.enrich_limit, enrich=not args.no_enrich,
spotify_full=args.spotify_full, include_top=args.include_top)
def cmd_display(args):
from src.workflows import run_display
run_display()
def cmd_enrich(args):
_setup_logging()
from src.db.database import init_db
from src.enrich.mbid import resolve_mbids, unresolved_count
init_db()
print(f"unresolved before: {unresolved_count(args.source)}")
print(f"resolved this run: {resolve_mbids(limit=args.limit, source=args.source)}")
print(f"unresolved after: {unresolved_count(args.source)}")
def cmd_match(args):
import json
from src.match.matcher import summary
print(json.dumps(summary(), indent=2))
def build_parser():
parser = argparse.ArgumentParser(prog="musicindexer", description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
fetch = sub.add_parser("fetch", help="incrementally fetch + scan + enrich + match")
fetch.add_argument("--enrich-limit", type=int, default=None,
help="cap MBID lookups this run (default: resolve all)")
fetch.add_argument("--no-enrich", action="store_true",
help="skip MusicBrainz enrichment; use MBIDs already on tracks")
fetch.add_argument("--spotify-full", action="store_true",
help="force a full Spotify resync (ignore snapshot/watermark)")
fetch.add_argument("--include-top", action="store_true",
help="also pull the top-tracks endpoint (off by default; "
"with --spotify-full, top-only tracks are otherwise pruned)")
sub.add_parser("display", help="open the Streamlit GUI")
enrich = sub.add_parser("enrich", help="resume MBID resolution only")
enrich.add_argument("--limit", type=int, default=None)
enrich.add_argument("--source", choices=["spotify", "itunes", "local"], default=None)
sub.add_parser("match", help="print present/missing summary")
return parser
def main(): def main():
flow = Flow.Flow() args = build_parser().parse_args()
try:
flow.fetch_spotify() {
# flow.fetch_local() "fetch": cmd_fetch,
"display": cmd_display,
flow.parse_spotify_library() "enrich": cmd_enrich,
#flow.parse_itunes_library() "match": cmd_match,
#flow.parse_local_library() }[args.cmd](args)
except KeyboardInterrupt:
#flow.load_libraries() # Ctrl+C: stop cleanly, no traceback. Work already committed is kept
# (each step writes incrementally), so just re-run to continue.
#flow.map_local_to_spotify() print("\ninterrupted — partial progress saved; re-run to continue.",
#flow.save_libraries() file=sys.stderr)
sys.exit(130)
# flow.log_stats()
# print("asd")
# test1() if __name__ == "__main__":
main() main()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,26 @@
{
"display_name": "ilusha",
"external_urls": {
"spotify": "https://open.spotify.com/user/31uszk7zb6zdzrzst4q3rvkfrfz4"
},
"followers": {
"href": null,
"total": 8
},
"href": "https://api.spotify.com/v1/users/31uszk7zb6zdzrzst4q3rvkfrfz4",
"id": "31uszk7zb6zdzrzst4q3rvkfrfz4",
"images": [
{
"url": "https://i.scdn.co/image/ab6775700000ee85b4a21a7f0c35258b3fa4b79d",
"height": 300,
"width": 300
},
{
"url": "https://i.scdn.co/image/ab67757000003b82b4a21a7f0c35258b3fa4b79d",
"height": 64,
"width": 64
}
],
"type": "user",
"uri": "spotify:user:31uszk7zb6zdzrzst4q3rvkfrfz4"
}

View file

@ -0,0 +1,33 @@
{
"country": "NL",
"display_name": "ilusha",
"email": "ilusha.basic@gmail.com",
"explicit_content": {
"filter_enabled": false,
"filter_locked": false
},
"external_urls": {
"spotify": "https://open.spotify.com/user/31uszk7zb6zdzrzst4q3rvkfrfz4"
},
"followers": {
"href": null,
"total": 8
},
"href": "https://api.spotify.com/v1/users/31uszk7zb6zdzrzst4q3rvkfrfz4",
"id": "31uszk7zb6zdzrzst4q3rvkfrfz4",
"images": [
{
"height": 300,
"url": "https://i.scdn.co/image/ab6775700000ee85b4a21a7f0c35258b3fa4b79d",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67757000003b82b4a21a7f0c35258b3fa4b79d",
"width": 64
}
],
"product": "free",
"type": "user",
"uri": "spotify:user:31uszk7zb6zdzrzst4q3rvkfrfz4"
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Some files were not shown because too many files have changed in this diff Show more