-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New Plugin: Save skipped imports #6140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 3 commits
daacd5a
9acc2ba
7432fab
b2ffe3c
5879c3d
7cc7bb2
a6e87c5
bdba5ff
12f3e84
45ad61b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| # 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.metadata_plugins import SearchFilter | ||
| from beets.plugins import ImportSession, ImportTask | ||
|
|
||
| __author__ = "[email protected]" | ||
| __version__ = "1.0" | ||
|
|
||
|
|
||
| def summary(task: "ImportTask"): | ||
| """Given an ImportTask, produce a short string identifying the | ||
| object. | ||
| """ | ||
| if task.is_album: | ||
| return f"{task.cur_artist} - {task.cur_album}" | ||
| else: | ||
| item = task.item # type: ignore[attr-defined] | ||
| return f"{item.artist} - {item.title}" | ||
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| 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: "ImportTask", 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) | ||
sourcery-ai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| result = f"{summary(task)}{' (' + link + ')' if link else ''}" | ||
| self._log.info(f"Skipped: {result}") | ||
| path = self.config["path"].get(str) | ||
| if path: | ||
| path = os.path.abspath(path) | ||
sourcery-ai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| try: | ||
| # Read existing lines (if file exists) and avoid duplicates. | ||
| try: | ||
| with open(path, "r", encoding="utf-8") as f: | ||
| existing = {line.rstrip("\n") for line in f} | ||
| except FileNotFoundError: | ||
| existing = set() | ||
|
|
||
| if result not in existing: | ||
| with open(path, "a", encoding="utf-8") as f: | ||
| f.write(f"{result}\n") | ||
sourcery-ai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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: "ImportTask", session: "ImportSession" | ||
| ) -> Optional[str]: | ||
EmberLightVFX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| except Exception as e: | ||
| self._log.debug(f"Could not initialize Spotify plugin: {e}") | ||
| return | ||
EmberLightVFX marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| # 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 # type: ignore[attr-defined] | ||
EmberLightVFX marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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] | ||
sourcery-ai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| query_type=search_type, # type: ignore[arg-type] | ||
| query_string=query_string, | ||
| filters=query_filters, | ||
EmberLightVFX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
| 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", "") | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| 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``. | ||
Uh oh!
There was an error while loading. Please reload this page.