first commit

This commit is contained in:
Soph :3 2026-01-01 21:34:03 +02:00
commit f2db1af089
9 changed files with 270 additions and 0 deletions

8
.env.example Normal file
View file

@ -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

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.venv
__pycache__
.env
.cache

20
README.md Normal file
View file

@ -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

124
main.py Normal file
View file

@ -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()

4
matcher.py Normal file
View file

@ -0,0 +1,4 @@
def make_query(track):
artist = track["artists"][0]["name"]
title = track["name"]
return f"{artist} {title}"

8
requirements.txt Normal file
View file

@ -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

33
spotify.py Normal file
View file

@ -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

55
subsonic.py Normal file
View file

@ -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}

14
utils.py Normal file
View file

@ -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)