Created
May 20, 2025 08:38
-
-
Save 0x8008/4ac766fcf20f64841b1128ab66f5d209 to your computer and use it in GitHub Desktop.
Copy last.fm scrobbles to your account indefinitely
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
| import time | |
| import requests | |
| import hashlib | |
| import os | |
| from datetime import datetime | |
| # Last.fm API configuration | |
| API_KEY = "" # Replace with your Last.fm API key | |
| API_SECRET = "" # Replace with your Last.fm API secret | |
| API_ROOT = "http://ws.audioscrobbler.com/2.0/" | |
| # User configuration | |
| SOURCE_USERNAME = "source_username" # Username to get scrobbles from | |
| TARGET_AUTH_TOKEN = "your_auth_token" # Your authentication token | |
| STORAGE_FILE = "last_scrobble.txt" # File to store the timestamp of the last scrobbled track | |
| def get_auth_token(): | |
| """ | |
| Instructions to get authentication token for the target account. | |
| You need to manually follow these steps once. | |
| """ | |
| print(f"1. Visit: http://www.last.fm/api/auth?api_key={API_KEY}&cb=http://localhost:5555") | |
| print("2. Allow access in your browser") | |
| print("3. Copy the token from the resulting URL (http://localhost:5555/?token=YOUR_TOKEN)") | |
| print("4. Use this token to authorize your script") | |
| token = input("Enter the token: ") | |
| # Get session key using the token | |
| signature = f"api_key{API_KEY}methodauth.getSessiontoken{token}{API_SECRET}" | |
| api_sig = hashlib.md5(signature.encode('utf-8')).hexdigest() | |
| params = { | |
| 'method': 'auth.getSession', | |
| 'api_key': API_KEY, | |
| 'token': token, | |
| 'api_sig': api_sig, | |
| 'format': 'json' | |
| } | |
| response = requests.get(API_ROOT, params=params) | |
| data = response.json() | |
| if 'session' in data and 'key' in data['session']: | |
| return data['session']['key'] | |
| else: | |
| print("Failed to get session key:", data) | |
| return None | |
| def get_recent_tracks(username, limit=200, page=1, from_timestamp=None): | |
| """ | |
| Get recent tracks for a user with pagination and timestamp filtering | |
| """ | |
| params = { | |
| 'method': 'user.getRecentTracks', | |
| 'user': username, | |
| 'api_key': API_KEY, | |
| 'format': 'json', | |
| 'limit': limit, | |
| 'page': page | |
| } | |
| if from_timestamp: | |
| params['from'] = from_timestamp | |
| response = requests.get(API_ROOT, params=params) | |
| return response.json() | |
| def normalize_tracks(data): | |
| """ | |
| Normalize the 'track' field to always be a list | |
| """ | |
| if 'recenttracks' in data and 'track' in data['recenttracks']: | |
| tracks = data['recenttracks']['track'] | |
| if isinstance(tracks, dict): | |
| # Convert single track dict to a list with one item | |
| data['recenttracks']['track'] = [tracks] | |
| elif isinstance(tracks, str): | |
| # Handle the case where 'track' is a string | |
| print(f"Warning: 'track' field is a string: {tracks}") | |
| data['recenttracks']['track'] = [] | |
| return data | |
| def scrobble_track(session_key, artist, track, album="", timestamp=None): | |
| """ | |
| Scrobble a track to Last.fm | |
| """ | |
| if timestamp is None: | |
| timestamp = int(time.time()) | |
| params = { | |
| 'method': 'track.scrobble', | |
| 'artist': artist, | |
| 'track': track, | |
| 'timestamp': timestamp, | |
| 'api_key': API_KEY, | |
| 'sk': session_key, | |
| 'format': 'json' | |
| } | |
| if album: | |
| params['album'] = album | |
| # Create API signature | |
| signature = ''.join([f"{k}{params[k]}" for k in sorted(params.keys()) if k != 'format']) | |
| signature = f"{signature}{API_SECRET}" | |
| api_sig = hashlib.md5(signature.encode('utf-8')).hexdigest() | |
| params['api_sig'] = api_sig | |
| response = requests.post(API_ROOT, data=params) | |
| return response.json() | |
| def get_last_timestamp(): | |
| """ | |
| Get the timestamp of the last scrobbled track from storage. | |
| If no previous timestamp exists, use the current time as starting point. | |
| """ | |
| if os.path.exists(STORAGE_FILE): | |
| with open(STORAGE_FILE, 'r') as f: | |
| try: | |
| return int(f.read().strip()) | |
| except: | |
| # Use current time if file exists but is invalid | |
| current_time = int(time.time()) | |
| save_last_timestamp(current_time) | |
| return current_time | |
| # Use current time as starting point if no file exists | |
| current_time = int(time.time()) | |
| save_last_timestamp(current_time) | |
| print(f"No previous timestamp found. Starting from current time: {datetime.fromtimestamp(current_time)}") | |
| return current_time | |
| def save_last_timestamp(timestamp): | |
| """ | |
| Save the timestamp of the last scrobbled track to storage | |
| """ | |
| with open(STORAGE_FILE, 'w') as f: | |
| f.write(str(timestamp)) | |
| def continuous_transfer(source_username, session_key, poll_interval=60): | |
| """ | |
| Continuously monitor and transfer new scrobbles from source account to target account | |
| """ | |
| last_timestamp = get_last_timestamp() | |
| scrobbles_today = 0 | |
| day_start = int(time.time()) // 86400 * 86400 # Start of current day | |
| print(f"Starting continuous scrobble transfer from {source_username}...") | |
| print(f"Only scrobbling tracks newer than: {datetime.fromtimestamp(last_timestamp)}") | |
| while True: | |
| try: | |
| # Reset daily counter if we're in a new day | |
| current_day_start = int(time.time()) // 86400 * 86400 | |
| if current_day_start > day_start: | |
| day_start = current_day_start | |
| scrobbles_today = 0 | |
| print(f"New day started. Scrobble counter reset.") | |
| # Check if we've hit the daily limit | |
| if scrobbles_today >= 2800: | |
| wait_time = day_start + 86400 - int(time.time()) | |
| print(f"Daily scrobble limit reached (2800). Waiting {wait_time} seconds until midnight...") | |
| time.sleep(wait_time) | |
| continue | |
| print(f"Checking for new scrobbles since {datetime.fromtimestamp(last_timestamp)}...") | |
| data = get_recent_tracks(source_username, limit=200, from_timestamp=last_timestamp) | |
| data = normalize_tracks(data) # Normalize the tracks data structure | |
| if 'recenttracks' not in data or 'track' not in data['recenttracks']: | |
| print("No recent tracks found or API error. Retrying in a minute...") | |
| time.sleep(poll_interval) | |
| continue | |
| tracks = data['recenttracks']['track'] | |
| if not isinstance(tracks, list): | |
| print(f"Unexpected type for 'track': {type(tracks)}. Retrying in a minute...") | |
| time.sleep(poll_interval) | |
| continue | |
| if not tracks: | |
| print("No new tracks to scrobble. Checking again in a minute...") | |
| time.sleep(poll_interval) | |
| continue | |
| # Filter out currently playing track | |
| tracks = [t for t in tracks if not (isinstance(t, dict) and '@attr' in t and 'nowplaying' in t['@attr'])] | |
| if not tracks: | |
| print("Only found currently playing track. Waiting for completed scrobbles...") | |
| time.sleep(poll_interval) | |
| continue | |
| # Sort tracks by timestamp ascending | |
| try: | |
| tracks.sort(key=lambda t: int(t['date']['uts']) if isinstance(t, dict) and 'date' in t and 'uts' in t['date'] else 0) | |
| except Exception as e: | |
| print(f"Error sorting tracks: {e}") | |
| print(f"Track data structure: {tracks}") | |
| time.sleep(poll_interval) | |
| continue | |
| for track in tracks: | |
| try: | |
| if not isinstance(track, dict): | |
| print(f"Skipping invalid track format: {track}") | |
| continue | |
| artist = track['artist']['#text'] if isinstance(track['artist'], dict) and '#text' in track['artist'] else str(track['artist']) | |
| title = track['name'] | |
| album = track['album']['#text'] if 'album' in track and isinstance(track['album'], dict) and '#text' in track['album'] else "" | |
| if 'date' in track and 'uts' in track['date']: | |
| timestamp = int(track['date']['uts']) | |
| else: | |
| print("Track missing timestamp, skipping...") | |
| continue | |
| if timestamp <= last_timestamp: | |
| continue | |
| print(f"Scrobbling: {artist} - {title} ({album}) from {datetime.fromtimestamp(timestamp)}") | |
| result = scrobble_track(session_key, artist, title, album, timestamp) | |
| if 'scrobbles' in result: | |
| print("Scrobble successful!") | |
| last_timestamp = timestamp | |
| save_last_timestamp(last_timestamp) | |
| scrobbles_today += 1 | |
| else: | |
| print(f"Scrobble failed: {result}") | |
| # Be nice to the API | |
| time.sleep(1) | |
| except Exception as e: | |
| print(f"Error processing track: {e}") | |
| print(f"Track data: {track}") | |
| continue | |
| print(f"Processed all new tracks. Waiting {poll_interval} seconds before checking again...") | |
| print(f"Scrobbles today: {scrobbles_today}/2800") | |
| time.sleep(poll_interval) | |
| except Exception as e: | |
| print(f"Error occurred: {e}") | |
| print("Waiting 5 minutes before retrying...") | |
| time.sleep(300) | |
| if __name__ == "__main__": | |
| print("Last.fm Continuous Scrobble Transfer Tool") | |
| print("---------------------------------------") | |
| # Check if we need to get a new auth token | |
| if not TARGET_AUTH_TOKEN or TARGET_AUTH_TOKEN == "your_auth_token": | |
| print("You need to get an authentication token first.") | |
| session_key = get_auth_token() | |
| if session_key: | |
| print(f"Your session key is: {session_key}") | |
| print("Update the TARGET_AUTH_TOKEN in the script with this value.") | |
| exit(0) | |
| # Ask for source username if not set | |
| if SOURCE_USERNAME == "source_username": | |
| SOURCE_USERNAME = input("Enter the username to get scrobbles from: ") | |
| # Ask for poll interval | |
| poll_interval = input("How often to check for new scrobbles (in seconds, default: 60): ") | |
| poll_interval = int(poll_interval) if poll_interval.isdigit() else 60 | |
| # Start the continuous transfer | |
| continuous_transfer(SOURCE_USERNAME, TARGET_AUTH_TOKEN, poll_interval) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment