mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
Merge f6ee49af0d into 2bd77b9895
This commit is contained in:
commit
a54094356b
12 changed files with 356 additions and 12 deletions
|
|
@ -166,7 +166,47 @@ def correct_list_fields(m: LibModel) -> None:
|
||||||
elif list_val:
|
elif list_val:
|
||||||
setattr(m, single_field, list_val[0])
|
setattr(m, single_field, list_val[0])
|
||||||
|
|
||||||
|
def sync_genre_fields() -> None:
|
||||||
|
"""Synchronize genre and genres fields with proper join/split logic.
|
||||||
|
|
||||||
|
The genre field stores a joined string of all genres (for backward
|
||||||
|
compatibility with users who store multiple genres as delimited strings),
|
||||||
|
while genres is the native list representation.
|
||||||
|
|
||||||
|
When multi_value_genres config is disabled, only the first genre is used.
|
||||||
|
"""
|
||||||
|
genre_val = getattr(m, "genre")
|
||||||
|
genres_val = getattr(m, "genres")
|
||||||
|
|
||||||
|
# Handle None values - treat as empty
|
||||||
|
if genres_val is None:
|
||||||
|
genres_val = []
|
||||||
|
if genre_val is None:
|
||||||
|
genre_val = ""
|
||||||
|
|
||||||
|
if config["multi_value_genres"]:
|
||||||
|
# New behavior: sync all genres using configurable separator
|
||||||
|
separator = config["genre_separator"].get(str)
|
||||||
|
if genres_val:
|
||||||
|
# If genres list exists, join it into genre string
|
||||||
|
setattr(m, "genre", separator.join(genres_val))
|
||||||
|
elif genre_val:
|
||||||
|
# If only genre string exists, split it into genres list
|
||||||
|
# and clean up the genre string
|
||||||
|
cleaned_genres = [
|
||||||
|
g.strip() for g in genre_val.split(separator) if g.strip()
|
||||||
|
]
|
||||||
|
setattr(m, "genres", cleaned_genres)
|
||||||
|
setattr(m, "genre", separator.join(cleaned_genres))
|
||||||
|
else:
|
||||||
|
# Old behavior: only sync first value (like albumtype)
|
||||||
|
if genre_val:
|
||||||
|
setattr(m, "genres", unique_list([genre_val, *genres_val]))
|
||||||
|
elif genres_val:
|
||||||
|
setattr(m, "genre", genres_val[0])
|
||||||
|
|
||||||
ensure_first_value("albumtype", "albumtypes")
|
ensure_first_value("albumtype", "albumtypes")
|
||||||
|
sync_genre_fields()
|
||||||
|
|
||||||
if hasattr(m, "mb_artistids"):
|
if hasattr(m, "mb_artistids"):
|
||||||
ensure_first_value("mb_artistid", "mb_artistids")
|
ensure_first_value("mb_artistid", "mb_artistids")
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ class Info(AttrDict[Any]):
|
||||||
data_source: str | None = None,
|
data_source: str | None = None,
|
||||||
data_url: str | None = None,
|
data_url: str | None = None,
|
||||||
genre: str | None = None,
|
genre: str | None = None,
|
||||||
|
genres: list[str] | None = None,
|
||||||
media: str | None = None,
|
media: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -83,6 +84,7 @@ class Info(AttrDict[Any]):
|
||||||
self.data_source = data_source
|
self.data_source = data_source
|
||||||
self.data_url = data_url
|
self.data_url = data_url
|
||||||
self.genre = genre
|
self.genre = genre
|
||||||
|
self.genres = genres or []
|
||||||
self.media = media
|
self.media = media
|
||||||
self.update(kwargs)
|
self.update(kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ sunique:
|
||||||
per_disc_numbering: no
|
per_disc_numbering: no
|
||||||
original_date: no
|
original_date: no
|
||||||
artist_credit: no
|
artist_credit: no
|
||||||
|
multi_value_genres: yes
|
||||||
|
# Separator for joining multiple genres. Default matches lastgenre's separator.
|
||||||
|
# Use "; " or " / " if you prefer a different format.
|
||||||
|
genre_separator: ", "
|
||||||
id3v23: no
|
id3v23: no
|
||||||
va_name: "Various Artists"
|
va_name: "Various Artists"
|
||||||
paths:
|
paths:
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ class Album(LibModel):
|
||||||
"albumartists_credit": types.MULTI_VALUE_DSV,
|
"albumartists_credit": types.MULTI_VALUE_DSV,
|
||||||
"album": types.STRING,
|
"album": types.STRING,
|
||||||
"genre": types.STRING,
|
"genre": types.STRING,
|
||||||
|
"genres": types.MULTI_VALUE_DSV,
|
||||||
"style": types.STRING,
|
"style": types.STRING,
|
||||||
"discogs_albumid": types.INTEGER,
|
"discogs_albumid": types.INTEGER,
|
||||||
"discogs_artistid": types.INTEGER,
|
"discogs_artistid": types.INTEGER,
|
||||||
|
|
@ -297,6 +298,7 @@ class Album(LibModel):
|
||||||
"albumartists_credit",
|
"albumartists_credit",
|
||||||
"album",
|
"album",
|
||||||
"genre",
|
"genre",
|
||||||
|
"genres",
|
||||||
"style",
|
"style",
|
||||||
"discogs_albumid",
|
"discogs_albumid",
|
||||||
"discogs_artistid",
|
"discogs_artistid",
|
||||||
|
|
@ -643,6 +645,7 @@ class Item(LibModel):
|
||||||
"albumartist_credit": types.STRING,
|
"albumartist_credit": types.STRING,
|
||||||
"albumartists_credit": types.MULTI_VALUE_DSV,
|
"albumartists_credit": types.MULTI_VALUE_DSV,
|
||||||
"genre": types.STRING,
|
"genre": types.STRING,
|
||||||
|
"genres": types.MULTI_VALUE_DSV,
|
||||||
"style": types.STRING,
|
"style": types.STRING,
|
||||||
"discogs_albumid": types.INTEGER,
|
"discogs_albumid": types.INTEGER,
|
||||||
"discogs_artistid": types.INTEGER,
|
"discogs_artistid": types.INTEGER,
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,14 @@ class BeatportObject:
|
||||||
if "artists" in data:
|
if "artists" in data:
|
||||||
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
|
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
|
||||||
if "genres" in data:
|
if "genres" in data:
|
||||||
self.genres = [str(x["name"]) for x in data["genres"]]
|
genre_list = [str(x["name"]) for x in data["genres"]]
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
genre_list = list(dict.fromkeys(genre_list))
|
||||||
|
if beets.config["multi_value_genres"]:
|
||||||
|
self.genres = genre_list
|
||||||
|
else:
|
||||||
|
# Even when disabled, populate with first genre for consistency
|
||||||
|
self.genres = [genre_list[0]] if genre_list else []
|
||||||
|
|
||||||
def artists_str(self) -> str | None:
|
def artists_str(self) -> str | None:
|
||||||
if self.artists is not None:
|
if self.artists is not None:
|
||||||
|
|
@ -306,11 +313,25 @@ class BeatportTrack(BeatportObject):
|
||||||
self.bpm = data.get("bpm")
|
self.bpm = data.get("bpm")
|
||||||
self.initial_key = str((data.get("key") or {}).get("shortName"))
|
self.initial_key = str((data.get("key") or {}).get("shortName"))
|
||||||
|
|
||||||
# Use 'subgenre' and if not present, 'genre' as a fallback.
|
# Extract genres list from subGenres or genres
|
||||||
if data.get("subGenres"):
|
if data.get("subGenres"):
|
||||||
self.genre = str(data["subGenres"][0].get("name"))
|
genre_list = [str(x.get("name")) for x in data["subGenres"]]
|
||||||
elif data.get("genres"):
|
elif data.get("genres"):
|
||||||
self.genre = str(data["genres"][0].get("name"))
|
genre_list = [str(x.get("name")) for x in data["genres"]]
|
||||||
|
else:
|
||||||
|
genre_list = []
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
genre_list = list(dict.fromkeys(genre_list))
|
||||||
|
|
||||||
|
if beets.config["multi_value_genres"]:
|
||||||
|
# New behavior: populate both genres list and joined string
|
||||||
|
separator = beets.config["genre_separator"].get(str)
|
||||||
|
self.genres = genre_list
|
||||||
|
self.genre = separator.join(genre_list) if genre_list else None
|
||||||
|
else:
|
||||||
|
# Old behavior: only populate single genre field with first value
|
||||||
|
self.genre = genre_list[0] if genre_list else None
|
||||||
|
|
||||||
|
|
||||||
class BeatportPlugin(MetadataSourcePlugin):
|
class BeatportPlugin(MetadataSourcePlugin):
|
||||||
|
|
@ -484,6 +505,7 @@ class BeatportPlugin(MetadataSourcePlugin):
|
||||||
data_source=self.data_source,
|
data_source=self.data_source,
|
||||||
data_url=release.url,
|
data_url=release.url,
|
||||||
genre=release.genre,
|
genre=release.genre,
|
||||||
|
genres=release.genres,
|
||||||
year=release_date.year if release_date else None,
|
year=release_date.year if release_date else None,
|
||||||
month=release_date.month if release_date else None,
|
month=release_date.month if release_date else None,
|
||||||
day=release_date.day if release_date else None,
|
day=release_date.day if release_date else None,
|
||||||
|
|
@ -509,6 +531,7 @@ class BeatportPlugin(MetadataSourcePlugin):
|
||||||
bpm=track.bpm,
|
bpm=track.bpm,
|
||||||
initial_key=track.initial_key,
|
initial_key=track.initial_key,
|
||||||
genre=track.genre,
|
genre=track.genre,
|
||||||
|
genres=track.genres,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_artist(self, artists):
|
def _get_artist(self, artists):
|
||||||
|
|
|
||||||
|
|
@ -329,12 +329,35 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
else:
|
else:
|
||||||
formatted = tags
|
formatted = tags
|
||||||
|
|
||||||
return self.config["separator"].as_str().join(formatted)
|
# Use global genre_separator when multi_value_genres is enabled
|
||||||
|
# for consistency with sync logic, otherwise use plugin's own separator
|
||||||
|
if config["multi_value_genres"]:
|
||||||
|
separator = config["genre_separator"].get(str)
|
||||||
|
else:
|
||||||
|
separator = self.config["separator"].as_str()
|
||||||
|
|
||||||
|
return separator.join(formatted)
|
||||||
|
|
||||||
def _get_existing_genres(self, obj: LibModel) -> list[str]:
|
def _get_existing_genres(self, obj: LibModel) -> list[str]:
|
||||||
"""Return a list of genres for this Item or Album. Empty string genres
|
"""Return a list of genres for this Item or Album. Empty string genres
|
||||||
are removed."""
|
are removed."""
|
||||||
separator = self.config["separator"].get()
|
# Prefer the genres field if it exists (multi-value support)
|
||||||
|
if isinstance(obj, library.Item):
|
||||||
|
genres_list = obj.get("genres", with_album=False)
|
||||||
|
else:
|
||||||
|
genres_list = obj.get("genres")
|
||||||
|
|
||||||
|
# If genres field exists and is not empty, use it
|
||||||
|
if genres_list:
|
||||||
|
return [g for g in genres_list if g]
|
||||||
|
|
||||||
|
# Otherwise fall back to splitting the genre field
|
||||||
|
# Use global genre_separator when multi_value_genres is enabled
|
||||||
|
if config["multi_value_genres"]:
|
||||||
|
separator = config["genre_separator"].get(str)
|
||||||
|
else:
|
||||||
|
separator = self.config["separator"].get()
|
||||||
|
|
||||||
if isinstance(obj, library.Item):
|
if isinstance(obj, library.Item):
|
||||||
item_genre = obj.get("genre", with_album=False).split(separator)
|
item_genre = obj.get("genre", with_album=False).split(separator)
|
||||||
else:
|
else:
|
||||||
|
|
@ -473,6 +496,17 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
obj.genre, label = self._get_genre(obj)
|
obj.genre, label = self._get_genre(obj)
|
||||||
self._log.debug("Resolved ({}): {}", label, obj.genre)
|
self._log.debug("Resolved ({}): {}", label, obj.genre)
|
||||||
|
|
||||||
|
# Also populate the genres list field if multi_value_genres is enabled
|
||||||
|
if config["multi_value_genres"]:
|
||||||
|
if obj.genre:
|
||||||
|
# Use global genre_separator for consistency with sync logic
|
||||||
|
separator = config["genre_separator"].get(str)
|
||||||
|
obj.genres = [
|
||||||
|
g.strip() for g in obj.genre.split(separator) if g.strip()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
obj.genres = []
|
||||||
|
|
||||||
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
|
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
|
||||||
|
|
||||||
@singledispatchmethod
|
@singledispatchmethod
|
||||||
|
|
|
||||||
|
|
@ -738,10 +738,19 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||||
for source in sources:
|
for source in sources:
|
||||||
for genreitem in source:
|
for genreitem in source:
|
||||||
genres[genreitem["name"]] += int(genreitem["count"])
|
genres[genreitem["name"]] += int(genreitem["count"])
|
||||||
info.genre = "; ".join(
|
genre_list = [
|
||||||
genre
|
genre
|
||||||
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
|
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
|
||||||
)
|
]
|
||||||
|
|
||||||
|
if config["multi_value_genres"]:
|
||||||
|
# New behavior: populate genres list and joined genre string
|
||||||
|
separator = config["genre_separator"].get(str)
|
||||||
|
info.genres = genre_list
|
||||||
|
info.genre = separator.join(genre_list) if genre_list else None
|
||||||
|
else:
|
||||||
|
# Old behavior: only populate single genre field with first value
|
||||||
|
info.genre = genre_list[0] if genre_list else None
|
||||||
|
|
||||||
# We might find links to external sources (Discogs, Bandcamp, ...)
|
# We might find links to external sources (Discogs, Bandcamp, ...)
|
||||||
external_ids = self.config["external_ids"].get()
|
external_ids = self.config["external_ids"].get()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,24 @@ been dropped.
|
||||||
|
|
||||||
New features:
|
New features:
|
||||||
|
|
||||||
|
- Add native support for multiple genres per album/track. The new ``genres``
|
||||||
|
field stores genres as a list and is written to files as multiple individual
|
||||||
|
genre tags (e.g., separate GENRE tags for FLAC/MP3). A new
|
||||||
|
``multi_value_genres`` config option (default: yes) controls this behavior.
|
||||||
|
When enabled, provides better interoperability with other music taggers. When
|
||||||
|
disabled, preserves the old single-genre behavior. The ``genre_separator``
|
||||||
|
config option (default: ``", "``) allows customizing the separator used when
|
||||||
|
joining multiple genres into a single string. The default matches the
|
||||||
|
:doc:`plugins/lastgenre` plugin's separator for seamless migration. The
|
||||||
|
:doc:`plugins/musicbrainz`, :doc:`plugins/beatport`, and
|
||||||
|
:doc:`plugins/lastgenre` plugins have been updated to populate the ``genres``
|
||||||
|
field.
|
||||||
|
|
||||||
|
**Migration note**: Most users don't need to do anything. If you previously
|
||||||
|
used a custom ``separator`` in the lastgenre plugin (not the default ``",
|
||||||
|
"``), set ``genre_separator`` to match your custom value. Alternatively, set
|
||||||
|
``multi_value_genres: no`` to preserve the old behavior entirely.
|
||||||
|
|
||||||
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
|
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
|
||||||
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
|
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
|
||||||
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
|
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,44 @@ Either ``yes`` or ``no``, indicating whether matched tracks and albums should
|
||||||
use the artist credit, rather than the artist. That is, if this option is turned
|
use the artist credit, rather than the artist. That is, if this option is turned
|
||||||
on, then ``artist`` will contain the artist as credited on the release.
|
on, then ``artist`` will contain the artist as credited on the release.
|
||||||
|
|
||||||
|
.. _multi_value_genres:
|
||||||
|
|
||||||
|
multi_value_genres
|
||||||
|
~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Either ``yes`` or ``no`` (default: ``yes``), controlling whether to use native
|
||||||
|
support for multiple genres per album/track. When enabled, the ``genres`` field
|
||||||
|
stores genres as a list and writes them to files as multiple individual genre
|
||||||
|
tags (e.g., separate GENRE tags for FLAC/MP3). The single ``genre`` field is
|
||||||
|
maintained as a joined string for backward compatibility. When disabled, only
|
||||||
|
the first genre is used (preserving the old behavior).
|
||||||
|
|
||||||
|
.. _genre_separator:
|
||||||
|
|
||||||
|
genre_separator
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Default: ``", "``.
|
||||||
|
|
||||||
|
The separator string used when joining multiple genres into the single ``genre``
|
||||||
|
field. This setting is only used when :ref:`multi_value_genres` is enabled. For
|
||||||
|
example, with the default separator, a track with genres ``["Rock",
|
||||||
|
"Alternative", "Indie"]`` will have ``genre`` set to ``"Rock, Alternative,
|
||||||
|
Indie"``. You can customize this to match your preferred format (e.g., ``"; "``
|
||||||
|
or ``" / "``).
|
||||||
|
|
||||||
|
The default (``", "``) matches the :doc:`lastgenre plugin's
|
||||||
|
</plugins/lastgenre>` default separator for seamless migration. When
|
||||||
|
:ref:`multi_value_genres` is enabled, this global separator takes precedence
|
||||||
|
over the lastgenre plugin's ``separator`` option to ensure consistency across
|
||||||
|
all genre-related operations.
|
||||||
|
|
||||||
|
**Custom separator migration**: If you previously used a custom (non-default)
|
||||||
|
``separator`` in the lastgenre plugin, set ``genre_separator`` to match your
|
||||||
|
custom value. You can check your existing format by running ``beet ls -f
|
||||||
|
'$genre' | head -20``. Alternatively, set ``multi_value_genres: no`` to preserve
|
||||||
|
the old behavior entirely.
|
||||||
|
|
||||||
.. _per_disc_numbering:
|
.. _per_disc_numbering:
|
||||||
|
|
||||||
per_disc_numbering
|
per_disc_numbering
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@ class LastGenrePluginTest(PluginTestCase):
|
||||||
"separator": "\u0000",
|
"separator": "\u0000",
|
||||||
"canonical": False,
|
"canonical": False,
|
||||||
"prefer_specific": False,
|
"prefer_specific": False,
|
||||||
|
"count": 10,
|
||||||
},
|
},
|
||||||
"Blues",
|
"Blues",
|
||||||
{
|
{
|
||||||
|
|
@ -567,6 +568,14 @@ def test_get_genre(config_values, item_genre, mock_genres, expected_result):
|
||||||
# Configure
|
# Configure
|
||||||
plugin.config.set(config_values)
|
plugin.config.set(config_values)
|
||||||
plugin.setup() # Loads default whitelist and canonicalization tree
|
plugin.setup() # Loads default whitelist and canonicalization tree
|
||||||
|
|
||||||
|
# If test specifies a separator, set it as the global genre_separator
|
||||||
|
# (when multi_value_genres is enabled, plugins use the global separator)
|
||||||
|
if "separator" in config_values:
|
||||||
|
from beets import config
|
||||||
|
|
||||||
|
config["genre_separator"] = config_values["separator"]
|
||||||
|
|
||||||
item = _common.item()
|
item = _common.item()
|
||||||
item.genre = item_genre
|
item.genre = item_genre
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -475,3 +475,138 @@ def test_correct_list_fields(
|
||||||
|
|
||||||
single_val, list_val = item[single_field], item[list_field]
|
single_val, list_val = item[single_field], item[list_field]
|
||||||
assert (not single_val and not list_val) or single_val == list_val[0]
|
assert (not single_val and not list_val) or single_val == list_val[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Tests for multi-value genres functionality
|
||||||
|
class TestGenreSync:
|
||||||
|
"""Test the genre/genres field synchronization."""
|
||||||
|
|
||||||
|
def test_sync_genres_enabled_list_to_string(self):
|
||||||
|
"""When multi_value_genres is enabled, genres list joins into genre string."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genres=["Rock", "Alternative", "Indie"])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == "Rock, Alternative, Indie"
|
||||||
|
assert item.genres == ["Rock", "Alternative", "Indie"]
|
||||||
|
|
||||||
|
def test_sync_genres_enabled_string_to_list(self):
|
||||||
|
"""When multi_value_genres is enabled, genre string splits into genres list."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genre="Rock, Alternative, Indie")
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == "Rock, Alternative, Indie"
|
||||||
|
assert item.genres == ["Rock", "Alternative", "Indie"]
|
||||||
|
|
||||||
|
def test_sync_genres_disabled_only_first(self):
|
||||||
|
"""When multi_value_genres is disabled, only first genre is used."""
|
||||||
|
config["multi_value_genres"] = False
|
||||||
|
|
||||||
|
item = Item(genres=["Rock", "Alternative", "Indie"])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == "Rock"
|
||||||
|
assert item.genres == ["Rock", "Alternative", "Indie"]
|
||||||
|
|
||||||
|
def test_sync_genres_disabled_string_becomes_list(self):
|
||||||
|
"""When multi_value_genres is disabled, genre string becomes first in list."""
|
||||||
|
config["multi_value_genres"] = False
|
||||||
|
|
||||||
|
item = Item(genre="Rock")
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == "Rock"
|
||||||
|
assert item.genres == ["Rock"]
|
||||||
|
|
||||||
|
def test_sync_genres_enabled_empty_genre(self):
|
||||||
|
"""Empty genre field with multi_value_genres enabled."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genre="")
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == ""
|
||||||
|
assert item.genres == []
|
||||||
|
|
||||||
|
def test_sync_genres_enabled_empty_genres(self):
|
||||||
|
"""Empty genres list with multi_value_genres enabled."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genres=[])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genre == ""
|
||||||
|
assert item.genres == []
|
||||||
|
|
||||||
|
def test_sync_genres_enabled_with_whitespace(self):
|
||||||
|
"""Genre string with extra whitespace gets cleaned up."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genre="Rock, Alternative , Indie")
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
assert item.genres == ["Rock", "Alternative", "Indie"]
|
||||||
|
assert item.genre == "Rock, Alternative, Indie"
|
||||||
|
|
||||||
|
def test_sync_genres_priority_list_over_string(self):
|
||||||
|
"""When both genre and genres exist, genres list takes priority."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
item = Item(genre="Jazz", genres=["Rock", "Alternative"])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
# genres list should take priority and update genre string
|
||||||
|
assert item.genres == ["Rock", "Alternative"]
|
||||||
|
assert item.genre == "Rock, Alternative"
|
||||||
|
|
||||||
|
def test_sync_genres_disabled_conflicting_values(self):
|
||||||
|
"""When multi_value_genres is disabled with conflicting values."""
|
||||||
|
config["multi_value_genres"] = False
|
||||||
|
|
||||||
|
item = Item(genre="Jazz", genres=["Rock", "Alternative"])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
# genre string should take priority and be added to front of list
|
||||||
|
assert item.genre == "Jazz"
|
||||||
|
assert item.genres == ["Jazz", "Rock", "Alternative"]
|
||||||
|
|
||||||
|
def test_sync_genres_none_values(self):
|
||||||
|
"""Handle None values in genre/genres fields without errors."""
|
||||||
|
config["multi_value_genres"] = True
|
||||||
|
|
||||||
|
# Test with None genre
|
||||||
|
item = Item(genre=None, genres=["Rock"])
|
||||||
|
correct_list_fields(item)
|
||||||
|
assert item.genres == ["Rock"]
|
||||||
|
assert item.genre == "Rock"
|
||||||
|
|
||||||
|
# Test with None genres
|
||||||
|
item = Item(genre="Jazz", genres=None)
|
||||||
|
correct_list_fields(item)
|
||||||
|
assert item.genre == "Jazz"
|
||||||
|
assert item.genres == ["Jazz"]
|
||||||
|
|
||||||
|
def test_sync_genres_disabled_empty_genres(self):
|
||||||
|
"""Handle disabled config with empty genres list."""
|
||||||
|
config["multi_value_genres"] = False
|
||||||
|
|
||||||
|
item = Item(genres=[])
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
# Should handle empty list without errors
|
||||||
|
assert item.genres == []
|
||||||
|
assert item.genre == ""
|
||||||
|
|
||||||
|
def test_sync_genres_disabled_none_genres(self):
|
||||||
|
"""Handle disabled config with genres=None."""
|
||||||
|
config["multi_value_genres"] = False
|
||||||
|
|
||||||
|
item = Item(genres=None)
|
||||||
|
correct_list_fields(item)
|
||||||
|
|
||||||
|
# Should handle None without errors
|
||||||
|
assert item.genres == []
|
||||||
|
assert item.genre == ""
|
||||||
|
|
|
||||||
|
|
@ -688,19 +688,48 @@ class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin):
|
||||||
self._assert_dest(b"/base/not_played")
|
self._assert_dest(b"/base/not_played")
|
||||||
|
|
||||||
def test_first(self):
|
def test_first(self):
|
||||||
self.i.genres = "Pop; Rock; Classical Crossover"
|
self.i.genre = "Pop; Rock; Classical Crossover"
|
||||||
self._setf("%first{$genres}")
|
self._setf("%first{$genre}")
|
||||||
self._assert_dest(b"/base/Pop")
|
self._assert_dest(b"/base/Pop")
|
||||||
|
|
||||||
def test_first_skip(self):
|
def test_first_skip(self):
|
||||||
self.i.genres = "Pop; Rock; Classical Crossover"
|
self.i.genre = "Pop; Rock; Classical Crossover"
|
||||||
self._setf("%first{$genres,1,2}")
|
self._setf("%first{$genre,1,2}")
|
||||||
self._assert_dest(b"/base/Classical Crossover")
|
self._assert_dest(b"/base/Classical Crossover")
|
||||||
|
|
||||||
def test_first_different_sep(self):
|
def test_first_different_sep(self):
|
||||||
self._setf("%first{Alice / Bob / Eve,2,0, / , & }")
|
self._setf("%first{Alice / Bob / Eve,2,0, / , & }")
|
||||||
self._assert_dest(b"/base/Alice & Bob")
|
self._assert_dest(b"/base/Alice & Bob")
|
||||||
|
|
||||||
|
def test_first_genres_list(self):
|
||||||
|
# Test that setting genres as a list syncs to genre field properly
|
||||||
|
# and works with %first template function
|
||||||
|
from beets import config
|
||||||
|
|
||||||
|
config["genre_separator"] = "; "
|
||||||
|
from beets.autotag import correct_list_fields
|
||||||
|
|
||||||
|
self.i.genres = ["Pop", "Rock", "Classical Crossover"]
|
||||||
|
correct_list_fields(self.i)
|
||||||
|
# genre field should now be synced
|
||||||
|
assert self.i.genre == "Pop; Rock; Classical Crossover"
|
||||||
|
# %first should work on the synced genre field
|
||||||
|
self._setf("%first{$genre}")
|
||||||
|
self._assert_dest(b"/base/Pop")
|
||||||
|
|
||||||
|
def test_first_genres_list_skip(self):
|
||||||
|
# Test that genres list works with %first skip parameter
|
||||||
|
from beets import config
|
||||||
|
|
||||||
|
config["genre_separator"] = "; "
|
||||||
|
from beets.autotag import correct_list_fields
|
||||||
|
|
||||||
|
self.i.genres = ["Pop", "Rock", "Classical Crossover"]
|
||||||
|
correct_list_fields(self.i)
|
||||||
|
# %first with skip should work on the synced genre field
|
||||||
|
self._setf("%first{$genre,1,2}")
|
||||||
|
self._assert_dest(b"/base/Classical Crossover")
|
||||||
|
|
||||||
|
|
||||||
class DisambiguationTest(BeetsTestCase, PathFormattingMixin):
|
class DisambiguationTest(BeetsTestCase, PathFormattingMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue