🎉 init

This commit is contained in:
Jack Merrill 2025-03-05 23:48:23 -05:00
commit e14f418549
Signed by: jack
GPG Key ID: F6BFCA1B80EA6AF7
11 changed files with 323 additions and 0 deletions

1
.cache Normal file
View File

@ -0,0 +1 @@
{"access_token": "BQBWXQ1ckGLrSfA-YY-2lNfuOkd3wifoGFgGLeLBUIzBpA8GYxXNTZnHjh3z3uUoQz-wyuWSQl0rpokmlJxsOPOfgv6jY4NDX_sM0n7CLUizpSCI4yTZVrDhp22pEdeCpp9OyM3awmdyc7I8wmjjELV5l-ipU1JHUyyOAmM9pZMsJaCk70yaJkLOM5VJbWK-1BGcpBrHP61g5xYsuuXFKY3qtq7ZXJyKUOXDuRNf_MZOu2_F9sKM6jARYRAD2d2OQVGqmnqcoWDIzTwrZtZMuQLA6WwabQd8Uq5ZSn4QYJery_GUq_58FipuSk_GsbPuVp1maOfMr4CP6o9AZH2US-w99SC4xljEsqqiNw", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-modify-private playlist-modify-public playlist-read-collaborative playlist-read-private user-library-read", "expires_at": 1741215249, "refresh_token": "AQCQ_aGEnBpVKH9nMJfWKV7c61kUubyy-Ki3ppUsgqom1LnbPobUjBDsrtMQJ49_VZ6zG-edqKJL41418B_IcUIRAvRgMkN5Qr58P4UHSAxLhShyDRfAHkSw07nr8q70WLQ"}

View File

@ -0,0 +1,3 @@
==> 2025-03-04 17:43:37 <==
# cmd: /opt/miniconda3/bin/conda create -p ./.conda
# conda version: 24.7.1

1
.conda/etc/aau_token Normal file
View File

@ -0,0 +1 @@
2QifZUSOTs8qIPOH81Obaw

13
.env Normal file
View File

@ -0,0 +1,13 @@
SPOTIFY_CLIENT_ID=dfc2029a49574c2abc752adf4ba96756
SPOTIFY_CLIENT_SECRET=c31d0ed2991040a1bc010be012f5f1b9
SUBSONIC_BASE_URL=http://192.168.1.201
SUBSONIC_PORT=4533
SUBSONIC_USERNAME=jack
SUBSONIC_PASSWORD=Chrome1738!
SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback
DEEZER_ARL=
YOUTUBE_REQUEST_HEADERS=
LIDARR_API_KEY=11c8fa0adc3e486eb14613d39c4f8ede
LIDARR_BASE_URL=http://192.168.1.167:8686
LIDARR_ROOT_DIR=/data/Music

26
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"python.languageServer": "Pylance",
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none",
"reportShadowedImports": "none"
},
"python.analysis.extraPaths": [
"c:\\Users\\me\\.vscode\\extensions\\continue.continue-0.0.412-win32-x64",
"/home/jack/.vscode/extensions/joedevivo.vscode-circuitpython-0.2.0-linux-x64/stubs",
"/home/jack/.config/Code/User/globalStorage/joedevivo.vscode-circuitpython/bundle/20250305/adafruit-circuitpython-bundle-py-20250305/lib",
"/Users/jack/.vscode/extensions/continue.continue-0.1.40-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.2.0-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.5.0-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.0-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.2-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.3-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.8-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.13-darwin-arm64",
"/Users/jack/.vscode/extensions/continue.continue-0.6.15-darwin-arm64",
"c:\\Users\\yoshi\\.vscode\\extensions\\continue.continue-0.6.15-win32-x64",
"c:\\Users\\yoshi\\.vscode\\extensions\\continue.continue-0.6.18-win32-x64",
"c:\\Users\\yoshi\\.vscode\\extensions\\continue.continue-0.8.0-win32-x64",
"c:\\Users\\yoshi\\.vscode\\extensions\\continue.continue-0.8.1-win32-x64"
],
"circuitpython.board.version": null
}

71
interactive/__init__.py Normal file
View File

