commit f2db1af0895523dfbbad2072631283123ef4df40 Author: fucksophie Date: Thu Jan 1 21:34:03 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6320908 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +SPOTIPY_CLIENT_ID=..? +SPOTIPY_CLIENT_SECRET=..? +SPOTIPY_REDIRECT_URI=http://127.0.0.1:8888/callback + +SUBSONIC_URL=https://music.example.com +SUBSONIC_USER=..? +SUBSONIC_PASS=..? +SUBSONIC_CLIENT=spotify-migrator diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bc318c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv +__pycache__ +.env +.cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1bce19 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# spotify2subsonic + +move your spotify playlists & liked songs to subsonic +42.5% written with devstral on my own computer +28.75% written by me manually +28.75% written by guessing spotify api (i didnt bother reading no docs) + +works flawlessly btw + +## how to use +0. copy .env.example to .env +1. make a spotify app in https://developer.spotify.com/dashboard +``` +SPOTIPY_CLIENT_ID=..? +SPOTIPY_CLIENT_SECRET=..? +``` +1.1. insert it's client id and secret into .env +2. insert your subsonic password & username into .env +3. install requirements with `pip install -r requirements.txt` +4. run python main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..faeac69 --- /dev/null +++ b/main.py @@ -0,0 +1,124 @@ +from dotenv import load_dotenv +load_dotenv() + +from spotify import ( + spotify_client, + get_playlists, + get_playlist_tracks, + get_liked_tracks, +) + +from subsonic import ( + search_song, + create_playlist, + add_to_playlist, + get_playlist_by_name, + get_playlist_songs, +) + +from matcher import make_query +from utils import parse_selection + + +LIKED_NAME = "Spotify – Liked Songs" + + +def main(): + sp = spotify_client() + playlists = get_playlists(sp) + + print("\nAvailable sources:\n") + + sources = [] + + # Normal playlists + for i, p in enumerate(playlists, 1): + print(f"{i:2d}. šŸ“€ {p['name']}") + sources.append(("playlist", p)) + + # Liked songs option + liked_index = len(sources) + 1 + print(f"{liked_index:2d}. ā¤ļø Liked Songs") + sources.append(("liked", None)) + + choice = input("\nSelect (e.g. 1,3,5 | 2-6 | all): ") + selected_indices = parse_selection(choice, len(sources)) + + if not selected_indices: + print("Nothing selected.") + return + + report = {} + + for idx in selected_indices: + source_type, payload = sources[idx] + + if source_type == "liked": + name = LIKED_NAME + print(f"\nā–¶ Migrating: {name}") + tracks = get_liked_tracks(sp) + else: + name = payload["name"] + print(f"\nā–¶ Migrating playlist: {name}") + tracks = get_playlist_tracks(sp, payload["id"]) + + existing = get_playlist_by_name(name) + if existing: + pid = existing["id"] + existing_songs = get_playlist_songs(pid) + print(" ↺ Playlist exists on Subsonic, retrying missing tracks") + else: + pid = create_playlist(name) + existing_songs = set() + print(" + Created new Subsonic playlist") + + added_now = [] + skipped = [] + missing = [] + + for t in tracks: + query = make_query(t) + sid = search_song(query) + + if not sid: + missing.append(query) + print(f" āœ— {query}") + continue + + if sid in existing_songs: + skipped.append(query) + print(f" ↷ Already exists: {query}") + continue + + added_now.append((query, sid)) + print(f" āœ” {query}") + + if added_now: + add_to_playlist(pid, [sid for _, sid in added_now]) + + report[name] = { + "added": [q for q, _ in added_now], + "skipped": skipped, + "missing": missing, + } + + print_summary(report) + + +def print_summary(report): + print("\n===== Migration Summary =====\n") + + for name, data in report.items(): + print(f"šŸ“€ {name}") + print(f" āœ” Added now: {len(data['added'])}") + print(f" ↷ Already existed: {len(data['skipped'])}") + print(f" āœ— Still missing: {len(data['missing'])}") + + if data["missing"]: + for m in data["missing"]: + print(f" - {m}") + print() + + +if __name__ == "__main__": + main() diff --git a/matcher.py b/matcher.py new file mode 100644 index 0000000..6b85de5 --- /dev/null +++ b/matcher.py @@ -0,0 +1,4 @@ +def make_query(track): + artist = track["artists"][0]["name"] + title = track["name"] + return f"{artist} {title}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9b96be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +certifi==2025.11.12 +charset-normalizer==3.4.4 +idna==3.11 +python-dotenv==1.2.1 +redis==7.1.0 +requests==2.32.5 +spotipy==2.25.2 +urllib3==2.6.2 diff --git a/spotify.py b/spotify.py new file mode 100644 index 0000000..6b66f59 --- /dev/null +++ b/spotify.py @@ -0,0 +1,33 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth + +def spotify_client(): + return spotipy.Spotify(auth_manager=SpotifyOAuth( + scope="user-library-read playlist-read-private playlist-read-collaborative" + )) + +def get_playlists(sp): + return sp.current_user_playlists(limit=50)["items"] + +def get_playlist_tracks(sp, playlist_id): + tracks = [] + results = sp.playlist_items(playlist_id, additional_types=["track"]) + while results: + for item in results["items"]: + if item["track"]: + tracks.append(item["track"]) + results = sp.next(results) if results["next"] else None + return tracks + + +def get_liked_tracks(sp): + tracks = [] + results = sp.current_user_saved_tracks(limit=50) + + while results: + for item in results["items"]: + if item["track"]: + tracks.append(item["track"]) + results = sp.next(results) if results["next"] else None + + return tracks diff --git a/subsonic.py b/subsonic.py new file mode 100644 index 0000000..cbd1b35 --- /dev/null +++ b/subsonic.py @@ -0,0 +1,55 @@ +import requests +import os +import hashlib +import random +import string + +BASE = os.environ["SUBSONIC_URL"] + "/rest" + +def _auth_params(): + salt = "".join(random.choices(string.ascii_letters + string.digits, k=6)) + token = hashlib.md5((os.environ["SUBSONIC_PASS"] + salt).encode()).hexdigest() + return { + "u": os.environ["SUBSONIC_USER"], + "t": token, + "s": salt, + "v": "1.16.1", + "c": os.environ["SUBSONIC_CLIENT"], + "f": "json", + } + +def _req(endpoint, **params): + p = _auth_params() + p.update(params) + r = requests.get(f"{BASE}/{endpoint}.view", params=p) + r.raise_for_status() + return r.json()["subsonic-response"] + +def search_song(query): + r = _req("search3", query=query, songCount=1) + songs = r.get("searchResult3", {}).get("song", []) + return songs[0]["id"] if songs else None + +def create_playlist(name): + r = _req("createPlaylist", name=name) + return r["playlist"]["id"] + +def add_to_playlist(pid, song_ids): + for sid in song_ids: + _req("updatePlaylist", playlistId=pid, songIdToAdd=sid) + + +def get_playlists(): + r = _req("getPlaylists") + return r["playlists"]["playlist"] + +def get_playlist_by_name(name): + for p in get_playlists(): + if p["name"] == name: + return p + return None + +def get_playlist_songs(pid): + r = _req("getPlaylist", id=pid) + entries = r["playlist"].get("entry", []) + return {e["id"] for e in entries} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..366a804 --- /dev/null +++ b/utils.py @@ -0,0 +1,14 @@ +def parse_selection(inp, max_index): + if inp.strip().lower() == "all": + return list(range(max_index)) + + result = set() + for part in inp.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-") + result.update(range(int(a) - 1, int(b))) + else: + result.add(int(part) - 1) + + return sorted(i for i in result if 0 <= i < max_index)