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.
This commit is contained in:
267
src-cleanup/plex_metadata.py
Normal file
267
src-cleanup/plex_metadata.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user