Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.
Implementation:
* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
Navidrome clients. Each uses the server's NATIVE append API:
- Plex: `existing_playlist.addItems(new_tracks)`
- Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
- Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
Falls back to `create_playlist` when the playlist doesn't exist
yet (first sync). No delete-recreate, no backup playlist created
(preserves playlist creation date + metadata + non-soulsync-managed
tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
song-id for Navidrome) — never re-adds a track already on the
playlist. Server-native identity, not fuzzy title+artist match,
so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
kwarg. Single if/else branch dispatches to `append_to_playlist` or
`update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
and the `/api/sync/start` HTTP handler. Validation on the API rejects
unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
to the Sync button in both modal renderers (sync-spotify.js for
Spotify playlists, sync-services.js for Deezer ARL playlists).
`startPlaylistSync` reads the select at click time; missing select
(other callers like discover.js) defaults to 'replace' so backward
compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
hides the Sync button entirely on it via `_isSoulsyncStandalone` —
dispatch never reaches that path, no defensive fallback needed.
15 new tests pin per-server append behavior:
- missing playlist → create_playlist delegation
- dedup filtering (existing IDs skipped, only new tracks added)
- empty new-track set short-circuits without API call
- failure paths return False without raising
- contract listing (KNOWN_PER_SERVER_METHODS includes
'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)
Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).
WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).
Closes CJFC discord request.
1893 lines
78 KiB
Python
1893 lines
78 KiB
Python
import requests
|
|
import time
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime
|
|
import json
|
|
from utils.logging_config import get_logger
|
|
from config.settings import config_manager
|
|
|
|
# Shared dataclasses live in the neutral media_server package — every
|
|
# server client used to define a near-identical XTrackInfo /
|
|
# XPlaylistInfo. Lifted to one canonical type so consumers (matching
|
|
# engine, sync service) get a single import.
|
|
from core.media_server.types import TrackInfo, PlaylistInfo
|
|
|
|
logger = get_logger("jellyfin_client")
|
|
|
|
|
|
class JellyfinArtist:
|
|
"""Wrapper class to mimic Plex artist object interface"""
|
|
def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'):
|
|
self._data = jellyfin_data
|
|
self._client = client
|
|
self.ratingKey = jellyfin_data.get('Id', '')
|
|
self.title = jellyfin_data.get('Name', 'Unknown Artist')
|
|
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
|
|
|
|
# Create genres property from Jellyfin data (empty list for now since data structure needs investigation)
|
|
self.genres = []
|
|
# TODO: Map Jellyfin genre data to match Plex format
|
|
|
|
# Create summary property from Jellyfin data (used for timestamp storage)
|
|
self.summary = jellyfin_data.get('Overview', '') or ''
|
|
|
|
# Create thumb property for artist images
|
|
self.thumb = self._get_artist_image_url()
|
|
|
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
|
"""Parse Jellyfin date string to datetime"""
|
|
if not date_str:
|
|
return None
|
|
try:
|
|
# Jellyfin uses ISO format: 2023-12-01T10:30:00.000Z
|
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
|
except:
|
|
return None
|
|
|
|
def _get_artist_image_url(self) -> Optional[str]:
|
|
"""Generate Jellyfin artist image URL"""
|
|
if not self.ratingKey:
|
|
return None
|
|
|
|
# Jellyfin primary image URL format
|
|
return f"/Items/{self.ratingKey}/Images/Primary"
|
|
|
|
def albums(self) -> List['JellyfinAlbum']:
|
|
"""Get all albums for this artist"""
|
|
return self._client.get_albums_for_artist(self.ratingKey)
|
|
|
|
class JellyfinAlbum:
|
|
"""Wrapper class to mimic Plex album object interface"""
|
|
def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'):
|
|
self._data = jellyfin_data
|
|
self._client = client
|
|
self.ratingKey = jellyfin_data.get('Id', '')
|
|
self.title = jellyfin_data.get('Name', 'Unknown Album')
|
|
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
|
|
self._artist_id = jellyfin_data.get('AlbumArtists', [{}])[0].get('Id', '') if jellyfin_data.get('AlbumArtists') else ''
|
|
|
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
|
if not date_str:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
|
except:
|
|
return None
|
|
|
|
def artist(self) -> Optional[JellyfinArtist]:
|
|
"""Get the album artist"""
|
|
if self._artist_id:
|
|
return self._client.get_artist_by_id(self._artist_id)
|
|
return None
|
|
|
|
def tracks(self) -> List['JellyfinTrack']:
|
|
"""Get all tracks for this album"""
|
|
return self._client.get_tracks_for_album(self.ratingKey)
|
|
|
|
class JellyfinTrack:
|
|
"""Wrapper class to mimic Plex track object interface"""
|
|
def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'):
|
|
self._data = jellyfin_data
|
|
self._client = client
|
|
self.ratingKey = jellyfin_data.get('Id', '')
|
|
self.title = jellyfin_data.get('Name', 'Unknown Track')
|
|
self.duration = jellyfin_data.get('RunTimeTicks', 0) // 10000 # Convert from ticks to milliseconds
|
|
self.trackNumber = jellyfin_data.get('IndexNumber')
|
|
self.year = jellyfin_data.get('ProductionYear')
|
|
self.userRating = jellyfin_data.get('UserData', {}).get('Rating')
|
|
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
|
|
|
|
self._album_id = jellyfin_data.get('AlbumId', '')
|
|
self._artist_ids = [artist.get('Id', '') for artist in jellyfin_data.get('ArtistItems', [])]
|
|
|
|
# File path and media info (used by quality scanner and DB update)
|
|
self.path = jellyfin_data.get('Path')
|
|
# Extract bitrate + file size from MediaSources if available.
|
|
# `file_size` powers the Library Disk Usage card on the Stats
|
|
# page — populated free during the deep scan from data Jellyfin
|
|
# already returns in MediaSources[].
|
|
media_sources = jellyfin_data.get('MediaSources', [])
|
|
self.bitRate = (media_sources[0].get('Bitrate') or 0) // 1000 if media_sources else None # Convert bps to kbps
|
|
self.file_size = (media_sources[0].get('Size') or 0) if media_sources else None
|
|
if self.file_size == 0:
|
|
self.file_size = None
|
|
|
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
|
if not date_str:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
|
except:
|
|
return None
|
|
|
|
def artist(self) -> Optional[JellyfinArtist]:
|
|
"""Get the primary track artist"""
|
|
if self._artist_ids:
|
|
return self._client.get_artist_by_id(self._artist_ids[0])
|
|
return None
|
|
|
|
def album(self) -> Optional[JellyfinAlbum]:
|
|
"""Get the track's album"""
|
|
if self._album_id:
|
|
return self._client.get_album_by_id(self._album_id)
|
|
return None
|
|
|
|
from core.media_server.contract import MediaServerClient
|
|
|
|
|
|
class JellyfinClient(MediaServerClient):
|
|
def __init__(self):
|
|
self.base_url: Optional[str] = None
|
|
self.api_key: Optional[str] = None
|
|
self.user_id: Optional[str] = None
|
|
self.music_library_id: Optional[str] = None
|
|
self._connection_attempted = False
|
|
self._is_connecting = False
|
|
|
|
# Performance optimization: comprehensive caches
|
|
self._album_cache = {}
|
|
self._track_cache = {}
|
|
self._artist_cache = {}
|
|
|
|
# Metadata-only mode flag for performance optimization
|
|
self._metadata_only_mode = False
|
|
self._all_albums_cache = None
|
|
self._all_tracks_cache = None
|
|
self._cache_populated = False
|
|
|
|
# Progress callback for UI updates during caching
|
|
self._progress_callback = None
|
|
|
|
def set_progress_callback(self, callback):
|
|
"""Set callback function for cache progress updates: callback(message)"""
|
|
self._progress_callback = callback
|
|
|
|
def reload_config(self):
|
|
"""Reset connection state so next ensure_connection() re-reads config.
|
|
Called when settings are saved to pick up URL/API key changes."""
|
|
self.base_url = None
|
|
self.api_key = None
|
|
self.user_id = None
|
|
self.music_library_id = None
|
|
self._connection_attempted = False
|
|
self.clear_cache()
|
|
logger.info("Jellyfin client config reset — will reconnect with new settings")
|
|
|
|
def ensure_connection(self) -> bool:
|
|
"""Ensure connection to Jellyfin server with lazy initialization."""
|
|
if self._connection_attempted:
|
|
return self.base_url is not None and self.api_key is not None
|
|
|
|
if self._is_connecting:
|
|
return False
|
|
|
|
self._is_connecting = True
|
|
try:
|
|
self._setup_client()
|
|
return self.base_url is not None and self.api_key is not None
|
|
finally:
|
|
self._is_connecting = False
|
|
self._connection_attempted = True
|
|
|
|
def _setup_client(self):
|
|
"""Setup Jellyfin client configuration"""
|
|
config = config_manager.get_jellyfin_config()
|
|
|
|
if not config.get('base_url'):
|
|
logger.warning("Jellyfin server URL not configured")
|
|
return
|
|
|
|
if not config.get('api_key'):
|
|
logger.warning("Jellyfin API key not configured")
|
|
return
|
|
|
|
self.base_url = config['base_url'].rstrip('/')
|
|
self.api_key = config['api_key']
|
|
|
|
try:
|
|
# Test connection and get system info
|
|
response = self._make_request('/System/Info')
|
|
if response:
|
|
server_name = response.get('ServerName', 'Unknown')
|
|
logger.info(f"Successfully connected to Jellyfin server: {server_name}")
|
|
|
|
# Get all users
|
|
users_response = self._make_request('/Users')
|
|
|
|
if not users_response:
|
|
logger.error("No users found on Jellyfin server")
|
|
return
|
|
|
|
# Check for a saved user preference
|
|
from database.music_database import MusicDatabase
|
|
db = MusicDatabase()
|
|
preferred_user = db.get_preference('jellyfin_user')
|
|
|
|
valid_user_found = False
|
|
|
|
# If a preferred user is saved, try that user first
|
|
if preferred_user:
|
|
for user in users_response:
|
|
if user.get('Name') == preferred_user:
|
|
candidate_id = user['Id']
|
|
try:
|
|
views_response = self._make_request(f'/Users/{candidate_id}/Views')
|
|
if views_response:
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music':
|
|
self.user_id = candidate_id
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Using preferred user: {preferred_user} (Music Library: {view.get('Name')})")
|
|
valid_user_found = True
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Preferred user {preferred_user} failed: {e}")
|
|
break
|
|
|
|
# Check for a saved library preference for the selected user
|
|
preferred_library = db.get_preference('jellyfin_music_library')
|
|
|
|
# Fall back to auto-detect if preference is missing or invalid
|
|
if not valid_user_found:
|
|
for user in users_response:
|
|
candidate_id = user['Id']
|
|
candidate_name = user.get('Name', 'Unknown')
|
|
|
|
try:
|
|
views_response = self._make_request(f'/Users/{candidate_id}/Views')
|
|
|
|
if views_response:
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
|
|
if collection_type == 'music':
|
|
self.user_id = candidate_id
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Using user: {candidate_name} (Music Library: {view.get('Name')})")
|
|
valid_user_found = True
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Skipping user {candidate_name} due to error: {e}")
|
|
continue
|
|
|
|
if valid_user_found:
|
|
break
|
|
|
|
# If we found a user, check if there's a saved library preference to apply
|
|
if valid_user_found and preferred_library:
|
|
try:
|
|
views_response = self._make_request(f'/Users/{self.user_id}/Views')
|
|
if views_response:
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music' and view.get('Name') == preferred_library:
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Applied saved library preference: {preferred_library}")
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Could not apply library preference: {e}")
|
|
|
|
if not valid_user_found:
|
|
logger.error("Connected to Jellyfin, but could not find any user with access to a Music library")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to Jellyfin server: {e}")
|
|
self.base_url = None
|
|
self.api_key = None
|
|
|
|
def _find_music_library(self):
|
|
"""Find the music library in Jellyfin"""
|
|
if not self.user_id:
|
|
return
|
|
|
|
try:
|
|
views_response = self._make_request(f'/Users/{self.user_id}/Views')
|
|
if not views_response:
|
|
return
|
|
|
|
for view in views_response.get('Items', []):
|
|
if view.get('CollectionType') == 'music':
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Found music library: {view.get('Name', 'Music')}")
|
|
break
|
|
|
|
if not self.music_library_id:
|
|
logger.warning("No music library found on Jellyfin server")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error finding music library: {e}")
|
|
|
|
def get_available_music_libraries(self) -> List[Dict[str, str]]:
|
|
"""Get list of all available music libraries from Jellyfin"""
|
|
if not self.ensure_connection() or not self.user_id:
|
|
return []
|
|
|
|
try:
|
|
views_response = self._make_request(f'/Users/{self.user_id}/Views')
|
|
if not views_response:
|
|
return []
|
|
|
|
music_libraries = []
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music':
|
|
music_libraries.append({
|
|
'title': view.get('Name', 'Music'),
|
|
'key': str(view['Id'])
|
|
})
|
|
|
|
logger.debug(f"Found {len(music_libraries)} music libraries")
|
|
return music_libraries
|
|
except Exception as e:
|
|
logger.error(f"Error getting music libraries: {e}")
|
|
return []
|
|
|
|
def set_music_library_by_name(self, library_name: str) -> bool:
|
|
"""Set the active music library by name"""
|
|
if not self.user_id:
|
|
return False
|
|
|
|
try:
|
|
views_response = self._make_request(f'/Users/{self.user_id}/Views')
|
|
if not views_response:
|
|
return False
|
|
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music' and view.get('Name') == library_name:
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Set music library to: {library_name}")
|
|
|
|
# Store preference in database
|
|
from database.music_database import MusicDatabase
|
|
db = MusicDatabase()
|
|
db.set_preference('jellyfin_music_library', library_name)
|
|
|
|
return True
|
|
|
|
logger.warning(f"Music library '{library_name}' not found")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error setting music library: {e}")
|
|
return False
|
|
|
|
def get_available_users(self) -> List[Dict[str, str]]:
|
|
"""Get list of users that have music libraries"""
|
|
if not self.ensure_connection():
|
|
return []
|
|
|
|
try:
|
|
users_response = self._make_request('/Users')
|
|
if not users_response:
|
|
return []
|
|
|
|
users_with_music = []
|
|
for user in users_response:
|
|
candidate_id = user['Id']
|
|
candidate_name = user.get('Name', 'Unknown')
|
|
|
|
try:
|
|
views_response = self._make_request(f'/Users/{candidate_id}/Views')
|
|
if views_response:
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music':
|
|
users_with_music.append({
|
|
'id': candidate_id,
|
|
'name': candidate_name
|
|
})
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Skipping user {candidate_name} during enumeration: {e}")
|
|
continue
|
|
|
|
logger.debug(f"Found {len(users_with_music)} users with music libraries")
|
|
return users_with_music
|
|
except Exception as e:
|
|
logger.error(f"Error getting available users: {e}")
|
|
return []
|
|
|
|
def set_user_by_name(self, username: str) -> bool:
|
|
"""Set the active user by name, re-discover their music library, and save preference"""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
users_response = self._make_request('/Users')
|
|
if not users_response:
|
|
return False
|
|
|
|
for user in users_response:
|
|
if user.get('Name') == username:
|
|
candidate_id = user['Id']
|
|
|
|
# Verify this user has a music library
|
|
views_response = self._make_request(f'/Users/{candidate_id}/Views')
|
|
if not views_response:
|
|
return False
|
|
|
|
for view in views_response.get('Items', []):
|
|
collection_type = (view.get('CollectionType') or '').lower()
|
|
if collection_type == 'music':
|
|
self.user_id = candidate_id
|
|
self.music_library_id = view['Id']
|
|
logger.info(f"Switched to user: {username} (Music Library: {view.get('Name')})")
|
|
|
|
# Save preference to database
|
|
from database.music_database import MusicDatabase
|
|
db = MusicDatabase()
|
|
db.set_preference('jellyfin_user', username)
|
|
|
|
# Clear caches since we switched users
|
|
self.clear_cache()
|
|
|
|
return True
|
|
|
|
logger.warning(f"User '{username}' has no music library")
|
|
return False
|
|
|
|
logger.warning(f"User '{username}' not found")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error setting user: {e}")
|
|
return False
|
|
|
|
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
"""Make authenticated request to Jellyfin API"""
|
|
if not self.base_url or not self.api_key:
|
|
return None
|
|
|
|
url = f"{self.base_url}{endpoint}"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
# Use configurable timeout for bulk operations (lots of data)
|
|
is_bulk_operation = params and params.get('Limit', 0) > 1000
|
|
config = config_manager.get_jellyfin_config()
|
|
bulk_timeout = int(config.get('api_timeout', 120))
|
|
timeout = bulk_timeout if is_bulk_operation else max(5, bulk_timeout // 6)
|
|
|
|
try:
|
|
response = requests.get(url, headers=headers, params=params, timeout=timeout)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error(f"Jellyfin API request failed: {e}")
|
|
return None
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Failed to parse Jellyfin response: {e}")
|
|
return None
|
|
|
|
def _populate_aggressive_cache(self):
|
|
"""Aggressively pre-populate ALL caches to eliminate individual API calls"""
|
|
if self._cache_populated:
|
|
return
|
|
|
|
# Check if we're in metadata-only mode and skip expensive operations
|
|
if self._metadata_only_mode:
|
|
logger.info("Skipping cache population for metadata-only operation")
|
|
self._cache_populated = True
|
|
return
|
|
|
|
logger.info("Starting aggressive Jellyfin cache population to eliminate slow individual API calls...")
|
|
if self._progress_callback:
|
|
self._progress_callback("Fetching all tracks in bulk...")
|
|
|
|
try:
|
|
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
|
|
logger.info("Fetching all tracks in bulk...")
|
|
all_tracks = []
|
|
start_index = 0
|
|
limit = 10000
|
|
consecutive_failures = 0
|
|
|
|
while True:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True,
|
|
'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
|
|
'SortBy': 'AlbumId,IndexNumber',
|
|
'SortOrder': 'Ascending',
|
|
'StartIndex': start_index,
|
|
'Limit': limit
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
|
|
if not response:
|
|
consecutive_failures += 1
|
|
# Wait before retrying — the server may still be processing the timed-out request
|
|
time.sleep(5)
|
|
if limit > 1000:
|
|
limit = limit // 2
|
|
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
|
logger.warning(f"Track fetch failed - reducing batch size to {limit}")
|
|
continue
|
|
elif consecutive_failures >= 2:
|
|
logger.warning("Multiple track fetch failures at minimum batch size - stopping")
|
|
break
|
|
else:
|
|
logger.warning("Track fetch failed at minimum batch size - retrying once")
|
|
continue
|
|
|
|
consecutive_failures = 0
|
|
batch_tracks = response.get('Items', [])
|
|
if not batch_tracks:
|
|
break
|
|
|
|
all_tracks.extend(batch_tracks)
|
|
|
|
if len(batch_tracks) < limit:
|
|
break
|
|
|
|
start_index += limit
|
|
progress_msg = f"Fetched {len(all_tracks)} tracks so far..."
|
|
logger.info(f" {progress_msg} (batch size: {limit})")
|
|
if self._progress_callback:
|
|
self._progress_callback(progress_msg)
|
|
|
|
# Group tracks by album ID for instant lookup
|
|
self._track_cache = {}
|
|
for track_data in all_tracks:
|
|
album_id = track_data.get('AlbumId')
|
|
if album_id:
|
|
if album_id not in self._track_cache:
|
|
self._track_cache[album_id] = []
|
|
self._track_cache[album_id].append(JellyfinTrack(track_data, self))
|
|
|
|
logger.info(f"Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums")
|
|
if self._progress_callback:
|
|
self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...")
|
|
|
|
# STEP 2: Fetch all albums in bulk (same proven pattern)
|
|
logger.info("Fetching all albums in bulk...")
|
|
all_albums = []
|
|
start_index = 0
|
|
limit = 10000
|
|
consecutive_failures = 0
|
|
|
|
while True:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True,
|
|
'Fields': 'AlbumArtists,Artists',
|
|
'SortBy': 'SortName',
|
|
'SortOrder': 'Ascending',
|
|
'StartIndex': start_index,
|
|
'Limit': limit
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
|
|
if not response:
|
|
consecutive_failures += 1
|
|
# Wait before retrying — the server may still be processing the timed-out request
|
|
time.sleep(5)
|
|
if limit > 1000:
|
|
limit = limit // 2
|
|
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
|
logger.warning(f"Album fetch failed - reducing batch size to {limit}")
|
|
continue
|
|
elif consecutive_failures >= 2:
|
|
logger.warning("Multiple album fetch failures at minimum batch size - stopping")
|
|
break
|
|
else:
|
|
logger.warning("Album fetch failed at minimum batch size - retrying once")
|
|
continue
|
|
|
|
consecutive_failures = 0
|
|
batch_albums = response.get('Items', [])
|
|
if not batch_albums:
|
|
break
|
|
|
|
all_albums.extend(batch_albums)
|
|
|
|
if len(batch_albums) < limit:
|
|
break
|
|
|
|
start_index += limit
|
|
progress_msg = f"Fetched {len(all_albums)} albums so far..."
|
|
logger.info(f" {progress_msg} (batch size: {limit})")
|
|
if self._progress_callback:
|
|
self._progress_callback(progress_msg)
|
|
|
|
# Group albums by artist ID for instant lookup
|
|
self._album_cache = {}
|
|
for album_data in all_albums:
|
|
album_artists = album_data.get('AlbumArtists', [])
|
|
for artist in album_artists:
|
|
artist_id = artist.get('Id')
|
|
if artist_id:
|
|
if artist_id not in self._album_cache:
|
|
self._album_cache[artist_id] = []
|
|
self._album_cache[artist_id].append(JellyfinAlbum(album_data, self))
|
|
|
|
logger.info(f"Cached {len(all_albums)} albums for {len(self._album_cache)} artists")
|
|
|
|
self._cache_populated = True
|
|
logger.info("AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!")
|
|
if self._progress_callback:
|
|
self._progress_callback("Cache complete! Now processing artists...")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in aggressive cache population: {e}")
|
|
# Don't set cache_populated to True on error so we can retry
|
|
|
|
def _populate_targeted_cache_for_albums(self, albums: List['JellyfinAlbum']):
|
|
"""Populate cache only for tracks in specific albums - much faster for incremental updates"""
|
|
if not albums:
|
|
return
|
|
|
|
logger.info(f"Starting targeted Jellyfin cache for {len(albums)} recent albums...")
|
|
if self._progress_callback:
|
|
self._progress_callback(f"Caching tracks for {len(albums)} recent albums...")
|
|
|
|
try:
|
|
album_ids = [album.ratingKey for album in albums]
|
|
cached_tracks = 0
|
|
|
|
# Process albums individually - Jellyfin API requires ParentId per album
|
|
for i, album_id in enumerate(album_ids):
|
|
try:
|
|
# Fetch tracks for this specific album
|
|
params = {
|
|
'ParentId': album_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True,
|
|
'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
|
|
'SortBy': 'IndexNumber',
|
|
'SortOrder': 'Ascending',
|
|
'Limit': 200 # Most albums won't have more than 200 tracks
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if response:
|
|
album_tracks = response.get('Items', [])
|
|
|
|
# Cache tracks for this album
|
|
if album_tracks:
|
|
self._track_cache[album_id] = []
|
|
for track_data in album_tracks:
|
|
self._track_cache[album_id].append(JellyfinTrack(track_data, self))
|
|
cached_tracks += 1
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error caching tracks for album {album_id}: {e}")
|
|
continue
|
|
|
|
# Progress update every 50 albums
|
|
if (i + 1) % 50 == 0 or i == len(album_ids) - 1:
|
|
progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..."
|
|
logger.info(f" {progress_msg}")
|
|
if self._progress_callback:
|
|
self._progress_callback(progress_msg)
|
|
|
|
logger.info(f"Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums")
|
|
if self._progress_callback:
|
|
self._progress_callback("Targeted cache complete! Now checking for new tracks...")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in targeted cache population: {e}")
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Check if connected to Jellyfin server"""
|
|
if not self._connection_attempted:
|
|
if not self._is_connecting:
|
|
self.ensure_connection()
|
|
return (self.base_url is not None and
|
|
self.api_key is not None and
|
|
self.user_id is not None and
|
|
self.music_library_id is not None)
|
|
|
|
def get_all_artists(self) -> List[JellyfinArtist]:
|
|
"""Get all artists from the music library - matches Plex interface"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
logger.error("Not connected to Jellyfin server or no music library")
|
|
return []
|
|
|
|
# PERFORMANCE OPTIMIZATION: Pre-populate ALL caches upfront for massive speedup
|
|
self._populate_aggressive_cache()
|
|
|
|
try:
|
|
# Use proper AlbumArtists endpoint to match Jellyfin's "Album Artists" tab
|
|
# This should return 3,966 artists including Weird Al
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'Recursive': True,
|
|
'SortBy': 'SortName',
|
|
'SortOrder': 'Ascending'
|
|
}
|
|
|
|
response = self._make_request('/Artists/AlbumArtists', params)
|
|
if not response:
|
|
return []
|
|
|
|
artists = []
|
|
for item in response.get('Items', []):
|
|
artist = JellyfinArtist(item, self)
|
|
# Cache the artist for quick lookup
|
|
self._artist_cache[artist.ratingKey] = artist
|
|
artists.append(artist)
|
|
|
|
logger.info(f"Retrieved {len(artists)} album artists from Jellyfin AlbumArtists endpoint (with aggressive caching)")
|
|
return artists
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting artists from Jellyfin: {e}")
|
|
return []
|
|
|
|
def get_all_artist_ids(self) -> set:
|
|
"""Get all artist IDs from Jellyfin (lightweight, for removal detection).
|
|
Does NOT trigger _populate_aggressive_cache()."""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return set()
|
|
try:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'Recursive': True,
|
|
'Fields': '',
|
|
'EnableTotalRecordCount': False
|
|
}
|
|
response = self._make_request('/Artists/AlbumArtists', params)
|
|
if not response:
|
|
return set()
|
|
ids = {item['Id'] for item in response.get('Items', []) if 'Id' in item}
|
|
logger.info(f"Retrieved {len(ids)} artist IDs from Jellyfin (lightweight)")
|
|
return ids
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist IDs from Jellyfin: {e}")
|
|
return set()
|
|
|
|
def get_all_album_ids(self) -> set:
|
|
"""Get all album IDs from Jellyfin (lightweight, paginated, for removal detection).
|
|
Does NOT trigger _populate_aggressive_cache()."""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return set()
|
|
try:
|
|
all_ids = set()
|
|
start_index = 0
|
|
limit = 10000
|
|
while True:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True,
|
|
'Fields': '',
|
|
'SortBy': 'SortName',
|
|
'SortOrder': 'Ascending',
|
|
'StartIndex': start_index,
|
|
'Limit': limit,
|
|
'EnableTotalRecordCount': False
|
|
}
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
break
|
|
items = response.get('Items', [])
|
|
if not items:
|
|
break
|
|
all_ids.update(item['Id'] for item in items if 'Id' in item)
|
|
if len(items) < limit:
|
|
break
|
|
start_index += limit
|
|
logger.info(f"Retrieved {len(all_ids)} album IDs from Jellyfin (lightweight)")
|
|
return all_ids
|
|
except Exception as e:
|
|
logger.error(f"Error getting album IDs from Jellyfin: {e}")
|
|
return set()
|
|
|
|
def get_albums_for_artist(self, artist_id: str) -> List[JellyfinAlbum]:
|
|
"""Get all albums for a specific artist"""
|
|
# Use cache if available
|
|
if artist_id in self._album_cache:
|
|
return self._album_cache[artist_id]
|
|
|
|
if not self.ensure_connection():
|
|
return []
|
|
|
|
try:
|
|
# Use smaller, faster API call
|
|
params = {
|
|
'ArtistIds': artist_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True,
|
|
'SortBy': 'ProductionYear,SortName',
|
|
'SortOrder': 'Ascending',
|
|
'Limit': 200 # Reasonable limit for most artists
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
albums = []
|
|
for item in response.get('Items', []):
|
|
albums.append(JellyfinAlbum(item, self))
|
|
|
|
# Cache the result
|
|
self._album_cache[artist_id] = albums
|
|
|
|
return albums
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting albums for artist {artist_id}: {e}")
|
|
return []
|
|
|
|
def get_tracks_for_album(self, album_id: str) -> List[JellyfinTrack]:
|
|
"""Get all tracks for a specific album"""
|
|
# Use cache if available
|
|
if album_id in self._track_cache:
|
|
return self._track_cache[album_id]
|
|
|
|
if not self.ensure_connection():
|
|
return []
|
|
|
|
try:
|
|
# Most albums have < 30 tracks, so this is reasonable
|
|
params = {
|
|
'ParentId': album_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Fields': 'Path,MediaSources',
|
|
'SortBy': 'IndexNumber',
|
|
'SortOrder': 'Ascending',
|
|
'Limit': 100 # Most albums won't hit this limit
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
tracks = []
|
|
for item in response.get('Items', []):
|
|
tracks.append(JellyfinTrack(item, self))
|
|
|
|
# Cache the result
|
|
self._track_cache[album_id] = tracks
|
|
|
|
return tracks
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting tracks for album {album_id}: {e}")
|
|
return []
|
|
|
|
def get_artist_by_id(self, artist_id: str) -> Optional[JellyfinArtist]:
|
|
"""Get a specific artist by ID"""
|
|
# Check cache first
|
|
if artist_id in self._artist_cache:
|
|
return self._artist_cache[artist_id]
|
|
|
|
if not self.ensure_connection():
|
|
return None
|
|
|
|
try:
|
|
response = self._make_request(f'/Users/{self.user_id}/Items/{artist_id}')
|
|
if response:
|
|
artist = JellyfinArtist(response, self)
|
|
# Cache for future use
|
|
self._artist_cache[artist_id] = artist
|
|
return artist
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting artist {artist_id}: {e}")
|
|
return None
|
|
|
|
def get_album_by_id(self, album_id: str) -> Optional[JellyfinAlbum]:
|
|
"""Get a specific album by ID"""
|
|
# Check if we can find this album in any artist's cache
|
|
for artist_albums in self._album_cache.values():
|
|
for album in artist_albums:
|
|
if album.ratingKey == album_id:
|
|
return album
|
|
|
|
if not self.ensure_connection():
|
|
return None
|
|
|
|
try:
|
|
response = self._make_request(f'/Users/{self.user_id}/Items/{album_id}')
|
|
if response:
|
|
return JellyfinAlbum(response, self)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting album {album_id}: {e}")
|
|
return None
|
|
|
|
def get_recently_added_albums(self, max_results: int = 400) -> List[JellyfinAlbum]:
|
|
"""Get recently added albums - used for incremental updates"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True,
|
|
'SortBy': 'DateCreated',
|
|
'SortOrder': 'Descending',
|
|
'Limit': max_results
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
albums = []
|
|
for item in response.get('Items', []):
|
|
albums.append(JellyfinAlbum(item, self))
|
|
|
|
logger.info(f"Retrieved {len(albums)} recently added albums from Jellyfin")
|
|
return albums
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting recently added albums: {e}")
|
|
return []
|
|
|
|
def get_recently_updated_albums(self, max_results: int = 400) -> List[JellyfinAlbum]:
|
|
"""Get recently updated albums - used for incremental updates"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True,
|
|
'SortBy': 'DateLastMediaAdded',
|
|
'SortOrder': 'Descending',
|
|
'Limit': max_results
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
albums = []
|
|
for item in response.get('Items', []):
|
|
albums.append(JellyfinAlbum(item, self))
|
|
|
|
logger.info(f"Retrieved {len(albums)} recently updated albums from Jellyfin")
|
|
return albums
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting recently updated albums: {e}")
|
|
return []
|
|
|
|
def get_recently_added_tracks(self, max_results: int = 5000) -> List[JellyfinTrack]:
|
|
"""Get recently added tracks directly - much faster for incremental updates"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True,
|
|
'SortBy': 'DateCreated',
|
|
'SortOrder': 'Descending',
|
|
'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
|
|
'Limit': max_results
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
tracks = []
|
|
for item in response.get('Items', []):
|
|
tracks.append(JellyfinTrack(item, self))
|
|
|
|
logger.info(f"Retrieved {len(tracks)} recently added tracks from Jellyfin")
|
|
return tracks
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting recently added tracks: {e}")
|
|
return []
|
|
|
|
def get_recently_updated_tracks(self, max_results: int = 5000) -> List[JellyfinTrack]:
|
|
"""Get recently updated tracks directly - much faster for incremental updates"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True,
|
|
'SortBy': 'DateLastSaved', # When track metadata was last saved
|
|
'SortOrder': 'Descending',
|
|
'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
|
|
'Limit': max_results
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
tracks = []
|
|
for item in response.get('Items', []):
|
|
tracks.append(JellyfinTrack(item, self))
|
|
|
|
logger.info(f"Retrieved {len(tracks)} recently updated tracks from Jellyfin")
|
|
return tracks
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting recently updated tracks: {e}")
|
|
return []
|
|
|
|
def get_library_stats(self) -> Dict[str, int]:
|
|
"""Get library statistics - matches Plex interface"""
|
|
if not self.ensure_connection() or not self.music_library_id:
|
|
return {}
|
|
|
|
try:
|
|
stats = {}
|
|
|
|
# Get artist count
|
|
artists_params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicArtist',
|
|
'Recursive': True
|
|
}
|
|
artists_response = self._make_request(f'/Users/{self.user_id}/Items', artists_params)
|
|
stats['artists'] = artists_response.get('TotalRecordCount', 0) if artists_response else 0
|
|
|
|
# Get album count
|
|
albums_params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'MusicAlbum',
|
|
'Recursive': True
|
|
}
|
|
albums_response = self._make_request(f'/Users/{self.user_id}/Items', albums_params)
|
|
stats['albums'] = albums_response.get('TotalRecordCount', 0) if albums_response else 0
|
|
|
|
# Get track count
|
|
tracks_params = {
|
|
'ParentId': self.music_library_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True
|
|
}
|
|
tracks_response = self._make_request(f'/Users/{self.user_id}/Items', tracks_params)
|
|
stats['tracks'] = tracks_response.get('TotalRecordCount', 0) if tracks_response else 0
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting library stats: {e}")
|
|
return {}
|
|
|
|
def get_play_history(self, limit=500):
|
|
"""Fetch recently played tracks for the active user.
|
|
|
|
Returns list of dicts with: track_title, artist, album, played_at,
|
|
duration_ms, track_id.
|
|
"""
|
|
if not self.ensure_connection() or not self.user_id:
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'UserId': self.user_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'SortBy': 'DatePlayed',
|
|
'SortOrder': 'Descending',
|
|
'Fields': 'UserData,MediaSources',
|
|
'Recursive': True,
|
|
'IsPlayed': True,
|
|
'Limit': limit,
|
|
}
|
|
if self.music_library_id:
|
|
params['ParentId'] = self.music_library_id
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response or 'Items' not in response:
|
|
return []
|
|
|
|
results = []
|
|
for item in response['Items']:
|
|
user_data = item.get('UserData', {})
|
|
played_at = user_data.get('LastPlayedDate')
|
|
if not played_at:
|
|
continue
|
|
duration_ticks = item.get('RunTimeTicks', 0)
|
|
results.append({
|
|
'track_title': item.get('Name', ''),
|
|
'artist': item.get('AlbumArtist', '') or (item.get('Artists', [''])[0] if item.get('Artists') else ''),
|
|
'album': item.get('Album', ''),
|
|
'played_at': played_at,
|
|
'duration_ms': duration_ticks // 10000 if duration_ticks else 0,
|
|
'track_id': item.get('Id', ''),
|
|
})
|
|
logger.info(f"Retrieved {len(results)} play history entries from Jellyfin")
|
|
return results
|
|
except Exception as e:
|
|
logger.error(f"Error getting Jellyfin play history: {e}")
|
|
return []
|
|
|
|
def get_track_play_counts(self):
|
|
"""Get PlayCount for all played audio items.
|
|
|
|
Returns dict of {item_id: play_count}.
|
|
"""
|
|
if not self.ensure_connection() or not self.user_id:
|
|
return {}
|
|
|
|
try:
|
|
params = {
|
|
'UserId': self.user_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Fields': 'UserData',
|
|
'Recursive': True,
|
|
'IsPlayed': True,
|
|
'Limit': 10000,
|
|
}
|
|
if self.music_library_id:
|
|
params['ParentId'] = self.music_library_id
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response or 'Items' not in response:
|
|
return {}
|
|
|
|
counts = {}
|
|
for item in response['Items']:
|
|
user_data = item.get('UserData', {})
|
|
play_count = user_data.get('PlayCount', 0)
|
|
if play_count > 0:
|
|
counts[item.get('Id', '')] = play_count
|
|
logger.info(f"Retrieved play counts for {len(counts)} tracks from Jellyfin")
|
|
return counts
|
|
except Exception as e:
|
|
logger.error(f"Error getting Jellyfin track play counts: {e}")
|
|
return {}
|
|
|
|
def clear_cache(self):
|
|
"""Clear all caches to force fresh data on next request"""
|
|
self._album_cache.clear()
|
|
self._track_cache.clear()
|
|
self._artist_cache.clear()
|
|
self._all_albums_cache = None
|
|
self._all_tracks_cache = None
|
|
self._cache_populated = False
|
|
logger.info("Jellyfin client cache cleared")
|
|
|
|
def get_cache_stats(self) -> Dict[str, int]:
|
|
"""Get statistics about cached data for performance monitoring"""
|
|
stats = {
|
|
'cached_artists': len(self._artist_cache),
|
|
'cached_artist_albums': len(self._album_cache),
|
|
'cached_album_tracks': len(self._track_cache),
|
|
'cache_populated': self._cache_populated
|
|
}
|
|
|
|
if self._all_albums_cache:
|
|
stats['bulk_albums_cached'] = len(self._all_albums_cache)
|
|
if self._all_tracks_cache:
|
|
stats['bulk_tracks_cached'] = len(self._all_tracks_cache)
|
|
|
|
return stats
|
|
|
|
def get_all_playlists(self) -> List[PlaylistInfo]:
|
|
"""Get all playlists from Jellyfin server"""
|
|
if not self.ensure_connection():
|
|
return []
|
|
|
|
try:
|
|
params = {
|
|
'IncludeItemTypes': 'Playlist',
|
|
'Recursive': True
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
return []
|
|
|
|
playlists = []
|
|
for item in response.get('Items', []):
|
|
playlist_info = PlaylistInfo(
|
|
id=item.get('Id', ''),
|
|
title=item.get('Name', 'Unknown Playlist'),
|
|
description=item.get('Overview'),
|
|
duration=item.get('RunTimeTicks', 0) // 10000,
|
|
leaf_count=item.get('ChildCount', 0),
|
|
tracks=[] # Will be populated when needed
|
|
)
|
|
playlists.append(playlist_info)
|
|
|
|
logger.info(f"Retrieved {len(playlists)} playlists from Jellyfin")
|
|
return playlists
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting playlists from Jellyfin: {e}")
|
|
return []
|
|
|
|
def get_playlist_by_name(self, name: str) -> Optional[PlaylistInfo]:
|
|
"""Get a specific playlist by name"""
|
|
playlists = self.get_all_playlists()
|
|
for playlist in playlists:
|
|
if playlist.title.lower() == name.lower():
|
|
return playlist
|
|
return None
|
|
|
|
def create_playlist(self, name: str, tracks) -> bool:
|
|
"""Create a new playlist with given tracks"""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
# Convert tracks to Jellyfin/Emby track IDs
|
|
track_ids = []
|
|
invalid_tracks = []
|
|
|
|
for track in tracks:
|
|
track_id = None
|
|
if hasattr(track, 'ratingKey'):
|
|
track_id = str(track.ratingKey)
|
|
elif hasattr(track, 'id'):
|
|
track_id = str(track.id)
|
|
|
|
# Validate that track_id is a properly formatted GUID
|
|
if track_id and self._is_valid_guid(track_id):
|
|
track_ids.append(track_id.strip())
|
|
else:
|
|
invalid_tracks.append(track)
|
|
if track_id:
|
|
logger.debug(f"Rejected invalid GUID format: '{track_id}'")
|
|
|
|
if invalid_tracks:
|
|
logger.warning(f"Found {len(invalid_tracks)} tracks with invalid/empty IDs - these will be skipped")
|
|
|
|
if not track_ids:
|
|
logger.warning(f"No valid tracks provided for playlist '{name}'")
|
|
return False
|
|
|
|
logger.info(f"Creating Jellyfin/Emby playlist '{name}' with {len(track_ids)} valid track IDs (filtered {len(invalid_tracks)} invalid)")
|
|
|
|
# For large playlists, create empty playlist first then add tracks in batches
|
|
if True:
|
|
return self._create_large_playlist(name, track_ids)
|
|
|
|
# Create playlist using POST request for smaller playlists
|
|
import requests
|
|
url = f"{self.base_url}/Playlists"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
data = {
|
|
'Name': name,
|
|
'UserId': self.user_id,
|
|
'MediaType': 'Audio',
|
|
'Ids': track_ids
|
|
}
|
|
|
|
response = requests.post(url, json=data, headers=headers, timeout=30)
|
|
|
|
# Log response details for debugging
|
|
logger.debug(f"Jellyfin playlist creation response: Status {response.status_code}")
|
|
if response.status_code >= 400:
|
|
logger.error(f"Jellyfin API error: {response.status_code} - {response.text}")
|
|
|
|
response.raise_for_status()
|
|
|
|
result = response.json()
|
|
if result and 'Id' in result:
|
|
logger.info(f"Created Jellyfin playlist '{name}' with {len(track_ids)} tracks")
|
|
return True
|
|
else:
|
|
logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating Jellyfin playlist '{name}': {e}")
|
|
return False
|
|
|
|
def _is_valid_guid(self, guid: str) -> bool:
|
|
"""Validate that a string is a properly formatted item ID for Emby/Jellyfin.
|
|
Jellyfin uses 32-char hex GUIDs, Emby uses integer IDs — both are valid."""
|
|
if not guid or not isinstance(guid, str):
|
|
return False
|
|
|
|
guid = guid.strip()
|
|
if not guid:
|
|
return False
|
|
|
|
# Emby uses integer IDs (e.g. "12345") — accept any numeric string
|
|
if guid.isdigit():
|
|
return True
|
|
|
|
# Jellyfin uses GUIDs (32 hex chars, optionally with hyphens for 36 total)
|
|
guid_no_hyphens = guid.replace('-', '')
|
|
if len(guid_no_hyphens) != 32:
|
|
return False
|
|
|
|
try:
|
|
int(guid_no_hyphens, 16)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
def _create_large_playlist(self, name: str, track_ids: List[str]) -> bool:
|
|
"""Create a large playlist by first creating empty playlist, then adding tracks in batches"""
|
|
try:
|
|
import requests
|
|
|
|
# Step 1: Create empty playlist
|
|
url = f"{self.base_url}/Playlists"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
# Don't include 'Ids' field for empty playlist - Emby doesn't handle empty arrays
|
|
data = {
|
|
'Name': name,
|
|
'UserId': self.user_id,
|
|
'MediaType': 'Audio'
|
|
}
|
|
|
|
logger.debug(f"Creating empty playlist with data: {data}")
|
|
response = requests.post(url, json=data, headers=headers, timeout=10)
|
|
|
|
if response.status_code >= 400:
|
|
logger.error(f"Failed to create empty playlist: HTTP {response.status_code}")
|
|
logger.error(f"Response body: {response.text}")
|
|
|
|
response.raise_for_status()
|
|
|
|
result = response.json()
|
|
if not result or 'Id' not in result:
|
|
logger.error(f"Failed to create empty Jellyfin playlist '{name}'")
|
|
return False
|
|
|
|
playlist_id = result['Id']
|
|
logger.info(f"Created empty Jellyfin playlist '{name}' (ID: {playlist_id})")
|
|
|
|
# Step 2: Add tracks in batches of 100
|
|
batch_size = 100
|
|
total_batches = (len(track_ids) + batch_size - 1) // batch_size
|
|
|
|
for i in range(0, len(track_ids), batch_size):
|
|
batch = track_ids[i:i + batch_size]
|
|
batch_num = (i // batch_size) + 1
|
|
|
|
logger.info(f"Adding batch {batch_num}/{total_batches} ({len(batch)} tracks) to playlist '{name}'")
|
|
|
|
# Add tracks to playlist using POST to /Playlists/{id}/Items
|
|
# IMPORTANT: Filter out any invalid/empty IDs to prevent GUID parse errors in Emby
|
|
valid_batch = [track_id for track_id in batch if track_id and self._is_valid_guid(track_id)]
|
|
|
|
if not valid_batch:
|
|
logger.warning(f"Batch {batch_num} has no valid track IDs, skipping")
|
|
continue
|
|
|
|
add_url = f"{self.base_url}/Playlists/{playlist_id}/Items"
|
|
|
|
# Use URL query parameters (required by Jellyfin/Emby API)
|
|
# The Ids parameter must be comma-separated GUIDs
|
|
add_params = {
|
|
'Ids': ','.join(valid_batch),
|
|
'UserId': self.user_id
|
|
}
|
|
|
|
add_response = requests.post(add_url, params=add_params, headers={'X-Emby-Token': self.api_key}, timeout=30)
|
|
|
|
if add_response.status_code not in [200, 204]:
|
|
logger.error(f"Failed to add batch {batch_num} to playlist '{name}': HTTP {add_response.status_code}")
|
|
logger.error(f" Response body: {add_response.text}")
|
|
logger.error(f" Track IDs in batch (first 5): {valid_batch[:5]}")
|
|
logger.error(f" Request URL: {add_url}")
|
|
logger.error(f" Request params: Ids={add_params['Ids'][:200]}... (truncated)")
|
|
# Continue with other batches even if one fails
|
|
|
|
logger.info(f"Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating large Jellyfin playlist '{name}': {e}")
|
|
return False
|
|
|
|
def copy_playlist(self, source_name: str, target_name: str) -> bool:
|
|
"""Copy a playlist to create a backup"""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
# Get the source playlist
|
|
source_playlist = self.get_playlist_by_name(source_name)
|
|
if not source_playlist:
|
|
logger.error(f"Source playlist '{source_name}' not found")
|
|
return False
|
|
|
|
# Get tracks from source playlist
|
|
source_tracks = self.get_playlist_tracks(source_playlist.id)
|
|
logger.debug(f"Retrieved {len(source_tracks) if source_tracks else 0} tracks from source playlist")
|
|
|
|
# Validate tracks
|
|
if not source_tracks:
|
|
logger.warning(f"Source playlist '{source_name}' has no tracks to copy")
|
|
return False
|
|
|
|
# Delete target playlist if it exists (for overwriting backup)
|
|
try:
|
|
target_playlist = self.get_playlist_by_name(target_name)
|
|
if target_playlist:
|
|
import requests
|
|
url = f"{self.base_url}/Items/{target_playlist.id}"
|
|
headers = {'X-Emby-Token': self.api_key}
|
|
|
|
response = requests.delete(url, headers=headers, timeout=10)
|
|
if response.status_code in [200, 204]:
|
|
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
|
except Exception as e:
|
|
logger.debug("backup playlist precheck: %s", e)
|
|
|
|
# Create new playlist with copied tracks
|
|
try:
|
|
success = self.create_playlist(target_name, source_tracks)
|
|
if success:
|
|
logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
|
return True
|
|
else:
|
|
logger.error(f"Failed to create backup playlist '{target_name}'")
|
|
return False
|
|
except Exception as create_error:
|
|
logger.error(f"Failed to create backup playlist: {create_error}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
|
|
return False
|
|
|
|
def get_playlist_tracks(self, playlist_id: str) -> List:
|
|
"""Get all tracks from a specific playlist"""
|
|
if not self.ensure_connection():
|
|
return []
|
|
|
|
try:
|
|
tracks = []
|
|
start_index = 0
|
|
limit = 1000
|
|
|
|
while True:
|
|
params = {
|
|
'ParentId': playlist_id,
|
|
'IncludeItemTypes': 'Audio',
|
|
'Recursive': True,
|
|
'Fields': 'AlbumId,ArtistItems,Path,MediaSources',
|
|
'SortBy': 'SortName',
|
|
'SortOrder': 'Ascending',
|
|
'StartIndex': start_index,
|
|
'Limit': limit,
|
|
}
|
|
|
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
|
if not response:
|
|
break
|
|
|
|
batch = response.get('Items', [])
|
|
for item in batch:
|
|
tracks.append(JellyfinTrack(item, self))
|
|
|
|
if len(batch) < limit:
|
|
break # Last page
|
|
start_index += limit
|
|
|
|
logger.debug(f"Retrieved {len(tracks)} tracks from playlist {playlist_id}")
|
|
return tracks
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
|
|
return []
|
|
|
|
def set_playlist_image(self, playlist_name: str, image_url: str) -> bool:
|
|
"""Set the poster image for a playlist by downloading from a URL."""
|
|
if not self.ensure_connection() or not image_url:
|
|
return False
|
|
try:
|
|
playlist = self.get_playlist_by_name(playlist_name)
|
|
if not playlist:
|
|
return False
|
|
playlist_id = playlist.get('Id') or playlist.get('id')
|
|
if not playlist_id:
|
|
return False
|
|
import requests as _req
|
|
img_resp = _req.get(image_url, timeout=15)
|
|
if img_resp.ok and img_resp.content:
|
|
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
|
upload_url = f"{self.base_url}/Items/{playlist_id}/Images/Primary"
|
|
upload_resp = _req.post(
|
|
upload_url,
|
|
headers={'X-Emby-Token': self.api_key, 'Content-Type': content_type},
|
|
data=img_resp.content,
|
|
timeout=15
|
|
)
|
|
if upload_resp.ok:
|
|
logger.info(f"Set playlist poster for '{playlist_name}'")
|
|
return True
|
|
else:
|
|
logger.debug(f"Playlist image upload returned {upload_resp.status_code}")
|
|
except Exception as e:
|
|
logger.debug(f"Could not set playlist poster for '{playlist_name}': {e}")
|
|
return False
|
|
|
|
def append_to_playlist(self, playlist_name: str, tracks) -> bool:
|
|
"""Append tracks to an existing playlist (creates it if missing).
|
|
|
|
Differs from `update_playlist`: never deletes existing tracks,
|
|
never recreates the playlist, no backup. Used by sync mode
|
|
'append' so user-added tracks on the server playlist survive
|
|
re-syncing the source. Dedupe-by-Id ensures we don't re-add
|
|
tracks the playlist already contains."""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
existing_playlist = self.get_playlist_by_name(playlist_name)
|
|
if not existing_playlist:
|
|
logger.info(
|
|
f"Jellyfin append: playlist '{playlist_name}' doesn't exist yet — "
|
|
f"creating with {len(tracks)} tracks"
|
|
)
|
|
return self.create_playlist(playlist_name, tracks)
|
|
|
|
playlist_id = existing_playlist.id
|
|
existing_tracks = self.get_playlist_tracks(playlist_id)
|
|
existing_ids = {
|
|
str(t.id) for t in existing_tracks if hasattr(t, 'id') and t.id
|
|
}
|
|
|
|
new_track_ids = []
|
|
for t in tracks:
|
|
tid = None
|
|
if hasattr(t, 'id'):
|
|
tid = str(t.id) if t.id else None
|
|
elif isinstance(t, dict):
|
|
tid = str(t.get('Id') or t.get('id') or '')
|
|
if tid and tid not in existing_ids and self._is_valid_guid(tid):
|
|
new_track_ids.append(tid)
|
|
|
|
if not new_track_ids:
|
|
logger.info(
|
|
f"Jellyfin append: no new tracks to add to '{playlist_name}' "
|
|
f"(all matched tracks already present)"
|
|
)
|
|
return True
|
|
|
|
import requests
|
|
batch_size = 100
|
|
total_added = 0
|
|
for i in range(0, len(new_track_ids), batch_size):
|
|
batch = new_track_ids[i:i + batch_size]
|
|
add_url = f"{self.base_url}/Playlists/{playlist_id}/Items"
|
|
add_params = {'Ids': ','.join(batch), 'UserId': self.user_id}
|
|
resp = requests.post(
|
|
add_url, params=add_params,
|
|
headers={'X-Emby-Token': self.api_key}, timeout=30,
|
|
)
|
|
if resp.status_code in (200, 204):
|
|
total_added += len(batch)
|
|
else:
|
|
logger.error(
|
|
f"Jellyfin append batch failed: HTTP {resp.status_code} - "
|
|
f"{resp.text[:200]}"
|
|
)
|
|
return False
|
|
|
|
logger.info(
|
|
f"Jellyfin append: added {total_added} new tracks to '{playlist_name}' "
|
|
f"(skipped {len(tracks) - total_added} already present or invalid)"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
|
|
return False
|
|
|
|
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
|
"""Update an existing playlist or create it if it doesn't exist"""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
existing_playlist = self.get_playlist_by_name(playlist_name)
|
|
|
|
# Check if backup is enabled in config
|
|
from config.settings import config_manager
|
|
create_backup = config_manager.get('playlist_sync.create_backup', True)
|
|
|
|
if existing_playlist and create_backup:
|
|
backup_name = f"{playlist_name} Backup"
|
|
logger.info(f"Creating backup playlist '{backup_name}' before sync")
|
|
|
|
if self.copy_playlist(playlist_name, backup_name):
|
|
logger.info("Backup created successfully")
|
|
else:
|
|
logger.warning("Failed to create backup, continuing with sync")
|
|
|
|
if existing_playlist:
|
|
# Delete existing playlist using DELETE request
|
|
import requests
|
|
url = f"{self.base_url}/Items/{existing_playlist.id}"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key
|
|
}
|
|
|
|
response = requests.delete(url, headers=headers, timeout=10)
|
|
if response.status_code in [200, 204]:
|
|
logger.info(f"Deleted existing Jellyfin playlist '{playlist_name}'")
|
|
else:
|
|
logger.warning(f"Could not delete existing playlist '{playlist_name}' (status: {response.status_code}), creating anyway")
|
|
|
|
# Create new playlist with tracks
|
|
return self.create_playlist(playlist_name, tracks)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating Jellyfin playlist '{playlist_name}': {e}")
|
|
return False
|
|
|
|
def trigger_library_scan(self, library_name: str = "Music") -> bool:
|
|
"""Trigger Jellyfin library scan for the specified library"""
|
|
if not self.ensure_connection():
|
|
return False
|
|
|
|
try:
|
|
# Get library info to find the correct library ID
|
|
libraries_response = self._make_request(f'/Users/{self.user_id}/Views')
|
|
if not libraries_response:
|
|
logger.error("Failed to get library list for scan")
|
|
return False
|
|
|
|
target_library_id = None
|
|
for library in libraries_response.get('Items', []):
|
|
if (library.get('CollectionType') == 'music' and
|
|
library_name.lower() in library.get('Name', '').lower()):
|
|
target_library_id = library['Id']
|
|
break
|
|
|
|
# Default to music_library_id if no specific library found
|
|
if not target_library_id:
|
|
target_library_id = self.music_library_id
|
|
|
|
if not target_library_id:
|
|
logger.error(f"No library found matching '{library_name}'")
|
|
return False
|
|
|
|
# Trigger the scan using POST request
|
|
import requests
|
|
url = f"{self.base_url}/Items/{target_library_id}/Refresh"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
params = {
|
|
'Recursive': True,
|
|
'ImageRefreshMode': 'ValidationOnly', # Don't refresh images, just metadata
|
|
'MetadataRefreshMode': 'ValidationOnly'
|
|
}
|
|
|
|
response = requests.post(url, headers=headers, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
logger.info(f"Triggered Jellyfin library scan for '{library_name}'")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to trigger Jellyfin library scan for '{library_name}': {e}")
|
|
return False
|
|
|
|
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
|
"""Check if Jellyfin library is currently scanning"""
|
|
if not self.ensure_connection():
|
|
logger.debug("Not connected to Jellyfin, cannot check scan status")
|
|
return False
|
|
|
|
try:
|
|
# Check scheduled tasks for library scan activities
|
|
response = self._make_request('/ScheduledTasks')
|
|
if not response:
|
|
logger.debug("Could not get scheduled tasks")
|
|
return False
|
|
|
|
for task in response:
|
|
task_name = task.get('Name', '').lower()
|
|
task_state = task.get('State', 'Idle')
|
|
|
|
# Look for library scan related tasks that are running
|
|
if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name):
|
|
if task_state in ['Running', 'Cancelling']:
|
|
logger.debug(
|
|
"Found running scan task: name=%s state=%s",
|
|
task.get('Name'),
|
|
task_state,
|
|
)
|
|
return True
|
|
|
|
logger.debug("No active scan tasks detected")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error checking if Jellyfin library is scanning: {e}")
|
|
return False
|
|
|
|
# Metadata update methods for compatibility with metadata updater
|
|
def update_artist_genres(self, artist, genres: List[str]):
|
|
"""Update artist genres - not implemented for Jellyfin"""
|
|
# Genre updates not supported via Jellyfin API - silently skip
|
|
return True
|
|
|
|
def update_artist_poster(self, artist, image_data: bytes):
|
|
"""Update artist poster image using Jellyfin API"""
|
|
try:
|
|
artist_id = artist.ratingKey
|
|
if not artist_id:
|
|
return False
|
|
|
|
import requests
|
|
|
|
url = f"{self.base_url}/Items/{artist_id}/Images/Primary"
|
|
|
|
# Use the working approach from successful Jellyfin implementation
|
|
from base64 import b64encode
|
|
|
|
# Base64 encode the image data (key difference!)
|
|
encoded_data = b64encode(image_data)
|
|
|
|
# Add /0 to URL for image index
|
|
url = f"{self.base_url}/Items/{artist_id}/Images/Primary/0"
|
|
|
|
headers = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'image/jpeg'
|
|
}
|
|
|
|
try:
|
|
logger.debug(f"Uploading {len(image_data)} bytes (base64 encoded) for {artist.title}")
|
|
|
|
response = requests.post(url, data=encoded_data, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
logger.info(f"Updated poster for {artist.title} - HTTP {response.status_code}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to upload poster for {artist.title}: {e}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating poster for {artist.title}: {e}")
|
|
return False
|
|
|
|
def update_album_poster(self, album, image_data: bytes):
|
|
"""Update album poster image using Jellyfin API"""
|
|
try:
|
|
album_id = album.ratingKey
|
|
if not album_id:
|
|
return False
|
|
|
|
import requests
|
|
|
|
url = f"{self.base_url}/Items/{album_id}/Images/Primary"
|
|
headers = {
|
|
'X-Emby-Token': self.api_key
|
|
}
|
|
|
|
# Try multiple approaches to find what works with Jellyfin
|
|
|
|
# Method 1: Try with different field names that Jellyfin might expect
|
|
method1_files = {'data': ('poster.jpg', image_data, 'image/jpeg')}
|
|
try:
|
|
response = requests.post(url, files=method1_files, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
logger.info(f"Updated poster for album '{album.title}' (method 1)")
|
|
return True
|
|
except Exception as e1:
|
|
logger.debug(f"Method 1 failed for album '{album.title}': {e1}")
|
|
|
|
# Method 2: Try with raw data and proper content-type
|
|
try:
|
|
headers_raw = {
|
|
'X-Emby-Token': self.api_key,
|
|
'Content-Type': 'image/jpeg'
|
|
}
|
|
response = requests.post(url, data=image_data, headers=headers_raw, timeout=30)
|
|
response.raise_for_status()
|
|
logger.info(f"Updated poster for album '{album.title}' (method 2)")
|
|
return True
|
|
except Exception as e2:
|
|
logger.debug(f"Method 2 failed for album '{album.title}': {e2}")
|
|
|
|
# Method 3: Try with different endpoint structure
|
|
try:
|
|
alt_url = f"{self.base_url}/Items/{album_id}/Images/Primary/0"
|
|
response = requests.post(alt_url, data=image_data, headers=headers_raw, timeout=30)
|
|
response.raise_for_status()
|
|
logger.info(f"Updated poster for album '{album.title}' (method 3)")
|
|
return True
|
|
except Exception as e3:
|
|
logger.debug(f"Method 3 failed for album '{album.title}': {e3}")
|
|
|
|
# All methods failed
|
|
logger.error(f"All image upload methods failed for album '{album.title}'")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating poster for album '{album.title}': {e}")
|
|
return False
|
|
|
|
def update_artist_biography(self, artist) -> bool:
|
|
"""Update artist overview/biography - not implemented for Jellyfin"""
|
|
# Biography updates not supported via Jellyfin API - silently skip
|
|
return True
|
|
|
|
def needs_update_by_age(self, artist, refresh_interval_days: int) -> bool:
|
|
"""Check if artist needs updating based on age threshold"""
|
|
try:
|
|
last_update = self.parse_update_timestamp(artist)
|
|
if not last_update:
|
|
# No timestamp found, needs update
|
|
return True
|
|
|
|
# Calculate days since last update
|
|
from datetime import datetime
|
|
days_since_update = (datetime.now() - last_update).days
|
|
|
|
# Use same logic as Plex client
|
|
needs_update = days_since_update >= refresh_interval_days
|
|
|
|
if not needs_update:
|
|
logger.debug(f"Skipping {artist.title}: updated {days_since_update} days ago (threshold: {refresh_interval_days})")
|
|
|
|
return needs_update
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error checking update age for {artist.title}: {e}")
|
|
return True # Default to needing update if error
|
|
|
|
def is_artist_ignored(self, artist) -> bool:
|
|
"""Check if artist is manually marked to be ignored"""
|
|
try:
|
|
# Check overview field where we store timestamps and ignore flags
|
|
overview = getattr(artist, 'overview', '') or ''
|
|
return '-IgnoreUpdate' in overview
|
|
except Exception as e:
|
|
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
|
|
return False
|
|
|
|
def parse_update_timestamp(self, artist) -> Optional[datetime]:
|
|
"""Parse the last update timestamp - not implemented for Jellyfin"""
|
|
# No timestamp tracking for Jellyfin - always return None (needs update)
|
|
return None
|
|
|
|
def set_metadata_only_mode(self, enabled: bool = True):
|
|
"""Enable metadata-only mode to skip expensive track caching"""
|
|
try:
|
|
self._metadata_only_mode = enabled
|
|
if enabled:
|
|
logger.info("Metadata-only mode enabled - will skip expensive track caching")
|
|
else:
|
|
logger.info("Metadata-only mode disabled")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error setting metadata-only mode: {e}")
|
|
return False
|