🎉 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