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
110 changes: 102 additions & 8 deletions backend/beets_flask/config/beets_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from beets_flask.logger import log
from beets_flask.utility import deprecation_warning

from .schema import BeetsSchema
from .schema import BeetsSchema, InboxSpecificOverridesSchema

_BEETS_EXAMPLE_PATH = Path(os.path.dirname(__file__)) / "config_b_example.yaml"
_BF_EXAMPLE_PATH = Path(os.path.dirname(__file__)) / "config_bf_example.yaml"
Expand All @@ -26,12 +26,15 @@
class BeetsFlaskConfig(ConfigExtra[BeetsSchema]):
"""Base config class with extra fields support."""

inspecific_data: InboxSpecificOverridesSchema

def __init__(self):
"""Initialize the config object with the default values."""
super().__init__(schema=BeetsSchema, data=BeetsSchema())
BeetsFlaskConfig.write_examples_as_user_defaults()
self.reload()
self.commit_to_beets()
self.store_inspecific_settings()

@classmethod
def get_beets_flask_config_path(cls) -> Path:
Expand Down Expand Up @@ -111,6 +114,9 @@ def commit_to_beets(self) -> None:
beets.config.read()

# Put our defaults that come from schema at lowest priority
# TODO: add support for optional schema fields (None)
# They should simply become beets defaults, but currently raise confuse errors.
# Likely because the None we add now takes precedence over whats there from beets.
beets.config.add(asdict_with_aliases(BeetsSchema()))

# Inserts user config into confuse
Expand All @@ -120,8 +126,8 @@ def commit_to_beets(self) -> None:
# is normally done by beets. Clear the list to force
# actual reload.
plugin_instances.clear()
load_plugins()
log.debug(f"Loading plugins: {get_plugin_names()}")
load_plugins()

# Beets config "Singleton" is not a real singleton, there might be copies
# in different submodules - we need to update all of them.
Expand Down Expand Up @@ -154,7 +160,8 @@ def validate(self):

# make sure to remove trailing slashes from user configured inbox paths
# we could also fix this in the frontend, but this was easier.
missing_folder_errors = []
multi_config_errors = []
inbox_folder_paths: list[str] = []
for key in self.data.gui.inbox.folders.keys():
folder = self.data.gui.inbox.folders[key]
if folder.path.endswith("/"):
Expand All @@ -181,17 +188,29 @@ def validate(self):
)
# prevent validation errors on our user examples and default value
):
missing_folder_errors.append(
multi_config_errors.append(
ConfigurationError(
f"Inbox folder path does not exist: {folder.path}",
section="gui.inbox.folders",
)
)

if len(missing_folder_errors) > 1:
raise MultiConfigurationError(missing_folder_errors)
elif len(missing_folder_errors) == 1:
raise missing_folder_errors[0]
# check that inbox paths are unique, since we use them to identify
# inbox-specific settings.
if folder.path in inbox_folder_paths:
multi_config_errors.append(
ConfigurationError(
f"Inbox folder path is not unique: {folder.path}",
section="gui.inbox.folders",
)
)
else:
inbox_folder_paths.append(folder.path)

if len(multi_config_errors) > 1:
raise MultiConfigurationError(multi_config_errors)
elif len(multi_config_errors) == 1:
raise multi_config_errors[0]

@classmethod
def write_examples_as_user_defaults(cls):
Expand Down Expand Up @@ -242,6 +261,81 @@ def write_examples_as_user_defaults(cls):
"Could not create beets_flask_config_example directories, "
+ "likely because this was not run inside the docker container."
)

# ------------------------ Inbox specific settings ----------------------- #

def store_inspecific_settings(self):
"""Keep the non-inbox-specific global settings around, for easier restore."""
self.inspecific_data = InboxSpecificOverridesSchema()
self.inspecific_data.plugins = self.data.plugins
self.inspecific_data.aisauce = self.data.aisauce

