mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 17:48:54 -04:00
Draft sort by circle of fiths feature
This commit is contained in:
@@ -155,6 +155,7 @@ format_raw_length: no
|
||||
sort_album: albumartist+ album+
|
||||
sort_item: artist+ album+ disc+ track+
|
||||
sort_case_insensitive: yes
|
||||
sort_initial_key_harmonic: yes
|
||||
|
||||
# --------------- Autotagger ---------------
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ from .queryparse import (
|
||||
query_from_strings,
|
||||
sort_from_strings,
|
||||
)
|
||||
from .sort import HarmonicKeySort
|
||||
from .types import Type
|
||||
|
||||
__all__ = [
|
||||
"AndQuery",
|
||||
"Database",
|
||||
"FieldQuery",
|
||||
"HarmonicKeySort",
|
||||
"Index",
|
||||
"InvalidQueryError",
|
||||
"MatchQuery",
|
||||
|
||||
@@ -205,6 +205,44 @@ class NullSort(Sort):
|
||||
return 0
|
||||
|
||||
|
||||
class HarmonicKeySort(FieldSort):
|
||||
"""Sort by musical key in Circle of Fifths order when
|
||||
``sort_initial_key_harmonic`` is enabled; falls back to standard
|
||||
case-insensitive alphabetical ordering otherwise.
|
||||
|
||||
Applies enharmonic normalization before lookup, so keys stored in either
|
||||
flat or sharp notation sort correctly relative to each other. Keys not
|
||||
present in the Circle of Fifths (or missing entirely) sort to the end.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _harmonic_enabled() -> bool:
|
||||
import beets
|
||||
|
||||
return beets.config["sort_initial_key_harmonic"].get(bool)
|
||||
|
||||
def is_slow(self) -> bool:
|
||||
return self._harmonic_enabled()
|
||||
|
||||
def order_clause(self) -> str | None:
|
||||
if self._harmonic_enabled():
|
||||
return None # Python-level sort; no SQL ORDER BY fragment
|
||||
return FixedFieldSort(
|
||||
self.field, self.ascending, self.case_insensitive
|
||||
).order_clause()
|
||||
|
||||
def sort(self, objs: list[AnyModel]) -> list[AnyModel]:
|
||||
if not self._harmonic_enabled():
|
||||
return FieldSort.sort(self, objs)
|
||||
from beets.util.musictheory import harmonic_sort_key
|
||||
|
||||
return sorted(
|
||||
objs,
|
||||
key=lambda obj: harmonic_sort_key(obj.get(self.field)),
|
||||
reverse=not self.ascending,
|
||||
)
|
||||
|
||||
|
||||
class SmartArtistSort(FieldSort):
|
||||
"""Sort by artist (either album artist or track artist),
|
||||
prioritizing the sort field over the raw field.
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
|
||||
|
||||
import beets
|
||||
from beets import util
|
||||
from beets.util.musictheory import normalize_key
|
||||
from beets.util.units import human_seconds_short, raw_seconds_short
|
||||
|
||||
from . import pathutils, query
|
||||
@@ -438,29 +438,15 @@ class MusicalKey(String):
|
||||
The standard format is C, Cm, C#, C#m, etc.
|
||||
"""
|
||||
|
||||
ENHARMONIC: ClassVar[dict[str, str]] = {
|
||||
r"db": "c#",
|
||||
r"eb": "d#",
|
||||
r"gb": "f#",
|
||||
r"ab": "g#",
|
||||
r"bb": "a#",
|
||||
}
|
||||
|
||||
null = None
|
||||
|
||||
def parse(self, key):
|
||||
key = key.lower()
|
||||
for flat, sharp in self.ENHARMONIC.items():
|
||||
key = re.sub(flat, sharp, key)
|
||||
key = re.sub(r"[\W\s]+minor", "m", key)
|
||||
key = re.sub(r"[\W\s]+major", "", key)
|
||||
return key.capitalize()
|
||||
def parse(self, string):
|
||||
return normalize_key(string)
|
||||
|
||||
def normalize(self, key):
|
||||
if key is None:
|
||||
def normalize(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
else:
|
||||
return self.parse(key)
|
||||
return self.parse(value)
|
||||
|
||||
|
||||
class DurationType(Float):
|
||||
|
||||
@@ -16,7 +16,7 @@ import beets
|
||||
from beets import dbcore, logging, plugins, util
|
||||
from beets.dbcore import types
|
||||
from beets.dbcore.pathutils import normalize_path_for_db
|
||||
from beets.dbcore.sort import SmartArtistSort
|
||||
from beets.dbcore.sort import HarmonicKeySort, SmartArtistSort
|
||||
from beets.util import (
|
||||
MoveOperation,
|
||||
bytestring_path,
|
||||
@@ -697,7 +697,10 @@ class Item(LibModel):
|
||||
|
||||
_formatter = FormattedItemMapping
|
||||
|
||||
_sorts: ClassVar[dict[str, type[FieldSort]]] = {"artist": SmartArtistSort}
|
||||
_sorts: ClassVar[dict[str, type[FieldSort]]] = {
|
||||
"artist": SmartArtistSort,
|
||||
"initial_key": HarmonicKeySort,
|
||||
}
|
||||
|
||||
@cached_classproperty
|
||||
def _queries(cls) -> dict[str, FieldQueryType]:
|
||||
|
||||
99
beets/util/musictheory.py
Normal file
99
beets/util/musictheory.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Music theory utilities: key normalization and Circle of Fifths ordering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Flat-to-sharp enharmonic equivalents. Keys are lowercase regex patterns;
|
||||
# values are the canonical sharp equivalents. Used by normalize_key() and
|
||||
# referenced by beets.dbcore.types.MusicalKey.
|
||||
ENHARMONIC: dict[str, str] = {
|
||||
r"db": "c#",
|
||||
r"eb": "d#",
|
||||
r"gb": "f#",
|
||||
r"ab": "g#",
|
||||
r"bb": "a#",
|
||||
}
|
||||
|
||||
# Keys in Circle of Fifths order, using canonical sharp-based notation
|
||||
# (as produced by normalize_key()). Major keys first, then their relative
|
||||
# minors at the matching positions.
|
||||
CIRCLE_OF_FIFTHS: tuple[str, ...] = (
|
||||
# Major keys
|
||||
"C",
|
||||
"G",
|
||||
"D",
|
||||
"A",
|
||||
"E",
|
||||
"B",
|
||||
"F#",
|
||||
"C#",
|
||||
"G#",
|
||||
"D#",
|
||||
"A#",
|
||||
"F",
|
||||
# Relative minor keys
|
||||
"Am",
|
||||
"Em",
|
||||
"Bm",
|
||||
"F#m",
|
||||
"C#m",
|
||||
"G#m",
|
||||
"D#m",
|
||||
"A#m",
|
||||
"Fm",
|
||||
"Cm",
|
||||
"Gm",
|
||||
"Dm",
|
||||
)
|
||||
|
||||
_POSITION: dict[str, int] = {k: i for i, k in enumerate(CIRCLE_OF_FIFTHS)}
|
||||
_UNKNOWN: int = len(CIRCLE_OF_FIFTHS)
|
||||
|
||||
|
||||
def normalize_key(key: str) -> str:
|
||||
"""Normalize a musical key string to canonical form.
|
||||
|
||||
Applies flat-to-sharp enharmonic conversion, handles ``minor``/``major``
|
||||
suffixes, and capitalizes the result. Mirrors the logic in
|
||||
``beets.dbcore.types.MusicalKey.parse()``.
|
||||
|
||||
Examples::
|
||||
|
||||
normalize_key("Db") == "C#"
|
||||
normalize_key("A minor") == "Am"
|
||||
normalize_key("F# major") == "F#"
|
||||
"""
|
||||
key = key.lower()
|
||||
for flat, sharp in ENHARMONIC.items():
|
||||
key = re.sub(flat, sharp, key)
|
||||
key = re.sub(r"[\W\s]+minor", "m", key)
|
||||
key = re.sub(r"[\W\s]+major", "", key)
|
||||
return key.capitalize()
|
||||
|
||||
|
||||
def harmonic_sort_key(key: str | None) -> int:
|
||||
"""Return the Circle of Fifths position index for harmonic sorting.
|
||||
|
||||
Applies enharmonic normalization before lookup, so flat variants (e.g.
|
||||
``Db``) resolve correctly to their sharp equivalent (``C#``). Keys not
|
||||
present in :data:`CIRCLE_OF_FIFTHS` — including ``None`` or empty strings
|
||||
— sort to the end (index ``len(CIRCLE_OF_FIFTHS)``).
|
||||
"""
|
||||
if not key:
|
||||
return _UNKNOWN
|
||||
return _POSITION.get(normalize_key(key), _UNKNOWN)
|
||||
Reference in New Issue
Block a user