Implement album deletion feature after metadata migration

- Added a new command-line argument `--delete` to allow users to delete single albums after successful metadata migration.
- Integrated the `unmonitor_and_delete_album` function to handle the deletion process for albums that meet the migration criteria.
- Enhanced the `migrate_plex_metadata` function to support exclusion of smart playlists during migration.
- Updated logging to provide detailed feedback on the deletion process and migration results.
This commit is contained in:
Danilo Reyes
2025-11-14 02:04:11 -06:00
parent af5a2bf825
commit cc9521f7a4
4 changed files with 149 additions and 33 deletions

View File

@@ -0,0 +1,49 @@
"""Lidarr album deletion functions"""
import logging
from typing import Dict
import requests
logger = logging.getLogger(__name__)
def unmonitor_and_delete_album(
base_url: str, headers: Dict[str, str], album_id: int
) -> bool:
"""Unmonitor and delete an album from Lidarr. Returns success status."""
try:
# First unmonitor the album
logger.debug(f"Unmonitoring album {album_id}...")
album_resp = requests.get(
f"{base_url.rstrip('/')}/api/v1/album/{album_id}",
headers=headers,
timeout=30,
)
album_resp.raise_for_status()
album_data = album_resp.json()
album_data["monitored"] = False
update_resp = requests.put(
f"{base_url.rstrip('/')}/api/v1/album/{album_id}",
headers=headers,
json=album_data,
timeout=30,
)
update_resp.raise_for_status()
# Then delete the album (deleteFiles=true to remove files, addImportListExclusion=false)
logger.debug(f"Deleting album {album_id}...")
delete_resp = requests.delete(
f"{base_url.rstrip('/')}/api/v1/album/{album_id}",
headers=headers,
params={"deleteFiles": "true", "addImportListExclusion": "false"},
timeout=30,
)
delete_resp.raise_for_status()
return True
except Exception as e:
logger.error(f"Failed to delete album {album_id}: {e}")
return False

View File

