🎉 init
This commit is contained in:
commit
e14f418549
1
.cache
Normal file
1
.cache
Normal 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"}
|
3
.conda/conda-meta/history
Normal file
3
.conda/conda-meta/history
Normal 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
1
.conda/etc/aau_token
Normal file
@ -0,0 +1 @@
|
|||||||
|
2QifZUSOTs8qIPOH81Obaw
|
13
.env
Normal file
13
.env
Normal 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
26
.vscode/settings.json
vendored
Normal 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
71
interactive/__init__.py
Normal 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}")
|
BIN
interactive/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
interactive/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
interactive/__pycache__/app.cpython-312.pyc
Normal file
BIN
interactive/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
14
interactive/app.tcss
Normal file
14
interactive/app.tcss
Normal 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
179
main.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user