@ -0,0 +1,71 @@
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import LoadingIndicator, SelectionList, Select, Label, Header
from textual.widgets.selection_list import Selection
from tunesynctool import SpotifyDriver, SubsonicDriver, Configuration
from tunesynctool.models import Playlist
config = Configuration.from_env()
class InteractiveApp(App):
CSS_PATH = "app.tcss"
source = None
target = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.source = None
self.target = None
def compose(self) -> ComposeResult:
yield Header(name="Spotidrome")
yield Vertical(
Horizontal(
Select(
id="source",
prompt="Select source",
options=[('Subsonic', 'subsonic'), ('Spotify', 'spotify')]
),
Select(id="target", prompt="Select target", options=[('Subsonic', 'subsonic'), ('Spotify', 'spotify')]),
classes="select-source-target",
),
Vertical(
SelectionList(
id="playlist-list",
classes="playlist-list"
),
classes="playlist-container",
),
classes="main-container"
)
@on(Select.Changed)
def on_select_changed(self, event: Select.Changed) -> None:
if event.control.id == "source":
self.source = event.value
elif event.control.id == "target":
self.target = event.value
if self.source and self.target:
self.load_playlists()
def load_playlists(self):
# Load playlists
if self.source == 'spotify':
driver = SpotifyDriver(config)
elif self.source == 'subsonic':
driver = SubsonicDriver(config)
else:
raise ValueError("Invalid source")
playlists = driver.get_user_playlists()
options = [(playlist.name, playlist.service_id) for playlist in playlists]
self.query_one("#playlist-list", expect_type=SelectionList).add_options(options)
self.query_one("#playlist-list", expect_type=SelectionList).refresh()
@on(SelectionList.SelectedChanged)
def on_playlist_selected(self, event: SelectionList.SelectedChanged) -> None:
if event.control.id == "playlist-list":
selected_playlist = event.control.selected
# self.query_one(Label).update(f"Selected playlist: {selected_playlist}")

Binary file not shown.

Binary file not shown.

14
interactive/app.tcss Normal file
View File

@ -0,0 +1,14 @@
Screen {
align: center top;
}
.main-container {
margin: 2;
}
SelectionList {
padding: 1;
border: solid $accent;
width: 1fr;
height: 80%;
}

179
main.py Normal file
View File

