good
This commit is contained in:
parent
6e5a0f76f6
commit
fc87fe1740
7 changed files with 4137 additions and 16 deletions
|
|
@ -1 +1 @@
|
||||||
{"access_token": "BQCAxqYls1GZziWWT_RK2oephXS0gdOA6aCR_ylemRRdjvZ3ZAvgzQDiZcOvP6DdENbfoZK2d5m-a61GUp1syw9sMS6U3qjvjhGGgACvl4jytuArO-ht2crjJmeVdl_88YIrBMrlZsBnOAmOyPH8xA7X1i9hy5H5_nO32iiM1t96sm9rT9zIAe6gCQpDt36SbLafT2GZz_7dH3k6QVZRkuESnyr9xXznIsshhemd2UFnRnTGzg8ptWp7eCVAiL5d", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1753746945, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
{"access_token": "BQAX2tP36P49Pqyfkqzyd5Flp_POENiQdd91NLRXhAnH2Ha8UyiAkM8PkubgHOUbZ3qWDtrPAatNMzHwhxwA4fSCBiRT9Aq8CMq4ayGGANhZxHC04XrQMan7oKKyeHvx6P46DrqDwGAeGx8joR2rUswd0C-iMpSqK8nk3krWdCUVhabMuDxVFtRWsTqbdmnXVNsqh9QK-XABRE0PZ2U1ZTaPJwT1M3gFvYKHxj-Rym3qbHR5kqSuEohZXtcKZBMq", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", "expires_at": 1753756658, "refresh_token": "AQDmfQkPCGObfJeTUIbW1hAAwhSqkuHRA3Qh2dqVYMRh0eCkFMQgPNJDDzF8y-BiaVbj80zePkK_XSfYH1aJutMtNbnsqRKWuxP31BTrMc7pdUdbE7Fma4oH8wpDUKdG3MM"}
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -6,6 +6,8 @@ from plexapi.exceptions import PlexApiException, NotFound
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import requests
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import re
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
|
@ -439,7 +441,11 @@ class PlexClient:
|
||||||
for genre in genres:
|
for genre in genres:
|
||||||
artist.addGenre(genre)
|
artist.addGenre(genre)
|
||||||
|
|
||||||
logger.info(f"Updated genres for {artist.title}: {genres}")
|
# Use safe logging to avoid Unicode encoding errors
|
||||||
|
try:
|
||||||
|
logger.info(f"Updated genres for {artist.title}: {len(genres)} genres")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
logger.info(f"Updated genres for artist (ID: {artist.ratingKey}): {len(genres)} genres")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating genres for {artist.title}: {e}")
|
logger.error(f"Error updating genres for {artist.title}: {e}")
|
||||||
|
|
@ -467,6 +473,123 @@ class PlexClient:
|
||||||
logger.error(f"Error updating poster for {artist.title}: {e}")
|
logger.error(f"Error updating poster for {artist.title}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def parse_update_timestamp(self, artist: PlexArtist) -> Optional[datetime]:
|
||||||
|
"""Parse the last update timestamp from artist summary"""
|
||||||
|
try:
|
||||||
|
# Get artist summary which stores our timestamp
|
||||||
|
summary = getattr(artist, 'summary', '') or ''
|
||||||
|
|
||||||
|
# Look for timestamp pattern: -updatedAtYYYY-MM-DD
|
||||||
|
pattern = r'-updatedAt(\d{4}-\d{2}-\d{2})'
|
||||||
|
match = re.search(pattern, summary)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
date_str = match.group(1)
|
||||||
|
return datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing timestamp for {artist.title}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_artist_ignored(self, artist: PlexArtist) -> bool:
|
||||||
|
"""Check if artist is manually marked to be ignored"""
|
||||||
|
try:
|
||||||
|
# Check summary field where we store timestamps and ignore flags
|
||||||
|
summary = getattr(artist, 'summary', '') or ''
|
||||||
|
return '-IgnoreUpdate' in summary
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def needs_update_by_age(self, artist: PlexArtist, refresh_interval_days: int) -> bool:
|
||||||
|
"""Check if artist needs updating based on age threshold"""
|
||||||
|
try:
|
||||||
|
# First check if artist is manually ignored
|
||||||
|
if self.is_artist_ignored(artist):
|
||||||
|
logger.debug(f"Artist {artist.title} is manually ignored")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If refresh_interval_days is 0, always update (full refresh)
|
||||||
|
if refresh_interval_days == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
last_update = self.parse_update_timestamp(artist)
|
||||||
|
|
||||||
|
# If no timestamp found, needs update
|
||||||
|
if last_update is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if last update is older than threshold
|
||||||
|
threshold_date = datetime.now() - timedelta(days=refresh_interval_days)
|
||||||
|
return last_update < threshold_date
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking update age for {artist.title}: {e}")
|
||||||
|
return True # Default to needing update if error
|
||||||
|
|
||||||
|
def update_artist_biography(self, artist: PlexArtist) -> bool:
|
||||||
|
"""Update artist summary with current timestamp"""
|
||||||
|
try:
|
||||||
|
# Get current summary/biography
|
||||||
|
current_summary = getattr(artist, 'summary', '') or ''
|
||||||
|
print(f"[DEBUG] Original summary for '{artist.title}': '{current_summary[:100]}...'")
|
||||||
|
|
||||||
|
# Preserve any IgnoreUpdate flag
|
||||||
|
ignore_flag = ''
|
||||||
|
if '-IgnoreUpdate' in current_summary:
|
||||||
|
ignore_flag = '-IgnoreUpdate'
|
||||||
|
# Remove IgnoreUpdate flag temporarily for processing
|
||||||
|
current_summary = current_summary.replace('-IgnoreUpdate', '').strip()
|
||||||
|
|
||||||
|
# Remove existing timestamp if present (ensures only one timestamp)
|
||||||
|
pattern = r'\s*-updatedAt\d{4}-\d{2}-\d{2}\s*'
|
||||||
|
clean_summary = re.sub(pattern, '', current_summary).strip()
|
||||||
|
|
||||||
|
# Build new summary with timestamp
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Add timestamp to summary field
|
||||||
|
new_summary = clean_summary
|
||||||
|
if ignore_flag:
|
||||||
|
new_summary = f"{new_summary}\n\n{ignore_flag}".strip()
|
||||||
|
new_summary = f"{new_summary}\n\n-updatedAt{today}".strip()
|
||||||
|
|
||||||
|
print(f"[DEBUG] Setting summary for '{artist.title}': ending with '{new_summary[-50:]}'")
|
||||||
|
|
||||||
|
# Use the correct Plex API syntax with .value (testing without lock first)
|
||||||
|
artist.edit(**{
|
||||||
|
'summary.value': new_summary
|
||||||
|
})
|
||||||
|
print(f"[DEBUG] Called artist.edit with summary.value (no lock) for '{artist.title}'")
|
||||||
|
|
||||||
|
# Add a small delay to let the edit process
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Reload to see the changes
|
||||||
|
artist.reload()
|
||||||
|
print(f"[DEBUG] Called artist.reload() for '{artist.title}'")
|
||||||
|
|
||||||
|
# Check if edit worked
|
||||||
|
updated_summary = getattr(artist, 'summary', '') or ''
|
||||||
|
print(f"[DEBUG] Summary after reload for '{artist.title}': ending with '{updated_summary[-50:]}'")
|
||||||
|
|
||||||
|
if updated_summary and '-updatedAt' in updated_summary:
|
||||||
|
logger.info(f"Updated summary timestamp for {artist.title}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG] Timestamp not found in summary after reload for '{artist.title}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEBUG] Exception in update_artist_biography for '{artist.title}': {e}")
|
||||||
|
logger.error(f"Error updating summary for {artist.title}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
def update_track_metadata(self, track_id: str, metadata: Dict[str, Any]) -> bool:
|
def update_track_metadata(self, track_id: str, metadata: Dict[str, Any]) -> bool:
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
3919
logs/app.log
3919
logs/app.log
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
||||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton,
|
QFrame, QGridLayout, QScrollArea, QSizePolicy, QPushButton,
|
||||||
QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout)
|
QProgressBar, QTextEdit, QSpacerItem, QGroupBox, QFormLayout, QComboBox)
|
||||||
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
|
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
|
||||||
from PyQt6.QtGui import QFont, QPalette, QColor
|
from PyQt6.QtGui import QFont, QPalette, QColor
|
||||||
import time
|
import time
|
||||||
|
|
@ -29,12 +29,13 @@ class MetadataUpdateWorker(QThread):
|
||||||
error = pyqtSignal(str) # error_message
|
error = pyqtSignal(str) # error_message
|
||||||
artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process
|
artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process
|
||||||
|
|
||||||
def __init__(self, artists, plex_client, spotify_client):
|
def __init__(self, artists, plex_client, spotify_client, refresh_interval_days=30):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.artists = artists
|
self.artists = artists
|
||||||
self.plex_client = plex_client
|
self.plex_client = plex_client
|
||||||
self.spotify_client = spotify_client
|
self.spotify_client = spotify_client
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
|
self.refresh_interval_days = refresh_interval_days
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
self.processed_count = 0
|
self.processed_count = 0
|
||||||
self.successful_count = 0
|
self.successful_count = 0
|
||||||
|
|
@ -116,18 +117,11 @@ class MetadataUpdateWorker(QThread):
|
||||||
self.error.emit(f"Metadata update failed: {str(e)}")
|
self.error.emit(f"Metadata update failed: {str(e)}")
|
||||||
|
|
||||||
def artist_needs_processing(self, artist):
|
def artist_needs_processing(self, artist):
|
||||||
"""Check if an artist needs metadata processing using smart detection"""
|
"""Check if an artist needs metadata processing using age-based detection"""
|
||||||
try:
|
try:
|
||||||
# Check if artist has a valid photo
|
# Use PlexClient's age-based checking with configured interval
|
||||||
has_valid_photo = self.artist_has_valid_photo(artist)
|
# This also handles the ignore flag check internally
|
||||||
|
return self.plex_client.needs_update_by_age(artist, self.refresh_interval_days)
|
||||||
# Check if artist has genres (more than just basic ones)
|
|
||||||
existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre)
|
|
||||||
for genre in (artist.genres or []))
|
|
||||||
has_good_genres = len(existing_genres) >= 2 # At least 2 genres indicates Spotify processing
|
|
||||||
|
|
||||||
# Process if missing photo OR insufficient genres
|
|
||||||
return not has_valid_photo or not has_good_genres
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
||||||
|
|
@ -178,9 +172,16 @@ class MetadataUpdateWorker(QThread):
|
||||||
changes_made.append("genres")
|
changes_made.append("genres")
|
||||||
|
|
||||||
if changes_made:
|
if changes_made:
|
||||||
|
# Update artist biography with timestamp to track last update
|
||||||
|
biography_updated = self.plex_client.update_artist_biography(artist)
|
||||||
|
if biography_updated:
|
||||||
|
changes_made.append("timestamp")
|
||||||
|
|
||||||
details = f"Updated {', '.join(changes_made)} (match: '{spotify_artist.name}', score: {highest_score:.2f})"
|
details = f"Updated {', '.join(changes_made)} (match: '{spotify_artist.name}', score: {highest_score:.2f})"
|
||||||
return True, details
|
return True, details
|
||||||
else:
|
else:
|
||||||
|
# Even if no metadata changes, update biography to record we checked this artist
|
||||||
|
self.plex_client.update_artist_biography(artist)
|
||||||
return True, "Already up to date"
|
return True, "Already up to date"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -774,6 +775,11 @@ class MetadataUpdaterWidget(QFrame):
|
||||||
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||||||
header_label.setStyleSheet("color: #ffffff;")
|
header_label.setStyleSheet("color: #ffffff;")
|
||||||
|
|
||||||
|
# Info label
|
||||||
|
info_label = QLabel("(type -IgnoreUpdate into artist summary to ignore metadata updates on this artist)")
|
||||||
|
info_label.setFont(QFont("Arial", 9))
|
||||||
|
info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;")
|
||||||
|
|
||||||
# Control section
|
# Control section
|
||||||
control_layout = QHBoxLayout()
|
control_layout = QHBoxLayout()
|
||||||
control_layout.setSpacing(15)
|
control_layout.setSpacing(15)
|
||||||
|
|
@ -801,6 +807,59 @@ class MetadataUpdaterWidget(QFrame):
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Refresh interval dropdown
|
||||||
|
refresh_info_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
refresh_label = QLabel("Refresh Interval:")
|
||||||
|
refresh_label.setFont(QFont("Arial", 9))
|
||||||
|
refresh_label.setStyleSheet("color: #b3b3b3;")
|
||||||
|
|
||||||
|
self.refresh_interval_combo = QComboBox()
|
||||||
|
self.refresh_interval_combo.setFixedHeight(32)
|
||||||
|
self.refresh_interval_combo.setFont(QFont("Arial", 10))
|
||||||
|
self.refresh_interval_combo.addItems([
|
||||||
|
"6 months",
|
||||||
|
"3 months",
|
||||||
|
"1 month",
|
||||||
|
"2 weeks",
|
||||||
|
"1 week",
|
||||||
|
"Full refresh"
|
||||||
|
])
|
||||||
|
self.refresh_interval_combo.setCurrentText("1 month") # Default selection
|
||||||
|
self.refresh_interval_combo.setStyleSheet("""
|
||||||
|
QComboBox {
|
||||||
|
background: #333333;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #555555;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
QComboBox:hover {
|
||||||
|
border: 1px solid #1db954;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
border: none;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 5px solid #ffffff;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background: #333333;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #555555;
|
||||||
|
selection-background-color: #1db954;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
refresh_info_layout.addWidget(refresh_label)
|
||||||
|
refresh_info_layout.addWidget(self.refresh_interval_combo)
|
||||||
|
|
||||||
# Current artist display
|
# Current artist display
|
||||||
artist_info_layout = QVBoxLayout()
|
artist_info_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
|
@ -816,6 +875,7 @@ class MetadataUpdaterWidget(QFrame):
|
||||||
artist_info_layout.addWidget(self.current_artist_label)
|
artist_info_layout.addWidget(self.current_artist_label)
|
||||||
|
|
||||||
control_layout.addWidget(self.start_button)
|
control_layout.addWidget(self.start_button)
|
||||||
|
control_layout.addLayout(refresh_info_layout)
|
||||||
control_layout.addLayout(artist_info_layout)
|
control_layout.addLayout(artist_info_layout)
|
||||||
control_layout.addStretch()
|
control_layout.addStretch()
|
||||||
|
|
||||||
|
|
@ -857,6 +917,7 @@ class MetadataUpdaterWidget(QFrame):
|
||||||
progress_layout.addWidget(self.progress_bar)
|
progress_layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
layout.addWidget(header_label)
|
layout.addWidget(header_label)
|
||||||
|
layout.addWidget(info_label)
|
||||||
layout.addLayout(control_layout)
|
layout.addLayout(control_layout)
|
||||||
layout.addLayout(progress_layout)
|
layout.addLayout(progress_layout)
|
||||||
|
|
||||||
|
|
@ -876,6 +937,20 @@ class MetadataUpdaterWidget(QFrame):
|
||||||
self.count_label.setText("0 / 0 artists")
|
self.count_label.setText("0 / 0 artists")
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
|
|
||||||
|
def get_refresh_interval_days(self) -> int:
|
||||||
|
"""Convert dropdown selection to number of days"""
|
||||||
|
interval_map = {
|
||||||
|
"6 months": 180,
|
||||||
|
"3 months": 90,
|
||||||
|
"1 month": 30,
|
||||||
|
"2 weeks": 14,
|
||||||
|
"1 week": 7,
|
||||||
|
"Full refresh": 0 # 0 means update everything
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = self.refresh_interval_combo.currentText()
|
||||||
|
return interval_map.get(selected, 30) # Default to 1 month
|
||||||
|
|
||||||
class ActivityItem(QWidget):
|
class ActivityItem(QWidget):
|
||||||
def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None):
|
def __init__(self, icon: str, title: str, subtitle: str, time: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -1216,11 +1291,15 @@ class DashboardPage(QWidget):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Get refresh interval from dropdown
|
||||||
|
refresh_interval_days = self.metadata_widget.get_refresh_interval_days()
|
||||||
|
|
||||||
# Start the metadata update worker (it will handle artist retrieval in background)
|
# Start the metadata update worker (it will handle artist retrieval in background)
|
||||||
self.metadata_worker = MetadataUpdateWorker(
|
self.metadata_worker = MetadataUpdateWorker(
|
||||||
None, # Artists will be loaded in the worker thread
|
None, # Artists will be loaded in the worker thread
|
||||||
self.data_provider.service_clients['plex'],
|
self.data_provider.service_clients['plex'],
|
||||||
self.data_provider.service_clients['spotify']
|
self.data_provider.service_clients['spotify'],
|
||||||
|
refresh_interval_days
|
||||||
)
|
)
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue