tunewrangler 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ from platformdirs import user_data_dir
4
+
5
+ APP_NAME = "TuneWrangler"
6
+ APP_AUTHOR = "Alexandre Chabot-Leclerc"
7
+
8
+ SPOTIFY_USERNAME = "example-spotify-user"
9
+ LASTFM_USERNAME = "example-lastfm-user"
10
+
11
+ HERE_DIR = Path(__file__).parent.absolute()
12
+ DATA_DIR = Path(user_data_dir(APP_NAME, APP_AUTHOR))
13
+
14
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+ TRACK_PLAYLISTS = {
17
+ 2021: "YOUR_PLAYLIST_01_ID",
18
+ 2022: "YOUR_PLAYLIST_02_ID",
19
+ 2023: "YOUR_PLAYLIST_03_ID",
20
+ 2024: "YOUR_PLAYLIST_04_ID",
21
+ 2025: "YOUR_PLAYLIST_05_ID",
22
+ }
23
+ ALBUM_PLAYLIST = {
24
+ 2021: "YOUR_PLAYLIST_06_ID",
25
+ 2022: "YOUR_PLAYLIST_07_ID",
26
+ 2023: "YOUR_PLAYLIST_08_ID",
27
+ 2024: "YOUR_PLAYLIST_09_ID",
28
+ 2025: "YOUR_PLAYLIST_10_ID",
29
+ }
30
+ RELEASE_RADAR_PLAYLIST = "YOUR_PLAYLIST_11_ID"
31
+ RELEASE_SINGLES_PLAYLIST = "YOUR_PLAYLIST_12_ID"
32
+ RELEASE_ALBUMS_PLAYLIST = "YOUR_PLAYLIST_13_ID"
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
tunewrangler/cli.py ADDED
@@ -0,0 +1,415 @@
1
+ """
2
+ Automation to save tracks or albums in Spotify.
3
+ """
4
+
5
+ import logging
6
+ import shutil
7
+ import subprocess
8
+ from datetime import datetime
9
+ from functools import wraps
10
+ from getpass import getpass
11
+
12
+ import click
13
+ import keyring
14
+
15
+ from tunewrangler import HERE_DIR
16
+ from tunewrangler.tidal_api import TidalRateLimitError, TidalUnsupportedFeatureError
17
+ from tunewrangler.tidal_client import TidalClient
18
+ from tunewrangler.spotify_client import SpotifyClient
19
+ from tunewrangler.errors import (
20
+ AlbumIsSingleError,
21
+ PlaylistExistsError,
22
+ NotPlayingError,
23
+ TrackNotFoundError,
24
+ )
25
+ from tunewrangler.last_fm_client import LastFmClient
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def handle_tidal_rate_limit(func):
31
+ @wraps(func)
32
+ def wrapper(*args, **kwargs):
33
+ try:
34
+ return func(*args, **kwargs)
35
+ except TidalRateLimitError as error:
36
+ raise click.ClickException(str(error)) from error
37
+ except TrackNotFoundError as error:
38
+ raise click.ClickException(str(error)) from error
39
+ except TidalUnsupportedFeatureError as error:
40
+ raise click.ClickException(str(error)) from error
41
+
42
+ return wrapper
43
+
44
+
45
+ def notify(title, message, subtitle=""):
46
+ notificator_path = (
47
+ HERE_DIR.parent
48
+ / "TuneWranglerNotificator.app"
49
+ / "Contents"
50
+ / "Resources"
51
+ / "Scripts"
52
+ / "notificator"
53
+ )
54
+ if not shutil.which(notificator_path):
55
+ logger.error("Notification script not found.")
56
+ return
57
+
58
+ cmd = [
59
+ notificator_path,
60
+ "--title",
61
+ title,
62
+ "--subtitle",
63
+ subtitle,
64
+ "--message",
65
+ message,
66
+ ]
67
+ subprocess.run(cmd)
68
+
69
+
70
+ class Client:
71
+ def __init__(self, music_client, lastfm_client):
72
+ self.music_client = music_client
73
+ self.lastfm_client = lastfm_client
74
+
75
+ def save_current_track(self):
76
+ track_info = self.music_client.save_current_track()
77
+ if track_info is None:
78
+ notify("Oops!", "Something went wrong. Nothing is playing.")
79
+ else:
80
+ artist_name, track_name, track_id = track_info
81
+ self.music_client.save_track_to_weekly_playlist(track_id)
82
+ self.lastfm_client.save_track(artist_name, track_name)
83
+
84
+ # Success, don't notify
85
+ # notify("🎶 Saved Track", f"Saved track {artist_name} - {track_name}")
86
+
87
+ def save_current_album(self):
88
+ artist_name, album_name = self.music_client.save_current_album()
89
+ # success, don't notify
90
+ # notify("💿 Saved Album", f"Saved album {artist_name} - {album_name}")
91
+
92
+ def favorite_current_track(self):
93
+ self.music_client.favorite_current_track()
94
+
95
+ def favorite_current_album(self):
96
+ self.music_client.favorite_current_album()
97
+
98
+ def save_album_of_current_track_in_new_playlist(self):
99
+ try:
100
+ (
101
+ artist_name,
102
+ album_name,
103
+ ) = self.music_client.save_album_of_current_track_in_new_playlist()
104
+ except NotPlayingError:
105
+ notify("Nothing Playing", "⛔️🎵")
106
+ notify(
107
+ "❌ Album is a Single",
108
+ f"This album wasn't saved because it's a single.",
109
+ )
110
+ except PlaylistExistsError:
111
+ notify(
112
+ "❌ Album already exists", f"There's already a playlist with that name."
113
+ )
114
+ except TrackNotFoundError:
115
+ notify("❌ Track Not Found", f"Could not find a matching track in Tidal.")
116
+ else:
117
+ # Success, don't notify
118
+ # notify(
119
+ # "✅ Saved New Playlist",
120
+ # f"Saved new playlist {artist_name} - {album_name}.",
121
+ # )
122
+ pass
123
+
124
+
125
+ @click.group()
126
+ @click.option(
127
+ "-s",
128
+ "--service",
129
+ type=click.Choice(["tidal", "spotify"], case_sensitive=False),
130
+ default="tidal",
131
+ help="Music provider. Default is Tidal.",
132
+ )
133
+ @click.option("-v", "--verbose", count=True, help="Increase verbosity.")
134
+ @click.option("-d", "--debug", is_flag=True, help="Start debugger on exceptions.")
135
+ @click.pass_context
136
+ def cli(ctx, service, verbose, debug):
137
+ logging_level = logging.WARN - 10 * verbose
138
+ logging.basicConfig(level=logging_level)
139
+ ctx.ensure_object(dict)
140
+ ctx.obj["DEBUG"] = debug
141
+ if service == "tidal":
142
+ ctx.obj["MUSIC_CLIENT"] = TidalClient()
143
+ elif service == "spotify":
144
+ ctx.obj["MUSIC_CLIENT"] = SpotifyClient()
145
+ ctx.obj["LASTFM_CLIENT"] = LastFmClient()
146
+
147
+
148
+ @cli.group(name="save")
149
+ def save_group():
150
+ """Save current playback items to your music services."""
151
+
152
+
153
+ @save_group.command(name="track")
154
+ @click.pass_context
155
+ @handle_tidal_rate_limit
156
+ def save_track(ctx):
157
+ """Save current track to library, weekly playlist, and Last.fm."""
158
+ ctx.obj["MUSIC_CLIENT"].authenticate()
159
+ ctx.obj["LASTFM_CLIENT"].authenticate()
160
+
161
+ client = Client(ctx.obj["MUSIC_CLIENT"], ctx.obj["LASTFM_CLIENT"])
162
+ client.save_current_track()
163
+
164
+
165
+ @save_group.command(name="album")
166
+ @click.pass_context
167
+ @handle_tidal_rate_limit
168
+ def save_album(ctx):
169
+ """Save current album to your music library."""
170
+ ctx.obj["MUSIC_CLIENT"].authenticate()
171
+
172
+ client = Client(ctx.obj["MUSIC_CLIENT"], None)
173
+ client.save_current_album()
174
+
175
+
176
+ @cli.group(name="collect")
177
+ def collect_group():
178
+ """Collect current playback items for later listening."""
179
+
180
+
181
+ @collect_group.command(name="album")
182
+ @click.pass_context
183
+ @handle_tidal_rate_limit
184
+ def collect_album(ctx):
185
+ """Create a playlist from the current album."""
186
+ ctx.obj["MUSIC_CLIENT"].authenticate()
187
+
188
+ client = Client(ctx.obj["MUSIC_CLIENT"], None)
189
+ client.save_album_of_current_track_in_new_playlist()
190
+
191
+
192
+ @cli.group(name="favorite")
193
+ def favorite_group():
194
+ """Favorite current playback items and file them by year."""
195
+
196
+
197
+ @favorite_group.command(name="track")
198
+ @click.pass_context
199
+ @handle_tidal_rate_limit
200
+ def favorite_track(ctx):
201
+ """Save current track to library and this year's playlist."""
202
+ ctx.obj["MUSIC_CLIENT"].authenticate()
203
+
204
+ client = Client(ctx.obj["MUSIC_CLIENT"], None)
205
+ client.favorite_current_track()
206
+
207
+
208
+ @favorite_group.command(name="album")
209
+ @click.pass_context
210
+ @handle_tidal_rate_limit
211
+ def favorite_album(ctx):
212
+ """Save current album to library and this year's albums playlist."""
213
+ ctx.obj["MUSIC_CLIENT"].authenticate()
214
+
215
+ client = Client(ctx.obj["MUSIC_CLIENT"], None)
216
+ client.favorite_current_album()
217
+
218
+
219
+ @cli.group(name="list")
220
+ def list_group():
221
+ """List music service resources."""
222
+
223
+
224
+ @list_group.command(name="playlists")
225
+ @click.pass_context
226
+ @handle_tidal_rate_limit
227
+ def list_playlists(ctx):
228
+ """List all playlists."""
229
+ ctx.obj["MUSIC_CLIENT"].authenticate()
230
+
231
+ music_client = ctx.obj["MUSIC_CLIENT"]
232
+ for name in music_client.list_playlist_names():
233
+ click.echo(name)
234
+
235
+
236
+ @cli.group(name="refresh")
237
+ def refresh_group():
238
+ """Refresh locally cached music service data."""
239
+
240
+
241
+ @refresh_group.command(name="playlists")
242
+ @click.pass_context
243
+ @handle_tidal_rate_limit
244
+ def refresh_playlists(ctx):
245
+ """Refresh playlist cache where supported."""
246
+ ctx.obj["MUSIC_CLIENT"].authenticate()
247
+
248
+ music_client = ctx.obj["MUSIC_CLIENT"]
249
+ music_client.update_playlist_cache()
250
+
251
+
252
+ @cli.group(name="url")
253
+ def url_group():
254
+ """Print URLs for current playback items."""
255
+
256
+
257
+ @url_group.command(name="track")
258
+ @click.pass_context
259
+ @handle_tidal_rate_limit
260
+ def track_url(ctx):
261
+ """Print URL of current track."""
262
+ ctx.obj["MUSIC_CLIENT"].authenticate()
263
+
264
+ music_client = ctx.obj["MUSIC_CLIENT"]
265
+ click.echo(music_client.get_url_of_current_track())
266
+
267
+
268
+ @url_group.command(name="album")
269
+ @click.pass_context
270
+ @handle_tidal_rate_limit
271
+ def album_url(ctx):
272
+ """Print URL of current album."""
273
+ ctx.obj["MUSIC_CLIENT"].authenticate()
274
+
275
+ music_client = ctx.obj["MUSIC_CLIENT"]
276
+ click.echo(music_client.get_url_of_current_album())
277
+
278
+
279
+ @url_group.command(name="weekly")
280
+ @click.pass_context
281
+ @handle_tidal_rate_limit
282
+ def weekly_playlist_url(ctx):
283
+ """Print this week's playlist URL."""
284
+ ctx.obj["MUSIC_CLIENT"].authenticate()
285
+
286
+ music_client = ctx.obj["MUSIC_CLIENT"]
287
+ url = music_client.get_weekly_playlist_url()
288
+ click.echo(url)
289
+
290
+
291
+ @cli.group(name="link")
292
+ def link_group():
293
+ """Print formatted links for current playback items."""
294
+
295
+
296
+ @link_group.command(name="track")
297
+ @click.pass_context
298
+ @handle_tidal_rate_limit
299
+ def track_link(ctx):
300
+ """Print Markdown link for current track."""
301
+ ctx.obj["MUSIC_CLIENT"].authenticate()
302
+
303
+ music_client = ctx.obj["MUSIC_CLIENT"]
304
+ info = music_client.get_current_track_info()
305
+ url = music_client.get_url_of_current_track()
306
+ click.echo(f"[{info['artist']} - {info['title']}]({url})")
307
+
308
+
309
+ @link_group.command(name="album")
310
+ @click.pass_context
311
+ @handle_tidal_rate_limit
312
+ def album_link(ctx):
313
+ """Print Markdown link for current album."""
314
+ ctx.obj["MUSIC_CLIENT"].authenticate()
315
+
316
+ music_client = ctx.obj["MUSIC_CLIENT"]
317
+ info = music_client.get_current_track_info()
318
+ url = music_client.get_url_of_current_album()
319
+ click.echo(f"[{info['artist']} - {info['album']}]({url})")
320
+
321
+
322
+ @link_group.command(name="weekly")
323
+ @click.pass_context
324
+ @handle_tidal_rate_limit
325
+ def weekly_link(ctx):
326
+ """Print Markdown link for this week's playlist."""
327
+ ctx.obj["MUSIC_CLIENT"].authenticate()
328
+
329
+ music_client = ctx.obj["MUSIC_CLIENT"]
330
+ url = music_client.get_weekly_playlist_url()
331
+ click.echo(f"[{datetime.now().strftime('%Y-W%V')}]({url})")
332
+
333
+
334
+ @cli.group(name="split")
335
+ def split_group():
336
+ """Split music service resources."""
337
+
338
+
339
+ @split_group.command(name="releases")
340
+ @click.pass_context
341
+ @handle_tidal_rate_limit
342
+ def split_releases(ctx):
343
+ """Split new release singles and albums."""
344
+ ctx.obj["MUSIC_CLIENT"].authenticate()
345
+
346
+ music_client = ctx.obj["MUSIC_CLIENT"]
347
+ music_client.split_new_release_singles_and_albums()
348
+
349
+
350
+ @cli.group(name="auth")
351
+ def auth_group():
352
+ """Manage service credentials."""
353
+
354
+
355
+ @auth_group.command(name="set")
356
+ @click.option(
357
+ "--service",
358
+ type=click.Choice(["spotify", "lastfm", "tidal"]),
359
+ help="Specify service for setting keys",
360
+ )
361
+ @click.pass_context
362
+ def keys_set(ctx, service):
363
+ """Set API keys for Spotify, LastFM, or TIDAL."""
364
+ if service == "spotify":
365
+ client_id = getpass("Enter your Spotify client-id: ")
366
+ client_secret = getpass("Enter your Spotify client-secret: ")
367
+ keyring.set_password(
368
+ "tunewrangler-spotify-client-id",
369
+ "tunewrangler-spotify-client-id",
370
+ client_id,
371
+ )
372
+ keyring.set_password(
373
+ "tunewrangler-spotify-client-secret",
374
+ "tunewrangler-spotify-client-secret",
375
+ client_secret,
376
+ )
377
+ click.echo("Spotify keys set successfully.")
378
+ elif service == "lastfm":
379
+ api_key = getpass("Enter your LastFM api-key: ")
380
+ shared_secret = getpass("Enter your LastFM shared-secret: ")
381
+ password = getpass("Enter your LastFM password: ")
382
+ keyring.set_password(
383
+ "tunewrangler-last-fm-api-key", "tunewrangler-last-fm-api-key", api_key
384
+ )
385
+ keyring.set_password(
386
+ "tunewrangler-last-fm-shared-secret",
387
+ "tunewrangler-last-fm-shared-secret",
388
+ shared_secret,
389
+ )
390
+ keyring.set_password(
391
+ "tunewrangler-last-fm-password",
392
+ "tunewrangler-last-fm-password",
393
+ password,
394
+ )
395
+ click.echo("LastFM keys set successfully.")
396
+ elif service == "tidal":
397
+ client_id = getpass("Enter your TIDAL client-id: ")
398
+ redirect_uri = getpass("Enter your TIDAL redirect URI: ")
399
+ keyring.set_password(
400
+ "tunewrangler-tidal-client-id",
401
+ "tunewrangler-tidal-client-id",
402
+ client_id,
403
+ )
404
+ keyring.set_password(
405
+ "tunewrangler-tidal-redirect-uri",
406
+ "tunewrangler-tidal-redirect-uri",
407
+ redirect_uri,
408
+ )
409
+ click.echo("TIDAL keys set successfully.")
410
+ else:
411
+ click.echo("Please specify a service: --service [spotify|lastfm|tidal]")
412
+
413
+
414
+ if __name__ == "__main__":
415
+ cli(obj={})
tunewrangler/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class SpotifyControlError(BaseException):
2
+ pass
3
+
4
+
5
+ class AlbumIsSingleError(SpotifyControlError):
6
+ pass
7
+
8
+
9
+ class PlaylistExistsError(SpotifyControlError):
10
+ pass
11
+
12
+
13
+ class AuthenticationError(SpotifyControlError):
14
+ pass
15
+
16
+
17
+ class NotPlayingError(SpotifyControlError):
18
+ pass
19
+
20
+
21
+ class TrackNotFoundError(SpotifyControlError):
22
+ pass
@@ -0,0 +1,68 @@
1
+ from tunewrangler import LASTFM_USERNAME
2
+ from tunewrangler.errors import AuthenticationError
3
+
4
+ import keyring
5
+ import pylast
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def missing_key_message(key_name):
12
+ return (
13
+ f"Cannot find {key_name!r} in the keychain. "
14
+ "Have you set your keys with `tunewrangler auth set`?"
15
+ )
16
+
17
+
18
+ class LastFmClient:
19
+ def __init__(self):
20
+ self.username = LASTFM_USERNAME
21
+ self.lastfm = None
22
+
23
+ def authenticate(self):
24
+ try:
25
+ logging.info("Logging in to LastFM")
26
+ api_key = keyring.get_password(
27
+ "tunewrangler-last-fm-api-key", "tunewrangler-last-fm-api-key"
28
+ )
29
+ if api_key is None:
30
+ raise AuthenticationError(
31
+ missing_key_message("tunewrangler-last-fm-api-key")
32
+ )
33
+
34
+ api_secret = keyring.get_password(
35
+ "tunewrangler-last-fm-shared-secret",
36
+ "tunewrangler-last-fm-shared-secret",
37
+ )
38
+ if api_secret is None:
39
+ raise AuthenticationError(
40
+ missing_key_message("tunewrangler-last-fm-shared-secret")
41
+ )
42
+
43
+ password = keyring.get_password(
44
+ "tunewrangler-last-fm-password", "tunewrangler-last-fm-password"
45
+ )
46
+ if password is None:
47
+ raise AuthenticationError(
48
+ missing_key_message("tunewrangler-last-fm-password")
49
+ )
50
+
51
+ self.lastfm = pylast.LastFMNetwork(
52
+ api_key=api_key,
53
+ api_secret=api_secret,
54
+ username=self.username,
55
+ password_hash=pylast.md5(password),
56
+ )
57
+ except Exception as e:
58
+ logger.error("Authentication failed: %s", e)
59
+ raise AuthenticationError("Can't authenticate to LastFM.")
60
+
61
+ def save_track(self, artist_name, track_name):
62
+ logging.info(
63
+ f"LastFmClient: getting current track: {artist_name} - {track_name}"
64
+ )
65
+ track = self.lastfm.get_track(artist_name, track_name)
66
+ logging.debug(f"Found track: {track}, {vars(track)}")
67
+ track.love()
68
+ logger.info(f"LastFM loved track: {track.artist.name} - {track.title}")