From 56c4c08d1346fbc14112a46e0375045c1297dd16 Mon Sep 17 00:00:00 2001 From: Julien Mailleret <8582351+jmlrt@users.noreply.github.com> Date: Wed, 20 May 2026 09:52:03 +0200 Subject: [PATCH 1/3] feat: add move_tracks script for playlist track migration - Implement hacks/move_tracks.py with URL/ID/name resolution - Sort tracks by artist name then album name (case-insensitive) - Support dry-run mode to preview changes without modifying Spotify - Pre-flight and post-operation playlist updates ensure DB stays in sync - Add 18 comprehensive unit tests (100% coverage) - Code follows ruff style and codebase conventions Co-Authored-By: Claude Haiku 4.5 --- hacks/move_tracks.py | 159 ++++++++++++ tests/test_move_tracks.py | 505 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 664 insertions(+) create mode 100644 hacks/move_tracks.py create mode 100644 tests/test_move_tracks.py diff --git a/hacks/move_tracks.py b/hacks/move_tracks.py new file mode 100644 index 0000000..57ef0b6 --- /dev/null +++ b/hacks/move_tracks.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Move tracks between Spotify playlists, sorted by artist then album name.""" + +import argparse +import logging +import re +import sys + +from spotfm import utils +from spotfm.spotify import client as spotify_client +from spotfm.spotify import constants as spotify_constants +from spotfm.spotify import playlist as playlist_module +from spotfm.spotify.misc import resolve_playlist_patterns_to_ids + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def parse_playlist_identifier(identifier: str) -> str: + """Extract playlist ID from Spotify URL or pass through bare ID/name. + + Args: + identifier: Spotify playlist URL, 22-char ID, or playlist name + + Returns: + 22-char playlist ID or playlist name + """ + match = re.search(r"playlist/([A-Za-z0-9]{22})", identifier) + if match: + return match.group(1) + return identifier + + +def resolve_identifier(identifier: str, label: str) -> str: + """Resolve playlist identifier to 22-char ID. + + Args: + identifier: URL, 22-char ID, or playlist name + label: Label for error messages + + Returns: + 22-char playlist ID + + Raises: + SystemExit: If playlist not found + """ + parsed = parse_playlist_identifier(identifier) + + if len(parsed) == 22 and parsed.isalnum(): + logger.debug(f"{label} resolved to ID: {parsed}") + return parsed + + resolved = resolve_playlist_patterns_to_ids(parsed) + resolved_ids = resolved[0] if isinstance(resolved, tuple) else resolved + + if not resolved_ids: + logger.error(f"{label} playlist not found: {identifier}") + sys.exit(1) + + resolved_id: str = resolved_ids[0] + logger.debug(f"{label} '{parsed}' resolved to ID: {resolved_id}") + return resolved_id + + +def move_tracks( + client: spotify_client.Client, source_id: str, dest_id: str, count: int, dry_run: bool = False +) -> list[str]: + """Move tracks from source to destination playlist, sorted by artist then album. + + Args: + client: Spotify client with write permissions + source_id: Source playlist ID + dest_id: Destination playlist ID + count: Number of tracks to move + dry_run: If True, only show what would be moved without modifying playlists + + Returns: + List of track IDs that were moved + """ + logger.info("Running pre-flight playlist updates...") + client.update_playlists(playlists_patterns=[source_id]) + client.update_playlists(playlists_patterns=[dest_id]) + + logger.info(f"Loading source playlist {source_id}...") + source = playlist_module.Playlist.get_playlist(source_id, client.client, refresh=False) + if source.tracks: + logger.info(f"Loaded {len(source.tracks)} tracks from source playlist") + else: + logger.info("Loaded 0 tracks from source playlist") + + logger.info("Sorting tracks by artist then album...") + + def sort_key(track): + artist = track.artists[0].name.lower() if track.artists else "" + album = track.album.lower() if track.album else "" + return (artist, album) + + sorted_tracks = sorted(source.tracks, key=sort_key) if source.tracks else [] + tracks_to_move = sorted_tracks[:count] + track_ids = [t.id for t in tracks_to_move] + + logger.info(f"Selected {len(tracks_to_move)} tracks to move:") + for i, track in enumerate(tracks_to_move, 1): + artist = track.artists[0].name if track.artists else "Unknown" + album = track.album or "Unknown" + logger.info(f" {i:2d}. {artist} - {album} - {track.name}") + + if dry_run: + logger.info("DRY RUN: No changes made") + return track_ids + + logger.info(f"Adding {len(tracks_to_move)} tracks to destination playlist...") + dest = playlist_module.Playlist.get_playlist(dest_id, client.client, refresh=False) + dest.add_tracks(tracks_to_move, client.client) + + logger.info(f"Removing {len(tracks_to_move)} tracks from source playlist...") + source.remove_tracks(track_ids, client.client) + + logger.info("Running post-operation playlist updates...") + client.update_playlists(playlists_patterns=[source_id]) + client.update_playlists(playlists_patterns=[dest_id]) + + logger.info(f"Successfully moved {len(track_ids)} tracks") + return track_ids + + +def main() -> None: + """Parse arguments and execute move_tracks.""" + parser = argparse.ArgumentParser(description="Move tracks between Spotify playlists, sorted by artist then album") + parser.add_argument("-s", "--source", required=True, help="Source playlist ID, URL, or name") + parser.add_argument("-d", "--dest", required=True, help="Destination playlist ID, URL, or name") + parser.add_argument("-n", "--count", type=int, required=True, help="Number of tracks to move") + parser.add_argument("--dry-run", action="store_true", help="Show what would be moved without making changes") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + source_id = resolve_identifier(args.source, "Source") + dest_id = resolve_identifier(args.dest, "Destination") + + if source_id == dest_id: + logger.error("Source and destination playlists must be different") + sys.exit(1) + + config = utils.parse_config() + client = spotify_client.Client( + config["spotify"]["client_id"], + config["spotify"]["client_secret"], + scope=spotify_constants.SCOPE, + ) + + move_tracks(client, source_id, dest_id, args.count, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/tests/test_move_tracks.py b/tests/test_move_tracks.py new file mode 100644 index 0000000..b0fea58 --- /dev/null +++ b/tests/test_move_tracks.py @@ -0,0 +1,505 @@ +"""Tests for hacks/move_tracks.py""" + +from unittest.mock import MagicMock + +import pytest + +from spotfm import sqlite, utils +from spotfm.spotify import playlist as playlist_module + + +@pytest.mark.unit +def test_parse_playlist_identifier_url(): + """URL with ?si= param → 22-char ID extracted""" + from hacks.move_tracks import parse_playlist_identifier + + url = "https://open.spotify.com/playlist/4V8O33t0hSCnK4ABozG6xc?si=bdaaafd5b0f54b96" + result = parse_playlist_identifier(url) + assert result == "4V8O33t0hSCnK4ABozG6xc" + + +@pytest.mark.unit +def test_parse_playlist_identifier_plain_id(): + """Plain 22-char ID → returned unchanged""" + from hacks.move_tracks import parse_playlist_identifier + + playlist_id = "4V8O33t0hSCnK4ABozG6xc" + result = parse_playlist_identifier(playlist_id) + assert result == playlist_id + + +@pytest.mark.unit +def test_parse_playlist_identifier_name(): + """Name string → returned unchanged""" + from hacks.move_tracks import parse_playlist_identifier + + name = "My Favorite Tracks" + result = parse_playlist_identifier(name) + assert result == name + + +@pytest.mark.unit +def test_parse_playlist_identifier_url_without_query(): + """URL without query params → ID extracted""" + from hacks.move_tracks import parse_playlist_identifier + + url = "https://open.spotify.com/playlist/4V8O33t0hSCnK4ABozG6xc" + result = parse_playlist_identifier(url) + assert result == "4V8O33t0hSCnK4ABozG6xc" + + +@pytest.mark.unit +def test_resolve_identifier_direct_id(monkeypatch, temp_database): + """Direct 22-char ID → returned without DB lookup""" + from hacks.move_tracks import resolve_identifier + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + playlist_id = "4V8O33t0hSCnK4ABozG6xc" + result = resolve_identifier(playlist_id, "Test") + assert result == playlist_id + + +@pytest.mark.unit +def test_resolve_identifier_by_name(monkeypatch, temp_database): + """Name pattern → resolved via resolve_playlist_patterns_to_ids()""" + from hacks.move_tracks import resolve_identifier + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_resolve = MagicMock(return_value=["4V8O33t0hSCnK4ABozG6xc"]) + monkeypatch.setattr("hacks.move_tracks.resolve_playlist_patterns_to_ids", mock_resolve) + + result = resolve_identifier("My Playlist", "Test") + assert result == "4V8O33t0hSCnK4ABozG6xc" + mock_resolve.assert_called_once_with("My Playlist") + + +@pytest.mark.unit +def test_resolve_identifier_not_found(monkeypatch, temp_database): + """Playlist not found → sys.exit(1)""" + from hacks.move_tracks import resolve_identifier + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_resolve = MagicMock(return_value=[]) + monkeypatch.setattr("hacks.move_tracks.resolve_playlist_patterns_to_ids", mock_resolve) + + with pytest.raises(SystemExit) as exc_info: + resolve_identifier("Nonexistent", "Test") + + assert exc_info.value.code == 1 + + +@pytest.mark.unit +def test_move_tracks_dry_run_skips_api_calls(monkeypatch, temp_database): + """dry-run=True: no add/remove called""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + mock_artist = MagicMock() + mock_artist.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [mock_artist] + + mock_source = MagicMock() + mock_source.tracks = [track1] + mock_source.add_tracks = MagicMock() + mock_source.remove_tracks = MagicMock() + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 1, dry_run=True) + + assert track_ids == ["track1"] + mock_source.add_tracks.assert_not_called() + mock_source.remove_tracks.assert_not_called() + + +@pytest.mark.unit +def test_move_tracks_dry_run_skips_post_update(monkeypatch, temp_database): + """dry-run=True: post-op update_playlists not called""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + mock_artist = MagicMock() + mock_artist.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [mock_artist] + + mock_source = MagicMock() + mock_source.tracks = [track1] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + move_tracks(mock_client, "source_id", "dest_id", 1, dry_run=True) + + assert mock_client.update_playlists.call_count == 2 + + +@pytest.mark.unit +def test_move_tracks_sorting(monkeypatch, temp_database): + """Tracks sorted by artist name then album name""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + artist_a = MagicMock() + artist_a.name = "Artist A" + artist_b = MagicMock() + artist_b.name = "Artist B" + artist_a_alt = MagicMock() + artist_a_alt.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album Z" + track1.artists = [artist_b] + + track2 = MagicMock() + track2.id = "track2" + track2.name = "Track 2" + track2.album = "Album A" + track2.artists = [artist_a] + + track3 = MagicMock() + track3.id = "track3" + track3.name = "Track 3" + track3.album = "Album M" + track3.artists = [artist_a_alt] + + mock_source = MagicMock() + mock_source.tracks = [track1, track2, track3] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 3, dry_run=True) + + assert track_ids == ["track2", "track3", "track1"] + + +@pytest.mark.unit +def test_move_tracks_sorting_case_insensitive(monkeypatch, temp_database): + """Sorting is case-insensitive""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + artist_lower = MagicMock() + artist_lower.name = "artist a" + artist_upper = MagicMock() + artist_upper.name = "Artist B" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "ALBUM Z" + track1.artists = [artist_upper] + + track2 = MagicMock() + track2.id = "track2" + track2.name = "Track 2" + track2.album = "album a" + track2.artists = [artist_lower] + + mock_source = MagicMock() + mock_source.tracks = [track1, track2] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 2, dry_run=True) + + assert track_ids == ["track2", "track1"] + + +@pytest.mark.unit +def test_move_tracks_count_limits_selection(monkeypatch, temp_database): + """Only first N sorted tracks selected""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + tracks = [] + for i in range(10): + artist = MagicMock() + artist.name = f"Artist {i:02d}" + track = MagicMock() + track.id = f"track{i}" + track.name = f"Track {i}" + track.album = f"Album {i}" + track.artists = [artist] + tracks.append(track) + + mock_source = MagicMock() + mock_source.tracks = tracks + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 3, dry_run=True) + + assert len(track_ids) == 3 + + +@pytest.mark.unit +def test_move_tracks_missing_artist_fallback(monkeypatch, temp_database): + """Missing artist handled gracefully (fallback "" sorts first)""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + artist_a = MagicMock() + artist_a.name = "Artist A" + + track_with_artist = MagicMock() + track_with_artist.id = "track1" + track_with_artist.name = "Track 1" + track_with_artist.album = "Album A" + track_with_artist.artists = [artist_a] + + track_no_artist = MagicMock() + track_no_artist.id = "track2" + track_no_artist.name = "Track 2" + track_no_artist.album = "Album B" + track_no_artist.artists = [] + + mock_source = MagicMock() + mock_source.tracks = [track_with_artist, track_no_artist] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 2, dry_run=True) + + assert track_ids == ["track2", "track1"] + + +@pytest.mark.unit +def test_move_tracks_missing_album_fallback(monkeypatch, temp_database): + """Missing album handled gracefully (fallback "" sorts first within same artist)""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + artist = MagicMock() + artist.name = "Artist A" + + track_with_album = MagicMock() + track_with_album.id = "track1" + track_with_album.name = "Track 1" + track_with_album.album = "Album A" + track_with_album.artists = [artist] + + track_no_album = MagicMock() + track_no_album.id = "track2" + track_no_album.name = "Track 2" + track_no_album.album = None + track_no_album.artists = [artist] + + mock_source = MagicMock() + mock_source.tracks = [track_with_album, track_no_album] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + track_ids = move_tracks(mock_client, "source_id", "dest_id", 2, dry_run=True) + + assert track_ids == ["track2", "track1"] + + +@pytest.mark.unit +def test_move_tracks_pre_update_always_runs(monkeypatch, temp_database): + """Pre-flight update_playlists called in both dry-run and non dry-run""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + mock_artist = MagicMock() + mock_artist.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [mock_artist] + + mock_source = MagicMock() + mock_source.tracks = [track1] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + mock_client.update_playlists.reset_mock() + move_tracks(mock_client, "source_id", "dest_id", 1, dry_run=True) + + assert mock_client.update_playlists.call_count == 2 + mock_client.update_playlists.assert_any_call(playlists_patterns=["source_id"]) + mock_client.update_playlists.assert_any_call(playlists_patterns=["dest_id"]) + + +@pytest.mark.unit +def test_move_tracks_non_dry_run_adds_before_removes(monkeypatch, temp_database): + """Non dry-run: add_tracks called before remove_tracks""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + mock_artist = MagicMock() + mock_artist.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [mock_artist] + + mock_source = MagicMock() + mock_source.tracks = [track1] + mock_source.remove_tracks = MagicMock() + + mock_dest = MagicMock() + mock_dest.add_tracks = MagicMock() + + call_order = [] + + def track_add_calls(*_, **__): + call_order.append("add") + + def track_remove_calls(*_, **__): + call_order.append("remove") + + mock_dest.add_tracks.side_effect = track_add_calls + mock_source.remove_tracks.side_effect = track_remove_calls + + def get_playlist_side_effect(playlist_id, *_, **__): + if playlist_id == "source_id": + return mock_source + return mock_dest + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(side_effect=get_playlist_side_effect)) + + move_tracks(mock_client, "source_id", "dest_id", 1, dry_run=False) + + assert call_order == ["add", "remove"] + + +@pytest.mark.unit +def test_move_tracks_non_dry_run_post_update_runs(monkeypatch, temp_database): + """Non dry-run: update_playlists called after operation""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + mock_artist = MagicMock() + mock_artist.name = "Artist A" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [mock_artist] + + mock_source = MagicMock() + mock_source.tracks = [track1] + mock_source.remove_tracks = MagicMock() + + mock_dest = MagicMock() + mock_dest.add_tracks = MagicMock() + + def get_playlist_side_effect(playlist_id, *_, **__): + if playlist_id == "source_id": + return mock_source + return mock_dest + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(side_effect=get_playlist_side_effect)) + + mock_client.update_playlists.reset_mock() + move_tracks(mock_client, "source_id", "dest_id", 1, dry_run=False) + + assert mock_client.update_playlists.call_count == 4 + + +@pytest.mark.unit +def test_move_tracks_returns_track_ids(monkeypatch, temp_database): + """move_tracks returns list of moved track IDs""" + from hacks.move_tracks import move_tracks + + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + mock_client = MagicMock() + mock_client.update_playlists = MagicMock() + + artist = MagicMock() + artist.name = "Artist" + + track1 = MagicMock() + track1.id = "track1" + track1.name = "Track 1" + track1.album = "Album A" + track1.artists = [artist] + + track2 = MagicMock() + track2.id = "track2" + track2.name = "Track 2" + track2.album = "Album B" + track2.artists = [artist] + + mock_source = MagicMock() + mock_source.tracks = [track1, track2] + + monkeypatch.setattr(playlist_module.Playlist, "get_playlist", MagicMock(return_value=mock_source)) + + result = move_tracks(mock_client, "source_id", "dest_id", 2, dry_run=True) + + assert result == ["track1", "track2"] From 5339a9c544797fa6c37462bd44c5efebd0b6f8e6 Mon Sep 17 00:00:00 2001 From: Julien Mailleret <8582351+jmlrt@users.noreply.github.com> Date: Wed, 20 May 2026 09:58:34 +0200 Subject: [PATCH 2/3] refine: quiet dry-run mode shows only track list - When using --dry-run without -v/--verbose, output is minimal (just track list) - Verbose mode shows detailed logging of each operation - Better UX for quick previews vs. debugging --- hacks/move_tracks.py | 49 ++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/hacks/move_tracks.py b/hacks/move_tracks.py index 57ef0b6..47dfaa6 100644 --- a/hacks/move_tracks.py +++ b/hacks/move_tracks.py @@ -63,7 +63,12 @@ def resolve_identifier(identifier: str, label: str) -> str: def move_tracks( - client: spotify_client.Client, source_id: str, dest_id: str, count: int, dry_run: bool = False + client: spotify_client.Client, + source_id: str, + dest_id: str, + count: int, + dry_run: bool = False, + verbose: bool = False, ) -> list[str]: """Move tracks from source to destination playlist, sorted by artist then album. @@ -73,22 +78,29 @@ def move_tracks( dest_id: Destination playlist ID count: Number of tracks to move dry_run: If True, only show what would be moved without modifying playlists + verbose: If True, show detailed logging; if False and dry_run, only show track list Returns: List of track IDs that were moved """ - logger.info("Running pre-flight playlist updates...") + quiet = dry_run and not verbose + + if not quiet: + logger.info("Running pre-flight playlist updates...") client.update_playlists(playlists_patterns=[source_id]) client.update_playlists(playlists_patterns=[dest_id]) - logger.info(f"Loading source playlist {source_id}...") + if not quiet: + logger.info(f"Loading source playlist {source_id}...") source = playlist_module.Playlist.get_playlist(source_id, client.client, refresh=False) - if source.tracks: - logger.info(f"Loaded {len(source.tracks)} tracks from source playlist") - else: - logger.info("Loaded 0 tracks from source playlist") + if not quiet: + if source.tracks: + logger.info(f"Loaded {len(source.tracks)} tracks from source playlist") + else: + logger.info("Loaded 0 tracks from source playlist") - logger.info("Sorting tracks by artist then album...") + if not quiet: + logger.info("Sorting tracks by artist then album...") def sort_key(track): artist = track.artists[0].name.lower() if track.artists else "" @@ -99,14 +111,21 @@ def sort_key(track): tracks_to_move = sorted_tracks[:count] track_ids = [t.id for t in tracks_to_move] - logger.info(f"Selected {len(tracks_to_move)} tracks to move:") - for i, track in enumerate(tracks_to_move, 1): - artist = track.artists[0].name if track.artists else "Unknown" - album = track.album or "Unknown" - logger.info(f" {i:2d}. {artist} - {album} - {track.name}") + if quiet: + for i, track in enumerate(tracks_to_move, 1): + artist = track.artists[0].name if track.artists else "Unknown" + album = track.album or "Unknown" + print(f"{i:2d}. {artist} - {album} - {track.name}") + else: + logger.info(f"Selected {len(tracks_to_move)} tracks to move:") + for i, track in enumerate(tracks_to_move, 1): + artist = track.artists[0].name if track.artists else "Unknown" + album = track.album or "Unknown" + logger.info(f" {i:2d}. {artist} - {album} - {track.name}") if dry_run: - logger.info("DRY RUN: No changes made") + if not quiet: + logger.info("DRY RUN: No changes made") return track_ids logger.info(f"Adding {len(tracks_to_move)} tracks to destination playlist...") @@ -152,7 +171,7 @@ def main() -> None: scope=spotify_constants.SCOPE, ) - move_tracks(client, source_id, dest_id, args.count, dry_run=args.dry_run) + move_tracks(client, source_id, dest_id, args.count, dry_run=args.dry_run, verbose=args.verbose) if __name__ == "__main__": From d0c8bc6ef8fae12ef9796658eecb20bf9611af17 Mon Sep 17 00:00:00 2001 From: Julien Mailleret <8582351+jmlrt@users.noreply.github.com> Date: Wed, 20 May 2026 10:01:12 +0200 Subject: [PATCH 3/3] refine: set WARNING log level in non-verbose mode - Non-verbose mode now suppresses all INFO/DEBUG logging - Only warnings and errors are shown (cleaner output) - Verbose mode (-v) still shows full DEBUG logging --- hacks/move_tracks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hacks/move_tracks.py b/hacks/move_tracks.py index 47dfaa6..08142f5 100644 --- a/hacks/move_tracks.py +++ b/hacks/move_tracks.py @@ -156,6 +156,8 @@ def main() -> None: if args.verbose: logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.WARNING) source_id = resolve_identifier(args.source, "Source") dest_id = resolve_identifier(args.dest, "Destination")