Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions spotdl/console/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"""

from spotdl.console.entry_point import console_entry_point
from spotdl.console.remove import remove as remove_cmd

__all__ = [
"console_entry_point",
"remove_cmd",
]
21 changes: 14 additions & 7 deletions spotdl/console/entry_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from spotdl.console.download import download
from spotdl.console.meta import meta
from spotdl.console.remove import remove as remove_cmd
from spotdl.console.save import save
from spotdl.console.sync import sync
from spotdl.console.url import url
Expand All @@ -32,6 +33,7 @@
"save": save,
"meta": meta,
"url": url,
"remove": remove_cmd,
}

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -88,8 +90,8 @@ def entry_point():
if is_ffmpeg_installed() is False:
download_ffmpeg()

# Check if ffmpeg is installed
if is_ffmpeg_installed(downloader_settings["ffmpeg"]) is False:
# Check if ffmpeg is installed (skip for remove command)
if arguments.operation != "remove" and is_ffmpeg_installed(downloader_settings["ffmpeg"]) is False:
raise FFmpegError(
"FFmpeg is not installed. Please run `spotdl --download-ffmpeg` to install it, "
"or `spotdl --ffmpeg /path/to/ffmpeg` to specify the path to ffmpeg."
Expand Down Expand Up @@ -138,15 +140,20 @@ def entry_point():
"Log in by adding the --user-auth flag"
)

# Initialize the downloader
# for download, load and preload operations
downloader = Downloader(downloader_settings)
# For remove command, we don't need a downloader
if arguments.operation == "remove":
OPERATIONS[arguments.operation](arguments.query)
return None

# Initialize downloader for other operations
downloader = Downloader(
downloader_settings,
skip_ffmpeg_check=arguments.operation == "remove"
)

def graceful_exit(_signal, _frame):
if spotify_settings["use_cache_file"]:
save_spotify_cache(spotify_client.cache)

downloader.progress_handler.close()
sys.exit(0)

signal.signal(signal.SIGINT, graceful_exit)
Expand Down
171 changes: 171 additions & 0 deletions spotdl/console/remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""
Remove module for the console.

This module provides functionality to remove songs that were downloaded from a Spotify playlist.
It matches files based on the same naming pattern used during download.
"""

import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

from spotdl.types.song import Song
from spotdl.utils.spotify import SpotifyClient
from spotdl.utils.search import get_simple_songs

__all__ = ["remove"]

logger = logging.getLogger(__name__)

def remove(
query: List[str],
downloader: Any = None, # Keep for backward compatibility
output: str = "{artists} - {title}.{output-ext}",
audio_format: str = "mp3",
) -> None:
"""
Remove songs from the local directory that match the given Spotify playlist URL.

### Arguments
- query: List of Spotify playlist URLs to remove songs from.
- output: Output format for the song file.
- audio_format: Audio format to look for when removing files.

### Example
```
python -m spotdl remove "https://open.spotify.com/playlist/..." \
--output "{artist} - {title}.{output-ext}" \
--format "mp3"
```
"""
# Initialize spotify client to get playlist data
spotify_client = SpotifyClient()

if not query:
logger.error("No playlist URL provided")
return

logger.info(f"Fetching songs from {len(query)} playlist(s)...")

# Get all songs from the playlist
songs = get_simple_songs(
query,
use_ytm_data=False,
playlist_numbering=False,
albums_to_ignore=[],
album_type=None,
playlist_retain_track_cover=False,
)

if not songs:
logger.warning("No songs found in the provided playlist URL")
return

logger.info(f"Found {len(songs)} songs in the playlist")

# Convert output format to match the file naming pattern
output = output.replace("{output-ext}", audio_format)

# Get the base directory from the output format
output_dir = os.path.dirname(output)
if not output_dir:
output_dir = "."

logger.info(f"Searching for matching files in: {os.path.abspath(output_dir)}")

# Get all files in the output directory
all_files = []
for root, _, files in os.walk(output_dir):
for file in files:
if file.lower().endswith(f".{audio_format.lower()}"):
all_files.append(Path(root) / file)

if not all_files:
logger.warning(f"No {audio_format} files found in the output directory")
return

logger.info(f"Found {len(all_files)} {audio_format} files to check")

# Track removed files
removed_count = 0
removed_files = []

for song in songs:
# Generate the expected filename for this song
try:
formatted_output = output.format(
title=song.name or "Unknown",
artists=", ".join(song.artists) if song.artists else "Unknown",
artist=song.artists[0] if song.artists else "Unknown",
album=song.album_name if song.album_name else "Unknown",
album_artist=song.album_artist if song.album_artist else "Unknown",
date=song.date if song.date else "Unknown",
year=song.year if song.year else "Unknown",
track_number=str(song.track_number).zfill(2) if song.track_number else "01",
tracks_in_album=str(song.tracks_count).zfill(2) if song.tracks_count else "01",
disc_number=str(song.disc_number).zfill(2) if song.disc_number else "01",
discs_in_album=str(song.disc_count).zfill(2) if song.disc_count else "01",
isrc=song.song_id if song.song_id else "Unknown",
output_ext=audio_format,
)
expected_filename = Path(formatted_output).resolve()
except (KeyError, IndexError) as e:
logger.warning(f"Error formatting output for song {song.name}: {str(e)}")
continue

# Look for files that match the expected pattern
for file_path in all_files[:]: # Make a copy of the list to modify it while iterating
try:
if file_path.name.lower() == expected_filename.name.lower():
try:
file_path.unlink()
logger.debug(f"Removed: {file_path}")
removed_files.append(str(file_path))
all_files.remove(file_path) # Remove from list to avoid double processing
removed_count += 1
except OSError as e:
logger.error(f"Error removing {file_path}: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error removing {file_path}: {str(e)}")
raise
except Exception as e:
logger.error(f"Error processing file {file_path}: {str(e)}")

# Print summary
if removed_count > 0:
logger.info("\nRemoved the following files:")
for file_path in removed_files:
logger.info(f"- {file_path}")

logger.info(f"\nSuccessfully removed {removed_count} files from the playlist.")
else:
logger.warning("\nNo matching files found to remove.")

# If we didn't find any files to remove, show a helpful message
if removed_count == 0:
logger.warning("\nThe files may have already been removed or the naming format doesn't match.")
logger.info("\nTip: Make sure the output format matches the one used when downloading the files.")
logger.info(f"Example format used: {output}")

if songs:
example_song = songs[0]
try:
example_output = output.format(
title=example_song.name or "Example Title",
artists="Example Artist",
artist="Example Artist",
album=example_song.album_name or "Example Album",
album_artist=example_song.album_artist or "Example Artist",
date=example_song.date or "2023",
year=example_song.year or "2023",
track_number="01",
tracks_in_album="12",
disc_number="01",
discs_in_album="01",
isrc="EXAMPLE123",
output_ext=audio_format,
)
logger.info(f"Example output: {example_output}")
except Exception as e:
logger.debug(f"Could not generate example output: {str(e)}")
7 changes: 6 additions & 1 deletion spotdl/download/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,15 @@ def __init__(
self,
settings: Optional[Union[DownloaderOptionalOptions, DownloaderOptions]] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
skip_ffmpeg_check: bool = False,
):
"""
Initialize the Downloader class.

### Arguments
- settings: The settings to use.
- loop: The event loop to use.
- skip_ffmpeg_check: Whether to skip FFmpeg installation check.

### Notes
- `search-query` uses the same format as `output`.
Expand All @@ -123,6 +125,9 @@ def __init__(
Namespace(config=False), dict(settings), DOWNLOADER_OPTIONS
) # type: ignore
)

# Skip FFmpeg check if requested (e.g., for remove command)
self.skip_ffmpeg_check = skip_ffmpeg_check

# Handle deprecated values in config file
modernize_settings(self.settings)
Expand All @@ -137,7 +142,7 @@ def __init__(
# If ffmpeg is the default value and it's not installed
# try to use the spotdl's ffmpeg
self.ffmpeg = self.settings["ffmpeg"]
if self.ffmpeg == "ffmpeg" and shutil.which("ffmpeg") is None:
if not self.skip_ffmpeg_check and self.ffmpeg == "ffmpeg" and shutil.which("ffmpeg") is None:
ffmpeg_exec = get_ffmpeg_path()
if ffmpeg_exec is None:
raise DownloaderError("ffmpeg is not installed")
Expand Down
2 changes: 1 addition & 1 deletion spotdl/utils/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

__all__ = ["OPERATIONS", "SmartFormatter", "parse_arguments"]

OPERATIONS = ["download", "save", "web", "sync", "meta", "url"]
OPERATIONS = ["download", "save", "web", "sync", "meta", "url", "remove"]


class SmartFormatter(argparse.HelpFormatter):
Expand Down