Compare commits

..

No commits in common. "db-redesign" and "main" have entirely different histories.

388 changed files with 287 additions and 7442114 deletions

View file

@ -1,18 +1,9 @@
# Copy to `.env`, fill in, then `source .env` before running. # Copy to `.env` and fill in, then `source .env` before running.
# Get these from https://developer.spotify.com/dashboard (your app's settings).
# --- the three things you point at your own data --------------------------- # Register `http://127.0.0.1:8888` as a Redirect URI in that app.
# 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 -------------------------------------------------------------- # Optional overrides:
# export MB_CONTACT=you@example.com # MusicBrainz User-Agent contact # export MUSIC_LIBRARY_PATH=~/Music
# export DATABASE_URL=postgresql+psycopg://u:p@localhost/mi # use Postgres instead of SQLite # export WORK_DIR=./work_dir/new

2
.gitignore vendored
View file

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

63
Env.py
View file

@ -1,62 +1,13 @@
"""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"))
def _load_dotenv(): # Spotify app credentials. NEVER hardcode these here — set them in the
"""Load a project-root .env into os.environ if present, so `python main.py` # environment (see .env.example) so they don't end up in version control.
works without remembering to `source .env`. Already-set environment # export SPOTIFY_CLIENT_ID=...
variables win (setdefault), so an explicit `source .env`/export still # export SPOTIFY_CLIENT_SECRET=...
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,96 +1,208 @@
"""Streamlit GUI: what's in your Spotify / iTunes libraries that you do NOT import time
have locally, decided by real ID matching (recording MBID / ISRC) with a
normalized-text fallback.
Reads only the database (run `python main.py ingest` and `enrich` first).
"""
import pandas as pd
import streamlit as st import streamlit as st
from st_aggrid import AgGrid, GridOptionsBuilder from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode
import pandas as pd
from src.db.database import init_db import Flow
from src.match.matcher import classify_all, summary
st.set_page_config(layout="wide", page_title="MusicIndexer — Missing tracks") 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.cache_data(ttl=30) class LibraryView:
def load_data(): def __init__(self):
init_db() self.lib : Library = None
missing, present = classify_all() self.libs : list[Library] = None
return missing, present, summary() self.view = None
self.lyrics_only = False
self.query = None
def add_libraries(self, libs : list[Library]):
self.libs = libs
def grid(df, key): def render(self):
gb = GridOptionsBuilder.from_dataframe(df) st.set_page_config(layout="wide")
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:
def main(): lib = st.selectbox(
st.title("🎧 Tracks you're missing locally") "Library",
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)}"
) )
local = summ.get("local", {}) for iter in self.libs:
st.caption( if iter.name == lib:
f"Local library: {local.get('total', 0)} tracks, " self.lib = iter
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.divider() st.write(f"Showing library '{self.lib.name}'")
# --- controls --- self.view = st.radio(
view = st.sidebar.radio("View", ["Missing", "Present", "All"]) "View",
sources = st.sidebar.multiselect("Sources", ["spotify", "itunes"], ["Tree"]
default=["spotify", "itunes"]) )
if view == "Missing": if self.view == "Tree":
rows = [dict(r, status="missing") for r in missing] self.render_artists_albums_tracks()
elif view == "Present": # elif self.view == "Tracks":
rows = [dict(r, status="present") for r in present] # self.render_tracks()
else: # elif self.view == "Playlists":
rows = ([dict(r, status="missing") for r in missing] # self.render_playlists()
+ [dict(r, status="present") for r in present])
rows = [r for r in rows if r["source"] in sources] def track_visible(
self,
track: Track,
artist: Artist | None,
album: Album | None,
):
if self.lyrics_only and not track.has_lyrics:
return False
if not rows: if self.query and self.query.lower() not in track.title.lower():
st.info("Nothing to show — run `python main.py ingest` (and `enrich`).") return False
return
df = pd.DataFrame(rows)[ if artist and all(a.id != artist.id for a in track.artists):
["status", "source", "origin", "artist", "title", "album", "matched_by", return False
"isrc", "mbid", "play_count", "spotify_id"]
] if album and (not track.album or track.album.id != album.id):
st.write(f"**{len(df)}** tracks") return False
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,
)
main() 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()

155
README.md
View file