def apply_inbox_specific_overrides(self, inbox_path: Path | str) -> None:
"""
Lift inbox-specific to the global config.

We allow some parts of the beets config to be set per inbox folder.
These are normal beets config options, which should be overwritten
whenever an action takes place in that inbox.

Calls `commit_to_beets` if any changes from defaults were noticed.

Arguments
---------
- inbox_path: str | Path
We identify inboxes by their absoulte path.
"""

inbox_path = Path(inbox_path)
if inbox_path not in [
Path(f.path) for f in self.data.gui.inbox.folders.values()
]:
raise ValueError(f"{inbox_path} is not a valid inbox")

overrides = [
f.overrides
for f in self.data.gui.inbox.folders.values()
if Path(f.path) == inbox_path
][0]

# apply supported changes. for now manually, later we can iterate the schema
commit_to_beets = False
if overrides.plugins != "_use_all":
if self.data.plugins != overrides.plugins:
commit_to_beets = True
self.data.plugins = overrides.plugins

if self.data.aisauce != overrides.aisauce:
commit_to_beets = True
self.data.aisauce = overrides.aisauce

if commit_to_beets:
self.commit_to_beets()
log.debug("Applied inbox-specific overrides to beets config")
else:
log.debug("No changes after applying inbox-specific overrides")

def reset_inbox_specific_overrides(self) -> None:
"""
Reset inbox-specific changes to the global config in an efficient way.

Avoids reloading the whole config, by only restoring keys that can
be tweaked to begin with.

Calls `commit_to_beets` if any changes from defaults were noticed.
"""
commit_to_beets = False

if self.data.plugins != self.inspecific_data.plugins:
commit_to_beets = True
self.data.plugins = cast(list[str], self.inspecific_data.plugins)

if self.data.aisauce != self.inspecific_data.aisauce:
commit_to_beets = True
self.data.aisauce = self.inspecific_data.aisauce

if commit_to_beets:
self.commit_to_beets()

# ------------------------------ Utility getters ----------------------------- #

Expand Down
69 changes: 54 additions & 15 deletions backend/beets_flask/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
from dataclasses import dataclass, field
from typing import Literal

# ---------------------------------------------------------------------------- #
# Beets #
# ---------------------------------------------------------------------------- #


@dataclass
class BeetsSchema:
gui: BeetsFlaskSchema = field(default_factory=lambda: BeetsFlaskSchema())

# Besides the beets-flask specific config, we want to ensure type safety
# for those fields of the native beets config that we use ourself.
# for those fields of the native beets config that we use ourselves.
directory: str = field(default="/music/imported")
ignore: list[str] = field(
default_factory=lambda: [".*", "*~", "System Volume Information", "lost+found"]
Expand All @@ -29,10 +33,12 @@ class BeetsSchema:
)
match: MatchSectionSchema = field(default_factory=lambda: MatchSectionSchema())


# ---------------------------------------------------------------------------- #
# Beets #
# ---------------------------------------------------------------------------- #
# known plugins
aisauce: PluginAiSauceSchema = field(default_factory=lambda: PluginAiSauceSchema())
# Note: we currently set the defaults from the schema in commit_to_beets.
# This prevents us from using None as default value in beets root-level entries.
# For instance `aisauce: PluginAiSauceSchema | None = None` wont work, we need
Copy link
Copy Markdown
Owner Author

@pSpitzner pSpitzner Jan 25, 2026

Choose a reason for hiding this comment

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

Reconsidering, I think we need to solve this, otherwise we will regret it later.

If we do not find a way to allow using None as the default value for a plugin section at root level (meaning "I dont care about it in the global settings"), we will not be able to allow overrides later while also having the typing.

Edit: with latest eyconf we can now pass Nones, and get through our own validation steps, so thats good.

However, problem remains in beets plugin loading logic:

