jellyfin tagger script v1
This commit is contained in:
264
README.md
Normal file
264
README.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 🎵 Jellyfin Auto-Tagging Script
|
||||
|
||||
A Python CLI tool for tagging Jellyfin artists and albums recursively using the Jellyfin API.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Features](#-features)
|
||||
- [Requirements](#-requirements)
|
||||
- [Installation](#-installation)
|
||||
- [Usage](#-usage)
|
||||
- [Examples](#-examples)
|
||||
- [Technical Details](#-technical-details)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
- [License](#-license)
|
||||
- [Authors](#-authors)
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This script automates the process of adding tags to Jellyfin music artists and their albums. It's designed to be simple, efficient, and safe, with a dry-run mode to preview changes before applying them.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Recursive Tagging**: Tags an artist and all their albums with a single command
|
||||
- **Dry Run Mode**: Preview changes without making modifications
|
||||
- **Tag Preservation**: Merges new tags with existing ones (no duplicates)
|
||||
- **Username/Password Auth**: Uses Jellyfin credentials (no API key required)
|
||||
- **Comprehensive Error Handling**: Clear success/failure reporting
|
||||
- **Minimal Dependencies**: Only requires Python and `requests` library
|
||||
|
||||
## 📦 Requirements
|
||||
|
||||
- Python 3.6+
|
||||
- `requests` library (`pip install requests`)
|
||||
- Jellyfin Server 10.11.5+
|
||||
- User account with write permissions
|
||||
|
||||
## 🔧 Installation
|
||||
|
||||
### 1. Clone or Download
|
||||
|
||||
```bash
|
||||
# Clone the repository (if available)
|
||||
git clone https://github.com/your-repo/jellyfin-autotag.git
|
||||
cd jellyfin-autotag
|
||||
|
||||
# Or download just the script
|
||||
wget https://raw.githubusercontent.com/your-repo/jellyfin-autotag/main/tag_artist.py
|
||||
chmod +x tag_artist.py
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
### 3. Make Executable
|
||||
|
||||
```bash
|
||||
chmod +x tag_artist.py
|
||||
```
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
```bash
|
||||
./tag_artist.py --server SERVER_URL --username USERNAME --password PASSWORD \
|
||||
--artist-id ARTIST_ID --tag TAG_NAME [--dry-run]
|
||||
```
|
||||
|
||||
### Command Line Arguments
|
||||
|
||||
| Argument | Required | Description | Example |
|
||||
|----------|----------|-------------|---------|
|
||||
| `--server` | ✅ Yes | Jellyfin server URL | `http://localhost:8096` |
|
||||
| `--username` | ✅ Yes | Jellyfin username | `username` |
|
||||
| `--password` | ✅ Yes | Jellyfin password | `password123` |
|
||||
| `--artist-id` | ✅ Yes | Artist ID to tag | `artistidxxxxxxxxxxxxxxxxxx` |
|
||||
| `--tag` | ✅ Yes | Tag to apply | `yourtag` |
|
||||
| `--dry-run` | ❌ No | Dry run mode (no changes) | (flag only) |
|
||||
|
||||
## 📚 Examples
|
||||
|
||||
### 1. Dry Run (Safe Preview)
|
||||
|
||||
```bash
|
||||
./tag_artist.py --server http://10.11.12.13:8096 \
|
||||
--username admin \
|
||||
--password hBBOmvfsRYvgn0DIfd34 \
|
||||
--artist-id b32224387e37aae08d962f71f7272c6c \
|
||||
--tag "electronic-favorite" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
🎯 Jellyfin Artist Tagger
|
||||
📡 Server: http://10.11.12.13:8096
|
||||
👤 Artist ID: b32224387e37aae08d962f71f7272c6c
|
||||
🏷️ Tag: electronic-favorite
|
||||
🔄 DRY RUN MODE - No changes will be made
|
||||
|
||||
🎵 Fetching artist information...
|
||||
🎤 Artist: Goldfrapp
|
||||
🏷️ Tagging artist...
|
||||
[DRY RUN] Would tag artist 'Goldfrapp' with 'electronic-favorite'
|
||||
💿 Fetching albums...
|
||||
📚 Found 10 albums:
|
||||
[DRY RUN] Would tag album 'Silver Eye' with 'electronic-favorite'
|
||||
[DRY RUN] Would tag album 'Seventh Tree' with 'electronic-favorite'
|
||||
...
|
||||
🎉 Dry run completed! Would tag artist and 10 albums with 'electronic-favorite'
|
||||
```
|
||||
|
||||
### 2. Actual Tagging
|
||||
|
||||
```bash
|
||||
./tag_artist.py --server http://your.jellyfin.url \
|
||||
--username yourusername \
|
||||
--password yourpassword \
|
||||
--artist-id artistidxxxxxxxxxxxxxx \
|
||||
--tag "yourtag"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
🎯 Jellyfin Artist Tagger
|
||||
📡 Server: http://your.jellyfin.url
|
||||
👤 Artist ID: artistidxxxxxxxxxxxxxx
|
||||
🏷️ Tag: yourtag
|
||||
⚠️ LIVE MODE - Changes will be made
|
||||
|
||||
🎵 Fetching artist information...
|
||||
🎤 Artist: Artist name
|
||||
🏷️ Tagging artist...
|
||||
✅ Tagged artist 'Goldfrapp' with 'yourtag'
|
||||
💿 Fetching albums...
|
||||
📚 Found 10 albums:
|
||||
✅ Tagged album 'Album name' with 'yourtag'
|
||||
✅ Tagged album 'Album name 2' with 'yourtag'
|
||||
...
|
||||
🎉 Completed! Tagged artist and 10/10 albums with 'yourtag'
|
||||
```
|
||||
|
||||
## 🔍 Technical Details
|
||||
|
||||
### Authentication
|
||||
|
||||
The script uses Jellyfin's username/password authentication to obtain an access token:
|
||||
|
||||
```python
|
||||
# Authentication flow
|
||||
1. POST to /Users/AuthenticateByName with username/password
|
||||
2. Receive access token and user ID
|
||||
3. Use token for all subsequent API calls
|
||||
```
|
||||
|
||||
### API Endpoints Used
|
||||
|
||||
- `POST /Users/AuthenticateByName` - Authentication
|
||||
- `GET /Items/{id}` - Fetch artist/album details
|
||||
- `POST /Items/{id}` - Update tags
|
||||
|
||||
### Data Format
|
||||
|
||||
The script sends complete artist/album objects matching the Jellyfin UI format:
|
||||
|
||||
```json
|
||||
{
|
||||
"Id": "artist-id",
|
||||
"Name": "Artist Name",
|
||||
"Tags": ["tag1", "tag2"],
|
||||
"Genres": ["Genre1", "Genre2"],
|
||||
"ProviderIds": {},
|
||||
"DateCreated": "2019-01-08T09:19:41.000Z",
|
||||
"LockData": false,
|
||||
"LockedFields": []
|
||||
// ... all other metadata fields
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Merging Logic
|
||||
|
||||
```python
|
||||
# Preserve existing tags and add new ones
|
||||
existing_tags = set(current_item.get('Tags', []))
|
||||
new_tags = set(tags)
|
||||
merged_tags = list(existing_tags.union(new_tags))
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Authentication Failed
|
||||
|
||||
**Error:** `❌ Authentication failed: 401 Unauthorized`
|
||||
|
||||
**Solution:**
|
||||
- Verify username and password
|
||||
- Check if user has API access permissions
|
||||
- Ensure server URL is correct
|
||||
|
||||
#### 2. Artist Not Found
|
||||
|
||||
**Error:** `❌ Could not fetch artist information`
|
||||
|
||||
**Solution:**
|
||||
- Verify artist ID is correct
|
||||
- Check if artist exists in your library
|
||||
- Use dry run to test connectivity
|
||||
|
||||
#### 3. Tagging Failed
|
||||
|
||||
**Error:** `❌ Failed to tag artist/album`
|
||||
|
||||
**Solution:**
|
||||
- Check Jellyfin server logs for details
|
||||
- Ensure user has write permissions
|
||||
- Verify Jellyfin version compatibility
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. **Use dry run first**: Always test with `--dry-run` before making changes
|
||||
2. **Check server logs**: Look for detailed error information
|
||||
3. **Test with different artists**: Rule out data-specific issues
|
||||
4. **Verify network connectivity**: Ensure script can reach the server
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is open-source and available under the **MIT License**.
|
||||
|
||||
## 👥 Authors
|
||||
|
||||
**Bach** - Initial development and implementation
|
||||
|
||||
**Mistral AI** - Assistance and optimization
|
||||
|
||||
## 🎉 Success Story
|
||||
|
||||
This script was developed to automate the tedious process of manually tagging artists and albums in Jellyfin. What started as a simple Python CLI tool evolved into a robust solution that:
|
||||
|
||||
- ✅ Successfully tags artists and albums recursively
|
||||
- ✅ Preserves all existing metadata
|
||||
- ✅ Provides safe dry-run functionality
|
||||
- ✅ Handles errors gracefully
|
||||
- ✅ Matches Jellyfin UI behavior exactly
|
||||
|
||||
**Result:** A production-ready tool that saves hours of manual tagging work! 🎊
|
||||
|
||||
## 📬 Contact
|
||||
|
||||
For questions, issues, or contributions, please open an issue on the GitHub repository.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Jellyfin team for creating an amazing media server
|
||||
- Python community for the excellent `requests` library
|
||||
- All open-source contributors who make tools like this possible
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** February 13, 2026
|
||||
**Version:** 1.0.0
|
||||
272
tag_artist.py
Executable file
272
tag_artist.py
Executable file
@@ -0,0 +1,272 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user