@ -5,38 +5,19 @@ 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.
It is **database-backed and incremental**: data lives in a DB (SQLite by ## Features
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.
## Pipeline - **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
ingest -> enrich (MBIDs) -> match -> GUI (follows `next`, retries dropped connections, honours `429` rate limits) with
``` progress bars.
- **Local backend** — index a folder of audio files via `mutagen` tags.
| Step | Command | Incremental rule | - **iTunes backend** — parse an iTunes Library XML export.
|------|---------|------------------| - **Matching** — map local tracks to Spotify tracks (fuzzy matcher; ISRC/MBID
| **ingest** | `python main.py ingest` | upsert tracks by `(source, source_id)`; never drops enrichment | matching planned).
| **enrich** | `python main.py enrich` | query MusicBrainz only for tracks with no MBID yet; every lookup (incl. "not found") cached; rate-limited & resumable | - **Stats & lyrics** — generate listening stats and fetch lyrics.
| **match** | `python main.py match` | classify each Spotify/iTunes track: present locally (ISRC > MBID > text) or missing | - **GUI** — browse libraries as artist → album → track tables.
| **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
@ -48,94 +29,72 @@ source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### Configure (`.env`) ### 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 ```bash
cp .env.example .env # then edit — it's auto-loaded (no `source` needed) cp .env.example .env
# edit .env with your client id/secret
source .env
``` ```
The project root `.env` is read automatically on startup; any variable already `Env.py` reads `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` (plus optional
exported in your shell still takes precedence. `MUSIC_LIBRARY_PATH` and `WORK_DIR`) from the environment.
Three things you point at your own data: ## Usage
| Variable | What | Edit `main.py` to uncomment the steps you want, then run:
|----------|------|
| `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 fetch # do all the work, incrementally, and report python main.py
python main.py display # open the GUI to see what you're missing
``` ```
**`fetch`** pulls Spotify, scans the local library, parses the iTunes export, The flow steps (see `Flow.py`) include:
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:
- **Spotify** — playlists are re-fetched only when their `snapshot_id` changes; | Step | What it does |
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` | `fetch_spotify()` | OAuth login + download all Spotify data into `work_dir/spotify/` |
forces a complete resync). | `fetch_local()` | index local audio files into `work_dir/local/` |
- Your library is **liked songs + playlist tracks**. The *top-tracks* endpoint | `parse_spotify_library()` / `parse_itunes_library()` / `parse_local_library()` | build normalized libraries |
is **not** pulled by default (those are just listening stats, not saved | `load_libraries()` / `save_libraries()` | (de)serialize libraries to `work_dir` |
music, and would inflate "missing"). Add `--include-top` to pull them too — | `map_local_to_spotify()` | match local tracks against the Spotify library |
they're fetched as full Spotify objects, so they carry an ISRC for real ID | `fetch_and_save_lyrics()` | fetch lyrics for tracks |
matching like everything else. | `log_stats()` | generate stats |
- 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.
Useful flags/helpers: 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 ```bash
python main.py fetch --enrich-limit 500 # cap MBID lookups this run ./gui.sh # runs: .venv/bin/streamlit run Gui.py
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 CLI: fetch / display / enrich / match main.py entry point (CLI flow)
Gui.py / gui.sh Streamlit GUI (missing-tracks viewer) Gui.py / gui.sh Streamlit GUI
Env.py config: LOCAL_LIBRARY_PATH / ITUNES_XML_PATH / DATA_DIR Flow.py orchestrates fetch / parse / map / stats
Env.py config (reads credentials from the environment)
src/ src/
workflows.py run_fetch (the whole pipeline) + run_display Library.py data model (Library / Artist / Album / Track)
db/ SQLAlchemy models + engine/session LibrarySerializer.py load/save libraries
ingest/ backends/
spotify_raw.py live fetch + ingest raw Spotify JSON (ISRC) spotify/ Web API client, OAuth, parser
local_scan.py incremental file scan (reads ISRC/MBID tags) local/ local-file indexer, parser, playlist generator
itunes_xml.py parse the iTunes Library.xml (plist) itunes/ iTunes XML parser
tags.py read ISRC / recording MBID / duration from a file navidrome/ Navidrome backend
enrich/ MusicBrainz MBID resolver (cached, resumable) mappers/ local↔Spotify matching (fuzzy / search)
match/ normalization + cross-source matcher lyrics/ lyrics fetching
backends/ lower-level Spotify Web API client / OAuth work_dir/ fetched data and serialized libraries (gitignored)
$DATA_DIR/ database + cached Spotify responses (gitignored)
``` ```
## Notes ## Notes
- The DB is the source of truth; everything is incremental and re-runnable. - Data is cached as JSON under `work_dir/` (gitignored); re-running reuses it.
- Local matching is real-ID where files are tagged (ISRC/MusicBrainz, e.g. via - The deprecated Spotify endpoints (audio-features, recommendations, etc.) are
Picard); untagged files fall back to MusicBrainz text lookup, then text. not used — only currently-supported endpoints.
- Only currently-supported Spotify endpoints are used (no deprecated
audio-features/recommendations/etc.).

113
main.py
View file

@ -1,95 +1,38 @@
"""MusicIndexer CLI. Two main workflows plus granular helpers. import Flow
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 _setup_logging(): def test1():
logging.basicConfig(level=logging.INFO, format="%(message)s") flow = Flow.Flow()
# flow.fetch_local()
flow.parse_local_library()
flow.load_libraries()
def cmd_fetch(args): # flow.map_local_to_spotify()
_setup_logging() # flow.log_stats()
from src.workflows import run_fetch
run_fetch(enrich_limit=args.enrich_limit, enrich=not args.no_enrich, # flow.fetch_and_save_lyrics()
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():
args = build_parser().parse_args() flow = Flow.Flow()
try:
{ flow.fetch_spotify()
"fetch": cmd_fetch, # flow.fetch_local()
"display": cmd_display,
"enrich": cmd_enrich, flow.parse_spotify_library()
"match": cmd_match, #flow.parse_itunes_library()
}[args.cmd](args) #flow.parse_local_library()
except KeyboardInterrupt:
# Ctrl+C: stop cleanly, no traceback. Work already committed is kept #flow.load_libraries()
# (each step writes incrementally), so just re-run to continue.
print("\ninterrupted — partial progress saved; re-run to continue.", #flow.map_local_to_spotify()
file=sys.stderr) #flow.save_libraries()
sys.exit(130)
# flow.log_stats()
# print("asd")
if __name__ == "__main__": # test1()
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.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

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.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,26 +0,0 @@
{
"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

@ -1,33 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

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