Skip to content

Instantly share code, notes, and snippets.

@tikhonp
Last active December 30, 2025 00:34
Show Gist options
  • Select an option

  • Save tikhonp/1821085d2a37d7dcbe003ee9ec6b2fbe to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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