From e64569742eb7e5a7d864c8338194fb700e539eaf Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:15:43 +0200 Subject: [PATCH 01/13] Added alembic migration system. --- .dockerignore | 4 ++ backend/alembic.ini | 14 +++++ backend/alembic/env.py | 95 ++++++++++++++++++++++++++++++++++ backend/alembic/script.py.mako | 28 ++++++++++ backend/pyproject.toml | 3 +- backend/uv.lock | 28 ++++++++++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako diff --git a/.dockerignore b/.dockerignore index cfd2c38c..04fd9869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,10 @@ !backend/launch_*.py !backend/generate_types.py +# DB migrations +!backend/alembic +!backend/alembic.ini + !configs/ !frontend/src/ diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..594d0a7c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,14 @@ +# Alembic Configuration +# Docs: https://alembic.sqlalchemy.org/en/latest/index.html + +[alembic] +script_location = %(here)s/alembic +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +prepend_sys_path = . +path_separator = os + +[post_write_hooks] +hooks = ruff +ruff.type = module +ruff.module = ruff +ruff.options = check --fix REVISION_SCRIPT_FILENAME diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..f1f9afbb --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,95 @@ +"""Alembic environment configuration for beets-flask database migrations. + +This module configures Alembic to use the beets-flask database configuration +for both autogenerate support and runtime migrations. +""" + +from alembic import context + +# Import beets_flask database components +from beets_flask.config.flask_config import get_flask_config +from beets_flask.database.models.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +# add your model's MetaData object here +# for 'autogenerate' support +# This is crucial for autogenerate to detect model changes +target_metadata = Base.metadata + + +def get_url() -> str: + """Get the database URL from beets-flask configuration. + + Returns + ------- + str: The database connection URI. + + """ + flask_config = get_flask_config() + return flask_config["DATABASE_URI"] + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + from sqlalchemy import engine_from_config, pool + + # Get the database URL from beets-flask config + url = get_url() + + # Create engine configuration with our URL + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = url + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..1548ac2e --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 323f9ebc..324f5294 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "numpy", "typing_extensions", "polars>=1.36.1", + "alembic>=1.18.4", ] [project.optional-dependencies] @@ -91,7 +92,7 @@ target-version = "py311" [tool.ruff.lint.per-file-ignores] "**/tests/**/*" = ["D"] - +"alembic/**" = ["D"] [tool.ruff.lint] select = [ diff --git a/backend/uv.lock b/backend/uv.lock index b49e3d02..3e96ee5c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -88,6 +88,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -188,6 +202,7 @@ source = { editable = "." } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, + { name = "alembic" }, { name = "beets" }, { name = "cachetools" }, { name = "confuse" }, @@ -271,6 +286,7 @@ typed = [ requires-dist = [ { name = "aiofiles" }, { name = "aiohttp" }, + { name = "alembic", specifier = ">=1.18.4" }, { name = "beets", specifier = "==2.5.1" }, { name = "beets-flask", extras = ["dev", "test", "typed"], marker = "extra == 'all'" }, { name = "beets-flask", extras = ["typed"], marker = "extra == 'dev'" }, @@ -1044,6 +1060,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/34/b11ab24abb78c73a1b82f6471c2d71bdd1bf2c8f30768ed2f26f1dddc083/libtmux-0.55.0-py3-none-any.whl", hash = "sha256:4b746533856e022c759e5c5cae97f4932e85dae316a2afd4391d6d0e891d6ab8", size = 80094, upload-time = "2026-03-08T00:57:54.141Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" From 6d8cda3e0f8ec05c7b564a93c90e81c22dc2c093 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:22:56 +0200 Subject: [PATCH 02/13] Removed unused with_db_session decorator --- backend/beets_flask/database/__init__.py | 3 +-- backend/beets_flask/database/setup.py | 23 ------------------- .../tests/unit/test_database/test_setup.py | 12 +--------- 3 files changed, 2 insertions(+), 36 deletions(-) diff --git a/backend/beets_flask/database/__init__.py b/backend/beets_flask/database/__init__.py index 78d3b6a7..7cb26efc 100644 --- a/backend/beets_flask/database/__init__.py +++ b/backend/beets_flask/database/__init__.py @@ -1,7 +1,6 @@ -from .setup import db_session_factory, setup_database, with_db_session +from .setup import db_session_factory, setup_database __all__ = [ "setup_database", "db_session_factory", - "with_db_session", ] diff --git a/backend/beets_flask/database/setup.py b/backend/beets_flask/database/setup.py index 09a3bb63..c336b0d5 100644 --- a/backend/beets_flask/database/setup.py +++ b/backend/beets_flask/database/setup.py @@ -1,5 +1,4 @@ from contextlib import contextmanager -from functools import wraps from quart import Quart from sqlalchemy import Engine, create_engine @@ -94,28 +93,6 @@ def db_session_factory(session: Session | None = None): session.close() # type: ignore -def with_db_session(func): - """Decorate a function with a db session as a keyword argument to the function. - - Example - ``` - @with_db_session - def my_function(session=None): - tag.foo = "bar" - session.merge(tag) - return tag.to_dict() - ``` - """ - - @wraps(func) - def wrapper(*args, **kwargs): - with db_session_factory() as session: - kwargs.setdefault("session", session) - return func(*args, **kwargs) - - return wrapper - - def _create_tables(engine) -> None: Base.metadata.create_all(bind=engine) diff --git a/backend/tests/unit/test_database/test_setup.py b/backend/tests/unit/test_database/test_setup.py index c887f8e3..e1244734 100644 --- a/backend/tests/unit/test_database/test_setup.py +++ b/backend/tests/unit/test_database/test_setup.py @@ -5,17 +5,7 @@ from sqlalchemy.orm import Session from beets_flask.database.models.states import FolderInDb -from beets_flask.database.setup import _reset_database, with_db_session - - -def test_with_db_session_decorator(testapp): - # Needs the testapp - - @with_db_session - def sample_function(session=None): - return session is not None - - assert sample_function() is True +from beets_flask.database.setup import _reset_database def test_reset( From 5acff2cf9600e12a81f96dc499a248afdb46884b Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:24:18 +0200 Subject: [PATCH 03/13] Added initial migration --- .../2026_04_08_1846-a986c03d9ba3_initial.py | 168 ++++++++++++++++++ backend/pyproject.toml | 2 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py diff --git a/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py b/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py new file mode 100644 index 00000000..6e6dc1ae --- /dev/null +++ b/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py @@ -0,0 +1,168 @@ +"""initial + +Revision ID: a986c03d9ba3 +Revises: +Create Date: 2026-04-08 18:46:00.556681 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a986c03d9ba3" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + import beets_flask.database.models.types + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "folder", + sa.Column("full_path", sa.String(), nullable=False), + sa.Column("is_album", sa.Boolean(), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("full_path", "id"), + ) + op.create_index( + op.f("ix_folder_created_at"), "folder", ["created_at"], unique=False + ) + op.create_index(op.f("ix_folder_full_path"), "folder", ["full_path"], unique=False) + op.create_table( + "session", + sa.Column("folder_hash", sa.String(), nullable=False), + sa.Column("folder_revision", sa.Integer(), nullable=False), + sa.Column( + "progress", + sa.Enum( + "NOT_STARTED", + "READING_FILES", + "GROUPING_ALBUMS", + "LOOKING_UP_CANDIDATES", + "IDENTIFYING_DUPLICATES", + "PREVIEW_COMPLETED", + "DELETION_COMPLETED", + "OFFERING_MATCHES", + "MATCH_THRESHOLD", + "WAITING_FOR_USER_SELECTION", + "EARLY_IMPORTING", + "IMPORTING", + "MANIPULATING_FILES", + "IMPORT_COMPLETED", + "DELETING", + name="progress", + ), + nullable=False, + ), + sa.Column("exc", sa.LargeBinary(), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["folder_hash"], + ["folder.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "folder_hash", "folder_revision", name="uq_folder_hash_revision" + ), + ) + op.create_index( + op.f("ix_session_created_at"), "session", ["created_at"], unique=False + ) + op.create_table( + "task", + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("chosen_candidate_id", sa.String(), nullable=True), + sa.Column("toppath", sa.LargeBinary(), nullable=True), + sa.Column("paths", sa.LargeBinary(), nullable=False), + sa.Column("old_paths", sa.LargeBinary(), nullable=True), + sa.Column("items", sa.LargeBinary(), nullable=False), + sa.Column( + "choice_flag", + sa.Enum( + "SKIP", "ASIS", "TRACKS", "APPLY", "ALBUMS", "RETAG", name="action" + ), + nullable=True, + ), + sa.Column("cur_artist", sa.String(), nullable=True), + sa.Column("cur_album", sa.String(), nullable=True), + sa.Column( + "progress", + sa.Enum( + "NOT_STARTED", + "READING_FILES", + "GROUPING_ALBUMS", + "LOOKING_UP_CANDIDATES", + "IDENTIFYING_DUPLICATES", + "PREVIEW_COMPLETED", + "DELETION_COMPLETED", + "OFFERING_MATCHES", + "MATCH_THRESHOLD", + "WAITING_FOR_USER_SELECTION", + "EARLY_IMPORTING", + "IMPORTING", + "MANIPULATING_FILES", + "IMPORT_COMPLETED", + "DELETING", + name="progress", + ), + nullable=False, + ), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["chosen_candidate_id"], ["candidate.id"], use_alter=True + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["session.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_task_created_at"), "task", ["created_at"], unique=False) + op.create_table( + "candidate", + sa.Column("task_id", sa.String(), nullable=False), + sa.Column("match", sa.LargeBinary(), nullable=False), + sa.Column("duplicate_ids", sa.String(), nullable=False), + sa.Column( + "mapping", beets_flask.database.models.types.IntDictType(), nullable=False + ), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["task_id"], + ["task.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_candidate_created_at"), "candidate", ["created_at"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_candidate_created_at"), table_name="candidate") + op.drop_table("candidate") + op.drop_index(op.f("ix_task_created_at"), table_name="task") + op.drop_table("task") + op.drop_index(op.f("ix_session_created_at"), table_name="session") + op.drop_table("session") + op.drop_index(op.f("ix_folder_full_path"), table_name="folder") + op.drop_index(op.f("ix_folder_created_at"), table_name="folder") + op.drop_table("folder") + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 324f5294..4e9e127c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -92,7 +92,7 @@ target-version = "py311" [tool.ruff.lint.per-file-ignores] "**/tests/**/*" = ["D"] -"alembic/**" = ["D"] +"**/alembic/**/*" = ["D"] [tool.ruff.lint] select = [ From c4455952c7eca283241b5734face5db882e19577 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:26:58 +0200 Subject: [PATCH 04/13] Added runtime validation for migration: - db exists -> migration table exists -> run migrate - db exists -> migration table does not exist -> create migration table -> run migrate - db does not exist -> run migrate --- backend/beets_flask/database/migration.py | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 backend/beets_flask/database/migration.py diff --git a/backend/beets_flask/database/migration.py b/backend/beets_flask/database/migration.py new file mode 100644 index 00000000..000730d7 --- /dev/null +++ b/backend/beets_flask/database/migration.py @@ -0,0 +1,72 @@ +from alembic import command +from alembic.config import Config +from sqlalchemy import Engine, create_engine, text + +from beets_flask.config.flask_config import get_flask_config +from beets_flask.logger import log + + +def run_migrations() -> None: + """Run all pending database migrations.""" + + alembic_config = Config("alembic.ini") + engine = create_engine(get_flask_config()["DATABASE_URI"]) + + if not _db_has_tables(engine): + # Completely empty database - run full migrations to create tables + log.info("Database empty, running initial migration...") + command.upgrade(alembic_config, "head") + elif not _alembic_initialized(engine): + # Has tables but no alembic tracking - stamp then upgrade + log.info("Database has no alembic tracking yet") + stamp_initial(alembic_config) + command.upgrade(alembic_config, "head") + else: + # Already tracked - just run pending migrations + log.info("Running database migrations...") + command.upgrade(alembic_config, "head") + + log.info("Database migrations complete.") + + +def stamp_initial(config: Config) -> str | None: + """Stamp the database with the base migration. + + Use this for existing databases that should be considered up-to-date + at the base migration, without running any schema changes. + """ + from alembic.script import ScriptDirectory + + script = ScriptDirectory.from_config(config) + base_rev = script.get_base() + + if base_rev is None: + log.warning("No migrations found, skipping stamp") + return None + + log.info(f"Stamping database with base migration: {base_rev}...") + command.stamp(config, base_rev) + log.info(f"Database stamped with {base_rev}.") + return base_rev + + +def _alembic_initialized(engine: Engine) -> bool: + """Check if alembic_version table exists and has content.""" + with engine.connect() as c: + result = c.execute(text("PRAGMA table_info(alembic_version)")) + if not result.fetchall(): + return False # Table doesn't exis + + # Check if has content + result = c.execute(text("SELECT EXISTS(SELECT 1 FROM alembic_version)")) + return bool(result.scalar()) + + +def _db_has_tables(engine: Engine) -> bool: + """Check if any tables exist in the database.""" + with engine.connect() as c: + result = c.execute( + text("SELECT COUNT(*) FROM sqlite_master WHERE type='table'") + ) + count = result.scalar() or 0 + return count > 0 From f40d836608b1a09b3b1cc1adb499b66eef76a95f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:27:27 +0200 Subject: [PATCH 05/13] Fixed minor circular FK issue --- backend/beets_flask/database/models/states.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/beets_flask/database/models/states.py b/backend/beets_flask/database/models/states.py index a4798d07..2e8e5712 100644 --- a/backend/beets_flask/database/models/states.py +++ b/backend/beets_flask/database/models/states.py @@ -347,7 +347,10 @@ class TaskStateInDb(Base): cascade="all, delete-orphan", ) # Set at the end of the import session - chosen_candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidate.id")) + # use_alter=True to break circular FK with candidate.task_id + chosen_candidate_id: Mapped[str | None] = mapped_column( + ForeignKey("candidate.id", use_alter=True) + ) chosen_candidate: Mapped[CandidateStateInDb | None] = relationship( back_populates="task", foreign_keys=[chosen_candidate_id], From 83898ca1ce99c8f482909ae02c8e9e72a5cebfe5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:30:47 +0200 Subject: [PATCH 06/13] Removed old db init scripts in favor for inline migration scripts. --- backend/beets_flask/logger.py | 8 ++++++++ backend/launch_db_init.py | 21 --------------------- backend/launch_server.py | 16 ++++++++++++++++ docker/entrypoints/entrypoint.sh | 25 ++++++++++++------------- docker/entrypoints/entrypoint_dev.sh | 12 ++++++++---- 5 files changed, 44 insertions(+), 38 deletions(-) delete mode 100644 backend/launch_db_init.py create mode 100644 backend/launch_server.py diff --git a/backend/beets_flask/logger.py b/backend/beets_flask/logger.py index d47290d6..4c3f7499 100644 --- a/backend/beets_flask/logger.py +++ b/backend/beets_flask/logger.py @@ -41,6 +41,14 @@ "level": os.getenv("LOG_LEVEL_BEETSFLASK", logging.INFO), "propagate": False, }, + "alembic.runtime.migration": { + "level": logging.INFO, + "propagate": True, + }, + "uvicorn": { + "level": logging.WARNING, + "propagate": True, + }, }, } diff --git a/backend/launch_db_init.py b/backend/launch_db_init.py deleted file mode 100644 index ea2b7406..00000000 --- a/backend/launch_db_init.py +++ /dev/null @@ -1,21 +0,0 @@ -import os - -# dirty workaround, we pretend this is a rq worker so we get the logger to create -# a child log with pid -os.environ.setdefault("RQ_JOB_ID", "dbin") - -from beets.ui import _open_library - -from beets_flask.config.beets_config import get_config -from beets_flask.database import setup_database -from beets_flask.logger import log - -if __name__ == "__main__": - log.debug("Launching database init worker") - - # ensue beets own db is created - config = get_config() - _open_library(config.beets_config) - - # ensure beets-flask db is created - setup_database() diff --git a/backend/launch_server.py b/backend/launch_server.py new file mode 100644 index 00000000..bec539e4 --- /dev/null +++ b/backend/launch_server.py @@ -0,0 +1,16 @@ +import uvicorn + +from beets_flask.logger import log + +if __name__ == "__main__": + log.info("Starting uvicorn server") + log.info("Server running on http://0.0.0.0:5001") + uvicorn.run( + "beets_flask.server.app:create_app", + factory=True, + host="0.0.0.0", + port=5001, + workers=4, + log_config=None, # Disable default uvicorn logging config + access_log=False, + ) diff --git a/docker/entrypoints/entrypoint.sh b/docker/entrypoints/entrypoint.sh index 0b826d17..6e01a66b 100755 --- a/docker/entrypoints/entrypoint.sh +++ b/docker/entrypoints/entrypoint.sh @@ -18,27 +18,26 @@ mkdir -p /config/beets-flask # Ignore warnings for production builds export PYTHONWARNINGS="ignore" + # running the server from inside the backend dir makes imports and redis easier cd /repo/backend +# Databse creation & migrations (beets-flask) +python -c "from beets_flask.database.migration import run_migrations; run_migrations()" + +# Database creation & migration (beets) +python -c "from beets.ui import _open_library; from beets_flask.config.beets_config import get_config; _open_library(get_config().beets_config)" + +# Redis server (if not set outside container) if [ -z "$REDIS_URL" ]; then redis-server --daemonize yes >/dev/null 2>&1 fi -# blocking -python ./launch_db_init.py +# FIXME: Logging is a bit strange for the workers atm a bit of unification could help python ./launch_redis_workers.py > /logs/redis_workers.log 2>&1 - -# keeps running in the background python ./launch_watchdog_worker.py & - redis-cli FLUSHALL >/dev/null 2>&1 -# we need to run with one worker for socketio to work -uvicorn beets_flask.server.app:create_app --port 5001 \ - --host 0.0.0.0 \ - --factory \ - --workers 4 \ - --use-colors \ - --log-level info \ - --no-access-log +# Launch server +sleep 0.5 +python ./launch_server.py diff --git a/docker/entrypoints/entrypoint_dev.sh b/docker/entrypoints/entrypoint_dev.sh index 740ecbe7..e721de1b 100755 --- a/docker/entrypoints/entrypoint_dev.sh +++ b/docker/entrypoints/entrypoint_dev.sh @@ -42,14 +42,18 @@ export FLASK_DEBUG=1 # running the server from inside the backend dir makes imports and redis easier cd /repo/backend -uv sync --locked -# No need to activate, we have this in PATH +uv sync --locked --active -redis-server --daemonize yes +# Databse creation & migrations (beets-flask) +python -c "from beets_flask.database.migration import run_migrations; run_migrations()" + +# Database creation & migration (beets) +python -c "from beets.ui import _open_library; from beets_flask.config.beets_config import get_config; _open_library(get_config().beets_config)" +redis-server --daemonize yes + # blocking -python ./launch_db_init.py python ./launch_redis_workers.py # keeps running in the background From cf82961ce94c00def8127bc14cbacc389483ec8f Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:41:13 +0200 Subject: [PATCH 07/13] Fixed minor formatting issues and added docs around the migrations. --- backend/alembic/script.py.mako | 9 ++++-- backend/pyproject.toml | 4 ++- docs/develop/resources/backend.md | 48 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index 1548ac2e..51179c5b 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -5,17 +5,20 @@ Revises: ${down_revision | comma,n} Create Date: ${create_date} """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa + +from alembic import op + ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: str | Sequence[str] | None = ${repr(down_revision)} branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} -depends_on: str | Sequence[str] | None = = ${repr(depends_on)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4e9e127c..1d43ff84 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -92,7 +92,7 @@ target-version = "py311" [tool.ruff.lint.per-file-ignores] "**/tests/**/*" = ["D"] -"**/alembic/**/*" = ["D"] +"**/alembic/**/*" = ["D","I"] [tool.ruff.lint] select = [ @@ -116,8 +116,10 @@ fixable = ["ALL"] [tool.ruff.lint.pydocstyle] convention = "numpy" + [tool.ruff.lint.isort] known-first-party = ["beets_flask", "tests"] +known-third-party = ["alembic", "sqlalchemy"] [tool.pytest.ini_options] # addopts = ["--import-mode=importlib", "--cov=beets_flask"] diff --git a/docs/develop/resources/backend.md b/docs/develop/resources/backend.md index 9403e3f1..9a04d954 100644 --- a/docs/develop/resources/backend.md +++ b/docs/develop/resources/backend.md @@ -24,4 +24,52 @@ BEETSFLASKDIR="/config/beets-flask" BEETSFLASKLOG="/logs/beets-flask.log" ``` +## Database Migrations Guide +We use [Alembic](https://alembic.sqlalchemy.org/) for database migrations. + +### Overview + +- Migrations are stored in `backend/alembic/versions/` +- The database tracks its current version in the `alembic_version` table +- Migration files define `upgrade()` and `downgrade()` functions + +### Quick Reference + +| Task | Command | +|------|---------| +| Create migration | `alembic revision --autogenerate -m "description"` | +| Apply all pending | `alembic upgrade head` | +| Roll back one | `alembic downgrade -1` | +| Check version | `alembic current` | +| See history | `alembic history` | +| Validate | `alembic check` | + +### Workflow: Creating a migration + +We use a local database (`./beets-flask-sqlite.db`) to avoid breaking the docker setup. + +1. Ensure you have a local database: + ```bash + cd backend + alembic upgrade head + ``` + +2. Edit the model (e.g., add a column to `states.py`) + +3. Generate the migration: + ```bash + alembic revision --autogenerate -m "add_column_name" + ``` + +4. Review the generated migration file in `alembic/versions/` + +5. Apply the migration: + ```bash + alembic upgrade head + ``` + +6. Validate with + ```bash + alembic current + ``` From 571e60d0502009e1caf00bd5cc1cb78f7f7791ec Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 8 Apr 2026 21:51:55 +0200 Subject: [PATCH 08/13] Added unittests for migration. --- backend/beets_flask/database/setup.py | 13 +- backend/pyproject.toml | 3 +- .../integration/test_routes/test_db_models.py | 3 +- .../unit/test_database/test_migration.py | 113 ++++++++++++++++++ .../unit/test_database/test_models_state.py | 3 +- docker/Dockerfile | 20 ++-- 6 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 backend/tests/unit/test_database/test_migration.py diff --git a/backend/beets_flask/database/setup.py b/backend/beets_flask/database/setup.py index c336b0d5..50e9300a 100644 --- a/backend/beets_flask/database/setup.py +++ b/backend/beets_flask/database/setup.py @@ -33,8 +33,6 @@ def setup_database(app: Quart | None = None) -> None: log.warning("Resetting database due to RESET_DB=True in config") _reset_database() - _create_tables(engine) - if app is not None: # Gracefully shutdown the database session, if launched # from within a Flask app context. @@ -93,13 +91,6 @@ def db_session_factory(session: Session | None = None): session.close() # type: ignore -def _create_tables(engine) -> None: - Base.metadata.create_all(bind=engine) - - def _reset_database(): - # Removes all data from the database but keeps schema - for t in reversed(Base.metadata.sorted_tables): - with db_session_factory() as session: - session.execute(t.delete()) - session.commit() + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1d43ff84..e3af6f86 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -123,7 +123,7 @@ known-third-party = ["alembic", "sqlalchemy"] [tool.pytest.ini_options] # addopts = ["--import-mode=importlib", "--cov=beets_flask"] -addopts = ["--import-mode=importlib"] +addopts = ["--import-mode=importlib", "--cov=beets_flask", "--cov-report=html"] filterwarnings = [ "error", "ignore::sqlalchemy.exc.SAWarning", @@ -134,6 +134,7 @@ asyncio_mode = "auto" log_format = "%(relativeCreated)-8d [%(levelname)-5s] %(name)s %(filename)-8s:%(lineno)d %(message)s" asyncio_default_fixture_loop_scope = "function" + [tool.coverage.report] omit = ["*/tests/*"] exclude_also = [ diff --git a/backend/tests/integration/test_routes/test_db_models.py b/backend/tests/integration/test_routes/test_db_models.py index 636151d6..d452f1f1 100644 --- a/backend/tests/integration/test_routes/test_db_models.py +++ b/backend/tests/integration/test_routes/test_db_models.py @@ -9,6 +9,7 @@ from beets_flask.database.models.states import FolderInDb, SessionStateInDb from beets_flask.importer.states import SessionState from tests.conftest import beets_lib_item +from tests.mixins.database import IsolatedDBMixin from tests.unit.test_importer.test_states import get_album_match @@ -48,7 +49,7 @@ async def session_in_db(db_session_factory, import_task, tmpdir_factory): db_session.commit() -class TestSessionEndpoint: +class TestSessionEndpoint(IsolatedDBMixin): """Test the end to end functionality of the model endpoints. We automatically generate the endpoints for the sqlalchemy models. Thus we also diff --git a/backend/tests/unit/test_database/test_migration.py b/backend/tests/unit/test_database/test_migration.py new file mode 100644 index 00000000..7424327a --- /dev/null +++ b/backend/tests/unit/test_database/test_migration.py @@ -0,0 +1,113 @@ +"""Tests for database migration module.""" + +from unittest.mock import Mock + +from sqlalchemy import text + +from beets_flask.database.migration import ( + _alembic_initialized, + _db_has_tables, + run_migrations, +) + + +class TestDbHasTables: + """Tests for _db_has_tables function.""" + + def test_returns_false_for_empty_database(self, db_session): + """Test that _db_has_tables returns False for an empty database.""" + # Drop all + from beets_flask.database.models.base import Base + + Base.metadata.drop_all(db_session.bind) + db_session.execute(text("DROP TABLE IF EXISTS test_table")) + db_session.commit() + + assert _db_has_tables(db_session.bind) is False + + def test_returns_true_when_tables_exist(self, db_session): + """Test that _db_has_tables returns True when tables exist.""" + db_session.execute(text("CREATE TABLE test_table (id INTEGER)")) + db_session.commit() + + assert _db_has_tables(db_session.bind) is True + + +class TestAlembicInitialized: + """Tests for _alembic_initialized function.""" + + def test_returns_false_when_table_does_not_exist(self, db_session): + """Test that _alembic_initialized returns False when table doesn't exist.""" + assert _alembic_initialized(db_session.bind) is False + + def test_returns_true_when_table_has_content(self, db_session): + """Test that _alembic_initialized returns True when table has content.""" + db_session.execute( + text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32))") + ) + db_session.execute( + text("INSERT INTO alembic_version (version_num) VALUES ('abc123')") + ) + db_session.commit() + + assert _alembic_initialized(db_session.bind) is True + + def test_returns_false_when_table_exists_but_empty(self, db_session): + """Test that _alembic_initialized returns False when table exists but is empty.""" + db_session.execute( + text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32))") + ) + db_session.execute(text("DELETE FROM alembic_version")) + db_session.commit() + + assert _alembic_initialized(db_session.bind) is False + + +class TestRunMigrations: + """Tests for run_migrations function.""" + + def test_runs_upgrade_empty_db(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=False) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + + # Check log + assert "Database empty" in caplog.text + assert "Database migrations complete." in caplog.text + + def test_runs_upgrade_alembic_missing(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=True) + mig._alembic_initialized = Mock(return_value=False) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + mig._alembic_initialized.assert_called_once() + + # Check log + assert "Database has no alembic" in caplog.text + assert "Database migrations complete." in caplog.text + + def test_runs_upgrade_alembic_exist(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=True) + mig._alembic_initialized = Mock(return_value=True) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + mig._alembic_initialized.assert_called_once() + + # Check log + assert "Running database migrations..." in caplog.text + assert "Database migrations complete." in caplog.text diff --git a/backend/tests/unit/test_database/test_models_state.py b/backend/tests/unit/test_database/test_models_state.py index 8d8b4765..b306b9e0 100644 --- a/backend/tests/unit/test_database/test_models_state.py +++ b/backend/tests/unit/test_database/test_models_state.py @@ -8,6 +8,7 @@ from beets_flask.importer.session import SessionState from beets_flask.importer.stages import Progress from tests.conftest import beets_lib_item +from tests.mixins.database import IsolatedDBMixin from tests.unit.test_importer.test_states import get_album_match log = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def import_task(beets_lib): return task -class TestSessionStateInDb: +class TestSessionStateInDb(IsolatedDBMixin): state: SessionState @pytest.fixture(autouse=True) diff --git a/docker/Dockerfile b/docker/Dockerfile index ffae6759..3ec4c019 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,6 +32,7 @@ ENV UV_LINK_MODE=copy ENV UV_NO_DEV=1 ENV UV_NO_EDITABLE=1 ENV UV_PYTHON_DOWNLOADS=0 +ENV UV_PROJECT_ENVIRONMENT=/venv # Install backend dependencies RUN --mount=type=cache,target=/root/.cache/uv \ @@ -40,11 +41,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-install-project # Install backend package -COPY ./backend/beets_flask/ /repo/backend/beets_flask/ -COPY ./backend/generate_types.py /repo/backend/ -COPY ./backend/launch_*.py /repo/backend/ -COPY ./backend/pyproject.toml /repo/backend -COPY ./backend/uv.lock /repo/backend +COPY ./backend/ /repo/backend/ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked @@ -89,6 +86,7 @@ ENV EDITOR="vi" ENV BEETSDIR="/config/beets" ENV BEETSFLASKDIR="/config/beets-flask" ENV BEETSFLASKLOG="/logs/beets-flask.log" +ENV UV_PROJECT_ENVIRONMENT=/venv # Create user and group RUN groupadd -g 1000 beetle && \ @@ -125,6 +123,11 @@ COPY --from=builder_ffmpeg /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg COPY --from=builder_ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/ffprobe RUN ffmpeg -version + +# Copy bf dependencies +COPY --from=builder_py --chown=beetle:beetle /venv /venv + + # ------------------------------------------------------------------------------------ # # Production # # ------------------------------------------------------------------------------------ # @@ -144,7 +147,7 @@ RUN chmod +x /repo/*.sh ENTRYPOINT [ \ "/bin/bash", "-c", \ - "source ./backend/.venv/bin/activate && \ + "source /venv/bin/activate && \ /repo/entrypoint_fix_permissions.sh && \ /repo/entrypoint_user_scripts.sh && \ su beetle -c /repo/entrypoint.sh" \ @@ -157,6 +160,7 @@ ENTRYPOINT [ \ FROM base AS dev + ENV IB_SERVER_CONFIG="dev_docker" RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \ @@ -192,10 +196,8 @@ RUN uv run python -c "import tomllib; print(tomllib.load(open('/repo/backend/pyp WORKDIR /repo ENTRYPOINT [ \ "/bin/bash", "-c", \ - "source ./backend/.venv/bin/activate && \ + "source /venv/bin/activate && \ ./docker/entrypoints/entrypoint_fix_permissions.sh && \ ./docker/entrypoints/entrypoint_user_scripts.sh && \ su beetle -c ./docker/entrypoints/entrypoint_dev.sh" \ ] - - From 9221e4032efdf57131c50456c48eea3ace9b7a0a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 9 Apr 2026 12:08:22 +0200 Subject: [PATCH 09/13] Fixed typing issue in new _reset_database function --- backend/beets_flask/database/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/beets_flask/database/setup.py b/backend/beets_flask/database/setup.py index 50e9300a..a46d86e4 100644 --- a/backend/beets_flask/database/setup.py +++ b/backend/beets_flask/database/setup.py @@ -92,5 +92,5 @@ def db_session_factory(session: Session | None = None): def _reset_database(): - Base.metadata.drop_all(bind=engine) - Base.metadata.create_all(bind=engine) + Base.metadata.drop_all(bind=engine) # type: ignore + Base.metadata.create_all(bind=engine) # type: ignore From 1dd6ff5ed2391aade9fa46727dab5346ef269493 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 9 Apr 2026 12:10:54 +0200 Subject: [PATCH 10/13] Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bb4065..5cf803af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - We now use `uv` (Universal Virtualenv) to manage python dependencies and run scripts in CI/CD. This should improve dependency resolution and installation times. - We now ship a static ffmpeg binary instead of installing ffmpeg via apt. This should reduce image size and improve compatibility across different host systems. +- Added a database migration setup using [Alembic](https://alembic.sqlalchemy.org/) for future database migrations. ## [1.2.0] - 25-12-17 From 0183c3677fe306d6592917a6c094dcc97489eec4 Mon Sep 17 00:00:00 2001 From: pSpitzner Date: Mon, 13 Apr 2026 19:28:49 +0200 Subject: [PATCH 11/13] added a comment --- backend/beets_flask/database/migration.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/beets_flask/database/migration.py b/backend/beets_flask/database/migration.py index 000730d7..c09a4fc6 100644 --- a/backend/beets_flask/database/migration.py +++ b/backend/beets_flask/database/migration.py @@ -1,3 +1,11 @@ +""" +Scaffold for initial alembic setup and all future migrations. + +Introduced for migration from 1.2.1 to 2.0. +We use a python wrapper instead of the cli, because this way we get configs and +env vars in our usual way. +""" + from alembic import command from alembic.config import Config from sqlalchemy import Engine, create_engine, text From a10cff8917d7dcd698b640f4f34ed8d41e127636 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Apr 2026 11:42:16 +0200 Subject: [PATCH 12/13] Added a small backup and integrity step to the migration run. --- backend/beets_flask/database/migration.py | 60 ++++++++++++++++------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/backend/beets_flask/database/migration.py b/backend/beets_flask/database/migration.py index c09a4fc6..4b8a44c2 100644 --- a/backend/beets_flask/database/migration.py +++ b/backend/beets_flask/database/migration.py @@ -1,11 +1,15 @@ """ Scaffold for initial alembic setup and all future migrations. -Introduced for migration from 1.2.1 to 2.0. -We use a python wrapper instead of the cli, because this way we get configs and -env vars in our usual way. +Introduced for migration from beets-flask v1.2.1 to v2.0. We use a python wrapper here +instead of the alembic cli, as this way we get configs and env vars in our usual way. """ +import shutil +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlparse + from alembic import command from alembic.config import Config from sqlalchemy import Engine, create_engine, text @@ -18,40 +22,33 @@ def run_migrations() -> None: """Run all pending database migrations.""" alembic_config = Config("alembic.ini") - engine = create_engine(get_flask_config()["DATABASE_URI"]) + db_url = get_flask_config()["DATABASE_URI"] + engine = create_engine(db_url) if not _db_has_tables(engine): # Completely empty database - run full migrations to create tables log.info("Database empty, running initial migration...") - command.upgrade(alembic_config, "head") + upgrade(alembic_config, db_url, engine) elif not _alembic_initialized(engine): # Has tables but no alembic tracking - stamp then upgrade log.info("Database has no alembic tracking yet") stamp_initial(alembic_config) - command.upgrade(alembic_config, "head") + upgrade(alembic_config, db_url, engine) else: # Already tracked - just run pending migrations log.info("Running database migrations...") - command.upgrade(alembic_config, "head") + upgrade(alembic_config, db_url, engine) log.info("Database migrations complete.") def stamp_initial(config: Config) -> str | None: - """Stamp the database with the base migration. + """Stamp the database with the initial migration. Use this for existing databases that should be considered up-to-date - at the base migration, without running any schema changes. + at the initial migration, without running any schema changes. """ - from alembic.script import ScriptDirectory - - script = ScriptDirectory.from_config(config) - base_rev = script.get_base() - - if base_rev is None: - log.warning("No migrations found, skipping stamp") - return None - + base_rev = "a986c03d9ba3" # a986c03d9ba3 == initial log.info(f"Stamping database with base migration: {base_rev}...") command.stamp(config, base_rev) log.info(f"Database stamped with {base_rev}.") @@ -78,3 +75,30 @@ def _db_has_tables(engine: Engine) -> bool: ) count = result.scalar() or 0 return count > 0 + + +def upgrade(alembic_config: Config, db_url: str, engine: Engine): + """Light wrapper around the alembic upgrade command. + + Adds backups and runs a cleanup after migrations. + """ + db_path = urlparse(db_url).path + ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + backup_path = Path(db_path).with_suffix(f".backup_{ts}.db") + shutil.copy2(db_path, backup_path) + log.info(f"SQLite backup created at {backup_path}") + + try: + command.upgrade(alembic_config, "head") + + with engine.begin() as conn: + conn.exec_driver_sql("PRAGMA wal_checkpoint(FULL);") + conn.exec_driver_sql("ANALYZE;") + conn.exec_driver_sql("REINDEX;") + conn.exec_driver_sql("VACUUM;") + result = conn.exec_driver_sql("PRAGMA integrity_check;").scalar() + if result != "ok": + raise RuntimeError(f"Integrity check failed: {result}") + except Exception: + log.exception("Migration failed! Please report this!") + raise From f080cf8c6c4423c92ac94d2c3f2f6174e4f93a36 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 14 Apr 2026 16:00:54 +0200 Subject: [PATCH 13/13] skip backup if no migration required. --- backend/beets_flask/database/migration.py | 16 ++++++++++++++++ .../tests/unit/test_database/test_migration.py | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/beets_flask/database/migration.py b/backend/beets_flask/database/migration.py index 4b8a44c2..538fb6dd 100644 --- a/backend/beets_flask/database/migration.py +++ b/backend/beets_flask/database/migration.py @@ -12,6 +12,8 @@ from alembic import command from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory from sqlalchemy import Engine, create_engine, text from beets_flask.config.flask_config import get_flask_config @@ -82,6 +84,10 @@ def upgrade(alembic_config: Config, db_url: str, engine: Engine): Adds backups and runs a cleanup after migrations. """ + if not _needs_migration(alembic_config, engine): + log.info("No pending migrations. Skipping.") + return # No backup, no upgrade + db_path = urlparse(db_url).path ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") backup_path = Path(db_path).with_suffix(f".backup_{ts}.db") @@ -102,3 +108,13 @@ def upgrade(alembic_config: Config, db_url: str, engine: Engine): except Exception: log.exception("Migration failed! Please report this!") raise + + +def _needs_migration(config: Config, engine: Engine) -> bool: + """Check if any migrations are pending.""" + script = ScriptDirectory.from_config(config) + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + current_rev = ctx.get_current_revision() + head_rev = script.get_current_head() + return current_rev != head_rev diff --git a/backend/tests/unit/test_database/test_migration.py b/backend/tests/unit/test_database/test_migration.py index 7424327a..90815e08 100644 --- a/backend/tests/unit/test_database/test_migration.py +++ b/backend/tests/unit/test_database/test_migration.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +import pytest from sqlalchemy import text from beets_flask.database.migration import ( @@ -66,6 +67,14 @@ def test_returns_false_when_table_exists_but_empty(self, db_session): class TestRunMigrations: """Tests for run_migrations function.""" + @pytest.fixture(autouse=True) + def setup(self): + import beets_flask.database.migration as mig + + mig.shutil.copy2 = Mock() + + return mig + def test_runs_upgrade_empty_db(self, caplog): import beets_flask.database.migration as mig