@@ -15,7 +15,8 @@ from dotenv import load_dotenv
from duplicate_finder import build_album_track_map, find_duplicate_singles from duplicate_finder import build_album_track_map, find_duplicate_singles
from lidarr_client import fetch_all_artists, fetch_albums_for_artist from lidarr_client import fetch_all_artists, fetch_albums_for_artist
from plex_metadata import get_plex_server, migrate_plex_metadata from lidarr_delete import unmonitor_and_delete_album
from plex_metadata import get_plex_server, get_smart_playlist_ids, migrate_plex_metadata
load_dotenv() load_dotenv()
@@ -56,6 +57,11 @@ def main() -> None:
action="store_true", action="store_true",
help="Migrate metadata (ratings, play counts) from singles to album tracks. Only applies to perfect matches (confidence >= 95).", help="Migrate metadata (ratings, play counts) from singles to album tracks. Only applies to perfect matches (confidence >= 95).",
) )
parser.add_argument(
"--delete",
action="store_true",
help="Delete single albums after successful metadata migration. Only deletes perfect matches (confidence >= 95) with successful migration.",
)
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig( logging.basicConfig(
@@ -155,8 +161,11 @@ def main() -> None:
) )
return return
smart_playlist_ids = get_smart_playlist_ids(plex_server)
logger.info("Migrating Plex metadata for perfect matches (confidence >= 95)...") logger.info("Migrating Plex metadata for perfect matches (confidence >= 95)...")
migration_count = 0 migration_count = 0
albums_to_delete = []
for dup in duplicates: for dup in duplicates:
for album_track in dup.get("verified_albums", []): for album_track in dup.get("verified_albums", []):
@@ -170,7 +179,11 @@ def main() -> None:
f"Migrating Plex metadata for '{dup['track_title']}' to album '{album_track['album_title']}'..." f"Migrating Plex metadata for '{dup['track_title']}' to album '{album_track['album_title']}'..."
) )
success, message = migrate_plex_metadata( success, message = migrate_plex_metadata(
plex_server, single_file_path, album_file_path, docker_mount plex_server,
single_file_path,
album_file_path,
docker_mount,
smart_playlist_ids,
) )
album_track["migration_message"] = message album_track["migration_message"] = message
@@ -179,12 +192,47 @@ def main() -> None:
if success: if success:
migration_count += 1 migration_count += 1
logger.info(f"{message}") logger.info(f"{message}")
if args.delete and dup["single_album_id"] not in [
a["album_id"] for a in albums_to_delete
]:
albums_to_delete.append(
{
"album_id": dup["single_album_id"],
"album_title": dup["single_album_title"],
"artist_name": artist_map.get(
dup["artist_id"], "Unknown"
),
}
)
else: else:
logger.warning(f"{message}") logger.warning(f"{message}")
logger.info(f"Completed Plex metadata migration for {migration_count} track(s)") logger.info(f"Completed Plex metadata migration for {migration_count} track(s)")
logger.info("") logger.info("")
if args.delete and albums_to_delete:
logger.info(
f"Deleting {len(albums_to_delete)} single album(s) from Lidarr..."
)
deleted_count = 0
for album_info in albums_to_delete:
album_id = album_info["album_id"]
album_title = album_info["album_title"]
artist_name = album_info["artist_name"]
logger.info(
f"Deleting album: {artist_name} - {album_title} (albumId: {album_id})..."
)
if unmonitor_and_delete_album(base_url, headers, album_id):
deleted_count += 1
logger.info(f" ✓ Successfully deleted album {album_id}")
else:
logger.error(f" ✗ Failed to delete album {album_id}")
logger.info(f"Deleted {deleted_count}/{len(albums_to_delete)} album(s)")
logger.info("")
verified_count = sum(1 for dup in duplicates if dup.get("verified_albums")) verified_count = sum(1 for dup in duplicates if dup.get("verified_albums"))
logger.info( logger.info(
f"Found {len(duplicates)} single track(s) that are duplicates of album tracks ({verified_count} verified by audio fingerprint):" f"Found {len(duplicates)} single track(s) that are duplicates of album tracks ({verified_count} verified by audio fingerprint):"

View File

@@ -29,55 +29,71 @@ def find_plex_track_by_path(
try: try:
mapped_path = map_docker_path(file_path, docker_mount) mapped_path = map_docker_path(file_path, docker_mount)
logger.debug(
f"Searching for track: lidarr_path={file_path}, mapped_path={mapped_path}"
)
music_sections = [ music_sections = [
s for s in plex_server.library.sections() if s.type == "artist" s for s in plex_server.library.sections() if s.type == "artist"
] ]
if not music_sections:
logger.warning("No music sections found in Plex")
return None
# Try searching by exact mapped path first # Strategy: Check track.locations (list of file paths)
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: for section in music_sections:
all_tracks = section.searchTracks() all_tracks = section.searchTracks()
for track in all_tracks: for track in all_tracks:
for media in track.media: track_locations = getattr(track, "locations", [])
for part in media.parts: if mapped_path in track_locations or file_path in track_locations:
if part.file and ( logger.debug(
part.file == mapped_path f"Found track by locations match: {track.title} - {track_locations[0] if track_locations else 'unknown'}"
or part.file == file_path )
or part.file.endswith(filename) return track
):
logger.debug(f"Found track by filename match: {part.file}")
return track
logger.warning( logger.warning(
f"Could not find Plex track for path: {file_path} (mapped: {mapped_path})" f"Could not find Plex track. Paths tried: {file_path}, {mapped_path}"
) )
return None return None
except Exception as e: except Exception as e:
logger.debug(f"Could not find Plex track for path {file_path}: {e}") logger.error(f"Error finding Plex track for path {file_path}: {e}")
import traceback
logger.debug(traceback.format_exc())
return None return None
def get_plex_playlists_for_track(plex_server, track) -> List: def get_smart_playlist_ids(plex_server) -> set:
"""Get all playlists containing this track""" """Get set of smart playlist IDs to exclude from migration"""
try:
smart_playlists = [
p.ratingKey
for p in plex_server.playlists()
if p.playlistType == "audio" and p.smart
]
if smart_playlists:
logger.info(
f"Found {len(smart_playlists)} smart playlists (will exclude from migration)"
)
return set(smart_playlists)
except Exception as e:
logger.debug(f"Could not get smart playlists: {e}")
return set()
def get_plex_playlists_for_track(
plex_server, track, exclude_smart_playlists: set = None
) -> List:
"""Get all manual playlists containing this track (excludes smart playlists)"""
if exclude_smart_playlists is None:
exclude_smart_playlists = set()
try: try:
return [ return [
playlist playlist
for playlist in plex_server.playlists() for playlist in plex_server.playlists()
if playlist.playlistType == "audio" if playlist.playlistType == "audio"
and playlist.ratingKey not in exclude_smart_playlists
and any(item.ratingKey == track.ratingKey for item in playlist.items()) and any(item.ratingKey == track.ratingKey for item in playlist.items())
] ]
except Exception as e: except Exception as e:
@@ -216,6 +232,7 @@ def migrate_plex_metadata(
single_file_path: str, single_file_path: str,
album_file_path: str, album_file_path: str,
docker_mount: Optional[str] = None, docker_mount: Optional[str] = None,
exclude_smart_playlists: set = None,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Migrate Plex metadata from single to album track. Returns (success, message)""" """Migrate Plex metadata from single to album track. Returns (success, message)"""
if not plex_server: if not plex_server:
@@ -231,7 +248,9 @@ def migrate_plex_metadata(
single_rating = getattr(single_track, "userRating", None) single_rating = getattr(single_track, "userRating", None)
single_plays = getattr(single_track, "viewCount", 0) or 0 single_plays = getattr(single_track, "viewCount", 0) or 0
single_playlists = get_plex_playlists_for_track(plex_server, single_track) single_playlists = get_plex_playlists_for_track(
plex_server, single_track, exclude_smart_playlists
)
logger.info( logger.info(
f" Single track metadata: rating={single_rating or 'none'}, plays={single_plays}, playlists={len(single_playlists)}" f" Single track metadata: rating={single_rating or 'none'}, plays={single_plays}, playlists={len(single_playlists)}"

View File

@@ -3,7 +3,7 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools] [tool.setuptools]
py-modules = ["main", "lidarr_client", "audio_verification", "track_verification", "plex_metadata", "duplicate_finder"] py-modules = ["main", "lidarr_client", "audio_verification", "track_verification", "plex_metadata", "duplicate_finder", "lidarr_delete"]
[project] [project]
name = "lidarr-cleanup-singles" name = "lidarr-cleanup-singles"