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:
49
src-cleanup/lidarr_delete.py
Normal file
49
src-cleanup/lidarr_delete.py
Normal 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
|
||||||
@@ -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):"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
):
|
|
||||||
logger.debug(f"Found track by filename match: {part.file}")
|
|
||||||
return track
|
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)}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user