272 lines
11 KiB
Python
Executable File
272 lines
11 KiB
Python
Executable File
#!/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() |