#!/usr/bin/env python3 """ Simple Jellyfin Artist Tagger Tags an artist and all their albums recursively using username/password authentication. Usage: python tag_artist.py --server URL --username USERNAME --password PASSWORD --artist-id ARTIST_ID --tag TAG_NAME [--dry-run] Example: python tag_artist.py --server http://localhost:8096 --username admin --password mypassword --artist-id abc123 --tag "Favorite" """ import argparse import requests import sys import json from typing import List, Dict, Any def parse_arguments() -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( description='Simple Jellyfin Artist Tagger' ) parser.add_argument('--server', required=True, help='Jellyfin server URL (e.g., http://localhost:8096)') parser.add_argument('--username', required=True, help='Jellyfin username') parser.add_argument('--password', required=True, help='Jellyfin password') parser.add_argument('--artist-id', required=True, help='Artist ID to tag') parser.add_argument('--tag', required=True, help='Tag name to apply') parser.add_argument('--dry-run', action='store_true', help='Dry run - show what would be done without making changes') return parser.parse_args() class JellyfinTagger: """Simple Jellyfin tagger using username/password authentication.""" def __init__(self, server_url: str, username: str, password: str): self.server_url = server_url.rstrip('/') self.username = username self.password = password self.session = requests.Session() self.user_id = None self.access_token = None # Set up headers self.session.headers.update({ 'Content-Type': 'application/json', 'X-Emby-Authorization': 'MediaBrowser Client="SimpleTagger", Device="Python Script", DeviceId="simple-tagger", Version="1.0.0"' }) # Authenticate self._authenticate() def _authenticate(self): """Authenticate with Jellyfin using username and password.""" auth_url = f"{self.server_url}/Users/AuthenticateByName" auth_data = { 'Username': self.username, 'Pw': self.password } try: response = self.session.post(auth_url, json=auth_data) response.raise_for_status() auth_result = response.json() self.access_token = auth_result.get('AccessToken') self.user_id = auth_result.get('User', {}).get('Id') # Update headers with access token self.session.headers.update({ 'X-Emby-Token': self.access_token }) except requests.exceptions.RequestException as e: print(f"❌ Authentication failed: {e}") sys.exit(1) def _make_request(self, method: str, endpoint: str, **kwargs) -> Any: """Make a request to the Jellyfin API.""" url = f"{self.server_url}/{endpoint.lstrip('/')}" try: response = self.session.request(method, url, **kwargs) response.raise_for_status() if response.content: return response.json() return None except requests.exceptions.HTTPError as e: print(f"❌ HTTP error {e.response.status_code}: {e}") if hasattr(e, 'response') and e.response.content: print(f" Response: {e.response.text}") return None except requests.exceptions.RequestException as e: print(f"❌ Request failed: {e}") if hasattr(e, 'response'): print(f" Status: {e.response.status_code}") if e.response.content: print(f" Content: {e.response.text}") return None def get_artist(self, artist_id: str) -> Dict[str, Any]: """Get artist information.""" params = {'userId': self.user_id} return self._make_request('GET', f"Items/{artist_id}", params=params) def get_artist_albums(self, artist_id: str) -> List[Dict[str, Any]]: """Get all albums by an artist.""" params = { 'artistIds': artist_id, 'includeItemTypes': 'MusicAlbum', 'recursive': True, 'fields': 'Tags', 'userId': self.user_id } result = self._make_request('GET', "Items", params=params) return result.get('Items', []) if result else [] def update_item_tags(self, item_id: str, tags: List[str], dry_run: bool = True) -> bool: """Update tags for a specific item using the same format as Jellyfin UI.""" # Get current item params = {'userId': self.user_id} current_item = self._make_request('GET', f"Items/{item_id}", params=params) if not current_item: return False # Merge new tags with existing ones existing_tags = set(current_item.get('Tags', [])) new_tags = set(tags) merged_tags = list(existing_tags.union(new_tags)) if dry_run: return True # Prepare update payload matching Jellyfin UI format update_data = { 'Id': item_id, 'Name': current_item.get('Name', ''), 'OriginalTitle': current_item.get('OriginalTitle', ''), 'ForcedSortName': current_item.get('ForcedSortName', ''), 'CommunityRating': current_item.get('CommunityRating', ''), 'CriticRating': current_item.get('CriticRating', ''), 'IndexNumber': current_item.get('IndexNumber'), 'AirsBeforeSeasonNumber': current_item.get('AirsBeforeSeasonNumber', ''), 'AirsAfterSeasonNumber': current_item.get('AirsAfterSeasonNumber', ''), 'AirsBeforeEpisodeNumber': current_item.get('AirsBeforeEpisodeNumber', ''), 'ParentIndexNumber': current_item.get('ParentIndexNumber'), 'DisplayOrder': current_item.get('DisplayOrder', ''), 'Album': current_item.get('Album', ''), 'AlbumArtists': current_item.get('AlbumArtists', []), 'ArtistItems': current_item.get('ArtistItems', []), 'Overview': current_item.get('Overview', ''), 'Status': current_item.get('Status', ''), 'AirDays': current_item.get('AirDays', []), 'AirTime': current_item.get('AirTime', ''), 'Genres': current_item.get('Genres', []), 'Tags': merged_tags, # Our updated tags 'Studios': current_item.get('Studios', []), 'PremiereDate': current_item.get('PremiereDate'), 'DateCreated': current_item.get('DateCreated'), 'EndDate': current_item.get('EndDate'), 'ProductionYear': current_item.get('ProductionYear', ''), 'Height': current_item.get('Height', ''), 'AspectRatio': current_item.get('AspectRatio', ''), 'Video3DFormat': current_item.get('Video3DFormat', ''), 'OfficialRating': current_item.get('OfficialRating', ''), 'CustomRating': current_item.get('CustomRating', ''), 'People': current_item.get('People', []), 'LockData': current_item.get('LockData', False), 'LockedFields': current_item.get('LockedFields', []), 'ProviderIds': current_item.get('ProviderIds', {}), 'PreferredMetadataLanguage': current_item.get('PreferredMetadataLanguage', ''), 'PreferredMetadataCountryCode': current_item.get('PreferredMetadataCountryCode', ''), 'Taglines': current_item.get('Taglines', []) } # Try the update update_params = {'userId': self.user_id} result = self._make_request('POST', f"Items/{item_id}", json=update_data, params=update_params) # Consider it successful if we get any response (including 204 No Content) return result is not False def main(): """Main function.""" args = parse_arguments() print("🎯 Jellyfin Artist Tagger") print(f"📡 Server: {args.server}") print(f"👤 Artist ID: {args.artist_id}") print(f"🏷️ Tag: {args.tag}") if args.dry_run: print("🔄 DRY RUN MODE - No changes will be made") else: print("⚠️ LIVE MODE - Changes will be made") print() # Initialize tagger tagger = JellyfinTagger(args.server, args.username, args.password) # Get artist information print("🎵 Fetching artist information...") artist = tagger.get_artist(args.artist_id) if not artist: print("❌ Could not fetch artist information") sys.exit(1) artist_name = artist.get('Name', 'Unknown Artist') print(f"🎤 Artist: {artist_name}") # Tag the artist print(f"🏷️ Tagging artist...") if args.dry_run: print(f"[DRY RUN] Would tag artist '{artist_name}' with '{args.tag}'") artist_success = True else: artist_success = tagger.update_item_tags(args.artist_id, [args.tag], False) if artist_success: print(f"✅ Tagged artist '{artist_name}' with '{args.tag}'") else: print(f"❌ Failed to tag artist '{artist_name}'") # Get albums print("💿 Fetching albums...") albums = tagger.get_artist_albums(args.artist_id) if not albums: print("❌ No albums found for this artist") else: print(f"📚 Found {len(albums)} albums:") album_success_count = 0 for album in albums: album_name = album.get('Name', 'Unknown Album') album_id = album.get('Id') if args.dry_run: print(f"[DRY RUN] Would tag album '{album_name}' with '{args.tag}'") album_success_count += 1 else: success = tagger.update_item_tags(album_id, [args.tag], False) if success: print(f"✅ Tagged album '{album_name}' with '{args.tag}'") album_success_count += 1 else: print(f"❌ Failed to tag album '{album_name}'") print() if args.dry_run: print(f"🎉 Dry run completed! Would tag artist and {len(albums)} albums with '{args.tag}'") else: print(f"🎉 Completed! Tagged artist and {album_success_count}/{len(albums)} albums with '{args.tag}'") if __name__ == "__main__": main()