Compare commits

..

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.
# 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.
# Copy to `.env`, fill in, then `source .env` before running.
# --- 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_SECRET=your_client_secret_here
# Optional overrides:
# export MUSIC_LIBRARY_PATH=~/Music
# export WORK_DIR=./work_dir/new
# --- optional --------------------------------------------------------------
# export MB_CONTACT=you@example.com # MusicBrainz User-Agent contact
# 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
.env
.venv
*.db
/data/
work_dir
old
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
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
# environment (see .env.example) so they don't end up in version control.
# export SPOTIFY_CLIENT_ID=...
# export SPOTIFY_CLIENT_SECRET=...
def _load_dotenv():
"""Load a project-root .env into os.environ if present, so `python main.py`
works without remembering to `source .env`. Already-set environment
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_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
from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode
Reads only the database (run `python main.py ingest` and `enrich` first).
"""
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 *
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) | {
}
st.set_page_config(layout="wide", page_title="MusicIndexer — Missing tracks")
class LibraryView:
def __init__(self):
self.lib : Library = None
self.libs : list[Library] = None
self.view = None
self.lyrics_only = False
self.query = None
@st.cache_data(ttl=30)
def load_data():
init_db()
missing, present = classify_all()
return missing, present, summary()
def add_libraries(self, libs : list[Library]):
self.libs = libs
def render(self):
st.set_page_config(layout="wide")
def grid(df, key):
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(
"Library",
options=[lib.name for lib in self.libs],
def main():
st.title("🎧 Tracks you're missing locally")
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:
if iter.name == lib:
self.lib = iter
local = summ.get("local", {})
st.caption(
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(
"View",
["Tree"]
)
# --- controls ---
view = st.sidebar.radio("View", ["Missing", "Present", "All"])
sources = st.sidebar.multiselect("Sources", ["spotify", "itunes"],
default=["spotify", "itunes"])
if self.view == "Tree":
self.render_artists_albums_tracks()
# elif self.view == "Tracks":
# self.render_tracks()
# elif self.view == "Playlists":
# self.render_playlists()
if view == "Missing":
rows = [dict(r, status="missing") for r in missing]
elif view == "Present":
rows = [dict(r, status="present") for r in present]
else:
rows = ([dict(r, status="missing") for r in missing]
+ [dict(r, status="present") for r in present])
def track_visible(
self,
track: Track,
artist: Artist | None,
album: Album | None,
):
if self.lyrics_only and not track.has_lyrics:
return False
rows = [r for r in rows if r["source"] in sources]
if self.query and self.query.lower() not in track.title.lower():
return False
if not rows:
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):
return False
if album and (not track.album or track.album.id != album.id):
return False
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,
)
df = pd.DataFrame(rows)[
["status", "source", "origin", "artist", "title", "album", "matched_by",
"isrc", "mbid", "play_count", "spotify_id"]
]
st.write(f"**{len(df)}** tracks")
grid(df, key=f"{view}-{'-'.join(sorted(sources))}")
def run():
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()
main()

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
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
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.
## Pipeline
```
ingest -> enrich (MBIDs) -> match -> GUI
```
| Step | Command | Incremental rule |
|------|---------|------------------|
| **ingest** | `python main.py ingest` | upsert tracks by `(source, source_id)`; never drops enrichment |
| **enrich** | `python main.py enrich` | query MusicBrainz only for tracks with no MBID yet; every lookup (incl. "not found") cached; rate-limited & resumable |
| **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
@ -29,72 +48,94 @@ 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.
### Configure (`.env`)
```bash
cp .env.example .env
# edit .env with your client id/secret
source .env
cp .env.example .env # then edit — it's auto-loaded (no `source` needed)
```
`Env.py` reads `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` (plus optional
`MUSIC_LIBRARY_PATH` and `WORK_DIR`) from the environment.
The project root `.env` is read automatically on startup; any variable already
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
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 |
|------|--------------|
| `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 |
- **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;
cover art downloads only when missing or its URL changed (`--spotify-full`
forces a complete resync).
- Your library is **liked songs + playlist tracks**. The *top-tracks* endpoint
is **not** pulled by default (those are just listening stats, not saved
music, and would inflate "missing"). Add `--include-top` to pull them too —
they're fetched as full Spotify objects, so they carry an ISRC for real ID
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
on `127.0.0.1:8888` catches the redirect and exchanges the code for a token.
### GUI
Useful flags/helpers:
```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
```
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)
main.py CLI: fetch / display / enrich / match
Gui.py / gui.sh Streamlit GUI (missing-tracks viewer)
Env.py config: LOCAL_LIBRARY_PATH / ITUNES_XML_PATH / DATA_DIR
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)
workflows.py run_fetch (the whole pipeline) + run_display
db/ SQLAlchemy models + engine/session
ingest/
spotify_raw.py live fetch + ingest raw Spotify JSON (ISRC)
local_scan.py incremental file scan (reads ISRC/MBID tags)
itunes_xml.py parse the iTunes Library.xml (plist)
tags.py read ISRC / recording MBID / duration from a file
enrich/ MusicBrainz MBID resolver (cached, resumable)
match/ normalization + cross-source matcher
backends/ lower-level Spotify Web API client / OAuth
$DATA_DIR/ database + cached Spotify responses (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.
- The DB is the source of truth; everything is incremental and re-runnable.
- Local matching is real-ID where files are tagged (ISRC/MusicBrainz, e.g. via
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():
flow = Flow.Flow()
def _setup_logging():
logging.basicConfig(level=logging.INFO, format="%(message)s")
# flow.fetch_local()
flow.parse_local_library()
flow.load_libraries()
# flow.map_local_to_spotify()
# flow.log_stats()
def cmd_fetch(args):
_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():
flow = Flow.Flow()
flow.fetch_spotify()
# flow.fetch_local()
flow.parse_spotify_library()
#flow.parse_itunes_library()
#flow.parse_local_library()
#flow.load_libraries()
#flow.map_local_to_spotify()
#flow.save_libraries()
# flow.log_stats()
# print("asd")
args = build_parser().parse_args()
try:
{
"fetch": cmd_fetch,
"display": cmd_display,
"enrich": cmd_enrich,
"match": cmd_match,
}[args.cmd](args)
except KeyboardInterrupt:
# Ctrl+C: stop cleanly, no traceback. Work already committed is kept
# (each step writes incrementally), so just re-run to continue.
print("\ninterrupted — partial progress saved; re-run to continue.",
file=sys.stderr)
sys.exit(130)
# test1()
main()
if __name__ == "__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