- Integrated `plexapi` and `python-dotenv` as dependencies in `flake.nix` and `pyproject.toml` for enhanced functionality. - Implemented new modules for audio verification and duplicate tracking, including `audio_verification.py`, `duplicate_finder.py`, and `track_verification.py`. - Updated `main.py` to utilize the new modules for identifying and managing duplicate single tracks in Lidarr, with detailed logging and confidence scoring. - Enhanced the `find_duplicate_singles` function to support audio verification results and metadata migration to Plex. - Refactored existing code for improved structure and maintainability, ensuring better integration of new features.
268 lines
9.5 KiB
Python
268 lines
9.5 KiB
Python
"""Plex metadata migration functions"""
|
||
|
||
import logging
|
||
from typing import List, Optional, Tuple
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def get_plex_server(plex_url: str, plex_token: str):
|
||
"""Connect to Plex server"""
|
||
try:
|
||
from plexapi.server import PlexServer
|
||
|
||
return PlexServer(plex_url, plex_token)
|
||
except ImportError:
|
||
logger.error("python-plexapi not installed. Install with: pip install plexapi")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"Failed to connect to Plex server: {e}")
|
||
return None
|
||
|
||
|
||
def find_plex_track_by_path(
|
||
plex_server, file_path: str, docker_mount: Optional[str] = None
|
||
):
|
||
"""Find a Plex track by its file path"""
|
||
from audio_verification import map_docker_path
|
||
import os
|
||
|
||
try:
|
||
mapped_path = map_docker_path(file_path, docker_mount)
|
||
music_sections = [
|
||
s for s in plex_server.library.sections() if s.type == "artist"
|
||
]
|
||
|
||
# Try searching by exact mapped path first
|
||
for section in music_sections:
|
||
results = section.search(filters={"track.file": mapped_path})
|
||
if results:
|
||
logger.debug(f"Found track by mapped path: {mapped_path}")
|
||
return results[0]
|
||
|
||
# Try original path (might be what Plex sees in Docker)
|
||
for section in music_sections:
|
||
results = section.search(filters={"track.file": file_path})
|
||
if results:
|
||
logger.debug(f"Found track by original path: {file_path}")
|
||
return results[0]
|
||
|
||
# Fallback: search by filename in all tracks
|
||
filename = os.path.basename(file_path)
|
||
for section in music_sections:
|
||
all_tracks = section.searchTracks()
|
||
for track in all_tracks:
|
||
for media in track.media:
|
||
for part in media.parts:
|
||
if part.file and (
|
||
part.file == mapped_path
|
||
or part.file == file_path
|
||
or part.file.endswith(filename)
|
||
):
|
||
logger.debug(f"Found track by filename match: {part.file}")
|
||
return track
|
||
|
||
logger.warning(
|
||
f"Could not find Plex track for path: {file_path} (mapped: {mapped_path})"
|
||
)
|
||
return None
|
||
except Exception as e:
|
||
logger.debug(f"Could not find Plex track for path {file_path}: {e}")
|
||
return None
|
||
|
||
|
||
def get_plex_playlists_for_track(plex_server, track) -> List:
|
||
"""Get all playlists containing this track"""
|
||
try:
|
||
return [
|
||
playlist
|
||
for playlist in plex_server.playlists()
|
||
if playlist.playlistType == "audio"
|
||
and any(item.ratingKey == track.ratingKey for item in playlist.items())
|
||
]
|
||
except Exception as e:
|
||
logger.debug(f"Could not get playlists: {e}")
|
||
return []
|
||
|
||
|
||
def migrate_rating(
|
||
single_track, album_track, single_rating, original_album_rating
|
||
) -> Tuple[List[str], List[str], List[str]]:
|
||
"""Migrate rating. Returns (changes, already_has, failures)"""
|
||
if not single_rating:
|
||
return [], [], []
|
||
|
||
if original_album_rating:
|
||
logger.info(f" Album already has rating: {original_album_rating}/10")
|
||
return [], [f"rating ({original_album_rating}/10)"], []
|
||
|
||
try:
|
||
logger.info(f" Setting rating to {single_rating}/10...")
|
||
album_track.rate(single_rating)
|
||
album_track.reload()
|
||
new_rating = getattr(album_track, "userRating", None)
|
||
|
||
if new_rating != single_rating:
|
||
logger.warning(
|
||
f" ⚠ Rating mismatch: expected {single_rating}, got {new_rating}"
|
||
)
|
||
return [], [], [f"rating (set to {single_rating} but got {new_rating})"]
|
||
|
||
logger.info(f" ✓ Rating verified: {new_rating}/10")
|
||
return [f"rating ({single_rating}/10) ✓ verified"], [], []
|
||
except Exception as e:
|
||
logger.error(f"Failed to migrate rating: {e}")
|
||
return [], [], [f"rating (error: {e})"]
|
||
|
||
|
||
def migrate_play_count(
|
||
album_track, single_plays, album_plays
|
||
) -> Tuple[List[str], List[str], List[str]]:
|
||
"""Migrate play count. Returns (changes, already_has, failures)"""
|
||
if single_plays <= 0:
|
||
return [], [], []
|
||
|
||
expected_count = album_plays + single_plays
|
||
logger.info(
|
||
f" Migrating play count: single={single_plays}, album={album_plays}, expected={expected_count}"
|
||
)
|
||
|
||
try:
|
||
list(
|
||
map(
|
||
lambda i: (
|
||
album_track.markPlayed(),
|
||
(
|
||
logger.debug(
|
||
f" Marked played {i + 1}/{single_plays} times..."
|
||
)
|
||
if (i + 1) % 10 == 0
|
||
else None
|
||
),
|
||
)[0],
|
||
range(single_plays),
|
||
)
|
||
)
|
||
|
||
album_track.reload()
|
||
new_count = getattr(album_track, "viewCount", 0) or 0
|
||
|
||
if new_count != expected_count:
|
||
logger.warning(
|
||
f" ⚠ Play count mismatch: expected {expected_count}, got {new_count}"
|
||
)
|
||
return (
|
||
[],
|
||
[],
|
||
[f"play count (expected {expected_count} but got {new_count})"],
|
||
)
|
||
|
||
logger.info(f" ✓ Play count verified: {new_count}")
|
||
return (
|
||
[f"play count ({album_plays} + {single_plays} = {new_count}) ✓ verified"],
|
||
[],
|
||
[],
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to migrate play count: {e}")
|
||
return [], [], [f"play count (error: {e})"]
|
||
|
||
|
||
def migrate_playlist(playlist, album_track) -> Tuple[List[str], List[str], List[str]]:
|
||
"""Migrate single playlist. Returns (changes, already_has, failures)"""
|
||
playlist_name = playlist.title
|
||
|
||
try:
|
||
if any(item.ratingKey == album_track.ratingKey for item in playlist.items()):
|
||
logger.info(f" Album already in playlist: '{playlist_name}'")
|
||
return [], [f"playlist '{playlist_name}'"], []
|
||
|
||
logger.info(f" Adding to playlist: '{playlist_name}'...")
|
||
playlist.addItems(album_track)
|
||
playlist.reload()
|
||
|
||
if not any(
|
||
item.ratingKey == album_track.ratingKey for item in playlist.items()
|
||
):
|
||
logger.warning(f" ⚠ Playlist '{playlist_name}' add failed verification")
|
||
return [], [], [f"playlist '{playlist_name}' (add failed)"]
|
||
|
||
logger.info(f" ✓ Playlist '{playlist_name}' verified")
|
||
return [f"added to playlist '{playlist_name}' ✓ verified"], [], []
|
||
except Exception as e:
|
||
logger.error(f"Failed to add to playlist '{playlist_name}': {e}")
|
||
return [], [], [f"playlist '{playlist_name}' (error: {e})"]
|
||
|
||
|
||
def format_migration_message(
|
||
changes: List[str], already_has: List[str], failures: List[str]
|
||
) -> str:
|
||
"""Format migration result message"""
|
||
parts = list(
|
||
filter(
|
||
None,
|
||
[
|
||
f"✅ Migrated: {', '.join(changes)}" if changes else None,
|
||
f"ℹ️ Already has: {', '.join(already_has)}" if already_has else None,
|
||
f"❌ Failed: {', '.join(failures)}" if failures else None,
|
||
],
|
||
)
|
||
)
|
||
return " | ".join(parts) if parts else "No metadata to migrate"
|
||
|
||
|
||
def migrate_plex_metadata(
|
||
plex_server,
|
||
single_file_path: str,
|
||
album_file_path: str,
|
||
docker_mount: Optional[str] = None,
|
||
) -> Tuple[bool, str]:
|
||
"""Migrate Plex metadata from single to album track. Returns (success, message)"""
|
||
if not plex_server:
|
||
return False, "Plex server not connected"
|
||
|
||
single_track = find_plex_track_by_path(plex_server, single_file_path, docker_mount)
|
||
album_track = find_plex_track_by_path(plex_server, album_file_path, docker_mount)
|
||
|
||
if not single_track:
|
||
return False, "Could not find single track in Plex"
|
||
if not album_track:
|
||
return False, "Could not find album track in Plex"
|
||
|
||
single_rating = getattr(single_track, "userRating", None)
|
||
single_plays = getattr(single_track, "viewCount", 0) or 0
|
||
single_playlists = get_plex_playlists_for_track(plex_server, single_track)
|
||
|
||
logger.info(
|
||
f" Single track metadata: rating={single_rating or 'none'}, plays={single_plays}, playlists={len(single_playlists)}"
|
||
)
|
||
if single_playlists:
|
||
logger.info(
|
||
f" Single is in playlists: {', '.join(p.title for p in single_playlists)}"
|
||
)
|
||
|
||
original_album_rating = getattr(album_track, "userRating", None)
|
||
album_plays = getattr(album_track, "viewCount", 0) or 0
|
||
|
||
rating_changes, rating_already, rating_failures = migrate_rating(
|
||
single_track, album_track, single_rating, original_album_rating
|
||
)
|
||
|
||
plays_changes, plays_already, plays_failures = migrate_play_count(
|
||
album_track, single_plays, album_plays
|
||
)
|
||
|
||
playlist_results = list(
|
||
map(lambda p: migrate_playlist(p, album_track), single_playlists)
|
||
)
|
||
playlist_changes = [c for result in playlist_results for c in result[0]]
|
||
playlist_already = [a for result in playlist_results for a in result[1]]
|
||
playlist_failures = [f for result in playlist_results for f in result[2]]
|
||
|
||
all_changes = rating_changes + plays_changes + playlist_changes
|
||
all_already = rating_already + plays_already + playlist_already
|
||
all_failures = rating_failures + plays_failures + playlist_failures
|
||
|
||
message = format_migration_message(all_changes, all_already, all_failures)
|
||
return len(all_failures) == 0, message
|