@ -0,0 +1,179 @@
import os
import click
from dotenv import load_dotenv
load_dotenv()
from tunesynctool import PlaylistSynchronizer, Configuration, SubsonicDriver, SpotifyDriver, Track, TrackMatcher
from tunesynctool.integrations import Musicbrainz
from pyarr import LidarrAPI
from tqdm import tqdm
from interactive import InteractiveApp
import musicbrainzngs
config = Configuration.from_env()
@click.group()
def cli():
"""Spotidrome tool CLI."""
pass
# @cli.command("lidarr_sync")
# @click.option('--tracks', help='List of tracks to sync with Lidarr')
def lidarr_sync(tracks: list[Track]):
"""Sync tracks with Lidarr."""
# Initialize the Lidarr API
lidarr_api = LidarrAPI(
os.getenv("LIDARR_BASE_URL"),
os.getenv("LIDARR_API_KEY"),
)
for track in tqdm(tracks, desc="Syncing tracks with Lidarr"):
mbid = track.musicbrainz_id
if not mbid:
mbid = Musicbrainz.id_from_track(track)
if not mbid:
tqdm.write(f"Track {track.title} by {track.primary_artist} has no MusicBrainz ID.")
continue
releaseGroup = musicbrainzngs.search_release_groups(query=f"{track.album_name} {track.primary_artist}")
if not releaseGroup:
tqdm.write(f"Release group not found for track {track.title} by {track.primary_artist}.")
continue
results = lidarr_api.lookup_album(f"lidarr:{releaseGroup['release-group-list'][0]['id']}")
if results:
if results[0]['monitored']:
tqdm.write(f"Album {track.album_name} already monitored on Lidarr.")
monitored = lidarr_api.get_album(foreignAlbumId=results[0]['foreignAlbumId'])
if monitored[0]['statistics']['percentOfTracks'] != 100:
tqdm.write(f"Album {track.album_name} not complete on Lidarr, searching for missing tracks.")
releases = lidarr_api.get_release(artistId=results[0]['artistId'], albumId=results[0]['id'])
tqdm.write(f"Found {len(releases)} releases for album {track.album_name}.")
if len(releases) == 0:
tqdm.write(f"No releases found for album {track.album_name}.")
continue
tqdm.write(f"Adding release {releases[0]['guid']} to Lidarr.")
lidarr_api._post(path="v1/release",data={
"guid": releases[0]['guid'],
"indexerId": releases[0]['indexerId'],
})
tqdm.write(f"Release {releases[0]['guid']} grabbed.")
continue
click.echo(f"Adding album {track.album_name} to Lidarr.")
lidarr_api.add_album(results[0], search_for_new_album=True, artist_search_for_missing_albums=True, root_dir=os.getenv("LIDARR_ROOT_DIR"))
click.echo(f"Album {track.album_name} added to Lidarr.")
else:
click.echo(f"Album {track.album_name} not found on Lidarr.")
continue
@cli.command("sync")
@click.option('--spotify-playlist-id', help='Spotify playlist ID')
@click.option('--subsonic-playlist-id', help='Subsonic playlist ID')
@click.option('--lidarr', default=True, help='Monitor Artists on Lidarr, then search for albums and download.')
def sync(spotify_playlist_id, subsonic_playlist_id, lidarr):
"""Sync a Spotify playlist with Subsonic.
"""
if not spotify_playlist_id:
raise click.BadParameter('Spotify playlist ID is required.')
# Initialize the Spotify driver
spotify_driver = SpotifyDriver(config)
# Initialize the Subsonic driver
subsonic_driver = SubsonicDriver(config)
# Initialize the playlist synchronizer
synchronizer = PlaylistSynchronizer(spotify_driver, subsonic_driver, config)
# Get the tracks from the Spotify playlist
tracks = spotify_driver.get_playlist_tracks(spotify_playlist_id, limit=500)
if lidarr:
lidarr_sync(tracks)
# Synchronize the playlist
synchronizer.sync(spotify_playlist_id, subsonic_playlist_id)
click.echo("Sync completed.")
@cli.command("transfer")
@click.option('--spotify-playlist-id', help='Spotify playlist ID')
@click.option('--lidarr', default=True, help='Monitor Artists on Lidarr, then search for albums and download.')
def transfer(spotify_playlist_id, lidarr):
"""Transfer a Spotify playlist with Subsonic.
"""
if not spotify_playlist_id:
raise click.BadParameter('Spotify playlist ID is required.')
# Initialize the Spotify driver
spotify_driver = SpotifyDriver(config)
# Initialize the Subsonic driver
subsonic_driver = SubsonicDriver(config)
source_tracks = spotify_driver.get_playlist_tracks(spotify_playlist_id)
source_playlist = spotify_driver.get_playlist(spotify_playlist_id)
if lidarr:
lidarr_sync(source_tracks)
click.echo("Lidarr sync started. Please check Lidarr for progress. Run `sync` when completed.")
matcher = TrackMatcher(subsonic_driver)
matched_tracks: list[Track] = []
click.echo(f"Matching tracks from {source_playlist.name}...")
click.echo(f"Total tracks to match: {len(source_tracks)}")
for track in tqdm(source_tracks, desc="Matching tracks"):
tqdm.write(f"Matching track: {track.title} by {track.primary_artist}")
matched_track = matcher.find_match(track)
if matched_track:
matched_tracks.append(matched_track)
tqdm.write(f"Matched track: {matched_track.title} by {matched_track.primary_artist}")
else:
tqdm.write(f"Track not found: {track.title} by {track.primary_artist}")
try:
target_playlist = subsonic_driver.create_playlist(source_playlist.name)
subsonic_driver.add_tracks_to_playlist(playlist_id=target_playlist.service_id, track_ids=(track.service_id for track in matched_tracks))
click.echo("Transfer completed.")
except Exception as e:
click.echo(f"Error during transfer: {e}")
@cli.command("list")
@click.option('--platform', type=click.Choice(['spotify', 'subsonic']), help='Platform to list playlists from')
def list_playlists(platform):
"""List playlists from a platform."""
if not platform:
raise click.BadParameter('Platform is required.')
if platform == 'spotify':
driver = SpotifyDriver(config)
elif platform == 'subsonic':
driver = SubsonicDriver(config)
playlists = driver.get_user_playlists()
for playlist in playlists:
click.echo(f"{playlist.name} ({playlist.service_id})")
@cli.command("interactive")
def interactive():
"""Interactive mode."""
app = InteractiveApp()
app.run()
if __name__ == '__main__':
cli()

15
setup.py Normal file
View File

@ -0,0 +1,15 @@
from setuptools import setup
setup(
name='spotidrome',
version='0.1.0',
py_modules=['main'],
install_requires=[
'Click',
],
entry_points={
'console_scripts': [
'main = main:cli',
],
},
)