Files
lidarr-mb-gap/src-cleanup/plex_metadata.py
Danilo Reyes af5a2bf825 Add audio verification and duplicate tracking features
- 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.
2025-11-14 01:32:41 -06:00

268 lines
9.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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