beets-flask-dev  |   File "/repo/backend/beets_flask/config/beets_config.py", line 132, in commit_to_beets
beets-flask-dev  |     load_plugins()
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/beets/plugins.py", line 488, in load_plugins
beets-flask-dev  |     send("pluginload")
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/beets/plugins.py", line 648, in send
beets-flask-dev  |     if (r := handler(**arguments)) is not None
beets-flask-dev  |              ^^^^^^^^^^^^^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/beets/plugins.py", line 329, in wrapper
beets-flask-dev  |     return func(*args, **kwargs)
beets-flask-dev  |            ^^^^^^^^^^^^^^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/beets/plugins.py", line 255, in _verify_config
beets-flask-dev  |     or "source_weight" not in self.config
beets-flask-dev  |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/confuse/core.py", line 148, in __contains__
beets-flask-dev  |     return self[key].exists()
beets-flask-dev  |            ^^^^^^^^^^^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/confuse/core.py", line 87, in exists
beets-flask-dev  |     self.first()
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/confuse/core.py", line 79, in first
beets-flask-dev  |     return util.iter_first(pairs)
beets-flask-dev  |            ^^^^^^^^^^^^^^^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/confuse/util.py", line 21, in iter_first
beets-flask-dev  |     return next(it)
beets-flask-dev  |            ^^^^^^^^
beets-flask-dev  |   File "/repo/backend/.venv/lib/python3.12/site-packages/confuse/core.py", line 459, in resolve
beets-flask-dev  |     raise ConfigTypeError(
beets-flask-dev  | confuse.exceptions.ConfigTypeError: aisauce must be a collection, not NoneType

Options:

  • Try to set the section in beets config to {} instead of None, but might need deep nesting, and will likely not solve the issue, once plugins realize a key is missing (like aisauces providers)
  • pop None sections before putting them into beets. ugly but sensible.

# to sepcify a default schema - to know the defaults for every plugin we add.


@dataclass
Expand Down Expand Up @@ -80,6 +86,24 @@ class BeetsFlaskSchema:
num_preview_workers: int = field(default=4)


# ---------------------------------- Library --------------------------------- #


@dataclass
class LibrarySectionSchema:
readonly: bool = False
artist_separators: list[str] = field(default_factory=lambda: [",", ";", "&"])


# --------------------------------- Terminal --------------------------------- #


@dataclass
class TerminalSectionSchema:
enabled: bool = True
start_path: str = "/repo"


# ----------------------------------- Inbox ---------------------------------- #


Expand All @@ -105,24 +129,39 @@ class InboxSectionSchema:
@dataclass
class InboxFolderSchema:
path: str
name: str = "_use_heading"
name: str = "_use_heading" # names should be unique
auto_threshold: float | None = None
autotag: Literal["auto", "preview", "bootleg", "off"] = "off"
overrides: InboxSpecificOverridesSchema = field(
default_factory=lambda: InboxSpecificOverridesSchema()
)


# ---------------------------------- Library --------------------------------- #
@dataclass
class InboxSpecificOverridesSchema:
plugins: list[str] | Literal["_use_all"] = "_use_all"
aisauce: PluginAiSauceSchema = field(default_factory=lambda: PluginAiSauceSchema())


@dataclass
class LibrarySectionSchema:
readonly: bool = False
artist_separators: list[str] = field(default_factory=lambda: [",", ";", "&"])
# ---------------------------------- Plugins --------------------------------- #


# --------------------------------- Terminal --------------------------------- #
@dataclass
class PluginAiSauceSchema:
mode: Literal["metadata_source", "metadata_cleanup"] = "metadata_cleanup"
providers: list[PluginAiSauceProviderSchema] = field(default_factory=lambda: [])
sources: list[PluginAiSauceSourceSchema] = field(default_factory=lambda: [])


@dataclass
class TerminalSectionSchema:
enabled: bool = True
start_path: str = "/repo"
class PluginAiSauceProviderSchema:
id: str
model: str
api_key: str
api_base_url: str

@dataclass
class PluginAiSauceSourceSchema:
provider_id: str
user_prompt: str | None
system_prompt: str | None
39 changes: 32 additions & 7 deletions backend/beets_flask/invoker/enqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
from collections.abc import Awaitable, Callable
from contextlib import contextmanager
from enum import Enum
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -433,7 +434,7 @@ async def run_preview(

log.info(f"Preview task on {hash=} {path=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
f_on_disk = FolderInDb.get_current_on_disk(hash, path)
if hash != f_on_disk.hash:
log.warning(
Expand Down Expand Up @@ -465,7 +466,6 @@ async def run_preview(
db_session.commit()

log.info(f"Preview done. {hash=} {path=}")
return


# redis preview queue
Expand All @@ -486,7 +486,7 @@ async def run_preview_add_candidates(
"""
log.info(f"Add preview candidates task on {hash=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
s_state_live = _get_live_state_by_folder(hash, path, db_session)

a_session = AddCandidatesSession(
Expand Down Expand Up @@ -523,7 +523,7 @@ async def run_import_candidate(
"""
log.info(f"Import task on {hash=} {path=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
s_state_live = _get_live_state_by_folder(hash, path, db_session)

i_session = ImportSession(
Expand Down Expand Up @@ -553,7 +553,7 @@ async def run_import_auto(
):
log.info(f"Auto Import task on {hash=} {path=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
s_state_live = _get_live_state_by_folder(hash, path, db_session)
i_session = AutoImportSession(
s_state_live,
Expand All @@ -577,7 +577,7 @@ async def run_import_auto(
async def run_import_bootleg(hash: str, path: str):
log.info(f"Bootleg Import task on {hash=} {path=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
# TODO: add duplicate action
# TODO: sort out how to generate previews for asis candidates
s_state_live = _get_live_state_by_folder(
Expand All @@ -600,7 +600,7 @@ async def run_import_bootleg(hash: str, path: str):
async def run_import_undo(hash: str, path: str, delete_files: bool):
log.info(f"Import Undo task on {hash=} {path=}")

with db_session_factory() as db_session:
with inbox_config_override(path), db_session_factory() as db_session:
s_state_live = _get_live_state_by_folder(hash, path, db_session)
i_session = UndoSession(s_state_live, delete_files=delete_files)

Expand All @@ -614,6 +614,9 @@ async def run_import_undo(hash: str, path: str, delete_files: bool):
log.info(f"Import Undo done. {hash=} {path=}")


# ---------------------------------- Helper ---------------------------------- #


def _get_live_state_by_folder(
hash: str, path: str, db_session: Session, create_if_not_exists=False
) -> SessionState:
Expand Down Expand Up @@ -658,3 +661,25 @@ def delete_items(task_ids: list[str], delete_files: bool = True):
lib = _open_library(get_config().beets_config)
for task_id in task_ids:
delete_from_beets(task_id, delete_files=delete_files, lib=lib)


@contextmanager
def inbox_config_override(path):
"""
Context manager for applying inbox-specific overrides.

Ensures that overrides are reset even if inner code raises exceptions.
"""
from beets_flask.watchdog.inbox import get_inbox_for_path

config = get_config()
inbox = get_inbox_for_path(path)
if inbox is None:
log.warning(f"{path} is not in an inbox, this should only happen in tests")
else:
config.apply_inbox_specific_overrides(inbox.path)

try:
yield config
finally:
config.reset_inbox_specific_overrides()
2 changes: 1 addition & 1 deletion backend/beets_flask/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"": { # root logger
"handlers": ["console", "file"],
"level": os.getenv("LOG_LEVEL_OTHERS", logging.WARNING),
"propagate": False,
"propagate": True,
},
"beets-flask": {
"handlers": ["console", "file"],
Expand Down
Loading
Loading