Compare commits
1 commit
main
...
mbid-finde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd6857287 |
17 changed files with 246 additions and 234 deletions
|
|
@ -1,9 +0,0 @@
|
||||||
# Copy to `.env` and fill in, then `source .env` before running.
|
|
||||||
# Get these from https://developer.spotify.com/dashboard (your app's settings).
|
|
||||||
# Register `http://127.0.0.1:8888` as a Redirect URI in that app.
|
|
||||||
export SPOTIFY_CLIENT_ID=your_client_id_here
|
|
||||||
export SPOTIFY_CLIENT_SECRET=your_client_secret_here
|
|
||||||
|
|
||||||
# Optional overrides:
|
|
||||||
# export MUSIC_LIBRARY_PATH=~/Music
|
|
||||||
# export WORK_DIR=./work_dir/new
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,8 +2,6 @@ prefetched*
|
||||||
__pycache__
|
__pycache__
|
||||||
.idea
|
.idea
|
||||||
secret
|
secret
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
work_dir
|
work_dir
|
||||||
old
|
old
|
||||||
tmp
|
tmp
|
||||||
14
Env.py
14
Env.py
|
|
@ -1,13 +1,7 @@
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Local config. Paths can be overridden via environment variables.
|
local_library_path = Path("/home/auser/Music/")
|
||||||
local_library_path = Path(os.environ.get("MUSIC_LIBRARY_PATH", "~/Music")).expanduser()
|
work_dir = Path("./work_dir/new")
|
||||||
work_dir = Path(os.environ.get("WORK_DIR", "./work_dir/new"))
|
|
||||||
|
|
||||||
# Spotify app credentials. NEVER hardcode these here — set them in the
|
client_id = '***REDACTED***'
|
||||||
# environment (see .env.example) so they don't end up in version control.
|
client_secret = "***REDACTED***"
|
||||||
# export SPOTIFY_CLIENT_ID=...
|
|
||||||
# export SPOTIFY_CLIENT_SECRET=...
|
|
||||||
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
|
|
||||||
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
|
||||||
|
|
|
||||||
13
Flow.py
13
Flow.py
|
|
@ -19,6 +19,8 @@ from src.backends.itunes.ParseItunesXml import ParseItunesXml
|
||||||
from src.mappers.RemoteLibraryResolver import resolve_remote_tracks
|
from src.mappers.RemoteLibraryResolver import resolve_remote_tracks
|
||||||
from src.StatGenerator import log_stats
|
from src.StatGenerator import log_stats
|
||||||
|
|
||||||
|
import src.backends.mb.mbidFinder as mb
|
||||||
|
import src.mappers.DummyLibGenerator as libgen
|
||||||
|
|
||||||
class Flow:
|
class Flow:
|
||||||
spotify_library : Library = None
|
spotify_library : Library = None
|
||||||
|
|
@ -26,7 +28,7 @@ class Flow:
|
||||||
itunes_library : Library = None
|
itunes_library : Library = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fetch_spotify():
|
def fetch_spotify(self):
|
||||||
fetch_all(client_id, client_secret, work_dir)
|
fetch_all(client_id, client_secret, work_dir)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -63,6 +65,10 @@ class Flow:
|
||||||
library_parsed = parser.parse(work_dir / "local" / "local_library.json")
|
library_parsed = parser.parse(work_dir / "local" / "local_library.json")
|
||||||
LibrarySaver(library_parsed).save(work_dir / "local_library.json")
|
LibrarySaver(library_parsed).save(work_dir / "local_library.json")
|
||||||
|
|
||||||
|
def find_mbids(self):
|
||||||
|
mb.find_mbids(self.spotify_library)
|
||||||
|
mb.find_mbids(self.itunes_library)
|
||||||
|
self.save_libraries()
|
||||||
|
|
||||||
def fetch_and_save_lyrics(self):
|
def fetch_and_save_lyrics(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -111,6 +117,11 @@ class Flow:
|
||||||
self.itunes_library = LibraryLoader().load(work_dir / "itunes_library.json")
|
self.itunes_library = LibraryLoader().load(work_dir / "itunes_library.json")
|
||||||
self.local_library = LibraryLoader().load(work_dir / "local_library.json")
|
self.local_library = LibraryLoader().load(work_dir / "local_library.json")
|
||||||
|
|
||||||
|
def gen_fake_libs(self):
|
||||||
|
libgen.generate_dummy_library(self.spotify_library, work_dir / "spotify" / "dummylib")
|
||||||
|
libgen.generate_dummy_library(self.itunes_library, work_dir / "itunes" / "dummylib")
|
||||||
|
|
||||||
|
|
||||||
def save_libraries(self):
|
def save_libraries(self):
|
||||||
LibrarySaver(self.spotify_library).save(work_dir / "spotify_library.json")
|
LibrarySaver(self.spotify_library).save(work_dir / "spotify_library.json")
|
||||||
LibrarySaver(self.local_library).save(work_dir / "local_library.json")
|
LibrarySaver(self.local_library).save(work_dir / "local_library.json")
|
||||||
|
|
|
||||||
6
Gui.py
6
Gui.py
|
|
@ -16,9 +16,9 @@ def entity_to_row(entity: Entity):
|
||||||
"PLay Count": entity.play_count,
|
"PLay Count": entity.play_count,
|
||||||
"Resolved": int(entity.resolved_percentage * 100),
|
"Resolved": int(entity.resolved_percentage * 100),
|
||||||
"AutoScore": entity.auto_score,
|
"AutoScore": entity.auto_score,
|
||||||
"Added": entity.date_added.strftime("%Y-%m-%d") if entity.date_added else None,
|
# "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,
|
# "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,
|
# "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,
|
"LocalId": str(entity.local_id) if entity.local_id else None,
|
||||||
"Path": str(entity.local_path) if entity.local_path else None,
|
"Path": str(entity.local_path) if entity.local_path else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
README.md
103
README.md
|
|
@ -1,100 +1,3 @@
|
||||||
# MusicIndexer
|
# spotifyFetcher
|
||||||
|
loads all data through web api<br>
|
||||||
Pull your Spotify library and listening stats via the Spotify Web API, index a
|
client secret - ***REDACTED***
|
||||||
local music library (audio tags + iTunes XML), and match the two so you can see
|
|
||||||
which tracks you own, what's missing, and your top artists/tracks/playlists — in
|
|
||||||
a Streamlit GUI.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Spotify backend** — OAuth (Authorization Code) login, then fetch the user
|
|
||||||
profile, liked tracks, all playlists and their tracks, long-term top tracks
|
|
||||||
and top artists, and playlist/profile cover art. Resilient paginated fetching
|
|
||||||
(follows `next`, retries dropped connections, honours `429` rate limits) with
|
|
||||||
progress bars.
|
|
||||||
- **Local backend** — index a folder of audio files via `mutagen` tags.
|
|
||||||
- **iTunes backend** — parse an iTunes Library XML export.
|
|
||||||
- **Matching** — map local tracks to Spotify tracks (fuzzy matcher; ISRC/MBID
|
|
||||||
matching planned).
|
|
||||||
- **Stats & lyrics** — generate listening stats and fetch lyrics.
|
|
||||||
- **GUI** — browse libraries as artist → album → track tables.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Requires Python 3.13+.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Credentials
|
|
||||||
|
|
||||||
Create an app at https://developer.spotify.com/dashboard and add
|
|
||||||
`http://127.0.0.1:8888` as a **Redirect URI**. Credentials are read from the
|
|
||||||
environment — never commit them.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# edit .env with your client id/secret
|
|
||||||
source .env
|
|
||||||
```
|
|
||||||
|
|
||||||
`Env.py` reads `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` (plus optional
|
|
||||||
`MUSIC_LIBRARY_PATH` and `WORK_DIR`) from the environment.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Edit `main.py` to uncomment the steps you want, then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The flow steps (see `Flow.py`) include:
|
|
||||||
|
|
||||||
| Step | What it does |
|
|
||||||
|------|--------------|
|
|
||||||
| `fetch_spotify()` | OAuth login + download all Spotify data into `work_dir/spotify/` |
|
|
||||||
| `fetch_local()` | index local audio files into `work_dir/local/` |
|
|
||||||
| `parse_spotify_library()` / `parse_itunes_library()` / `parse_local_library()` | build normalized libraries |
|
|
||||||
| `load_libraries()` / `save_libraries()` | (de)serialize libraries to `work_dir` |
|
|
||||||
| `map_local_to_spotify()` | match local tracks against the Spotify library |
|
|
||||||
| `fetch_and_save_lyrics()` | fetch lyrics for tracks |
|
|
||||||
| `log_stats()` | generate stats |
|
|
||||||
|
|
||||||
On first `fetch_spotify()` a browser opens for the Spotify login; a local server
|
|
||||||
on `127.0.0.1:8888` catches the redirect and exchanges the code for a token.
|
|
||||||
|
|
||||||
### GUI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gui.sh # runs: .venv/bin/streamlit run Gui.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
main.py entry point (CLI flow)
|
|
||||||
Gui.py / gui.sh Streamlit GUI
|
|
||||||
Flow.py orchestrates fetch / parse / map / stats
|
|
||||||
Env.py config (reads credentials from the environment)
|
|
||||||
src/
|
|
||||||
Library.py data model (Library / Artist / Album / Track)
|
|
||||||
LibrarySerializer.py load/save libraries
|
|
||||||
backends/
|
|
||||||
spotify/ Web API client, OAuth, parser
|
|
||||||
local/ local-file indexer, parser, playlist generator
|
|
||||||
itunes/ iTunes XML parser
|
|
||||||
navidrome/ Navidrome backend
|
|
||||||
mappers/ local↔Spotify matching (fuzzy / search)
|
|
||||||
lyrics/ lyrics fetching
|
|
||||||
work_dir/ fetched data and serialized libraries (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Data is cached as JSON under `work_dir/` (gitignored); re-running reuses it.
|
|
||||||
- The deprecated Spotify endpoints (audio-features, recommendations, etc.) are
|
|
||||||
not used — only currently-supported endpoints.
|
|
||||||
|
|
|
||||||
6
gui.sh
6
gui.sh
|
|
@ -1,5 +1 @@
|
||||||
#!/usr/bin/env bash
|
streamlit run Gui.py
|
||||||
# Run from the script's directory using the project virtualenv,
|
|
||||||
# so it works regardless of whether the venv is activated / on PATH.
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
exec .venv/bin/streamlit run Gui.py "$@"
|
|
||||||
|
|
|
||||||
15
main.py
15
main.py
|
|
@ -17,17 +17,20 @@ def test1():
|
||||||
def main():
|
def main():
|
||||||
flow = Flow.Flow()
|
flow = Flow.Flow()
|
||||||
|
|
||||||
flow.fetch_spotify()
|
# flow.fetch_spotify()
|
||||||
# flow.fetch_local()
|
# flow.fetch_local()
|
||||||
|
|
||||||
flow.parse_spotify_library()
|
flow.parse_spotify_library()
|
||||||
#flow.parse_itunes_library()
|
flow.parse_itunes_library()
|
||||||
#flow.parse_local_library()
|
flow.parse_local_library()
|
||||||
|
|
||||||
#flow.load_libraries()
|
flow.load_libraries()
|
||||||
|
|
||||||
#flow.map_local_to_spotify()
|
# flow.gen_fake_libs()
|
||||||
#flow.save_libraries()
|
# flow.find_mbids()
|
||||||
|
|
||||||
|
flow.map_local_to_spotify()
|
||||||
|
flow.save_libraries()
|
||||||
|
|
||||||
# flow.log_stats()
|
# flow.log_stats()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
streamlit==1.58.0
|
|
||||||
streamlit-aggrid==1.2.1.post2
|
|
||||||
pandas==3.0.3
|
|
||||||
Pillow==12.2.0
|
|
||||||
mutagen==1.47.0
|
|
||||||
rapidfuzz==3.14.5
|
|
||||||
requests==2.34.2
|
|
||||||
tqdm==4.68.2
|
|
||||||
xmltodict==1.0.4
|
|
||||||
|
|
@ -187,6 +187,8 @@ class ParseItunesXml:
|
||||||
for track in self.library.tracks:
|
for track in self.library.tracks:
|
||||||
track.auto_score = track.play_count
|
track.auto_score = track.play_count
|
||||||
|
|
||||||
|
self.library.auto_score()
|
||||||
|
|
||||||
return self.library
|
return self.library
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ def get_artist(tags, fallback=None) -> list :
|
||||||
|
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
def get_mb_artist(tags, fallback=None) -> list :
|
||||||
|
artist_fields = ["TXXX:MusicBrainz Artist Id", "----:com.apple.iTunes:MusicBrainz Artist Id", "MUSICBRAINZ_ARTISTID"]
|
||||||
|
for field in artist_fields:
|
||||||
|
if field in tags and tags[field]:
|
||||||
|
values = tags.get(field)
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return values.text
|
||||||
|
return values
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
def safe_tag_get(tags, key, index=0):
|
def safe_tag_get(tags, key, index=0):
|
||||||
try:
|
try:
|
||||||
value = tags.get(key, None)
|
value = tags.get(key, None)
|
||||||
|
|
@ -147,6 +158,7 @@ def update_local_library(root_dir, output_dir):
|
||||||
tags = audio.tags
|
tags = audio.tags
|
||||||
|
|
||||||
track['artists'] = get_artist(tags)
|
track['artists'] = get_artist(tags)
|
||||||
|
# track['artists_mbid'] = get_mb_artist(tags)
|
||||||
track['name'] = get_title(tags)
|
track['name'] = get_title(tags)
|
||||||
track['album'] = get_album(tags)
|
track['album'] = get_album(tags)
|
||||||
|
|
||||||
|
|
|
||||||
54
src/backends/mb/mbidFinder.py
Normal file
54
src/backends/mb/mbidFinder.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from src.Library import *
|
||||||
|
|
||||||
|
import musicbrainzngs as mb
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
mb.set_useragent(
|
||||||
|
"resolve_spotify",
|
||||||
|
"1.0",
|
||||||
|
"ilusha.main@gmail.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_mbid_by_tags(track: Track) -> str | None:
|
||||||
|
if not track.title or not track.artists:
|
||||||
|
return None
|
||||||
|
|
||||||
|
artist_names = [a.title for a in track.artists if a.title]
|
||||||
|
if not artist_names:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query_parts = [
|
||||||
|
f'recording:"{track.title}"',
|
||||||
|
f'artist:"{artist_names[0]}"'
|
||||||
|
]
|
||||||
|
|
||||||
|
query = " AND ".join(query_parts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = mb.search_recordings(query=query, limit=5)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
recordings = result.get("recording-list", [])
|
||||||
|
if not recordings:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pick best-scored result
|
||||||
|
best = max(
|
||||||
|
recordings,
|
||||||
|
key=lambda r: int(r.get("score", 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
return best.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def find_mbids(library: Library):
|
||||||
|
tracks = library.tracks
|
||||||
|
|
||||||
|
for track in tqdm(tracks, desc="Resolving MBIDs of " + library.name + " library", unit="track"):
|
||||||
|
if track.mbid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
track.mbid = find_mbid_by_tags(track)
|
||||||
|
if not track.mbid:
|
||||||
|
print(f"not found for track {track.title} {track.artists[0].title}")
|
||||||
|
|
@ -31,9 +31,9 @@ class Parser:
|
||||||
|
|
||||||
self.library.update_cache()
|
self.library.update_cache()
|
||||||
self.library.name = "Spotify"
|
self.library.name = "Spotify"
|
||||||
self.library.auto_score()
|
|
||||||
|
|
||||||
self.score_tracks()
|
self.score_tracks()
|
||||||
|
self.library.auto_score()
|
||||||
|
|
||||||
return self.library
|
return self.library
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import os.path
|
||||||
client_id = None
|
client_id = None
|
||||||
client_secret = None
|
client_secret = None
|
||||||
token = None
|
token = None
|
||||||
refresh_token = None
|
|
||||||
|
|
||||||
|
|
||||||
def retrieve_web_api_token(authorization_code):
|
def retrieve_web_api_token(authorization_code):
|
||||||
|
|
@ -45,10 +44,7 @@ def retrieve_web_api_token(authorization_code):
|
||||||
print(token_response.json())
|
print(token_response.json())
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
global refresh_token
|
token = token_response.json()['access_token']
|
||||||
response_json = token_response.json()
|
|
||||||
token = response_json['access_token']
|
|
||||||
refresh_token = response_json.get('refresh_token')
|
|
||||||
|
|
||||||
|
|
||||||
class RequestHandler(BaseHTTPRequestHandler):
|
class RequestHandler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -77,7 +73,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
|
||||||
def run_redirect_server():
|
def run_redirect_server():
|
||||||
httpd = HTTPServer(('127.0.0.1', 8888), RequestHandler)
|
httpd = HTTPServer(('localhost', 8888), RequestHandler)
|
||||||
print('Starting HTTP redirection server')
|
print('Starting HTTP redirection server')
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
@ -92,9 +88,8 @@ def open_authenticate_page():
|
||||||
"scope": "user-top-read playlist-read-collaborative user-read-email user-library-read user-read-private playlist-read-private",
|
"scope": "user-top-read playlist-read-collaborative user-read-email user-library-read user-read-private playlist-read-private",
|
||||||
}
|
}
|
||||||
|
|
||||||
auth_url = f"{authorize_url}?{urllib.parse.urlencode(params)}"
|
auth_url = f"{authorize_url}?client_id={params['client_id']}&response_type={params['response_type']}&redirect_uri={params['redirect_uri']}&scope={params['scope']}"
|
||||||
webbrowser.open(auth_url)
|
webbrowser.open(auth_url)
|
||||||
print(f"If the browser did not open, visit:\n{auth_url}")
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate(id, secret):
|
def authenticate(id, secret):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import requests
|
import requests
|
||||||
import urllib.parse
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
from src.backends.spotify.SpotifyAuthenticator import authenticate
|
from src.backends.spotify.SpotifyAuthenticator import authenticate
|
||||||
|
|
||||||
|
|
@ -12,94 +8,96 @@ from src.helpers import *
|
||||||
|
|
||||||
FETCH_STEP = 50
|
FETCH_STEP = 50
|
||||||
token = ''
|
token = ''
|
||||||
REQUEST_TIMEOUT = 30
|
|
||||||
|
|
||||||
|
|
||||||
def _build_session():
|
|
||||||
"""Session that retries transient failures (dropped connections, 5xx) and
|
|
||||||
honours Spotify's 429 Retry-After, so a single reset mid-fetch doesn't
|
|
||||||
abort the whole run."""
|
|
||||||
session = requests.Session()
|
|
||||||
retry = Retry(
|
|
||||||
total=6,
|
|
||||||
connect=6,
|
|
||||||
read=6,
|
|
||||||
backoff_factor=1.5, # sleeps ~0, 1.5, 3, 6, 12, 24s between attempts
|
|
||||||
status_forcelist=(429, 500, 502, 503, 504),
|
|
||||||
allowed_methods=frozenset({"GET", "POST"}),
|
|
||||||
respect_retry_after_header=True,
|
|
||||||
raise_on_status=False,
|
|
||||||
)
|
|
||||||
adapter = HTTPAdapter(max_retries=retry)
|
|
||||||
session.mount("https://", adapter)
|
|
||||||
session.mount("http://", adapter)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
_session = _build_session()
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(endpoint, method='GET', body=None):
|
def fetch(endpoint, method='GET', body=None):
|
||||||
url = endpoint if endpoint.startswith('http') else f'https://api.spotify.com/{endpoint}'
|
url = f'https://api.spotify.com/{endpoint}'
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {token}',
|
'Authorization': f'Bearer {token}',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = _session.request(method, url, headers=headers, json=body, timeout=REQUEST_TIMEOUT)
|
response = requests.request(method, url, headers=headers, json=body)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def fetch_paginated(endpoint, desc="Fetching", leave=True):
|
|
||||||
"""Collect every item of a paging object by following its `next` field,
|
|
||||||
which is Spotify's recommended way to page (more robust than tracking
|
|
||||||
offset/total manually). Shows a tqdm progress bar driven by the paging
|
|
||||||
object's `total`."""
|
|
||||||
items = []
|
|
||||||
page = fetch(endpoint)
|
|
||||||
with tqdm(total=page.get('total'), desc=desc, unit="item", leave=leave) as pbar:
|
|
||||||
while True:
|
|
||||||
batch = page.get('items', [])
|
|
||||||
items += batch
|
|
||||||
pbar.update(len(batch))
|
|
||||||
next_url = page.get('next')
|
|
||||||
if not next_url:
|
|
||||||
break
|
|
||||||
page = fetch(next_url)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def find_song(song_pattern):
|
def find_song(song_pattern):
|
||||||
query = urllib.parse.quote(song_pattern)
|
return fetch(f"v1/search/?type=track&track=1&q={song_pattern}")['tracks']['items']
|
||||||
return fetch(f"v1/search?type=track&limit=10&q={query}")['tracks']['items']
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_playlists(user_id):
|
def fetch_playlists(user_id):
|
||||||
return fetch_paginated(f"v1/me/playlists?limit={FETCH_STEP}", desc="Fetching playlists")
|
print("Fetching playlists")
|
||||||
|
endpoint = f"v1/me/playlists"
|
||||||
|
pl_total = fetch(f'{endpoint}?limit=1')['total']
|
||||||
|
pl_count = 0
|
||||||
|
playlists = []
|
||||||
|
|
||||||
|
while pl_count < pl_total:
|
||||||
|
res = fetch(f'{endpoint}?limit={FETCH_STEP}&offset={pl_count}')
|
||||||
|
playlists += res['items']
|
||||||
|
pl_count += FETCH_STEP
|
||||||
|
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
|
||||||
def fetch_playlist_items(playlist_id):
|
def fetch_playlist_items(playlist_id):
|
||||||
return fetch_paginated(
|
endpoint = f"v1/playlists/{playlist_id}/tracks"
|
||||||
f"v1/playlists/{playlist_id}/tracks?limit={FETCH_STEP}",
|
items = fetch(f'{endpoint}?fields=total')['total']
|
||||||
desc="Fetching playlist tracks",
|
loaded = 0
|
||||||
leave=False,
|
tracks = []
|
||||||
)
|
|
||||||
|
while loaded < items:
|
||||||
|
res = fetch(f'{endpoint}?limit={FETCH_STEP}&offset={loaded}')
|
||||||
|
|
||||||
|
tracks += res['items']
|
||||||
|
loaded += FETCH_STEP
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
def fetch_tracks(user_id):
|
def fetch_tracks(user_id):
|
||||||
tracks = fetch_paginated(f"v1/me/tracks?limit={FETCH_STEP}&market=ES", desc="Fetching liked tracks")
|
print("Fetching tracks")
|
||||||
|
endpoint = f"v1/me/tracks"
|
||||||
|
total = fetch(f"{endpoint}?limit=1&market=ES")['total']
|
||||||
|
current = 0
|
||||||
|
tracks = []
|
||||||
|
|
||||||
|
while total > current:
|
||||||
|
res = fetch(f"{endpoint}?limit={FETCH_STEP}&offset={current}&market=ES")
|
||||||
|
tracks += res['items']
|
||||||
|
current += FETCH_STEP
|
||||||
|
|
||||||
save_data(tracks, "tracks")
|
save_data(tracks, "tracks")
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
def fetch_tracks_top():
|
def fetch_tracks_top():
|
||||||
tracks = fetch_paginated(f"v1/me/top/tracks?limit={FETCH_STEP}&time_range=long_term", desc="Fetching top tracks")
|
print("Fetching top tracks")
|
||||||
|
total = fetch(f'v1/me/top/tracks?fields=total')['total']
|
||||||
|
current = 0
|
||||||
|
tracks = []
|
||||||
|
|
||||||
|
while current < total:
|
||||||
|
res = fetch(f'v1/me/top/tracks?limit={FETCH_STEP}&offset={current}&time_range=long_term')
|
||||||
|
tracks += res['items']
|
||||||
|
current += FETCH_STEP
|
||||||
|
|
||||||
save_data(tracks, "top_tracks")
|
save_data(tracks, "top_tracks")
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
def fetch_artists_top():
|
def fetch_artists_top():
|
||||||
artists = fetch_paginated(f"v1/me/top/artists?limit={FETCH_STEP}&time_range=long_term", desc="Fetching top artists")
|
print("Fetching top artists")
|
||||||
|
|
||||||
|
total = fetch(f'v1/me/top/artists?fields=total')['total']
|
||||||
|
current = 0
|
||||||
|
artists = []
|
||||||
|
|
||||||
|
while current < total:
|
||||||
|
res = fetch(f'v1/me/top/artists?limit={FETCH_STEP}&offset={current}&time_range=long_term')
|
||||||
|
artists += res['items']
|
||||||
|
current += FETCH_STEP
|
||||||
|
|
||||||
save_data(artists, "top_artists")
|
save_data(artists, "top_artists")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
|
|
@ -142,7 +140,7 @@ def save_image_from_url(url, name, image_dir="playlist_covers"):
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise RuntimeError("Cannot fetch the playlist cover")
|
raise "Cannot fetch the playlist cover"
|
||||||
|
|
||||||
image = Image.open(BytesIO(response.content))
|
image = Image.open(BytesIO(response.content))
|
||||||
|
|
||||||
|
|
|
||||||
46
src/mappers/DummyLibGenerator.py
Normal file
46
src/mappers/DummyLibGenerator.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
|
||||||
|
from src.Library import *
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
return (
|
||||||
|
s.replace("/", "_")
|
||||||
|
.replace("\\", "_")
|
||||||
|
.replace(":", "_")
|
||||||
|
.replace("*", "_")
|
||||||
|
.replace("?", "_")
|
||||||
|
.replace('"', "_")
|
||||||
|
.replace("<", "_")
|
||||||
|
.replace(">", "_")
|
||||||
|
.replace("|", "_")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_dummy_library(library: Library, root_path: Path):
|
||||||
|
root_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for artist in library.artists:
|
||||||
|
artist_path = root_path / safe_name(artist.title or f"artist_{artist.id}")
|
||||||
|
artist_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for album in artist.albums:
|
||||||
|
album_path = artist_path / safe_name(album.title or f"album_{album.id}")
|
||||||
|
album_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for track in album.tracks:
|
||||||
|
track_title = track.title or f"track_{track.id}"
|
||||||
|
track_file = album_path / f"{safe_name(track_title)}.mp3"
|
||||||
|
|
||||||
|
# duration_ms = max(track.duration_ms or 100, 100)
|
||||||
|
duration_ms = 10
|
||||||
|
silence = AudioSegment.silent(duration=duration_ms)
|
||||||
|
silence.export(track_file, format="mp3")
|
||||||
|
|
||||||
|
audio = EasyID3(track_file)
|
||||||
|
audio["title"] = track.title or ""
|
||||||
|
audio["artist"] = ", ".join([a.title or "" for a in track.artists])
|
||||||
|
audio["album"] = album.title or ""
|
||||||
|
audio["tracknumber"] = str(album.tracks.index(track) + 1)
|
||||||
|
audio.save()
|
||||||
|
|
@ -4,6 +4,8 @@ from tqdm import tqdm
|
||||||
|
|
||||||
from src.Library import *
|
from src.Library import *
|
||||||
from src.helpers import *
|
from src.helpers import *
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
|
|
@ -11,34 +13,49 @@ user_def_artist_map = {
|
||||||
"donda" : "kanye west",
|
"donda" : "kanye west",
|
||||||
}
|
}
|
||||||
|
|
||||||
def normalize(s):
|
user_def_album_map = {
|
||||||
return unicodedata.normalize("NFKC", s).replace("‐", "-")
|
"music" : "music sorry 4 da wait",
|
||||||
|
}
|
||||||
|
|
||||||
def get_score(first: str, second: str) -> float:
|
def get_score(first: str, second: str) -> float:
|
||||||
first_norm = normalize(first).lower()
|
return float(first == second) * 100
|
||||||
second_norm = normalize(second).lower()
|
|
||||||
return float(first_norm == second_norm) * 100
|
def clean_title(title: str) -> str:
|
||||||
return fuzz.token_set_ratio(first, second)
|
title = unicodedata.normalize("NFKC", title).replace("‐", "-")
|
||||||
|
title = unicodedata.normalize("NFKD", title)
|
||||||
|
title = re.sub(r"\([^)]*\)", "", title)
|
||||||
|
title = "".join(
|
||||||
|
c for c in title
|
||||||
|
if not unicodedata.category(c).startswith("P")
|
||||||
|
)
|
||||||
|
title = title.translate(str.maketrans("", "", string.punctuation))
|
||||||
|
title = title.lower()
|
||||||
|
return " ".join(title.split())
|
||||||
|
|
||||||
|
def find_mapping(first, second, mapping):
|
||||||
|
for key, value in mapping.items():
|
||||||
|
if (first == key and second == value) or (first == value and second == key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_album_score(first: str, second: str) -> float:
|
def get_album_score(first: str, second: str) -> float:
|
||||||
|
first = clean_title(first)
|
||||||
|
second = clean_title(second)
|
||||||
|
|
||||||
|
if find_mapping(first, second, user_def_album_map):
|
||||||
|
return 100
|
||||||
|
|
||||||
return get_score(first, second)
|
return get_score(first, second)
|
||||||
|
|
||||||
def get_track_score(first: str, second: str) -> float:
|
def get_track_score(first: str, second: str) -> float:
|
||||||
return get_score(first, second)
|
return get_score(clean_title(first), clean_title(second))
|
||||||
|
|
||||||
def get_artist_score(first: str, second: str) -> float:
|
def get_artist_score(first: str, second: str) -> float:
|
||||||
if not first or not second:
|
first = clean_title(first)
|
||||||
return 0.0
|
second = clean_title(second)
|
||||||
|
|
||||||
first_lower = first.lower()
|
if find_mapping(first, second, user_def_artist_map):
|
||||||
second_lower = second.lower()
|
return 100
|
||||||
|
|
||||||
for key, value in user_def_artist_map.items():
|
|
||||||
key_lower = key.lower()
|
|
||||||
value_lower = value.lower()
|
|
||||||
if (first_lower == key_lower and second_lower == value_lower) or \
|
|
||||||
(first_lower == value_lower and second_lower == key_lower):
|
|
||||||
return 100.0
|
|
||||||
|
|
||||||
return get_score(first, second)
|
return get_score(first, second)
|
||||||
|
|
||||||
|
|
@ -97,6 +114,7 @@ def generate_map(local_lib : Library, remote_lib : Library, output_dir : Path):
|
||||||
if remote_track.id not in artist_map or score > track_map[remote_track.id][1]:
|
if remote_track.id not in artist_map or score > track_map[remote_track.id][1]:
|
||||||
track_map[remote_track.id] = (local_track.id, score)
|
track_map[remote_track.id] = (local_track.id, score)
|
||||||
remote_track.local_id = local_track.id
|
remote_track.local_id = local_track.id
|
||||||
|
remote_track.local_path = local_track.local_path
|
||||||
|
|
||||||
process_bar.update(1)
|
process_bar.update(1)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue