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
162 changes: 162 additions & 0 deletions beetsplug/saveskippedsongs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# This file is part of beets.
# Copyright 2025, Jacob Danell.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

"""
Save all skipped songs to a text file for later review.
This plugin uses the Spotify plugin (if available) to try to find
the Spotify links for the skipped songs.
"""

import os
from typing import TYPE_CHECKING, Optional

from beets import plugins
from beets.importer import Action
from beets.plugins import BeetsPlugin

if TYPE_CHECKING:
from beets.importer import ImportSession, SingletonImportTask
from beets.metadata_plugins import SearchFilter

__author__ = "[email protected]"
__version__ = "1.0"


def summary(task: "SingletonImportTask"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the more general ImportTask is a better suited typehint than the than the derived SingletonImportTask - I don't think there's any functionality in this plugin that appears to be Singleton only?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be if you skipp a singleton song?

"""Given an ImportTask, produce a short string identifying the
object.
"""
if task.is_album:
return f"{task.cur_artist} - {task.cur_album}"
return f"{task.item.artist} - {task.item.title}"


class SaveSkippedSongsPlugin(BeetsPlugin):
def __init__(self):
"""Initialize the plugin and read configuration."""
super().__init__()
self.config.add(
{
"spotify": True,
"path": "skipped_songs.txt",
}
)
self.register_listener("import_task_choice", self.log_skipped_song)

def log_skipped_song(
self, task: "SingletonImportTask", session: "ImportSession"
):
if task.choice_flag == Action.SKIP:
# If spotify integration is enabled, try to match with Spotify
link = None
if self.config["spotify"].get(bool):
link = self._match_with_spotify(task, session)

result = f"{summary(task)}{' (' + link + ')' if link else ''}"
self._log.info(f"Skipped: {result}")
path = self.config["path"].get(str)
if path:
# Expand user home (~) and environment variables in the path
path = os.path.expanduser(os.path.expandvars(path))
path = os.path.abspath(path)
try:
# Read existing lines (if file exists) and avoid duplicates.
try:
with open(path, "r", encoding="utf-8") as f:
existing = {
line.rstrip("\n").strip().lower() for line in f
}
except FileNotFoundError:
existing = set()

normalized_result = result.strip().lower()
if normalized_result not in existing:
with open(path, "a", encoding="utf-8") as f:
f.write(f"{result}\n")
else:
self._log.debug(f"Song already recorded in {path}")
except OSError as exc:
# Don't crash import; just log the I/O problem.
self._log.debug(
f"Could not write skipped song to {path}: {exc}"
)

def _match_with_spotify(
self, task: "SingletonImportTask", session: "ImportSession"
) -> Optional[str]:
"""Try to match the skipped track/album with Spotify by directly
calling the Spotify API search.
"""
try:
# Try to get the spotify plugin if it's already loaded
spotify_plugin = None
for plugin in plugins.find_plugins():
if plugin.name == "spotify":
spotify_plugin = plugin
break
Comment on lines +103 to +107
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can assume that if a user doesn't have spotify loaded as a plugin, they may not be interested in the spotify feature.

Could also avoid having to make as second API call as well, since by the time the user application or may skip a song, it will have probably already attempted to grab candidates from the import task. It should be available under ImportTask.candidates here, and then it'd just be a member of the AlbumInfo.info or TrackMatch.info object - which should come with the distance already calculated nicely too. Could let the user just filter what database source URLs they wanted printed with it in a config option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably just don't really understand your explenation so if I'm wrong please correct me.

For the addition of the spotify links to be added you would need the spotify plugin to be configured but disabled in your config. If it's active beets will mostly just pick the spotify match as the best match and move on (This is some info I should add to the documentation now when thinking about it).

If the spotify plugin is disabled we would need to do the API call when the user presses skip to see if there is any Spotify matches (without picking it as the beets match)


# If not loaded, try to load it dynamically
if not spotify_plugin:
try:
from beetsplug.spotify import SpotifyPlugin

spotify_plugin = SpotifyPlugin()
self._log.debug("Loaded Spotify plugin dynamically")
except ImportError as e:
self._log.debug(f"Could not import Spotify plugin: {e}")
return None
except Exception as e:
self._log.debug(f"Could not initialize Spotify plugin: {e}")
return None

# Build search parameters based on the task
query_filters: SearchFilter = {}
if task.is_album:
query_string = task.cur_album or ""
if task.cur_artist:
query_filters["artist"] = task.cur_artist
search_type = "album"
else:
# For singleton imports
item = task.item
query_string = item.title or ""
if item.artist:
query_filters["artist"] = item.artist
if item.album:
query_filters["album"] = item.album
search_type = "track"

self._log.info(
f"Searching Spotify for: {query_string} ({query_filters})"
)

# Call the Spotify API directly via the plugin's search method
results = spotify_plugin._search_api( # type: ignore[attr-defined]
query_type=search_type, # type: ignore[arg-type]
filters=query_filters,
query_string=query_string,
)

if results:
self._log.info(f"Found {len(results)} Spotify match(es)!")
self._log.info("Returning first Spotify match link")
return results[0].get("external_urls", {}).get("spotify", None)
else:
self._log.info("No Spotify matches found")

except AttributeError as e:
self._log.debug(f"Spotify plugin method not available: {e}")
except Exception as e:
self._log.debug(f"Error searching Spotify: {e}")
return None
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ New features:
resolved. The ``extended_debug`` config setting and ``--debug`` option
have been removed.
- Added support for Python 3.13.
- :doc:`plugins/saveskippedsongs`: Added new plugin that saves skipped songs
to a text file during import for later review.

Bug fixes:

Expand Down
25 changes: 25 additions & 0 deletions docs/plugins/saveskippedsongs.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Save Skipped Songs Plugin
=========================

The ``saveskippedsongs`` plugin will save the name of the skipped song/album to
a text file during import for later review.

It will also allow you to try to find the Spotify link for the skipped songs if
the Spotify plugin is installed and configured. This information can later be
used together with other MB importers like Harmony.

If any song has already been written to the file, it will not be written again.

To use the ``saveskippedsongs`` plugin, enable it in your configuration (see
:ref:`using-plugins`).

Configuration
-------------

To configure the plugin, make a ``saveskippedsongs:`` section in your
configuration file. The available options are:

- **spotify**: Search Spotify for the song/album and return the link. Default:
``yes``.
- **path**: Path to the file to write the skipped songs to. Default:
``skipped_songs.txt``.
Comment on lines +24 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to specify that it stores in the user's home directory.

Copy link
Contributor Author

@EmberLightVFX EmberLightVFX Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it will save the file in the dir that I run the beets command from. Should I maybe change the default path to ~/skipped_songs.txt to make it a more specified path?

Loading