Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions clear_tidal_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Test script to clear all saved albums, playlists, and favorites from Tidal account.

WARNING: This will permanently delete all your Tidal favorites, saved albums, and user-created playlists!
Use this only for testing purposes on a test account.

This script integrates with the spotify_to_tidal configuration system:
- Uses the same Tidal authentication flow as the main application
- Loads config.yml (or custom config file) for consistency
- Reuses existing .session.yml file for Tidal authentication
- Does not require Spotify credentials since it only touches Tidal

Usage:
./clear_tidal_account.py # Use default config.yml
./clear_tidal_account.py --config test.yml # Use custom config file

The script will:
1. Load the config file (optional, mainly for consistency)
2. Authenticate with Tidal using existing session or OAuth flow
3. Clear all favorite tracks from your Tidal library
4. Clear all saved albums from your Tidal collection
5. Delete all user-created playlists (not system playlists like "My Mix")

Multiple confirmation prompts ensure you don't accidentally delete everything.
"""

import asyncio
import sys
import yaml
import argparse
from pathlib import Path

# Add the src directory to the path so we can import the modules
sys.path.insert(0, str(Path(__file__).parent / 'src'))

from spotify_to_tidal.auth import open_tidal_session
from spotify_to_tidal.tidalapi_patch import get_all_favorites, get_all_playlists, get_all_saved_albums
import tidalapi
from tqdm import tqdm


async def clear_favorites(session: tidalapi.Session):
"""Clear all favorite tracks"""
print("Fetching all favorite tracks...")
favorites = await get_all_favorites(session.user.favorites)

if not favorites:
print("No favorite tracks to clear.")
return

print(f"Found {len(favorites)} favorite tracks. Clearing...")

# Remove favorites in chunks to avoid API rate limits
chunk_size = 20
with tqdm(desc="Removing favorite tracks", total=len(favorites)) as progress:
for i in range(0, len(favorites), chunk_size):
chunk = favorites[i:i + chunk_size]
track_ids = [track.id for track in chunk]

# Remove tracks from favorites
for track_id in track_ids:
try:
session.user.favorites.remove_track(track_id)
except Exception as e:
print(f"Error removing track {track_id}: {e}")

progress.update(len(chunk))

print("✓ All favorite tracks cleared.")


async def clear_saved_albums(session: tidalapi.Session):
"""Clear all saved albums"""
print("Fetching all saved albums...")
albums = await get_all_saved_albums(session.user)

if not albums:
print("No saved albums to clear.")
return

print(f"Found {len(albums)} saved albums. Clearing...")

# Remove albums in chunks
chunk_size = 20
with tqdm(desc="Removing saved albums", total=len(albums)) as progress:
for i in range(0, len(albums), chunk_size):
chunk = albums[i:i + chunk_size]

for album in chunk:
try:
session.user.favorites.remove_album(album.id)
except Exception as e:
print(f"Error removing album {album.id} ({album.name}): {e}")

progress.update(len(chunk))

print("✓ All saved albums cleared.")


async def clear_user_playlists(session: tidalapi.Session):
"""Clear all user-created playlists (not system playlists)"""
print("Fetching all playlists...")
playlists = await get_all_playlists(session.user)

# Filter to only user-created playlists (not system ones like "My Mix")
user_playlists = [p for p in playlists if isinstance(p, tidalapi.UserPlaylist) and p.creator.id == session.user.id]

if not user_playlists:
print("No user-created playlists to clear.")
return

print(f"Found {len(user_playlists)} user-created playlists:")
for playlist in user_playlists:
print(f" - {playlist.name} ({playlist.num_tracks} tracks)")

# Ask for confirmation since this is destructive
response = input(f"\nAre you sure you want to DELETE all {len(user_playlists)} user playlists? (yes/no): ")
if response.lower() != 'yes':
print("Playlist deletion cancelled.")
return

print("Deleting playlists...")
with tqdm(desc="Deleting playlists", total=len(user_playlists)) as progress:
for playlist in user_playlists:
try:
playlist.delete()
print(f"✓ Deleted playlist: {playlist.name}")
except Exception as e:
print(f"✗ Error deleting playlist {playlist.name}: {e}")

progress.update(1)

print("✓ All user playlists cleared.")


async def main():
"""Main function to clear all Tidal account data"""
# Parse command line arguments
parser = argparse.ArgumentParser(description="Clear all data from Tidal account")
parser.add_argument('--config', default='config.yml', help='location of the config file')
args = parser.parse_args()

print("🚨 WARNING: This script will permanently delete ALL of the following from your Tidal account:")
print(" - All favorite tracks")
print(" - All saved albums")
print(" - All user-created playlists")
print("\nThis action CANNOT be undone!")

response = input("\nAre you absolutely sure you want to proceed? (type 'DELETE' to confirm): ")
if response != 'DELETE':
print("Operation cancelled. Your Tidal account is unchanged.")
return

try:
# Load config if available (mainly for consistency with main app)
try:
with open(args.config, 'r') as f:
config = yaml.safe_load(f)
print(f"✓ Loaded config from {args.config}")
except FileNotFoundError:
print(f"Config file {args.config} not found, proceeding without it...")
config = None
except Exception as e:
print(f"Warning: Could not load config: {e}")
config = None

# Get Tidal session (same way as main app)
print("\nAuthenticating with Tidal...")
session = open_tidal_session(config.get('tidal') if config else None)

if not session.check_login():
print("❌ Could not connect to Tidal")
return 1

print(f"✓ Authenticated as: {session.user.first_name} {session.user.last_name}")

# Clear favorites
print("\n" + "="*50)
await clear_favorites(session)

# Clear saved albums
print("\n" + "="*50)
await clear_saved_albums(session)

# Clear user playlists
print("\n" + "="*50)
await clear_user_playlists(session)

print("\n" + "="*50)
print("🎉 Tidal account successfully cleared!")
print("Your account now has no favorites, saved albums, or user playlists.")

except Exception as e:
print(f"\n❌ Error: {e}")
return 1

return 0


if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
13 changes: 9 additions & 4 deletions example_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ spotify:
#excluded_playlists:
# - spotify:playlist:1ABCDEqsABCD6EaABCDa0a

# default setting for syncing favorites when no command line arguments are provided
# - when true: favorites will be synced by default (overriden when any command line arg provided)
# - when false: favorites can only be synced manually via --sync-favorites argument
sync_favorites_default: true
# default syncing settings for syncing when no command line arguments are provided
sync_playlists_default: true # default to sync playlists
sync_favorites_default: true # default to sync favorites
sync_albums_default: true # default to sync albums

# increasing these parameters should increase the search speed, while decreasing reduces likelihood of 429 errors
max_concurrency: 10 # max concurrent connections at any given time
rate_limit: 10 # max sustained connections per second

# fuzzy matching settings - helps find tracks with slightly different names/spellings
enable_fuzzy_matching: true # set to false to disable fuzzy matching (uses only exact matching)
fuzzy_name_threshold: 0.80 # similarity threshold for track names (0.0-1.0, higher = more strict)
fuzzy_artist_threshold: 0.75 # similarity threshold for artist names (0.0-1.0, higher = more strict)
24 changes: 19 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,37 @@ Setup

Usage
----
To synchronize all of your Spotify playlists with your Tidal account run the following from the project root directory
To synchronize all of your Spotify playlists, favourites and albums with your Tidal account run the following from the project root directory
Windows ignores python module paths by default, but you can run them using `python3 -m spotify_to_tidal`

```bash
spotify_to_tidal
```

You can also just synchronize a specific playlist by doing the following:
Use `--sync-playlists`, `--sync-favorites` and/or `--sync-albums` to limit the sync to one or more types. For example:

Synchronise your 'Liked Songs':

```bash
spotify_to_tidal --uri 1ABCDEqsABCD6EaABCDa0a # accepts playlist id or full playlist uri
spotify_to_tidal --sync-favorites
```

or sync just your 'Liked Songs' with:
Synchronize your saved albums:

```bash
spotify_to_tidal --sync-favorites
spotify_to_tidal --sync-albums
```

Options can be combined, for example:

```bash
spotify_to_tidal --sync-favorites --sync-albums
```

You can also just synchronize a specific playlist by doing the following:

```bash
spotify_to_tidal --uri 1ABCDEqsABCD6EaABCDa0a # accepts playlist id or full playlist uri
```

See example_config.yml for more configuration options, and `spotify_to_tidal --help` for more options.
Expand Down
57 changes: 40 additions & 17 deletions src/spotify_to_tidal/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,60 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument('--config', default='config.yml', help='location of the config file')
parser.add_argument('--uri', help='synchronize a specific URI instead of the one in the config')
parser.add_argument('--sync-playlists', action=argparse.BooleanOptionalAction, help='synchronize the playlists')
parser.add_argument('--sync-favorites', action=argparse.BooleanOptionalAction, help='synchronize the favorites')
parser.add_argument('--sync-albums', action=argparse.BooleanOptionalAction, help='synchronize saved albums')
args = parser.parse_args()

with open(args.config, 'r') as f:
config = yaml.safe_load(f)

# Determine what to sync based on arguments and config
# If no sync options are specified via CLI args, default to syncing everything
# unless config defaults override this behavior
any_sync_args_specified = any([
args.sync_playlists is not None,
args.sync_favorites is not None,
args.sync_albums is not None
])

if any_sync_args_specified:
# Explicit args provided - only sync what's explicitly enabled
sync_playlists = args.sync_playlists if args.sync_playlists is not None else False
sync_favorites = args.sync_favorites if args.sync_favorites is not None else False
sync_albums = args.sync_albums if args.sync_albums is not None else False
else:
# No explicit args - use config defaults, but default to True if config doesn't specify
sync_playlists = config.get('sync_playlists_default', True)
sync_favorites = config.get('sync_favorites_default', True)
sync_albums = config.get('sync_albums_default', True)

print("Opening Spotify session")
spotify_session = _auth.open_spotify_session(config['spotify'])
print("Opening Tidal session")
tidal_session = _auth.open_tidal_session()
if not tidal_session.check_login():
sys.exit("Could not connect to Tidal")
if args.uri:
# if a playlist ID is explicitly provided as a command line argument then use that
spotify_playlist = spotify_session.playlist(args.uri)
tidal_playlists = _sync.get_tidal_playlists_wrapper(tidal_session)
tidal_playlist = _sync.pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists)
_sync.sync_playlists_wrapper(spotify_session, tidal_session, [tidal_playlist], config)
sync_favorites = args.sync_favorites # only sync favorites if command line argument explicitly passed
elif args.sync_favorites:
sync_favorites = True # sync only the favorites
elif config.get('sync_playlists', None):
# if the config contains a sync_playlists list of mappings then use that
_sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_playlists_from_config(spotify_session, tidal_session, config), config)
sync_favorites = args.sync_favorites is None and config.get('sync_favorites_default', True)
else:
# otherwise sync all the user playlists in the Spotify account and favorites unless explicitly disabled
_sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_user_playlist_mappings(spotify_session, tidal_session, config), config)
sync_favorites = args.sync_favorites is None and config.get('sync_favorites_default', True)

if sync_playlists:
if args.uri:
# if a playlist ID is explicitly provided as a command line argument then use that
spotify_playlist = spotify_session.playlist(args.uri)
tidal_playlists = _sync.get_tidal_playlists_wrapper(tidal_session)
playlist_mapping = _sync.pick_tidal_playlist_for_spotify_playlist(spotify_playlist, tidal_playlists)
_sync.sync_playlists_wrapper(spotify_session, tidal_session, [playlist_mapping], config)
elif config.get('sync_playlists', None):
# if the config contains a sync_playlists list of mappings then use that
_sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_playlists_from_config(spotify_session, tidal_session, config), config)
else:
# otherwise sync all the user playlists in the Spotify account
_sync.sync_playlists_wrapper(spotify_session, tidal_session, _sync.get_user_playlist_mappings(spotify_session, tidal_session, config), config)

if sync_favorites:
_sync.sync_favorites_wrapper(spotify_session, tidal_session, config)

if sync_albums:
_sync.sync_albums_wrapper(spotify_session, tidal_session, config)

if __name__ == '__main__':
main()
Expand Down
15 changes: 15 additions & 0 deletions src/spotify_to_tidal/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ def insert(self, mapping: tuple[str, int]):
self.data[mapping[0]] = mapping[1]


class AlbumMatchCache:
"""
Non-persistent mapping of spotify album ids -> tidal album ids
This should NOT be accessed concurrently from multiple processes
"""
data: Dict[str, str] = {}

def get(self, album_id: str) -> str | None:
return self.data.get(album_id, None)

def insert(self, mapping: tuple[str, str]):
self.data[mapping[0]] = mapping[1]


# Main singleton instance
failure_cache = MatchFailureDatabase()
track_match_cache = TrackMatchCache()
album_match_cache = AlbumMatchCache()
Loading