Last active
December 30, 2025 00:34
-
-
Save tikhonp/1821085d2a37d7dcbe003ee9ec6b2fbe to your computer and use it in GitHub Desktop.
Last.fm to Swingmusic Sync Script. Transfers loved tracks from Last.fm and marks them as liked in Swingmusic.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Last.fm to Swingmusic Sync Script | |
| Transfers loved tracks from Last.fm and marks them as liked in Swingmusic | |
| Tested with python 3.14 | |
| """ | |
| import requests # the only external dependency | |
| import json | |
| import time | |
| from typing import List, Dict, Optional, Tuple | |
| from difflib import SequenceMatcher | |
| from dataclasses import dataclass | |
| import argparse | |
| import sys | |
| @dataclass | |
| class Track: | |
| """Represents a music track""" | |
| title: str | |
| artist: str | |
| album: str = "" | |
| def __str__(self): | |
| return f"{self.artist} - {self.title}" + (f" ({self.album})" if self.album else "") | |
| class LastFMClient: | |
| """Client for Last.fm API""" | |
| def __init__(self, api_key: str, username: str): | |
| self.api_key = api_key | |
| self.username = username | |
| self.base_url = "http://ws.audioscrobbler.com/2.0/" | |
| def get_loved_tracks(self, limit: int = 50) -> List[Track]: | |
| """Fetch all loved tracks from Last.fm""" | |
| loved_tracks = [] | |
| page = 1 | |
| total_pages = 1 | |
| print(f"Fetching loved tracks from Last.fm for user: {self.username}") | |
| while page <= total_pages: | |
| params = { | |
| 'method': 'user.getlovedtracks', | |
| 'user': self.username, | |
| 'api_key': self.api_key, | |
| 'format': 'json', | |
| 'limit': limit, | |
| 'page': page | |
| } | |
| try: | |
| response = requests.get(self.base_url, params=params) | |
| response.raise_for_status() | |
| data = response.json() | |
| if 'lovedtracks' not in data: | |
| print(f"Error: {data.get('message', 'Unknown error')}") | |
| break | |
| total_pages = int(data['lovedtracks']['@attr']['totalPages']) | |
| tracks_data = data['lovedtracks']['track'] | |
| # Handle single track case | |
| if isinstance(tracks_data, dict): | |
| tracks_data = [tracks_data] | |
| for track_data in tracks_data: | |
| track = Track( | |
| title=track_data['name'], | |
| artist=track_data['artist']['name'], | |
| album=track_data.get('album', {}).get('#text', '') if isinstance(track_data.get('album'), dict) else '' | |
| ) | |
| loved_tracks.append(track) | |
| print(f" Fetched page {page}/{total_pages} ({len(tracks_data)} tracks)") | |
| page += 1 | |
| time.sleep(0.2) # Rate limiting | |
| except requests.RequestException as e: | |
| print(f"Error fetching from Last.fm: {e}") | |
| break | |
| print(f"Total loved tracks fetched: {len(loved_tracks)}") | |
| return loved_tracks | |
| class SwingMusicClient: | |
| """Client for Swingmusic API""" | |
| def __init__(self, base_url: str, username: str, password: str): | |
| self.base_url = base_url.rstrip('/') | |
| self.session = requests.Session() | |
| self._authenticate(username, password) | |
| def _authenticate(self, username: str, password: str): | |
| """Authenticate with Swingmusic""" | |
| try: | |
| # Adjust this endpoint based on your Swingmusic API | |
| auth_url = f"{self.base_url}/auth/login" | |
| response = self.session.post(auth_url, json={ | |
| 'username': username, | |
| 'password': password | |
| }) | |
| response.raise_for_status() | |
| print("Successfully authenticated with Swingmusic") | |
| except requests.RequestException as e: | |
| print(f"Authentication failed: {e}") | |
| sys.exit(1) | |
| def search_track(self, track: Track) -> Optional[List[Dict]]: | |
| """Search for a track in Swingmusic""" | |
| try: | |
| # Try searching by track title and artist | |
| search_url = f"{self.base_url}/search/top" | |
| params = { | |
| 'q': track.title, | |
| 'limit': 5, | |
| } | |
| response = self.session.get(search_url, params=params) | |
| response.raise_for_status() | |
| results = response.json() | |
| # Return results for fuzzy matching | |
| return results.get('tracks', []) if isinstance(results, dict) else [] | |
| except requests.RequestException as e: | |
| print(f"Error searching for track: {e}") | |
| return None | |
| def like_track(self, track_id: str) -> bool: | |
| """Mark a track as liked in Swingmusic""" | |
| try: | |
| # Adjust this endpoint based on your Swingmusic API | |
| like_url = f"{self.base_url}/favorites/add" | |
| data = { | |
| "type": "track", | |
| "hash": track_id | |
| } | |
| response = self.session.post(like_url, json=data) | |
| response.raise_for_status() | |
| return True | |
| except requests.RequestException as e: | |
| print(f"Error liking track: {e}") | |
| return False | |
| class FuzzyMatcher: | |
| """Handles fuzzy matching between tracks""" | |
| @staticmethod | |
| def normalize_string(s: str) -> str: | |
| """Normalize string for comparison""" | |
| return s.lower().strip().replace('-', ' ').replace('_', ' ') | |
| @staticmethod | |
| def similarity_ratio(s1: str, s2: str) -> float: | |
| """Calculate similarity ratio between two strings""" | |
| return SequenceMatcher(None, | |
| FuzzyMatcher.normalize_string(s1), | |
| FuzzyMatcher.normalize_string(s2)).ratio() | |
| @staticmethod | |
| def find_best_match(track: Track, candidates: List[Dict], threshold: float = 0.75) -> Tuple[Optional[Dict], float]: | |
| """ | |
| Find best matching track from candidates | |
| Args: | |
| track: Track to match | |
| candidates: List of candidate tracks from Swingmusic | |
| threshold: Minimum similarity threshold (0-1) | |
| Returns: | |
| Tuple of (best_match, confidence_score) | |
| """ | |
| best_match = None | |
| best_score = 0.0 | |
| for candidate in candidates: | |
| # Extract candidate info (adjust field names based on your API) | |
| candidate_title = candidate.get('title', '') | |
| candidate_artist = " ".join(map(lambda x: x.get('name', ''), iter(candidate.get('artists', [])))) | |
| candidate_album = candidate.get('album', '') | |
| # Calculate similarity scores | |
| title_score = FuzzyMatcher.similarity_ratio(track.title, candidate_title) | |
| artist_score = FuzzyMatcher.similarity_ratio(track.artist, candidate_artist) | |
| # Album score is optional but weighted lower | |
| album_score = 0.0 | |
| if track.album and candidate_album: | |
| album_score = FuzzyMatcher.similarity_ratio(track.album, candidate_album) | |
| # Weighted average: title and artist are most important | |
| combined_score = (title_score * 0.5 + artist_score * 0.4 + album_score * 0.1) | |
| # Boost score if both title and artist have high similarity | |
| if title_score > 0.8 and artist_score > 0.8: | |
| combined_score *= 1.1 # 10% bonus | |
| if combined_score > best_score: | |
| best_score = combined_score | |
| best_match = candidate | |
| # Return match only if above threshold | |
| if best_score >= threshold: | |
| return best_match, best_score | |
| return None, 0.0 | |
| def sync_tracks(lastfm_api_key: str, lastfm_username: str, | |
| swingmusic_url: str, swingmusic_username: str, | |
| swingmusic_password: str, | |
| similarity_threshold: float = 0.75, | |
| dry_run: bool = False): | |
| """ | |
| Main sync function | |
| Args: | |
| lastfm_api_key: Last.fm API key | |
| lastfm_username: Last.fm username | |
| swingmusic_url: Swingmusic server URL | |
| swingmusic_username: Swingmusic username (optional) | |
| swingmusic_password: Swingmusic password (optional) | |
| similarity_threshold: Minimum similarity for matching (0-1) | |
| dry_run: If True, don't actually like tracks | |
| """ | |
| # Initialize clients | |
| lastfm = LastFMClient(lastfm_api_key, lastfm_username) | |
| swingmusic = SwingMusicClient(swingmusic_url, swingmusic_username, swingmusic_password) | |
| # Fetch loved tracks from Last.fm | |
| loved_tracks = lastfm.get_loved_tracks() | |
| if not loved_tracks: | |
| print("No loved tracks found on Last.fm") | |
| return | |
| # Track results | |
| matched_tracks = [] | |
| unmatched_tracks = [] | |
| failed_likes = [] | |
| print(f"\n{'='*60}") | |
| print("Searching for tracks in Swingmusic...") | |
| print(f"Similarity threshold: {similarity_threshold}") | |
| print(f"{'='*60}\n") | |
| for i, track in enumerate(loved_tracks, 1): | |
| print(f"[{i}/{len(loved_tracks)}] Searching: {track}") | |
| # Search for track | |
| candidates = swingmusic.search_track(track) | |
| if not candidates: | |
| print(" ❌ No candidates found") | |
| unmatched_tracks.append(track) | |
| continue | |
| # Find best match using fuzzy matching | |
| match, confidence = FuzzyMatcher.find_best_match(track, candidates, similarity_threshold) | |
| if match: | |
| print(f" ✓ Match found (confidence: {confidence:.2%})") | |
| print(f" → {match.get('artist', '')} - {match.get('title', match.get('name', ''))}") | |
| # Like the track if not in dry-run mode | |
| if not dry_run and not match.get('is_favorite', False): | |
| track_id = match.get('id', match.get('trackhash', '')) | |
| if track_id: | |
| if swingmusic.like_track(track_id): | |
| print(" ✓ Liked successfully") | |
| matched_tracks.append((track, match, confidence)) | |
| else: | |
| print(" ❌ Failed to like") | |
| failed_likes.append(track) | |
| else: | |
| print(" ❌ No track ID found") | |
| failed_likes.append(track) | |
| else: | |
| matched_tracks.append((track, match, confidence)) | |
| print(" [DRY RUN] Would like this track") | |
| else: | |
| print(f" ❌ No match above threshold ({confidence:.2%})") | |
| unmatched_tracks.append(track) | |
| time.sleep(0.1) # Small delay between requests | |
| # Print summary | |
| print(f"\n{'='*60}") | |
| print("SYNC SUMMARY") | |
| print(f"{'='*60}") | |
| print(f"Total loved tracks: {len(loved_tracks)}") | |
| print(f"Successfully matched: {len(matched_tracks)}") | |
| print(f"Failed to match: {len(unmatched_tracks)}") | |
| if not dry_run: | |
| print(f"Failed to like: {len(failed_likes)}") | |
| print(f"{'='*60}\n") | |
| # Save unmatched tracks to file | |
| if unmatched_tracks: | |
| with open('unmatched_tracks.json', 'w', encoding='utf-8') as f: | |
| json.dump([{ | |
| 'title': t.title, | |
| 'artist': t.artist, | |
| 'album': t.album | |
| } for t in unmatched_tracks], f, indent=2, ensure_ascii=False) | |
| print("Unmatched tracks saved to: unmatched_tracks.json") | |
| # Save matched tracks to file | |
| if matched_tracks: | |
| with open('matched_tracks.json', 'w', encoding='utf-8') as f: | |
| json.dump([{ | |
| 'lastfm': {'title': t[0].title, 'artist': t[0].artist, 'album': t[0].album}, | |
| 'swingmusic': {'title': t[1].get('title', t[1].get('name', '')), | |
| 'artist': t[1].get('artist', ''), | |
| 'id': t[1].get('id', t[1].get('trackhash', ''))}, | |
| 'confidence': t[2] | |
| } for t in matched_tracks], f, indent=2, ensure_ascii=False) | |
| print("Matched tracks saved to: matched_tracks.json") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description='Sync Last.fm loved tracks to Swingmusic', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| # Basic usage | |
| python sync.py --lastfm-key YOUR_API_KEY --lastfm-user YOUR_USERNAME --swingmusic-url http://localhost:1970 | |
| # With Swingmusic authentication | |
| python sync.py --lastfm-key YOUR_API_KEY --lastfm-user YOUR_USERNAME \\ | |
| --swingmusic-url http://localhost:1970 \\ | |
| --swingmusic-user admin --swingmusic-pass yourpassword | |
| # Dry run to test without liking | |
| python sync.py --lastfm-key YOUR_API_KEY --lastfm-user YOUR_USERNAME \\ | |
| --swingmusic-url http://localhost:1970 --dry-run \\ | |
| --swingmusic-user admin --swingmusic-pass yourpassword | |
| # Lower similarity threshold for more matches | |
| python sync.py --lastfm-key YOUR_API_KEY --lastfm-user YOUR_USERNAME \\ | |
| --swingmusic-url http://localhost:1970 --threshold 0.65 \\ | |
| --swingmusic-user admin --swingmusic-pass yourpassword | |
| """ | |
| ) | |
| parser.add_argument('--lastfm-key', required=True, | |
| help='Last.fm API key (get from https://www.last.fm/api/account/create)') | |
| parser.add_argument('--lastfm-user', required=True, | |
| help='Last.fm username') | |
| parser.add_argument('--swingmusic-url', required=True, | |
| help='Swingmusic server URL (e.g., http://localhost:1970)') | |
| parser.add_argument('--swingmusic-user', required=True, | |
| help='Swingmusic username') | |
| parser.add_argument('--swingmusic-pass', required=True, | |
| help='Swingmusic password') | |
| parser.add_argument('--threshold', type=float, default=0.65, | |
| help='Similarity threshold for matching (0-1, default: 0.65)') | |
| parser.add_argument('--dry-run', action='store_true', | |
| help='Test without actually liking tracks') | |
| args = parser.parse_args() | |
| # Validate threshold | |
| if not 0 <= args.threshold <= 1: | |
| print("Error: Threshold must be between 0 and 1") | |
| sys.exit(1) | |
| # Run sync | |
| sync_tracks( | |
| lastfm_api_key=args.lastfm_key, | |
| lastfm_username=args.lastfm_user, | |
| swingmusic_url=args.swingmusic_url, | |
| swingmusic_username=args.swingmusic_user, | |
| swingmusic_password=args.swingmusic_pass, | |
| similarity_threshold=args.threshold, | |
| dry_run=args.dry_run | |
| ) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment