commit e14f41854981430767ecb50752facb2528a783fd Author: Jack Merrill Date: Wed Mar 5 23:48:23 2025 -0500 :tada: init diff --git a/.cache b/.cache new file mode 100644 index 0000000..4361702 --- /dev/null +++ b/.cache @@ -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"} \ No newline at end of file diff --git a/.conda/conda-meta/history b/.conda/conda-meta/history new file mode 100644 index 0000000..e787f9a --- /dev/null +++ b/.conda/conda-meta/history @@ -0,0 +1,3 @@ +==> 2025-03-04 17:43:37 <== +# cmd: /opt/miniconda3/bin/conda create -p ./.conda +# conda version: 24.7.1 diff --git a/.conda/etc/aau_token b/.conda/etc/aau_token new file mode 100644 index 0000000..cb1dd8c --- /dev/null +++ b/.conda/etc/aau_token @@ -0,0 +1 @@ +2QifZUSOTs8qIPOH81Obaw \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..ad2fb03 --- /dev/null +++ b/.env @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..edacb81 --- /dev/null +++ b/.vscode/settings.json @@ -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 +} \ No newline at end of file diff --git a/interactive/__init__.py b/interactive/__init__.py new file mode 100644 index 0000000..06d1a46 --- /dev/null +++ b/interactive/__init__.py @@ -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}") \ No newline at end of file diff --git a/interactive/__pycache__/__init__.cpython-312.pyc b/interactive/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2a80dec Binary files /dev/null and b/interactive/__pycache__/__init__.cpython-312.pyc differ diff --git a/interactive/__pycache__/app.cpython-312.pyc b/interactive/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..ea5a687 Binary files /dev/null and b/interactive/__pycache__/app.cpython-312.pyc differ diff --git a/interactive/app.tcss b/interactive/app.tcss new file mode 100644 index 0000000..a03c95a --- /dev/null +++ b/interactive/app.tcss @@ -0,0 +1,14 @@ +Screen { + align: center top; +} + +.main-container { + margin: 2; +} + +SelectionList { + padding: 1; + border: solid $accent; + width: 1fr; + height: 80%; +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..05d5732 --- /dev/null +++ b/main.py @@ -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() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6eedb8b --- /dev/null +++ b/setup.py @@ -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', + ], + }, +) \ No newline at end of file