From b2388582ebae10a8e16c36bb29f1c45d4fef9555 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:42:58 +0000 Subject: [PATCH] Add a music profile to audio assets, read from ID3/Vorbis/MP4 tags So Gecko Video Creator can find a soundtrack that fits a scene instead of choosing by title. New nullable columns on Asset: genres, moods, instruments (JSON lists), bpm, key, vocals and energy. - Harvested on upload and by the existing re-read job from the tag block ffprobe already returns: TCON/TMOO/TBPM/TKEY and lyrics for ID3, the Vorbis GENRE/MOOD/BPM/INITIALKEY/LYRICS comments, MP4 genre/tmpo. Fills blanks only, stamped "embedded". Audio only. - URL import carries a site's genre onto audio imports. - Editable in a new Music tab and through PATCH /api/assets/{id}. - Indexed in a new asset_fts music_text column (rebuild + repopulate migration) and in the asset's embedding text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DCL4jm6ogZBBhnjEfBBaXU --- .../20261026_0900_add_music_profile.py | 52 ++++ ...61026_0901_rebuild_asset_fts_with_music.py | 139 +++++++++ backend/app/enrichment/embed.py | 10 +- backend/app/enrichment/harvest_attribution.py | 6 +- backend/app/enrichment/import_url.py | 6 +- backend/app/ingest/embedded_metadata.py | 69 ++++- backend/app/ingest/ytdlp.py | 11 + backend/app/models/asset.py | 18 ++ backend/app/music.py | 184 +++++++++++ backend/app/routers/assets.py | 4 +- backend/app/schemas_assets.py | 45 ++- backend/app/search/fts.py | 24 +- backend/app/services/assets.py | 24 +- backend/tests/fixtures/music_audio.flac | Bin 0 -> 20523 bytes backend/tests/fixtures/music_audio.mp3 | Bin 0 -> 8680 bytes backend/tests/make_fixtures.py | 39 +++ backend/tests/test_embedded_metadata.py | 2 +- backend/tests/test_migrations.py | 39 +++ backend/tests/test_music.py | 288 ++++++++++++++++++ docs/music-profile.md | 61 ++++ frontend/src/api/assets.ts | 27 ++ frontend/src/components/AssetDetail.tsx | 14 + frontend/src/components/MusicPanel.test.tsx | 114 +++++++ frontend/src/components/MusicPanel.tsx | 247 +++++++++++++++ frontend/src/test-fixtures.ts | 17 +- frontend/src/utils/terms.ts | 7 + 26 files changed, 1426 insertions(+), 21 deletions(-) create mode 100644 backend/alembic/versions/20261026_0900_add_music_profile.py create mode 100644 backend/alembic/versions/20261026_0901_rebuild_asset_fts_with_music.py create mode 100644 backend/app/music.py create mode 100644 backend/tests/fixtures/music_audio.flac create mode 100644 backend/tests/fixtures/music_audio.mp3 create mode 100644 backend/tests/test_music.py create mode 100644 docs/music-profile.md create mode 100644 frontend/src/components/MusicPanel.test.tsx create mode 100644 frontend/src/components/MusicPanel.tsx create mode 100644 frontend/src/utils/terms.ts diff --git a/backend/alembic/versions/20261026_0900_add_music_profile.py b/backend/alembic/versions/20261026_0900_add_music_profile.py new file mode 100644 index 0000000..e0e9fab --- /dev/null +++ b/backend/alembic/versions/20261026_0900_add_music_profile.py @@ -0,0 +1,52 @@ +"""add the music profile to asset + +What a track sounds like (genres, moods, instruments, tempo, key, vocals, energy), so +Video Creator can find a soundtrack that fits a scene. See app/music.py. + +All nullable and all plain ALTER on SQLite, so the upgrade does not recreate `asset`. +The downgrade's drops do, hence the PRAGMA dance `a8e1c0f4d2b6` explains. + +Split from `d71e4b9a2c86`, which rebuilds the search index, for the reason `3f7a21c9d4e5` +and `9c2e08b4a1f7` were split: that half can fail and this half cannot. + +Revision ID: c3a8f15e7b20 +Revises: 5b9e3d71c2a4 +Create Date: 2026-10-26 09:00:00.000000+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'c3a8f15e7b20' +down_revision: Union[str, None] = '5b9e3d71c2a4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.add_column(sa.Column('music_genres', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('music_moods', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('music_instruments', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('music_bpm', sa.Float(), nullable=True)) + batch_op.add_column(sa.Column('music_key', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('music_vocals', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('music_energy', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=OFF') + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.drop_column('music_energy') + batch_op.drop_column('music_vocals') + batch_op.drop_column('music_key') + batch_op.drop_column('music_bpm') + batch_op.drop_column('music_instruments') + batch_op.drop_column('music_moods') + batch_op.drop_column('music_genres') + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=ON') diff --git a/backend/alembic/versions/20261026_0901_rebuild_asset_fts_with_music.py b/backend/alembic/versions/20261026_0901_rebuild_asset_fts_with_music.py new file mode 100644 index 0000000..482a069 --- /dev/null +++ b/backend/alembic/versions/20261026_0901_rebuild_asset_fts_with_music.py @@ -0,0 +1,139 @@ +"""rebuild asset_fts with a music column + +A music profile nobody can search for is no use to the one thing that wants it: Video +Creator looking for "tense slow piano". So it reaches the keyword index as `music_text`. + +FTS5 has no ALTER TABLE ADD COLUMN, so this drops and recreates the table and then +repopulates it, exactly as `9c2e08b4a1f7` did for attribution; that migration's +docstring explains why the repopulate is not optional. The new column is appended last +because `search_assets` addresses `name` by column index in snippet(). + +The repopulated text is composed to match `app/search/fts.py::index_asset` and +`app/music.py::index_text`, so the first ordinary edit of an asset does not silently +rewrite its index entry into something different. The music columns were only added by +the revision before, so on a real upgrade they are all NULL and `music_text` comes out +empty. It is composed properly anyway, so a downgrade and re-upgrade of a library that +has profiles does not lose them from the index. + +Revision ID: d71e4b9a2c86 +Revises: c3a8f15e7b20 +Create Date: 2026-10-26 09:01:00.000000+00:00 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = 'd71e4b9a2c86' +down_revision: Union[str, None] = 'c3a8f15e7b20' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Literal copies, not imports from app/search/fts.py: a migration has to describe the +# schema as it was when it was written. test_migrations.py asserts they still agree. +ASSET_FTS_DDL = """ +CREATE VIRTUAL TABLE asset_fts USING fts5( + asset_id UNINDEXED, + user_id UNINDEXED, + name, + description, + summary, + tags_text, + attribution_text, + music_text, + tokenize='porter unicode61' +) +""" + +_TAGS_TEXT = """ + COALESCE( + (SELECT group_concat(t.name, ' ') + FROM assettag at + JOIN tag t ON t.id = at.tag_id + WHERE at.asset_id = a.id), + '' + )""" + +_ATTRIBUTION_TEXT = """ + TRIM( + COALESCE(a.creator, '') || ' ' || + COALESCE(a.publisher, '') || ' ' || + COALESCE(a.source_title, '') || ' ' || + COALESCE(a.license, '') || ' ' || + COALESCE(a.credit_line, '') || ' ' || + COALESCE(a.source_url, '') + )""" + +# json_each over a NULL or non-array yields no rows, so an unprofiled asset gets ''. +# json_valid guards a malformed value, which `music.decode_terms` also reads as empty. +_MUSIC_TEXT = """ + TRIM( + COALESCE((SELECT group_concat(j.value, ' ') FROM json_each( + CASE WHEN json_valid(a.music_genres) THEN a.music_genres END) j), '') || ' ' || + COALESCE((SELECT group_concat(j.value, ' ') FROM json_each( + CASE WHEN json_valid(a.music_moods) THEN a.music_moods END) j), '') || ' ' || + COALESCE((SELECT group_concat(j.value, ' ') FROM json_each( + CASE WHEN json_valid(a.music_instruments) THEN a.music_instruments END) j), '') || ' ' || + CASE a.music_vocals WHEN 1 THEN 'vocals' WHEN 0 THEN 'instrumental' ELSE '' END || ' ' || + CASE WHEN a.music_energy IN ('low', 'medium', 'high') + THEN a.music_energy || ' energy' ELSE '' END + )""" + +REPOPULATE = f""" +INSERT INTO asset_fts ( + asset_id, user_id, name, description, summary, tags_text, attribution_text, music_text +) +SELECT + a.id, + a.user_id, + COALESCE(a.name, ''), + COALESCE(a.description, ''), + COALESCE(a.summary, ''), + {_TAGS_TEXT}, + {_ATTRIBUTION_TEXT}, + {_MUSIC_TEXT} +FROM asset a +""" + +# The previous shape, for downgrade. Same reasoning: literal, not imported. +ASSET_FTS_DDL_WITHOUT_MUSIC = """ +CREATE VIRTUAL TABLE asset_fts USING fts5( + asset_id UNINDEXED, + user_id UNINDEXED, + name, + description, + summary, + tags_text, + attribution_text, + tokenize='porter unicode61' +) +""" + +REPOPULATE_WITHOUT_MUSIC = f""" +INSERT INTO asset_fts ( + asset_id, user_id, name, description, summary, tags_text, attribution_text +) +SELECT + a.id, + a.user_id, + COALESCE(a.name, ''), + COALESCE(a.description, ''), + COALESCE(a.summary, ''), + {_TAGS_TEXT}, + {_ATTRIBUTION_TEXT} +FROM asset a +""" + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS asset_fts") + op.execute(ASSET_FTS_DDL) + op.execute(REPOPULATE) + + +def downgrade() -> None: + # Repopulating for the same reason as the upgrade: an emptied index is a silent, + # data-shaped loss. + op.execute("DROP TABLE IF EXISTS asset_fts") + op.execute(ASSET_FTS_DDL_WITHOUT_MUSIC) + op.execute(REPOPULATE_WITHOUT_MUSIC) diff --git a/backend/app/enrichment/embed.py b/backend/app/enrichment/embed.py index 75c5963..17cd511 100644 --- a/backend/app/enrichment/embed.py +++ b/backend/app/enrichment/embed.py @@ -13,6 +13,7 @@ from sqlmodel import Session, col, select +from app import music from app.embeddings import EmbeddingError, build_embedder from app.models.asset import Asset from app.models.embedding import OWNER_ASSET, OWNER_SEGMENT @@ -58,7 +59,14 @@ def run(session: Session, asset: Asset, progress: Progress) -> int: def _embed_asset_text(session: Session, asset: Asset, embedder) -> int: """One vector for what the asset *is*, as opposed to what is said in it.""" - parts = [asset.name or "", asset.description or "", asset.summary or ""] + # The music profile rides along so "tense slow piano" finds a track by meaning, not + # only by the exact words the keyword index holds. Empty for everything but music. + parts = [ + asset.name or "", + asset.description or "", + asset.summary or "", + music.index_text(asset), + ] text = "\n".join(part for part in parts if part.strip()).strip() if not text: return 0 diff --git a/backend/app/enrichment/harvest_attribution.py b/backend/app/enrichment/harvest_attribution.py index f0874cd..b9b40f6 100644 --- a/backend/app/enrichment/harvest_attribution.py +++ b/backend/app/enrichment/harvest_attribution.py @@ -9,6 +9,10 @@ one progress bar, one line in the activity feed rather than a wall of near-identical ones. +It fills an audio file's music profile from the same tag block at the same time — genre, +tempo, key and mood — so a library of songs uploaded before that existed catches up in +the same pass rather than needing a job of its own. + Safe to run repeatedly. It only ever fills fields that are blank, so a second run is a no-op over everything the first one filled and over everything the user has since corrected by hand — there is no "already harvested" flag to keep, and no way for this to @@ -79,7 +83,7 @@ def run(session: Session, user_id: str, progress: Progress) -> HarvestResult: continue before = {name: getattr(asset, name, None) for name in found} - asset_service.apply_embedded_attribution(session, asset, found) + asset_service.apply_embedded_fields(session, asset, found) session.refresh(asset) if any(getattr(asset, name, None) != before[name] for name in found): attributed += 1 diff --git a/backend/app/enrichment/import_url.py b/backend/app/enrichment/import_url.py index 9449510..09c65cf 100644 --- a/backend/app/enrichment/import_url.py +++ b/backend/app/enrichment/import_url.py @@ -36,7 +36,7 @@ from app.enrichment.transcribe import store_transcript from app.ingest import captions as caption_parser from app.ingest import thumbnails, ytdlp -from app.ingest.filetypes import SOURCE_URL, TYPE_IMAGE +from app.ingest.filetypes import SOURCE_URL, TYPE_AUDIO, TYPE_IMAGE from app.ingest.ytdlp import ImportedMetadata, UrlImportError from app.jobs.runner import readable_error, set_fields from app.models.asset import Asset @@ -324,6 +324,10 @@ def _apply_metadata(session: Session, asset: Asset, meta: ImportedMetadata) -> N changes["retrieved_at"] = utcnow() if meta.description: changes["description"] = meta.description + # A genre describes the music only when the import is music. A video's "genre" is + # the programme's, which is not what the music profile records. + if asset.asset_type == TYPE_AUDIO: + changes.update(meta.music) try: asset_service.apply_embedded_metadata(session, asset, changes) except Exception: # noqa: BLE001 - the video is in; losing its metadata must not undo that diff --git a/backend/app/ingest/embedded_metadata.py b/backend/app/ingest/embedded_metadata.py index 7ae4b99..2acb1c5 100644 --- a/backend/app/ingest/embedded_metadata.py +++ b/backend/app/ingest/embedded_metadata.py @@ -20,6 +20,11 @@ `title` is frequently boilerplate from an encoder, and silently renaming somebody's asset on upload is a worse failure than leaving a field blank. - `description` / `summary` — not attribution, and owned by the enrichment path. + +An audio file's tags also carry a music profile — genre, tempo, key, mood — which is read +here too, by `_music_from_tags`. Same standing as the attribution beside it: what the +file says, filling blanks, stamped `embedded`. Only for audio: a video's `genre` is the +film's ("Documentary"), not a description of its soundtrack. """ from __future__ import annotations @@ -30,6 +35,7 @@ from pathlib import Path from typing import Any, Mapping, Optional +from app import music from app.ingest.filetypes import TYPE_AUDIO, TYPE_DOCUMENT, TYPE_IMAGE, TYPE_VIDEO, extension_of logger = logging.getLogger(__name__) @@ -62,6 +68,18 @@ "source_url": ("purl", "url", "wxxx", "comment"), } +# Music tags, by the key ffprobe reports (lowercased in `probe.py`). ffprobe names the +# ID3 frames it knows (TCON is `genre`) and passes the rest through under their frame id, +# so TBPM, TKEY and TMOO arrive as `tbpm`, `tkey`, `tmoo`. The plain names are the Vorbis +# comment spellings FLAC and Ogg use, and what ffmpeg itself writes into a TXXX frame; +# `tmpo` is the MP4 tempo atom. +_MUSIC_TAG_SOURCES: dict[str, tuple[str, ...]] = { + "music_genres": ("genre",), + "music_moods": ("tmoo", "mood"), + "music_bpm": ("tbpm", "bpm", "tmpo", "tempo"), + "music_key": ("tkey", "initialkey", "initial_key", "key"), +} + def harvest( path: Path, @@ -76,7 +94,10 @@ def harvest( never clobber something a person typed. """ try: - if asset_type in (TYPE_VIDEO, TYPE_AUDIO): + if asset_type == TYPE_AUDIO: + tags = probe_tags or {} + return {**_from_container_tags(tags), **_music_from_tags(tags)} + if asset_type == TYPE_VIDEO: return _from_container_tags(probe_tags or {}) if asset_type == TYPE_IMAGE: return _from_image(path) @@ -128,6 +149,52 @@ def _from_container_tags(tags: Mapping[str, str]) -> dict[str, Any]: return found +def _music_from_tags(tags: Mapping[str, str]) -> dict[str, Any]: + """A music profile out of an audio file's tag block, in column form. + + Vocals are only ever inferred from evidence a writer left on purpose: a lyrics frame + means words are sung, and a genre of "Instrumental" (ID3v1 genre 33) means none are. + Anything else leaves it unknown — guessing "instrumental" for an untagged track would + put songs under every search for music without words. + """ + found: dict[str, Any] = {} + + for key in _MUSIC_TAG_SOURCES["music_genres"]: + genres = music.split_terms(tags.get(key)) + if genres: + found["music_genres"] = music.encode_terms(genres) + if any(genre.lower() == "instrumental" for genre in genres): + found["music_vocals"] = False + break + + for key in _MUSIC_TAG_SOURCES["music_moods"]: + moods = music.split_terms(tags.get(key)) + if moods: + found["music_moods"] = music.encode_terms(moods) + break + + for key in _MUSIC_TAG_SOURCES["music_bpm"]: + bpm = music.parse_bpm(tags.get(key)) + if bpm is not None: + found["music_bpm"] = bpm + break + + for key in _MUSIC_TAG_SOURCES["music_key"]: + musical_key = music.clean_key(tags.get(key)) + if musical_key: + found["music_key"] = musical_key + break + + # ID3 USLT arrives as `lyrics` or `lyrics-eng`; Vorbis as `lyrics` or `unsyncedlyrics`. + if any( + (name.startswith("lyrics") or name == "unsyncedlyrics") and (value or "").strip() + for name, value in tags.items() + ): + found["music_vocals"] = True + + return found + + # ─── images ────────────────────────────────────────────────────────────────── diff --git a/backend/app/ingest/ytdlp.py b/backend/app/ingest/ytdlp.py index c2bb7e3..e1f6921 100644 --- a/backend/app/ingest/ytdlp.py +++ b/backend/app/ingest/ytdlp.py @@ -34,6 +34,7 @@ from fastapi import HTTPException, status from yt_dlp.utils import DownloadCancelled, DownloadError +from app import music from app.config import settings from app.ingest.embedded_metadata import normalise_partial_date from app.jobs.runner import JobCancelled @@ -517,6 +518,10 @@ class ImportedMetadata: # yt-dlp's name for the site ("youtube", "rumble"), for labelling where tags and # captions came from. extractor: str = "" + # Music profile columns, in column form. Only the genre: it is the one music field + # sites report (SoundCloud and Bandcamp do; YouTube's `categories` is the site's own + # taxonomy, "Music" for every song, and says nothing about which music). + music: dict[str, Any] = field(default_factory=dict) @property def source_url(self) -> Optional[str]: @@ -566,6 +571,11 @@ def put(name: str, value: Any) -> None: if published: attribution["published_date"] = published + genres = info.get("genres") + if not isinstance(genres, (list, tuple)): + genres = music.split_terms(info.get("genre")) + encoded_genres = music.encode_terms(genres) + return ImportedMetadata( title=title[:255], description=description, @@ -573,6 +583,7 @@ def put(name: str, value: Any) -> None: tags=_tags(info.get("tags")), chapters=_chapters(info.get("chapters")), extractor=_text(info.get("extractor_key") or info.get("extractor")).lower(), + music={"music_genres": encoded_genres} if encoded_genres else {}, ) diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py index 1d7d3a1..931f2cf 100644 --- a/backend/app/models/asset.py +++ b/backend/app/models/asset.py @@ -125,6 +125,24 @@ class Asset(SQLModel, table=True): # clip's inherited attribution one level down. credit_line: Optional[str] = None + # ─── music profile ─────────────────────────────────────────────────────── + # What a track sounds like, so Video Creator can find music that fits a scene rather + # than choosing by title. Parsing and the field list live in app/music.py. Filled from + # the file's own tags (ID3 TCON/TBPM/TKEY/TMOO and their Vorbis and MP4 equivalents) + # where present, and by hand otherwise. + # + # JSON lists of short terms, NULL when nothing is known. + music_genres: Optional[str] = None + music_moods: Optional[str] = None + music_instruments: Optional[str] = None + music_bpm: Optional[float] = None + # As the tag writer spelled it: "Am", "A minor" and Camelot "8A" are all left alone. + music_key: Optional[str] = None + # A nullable bool, and the NULL is load-bearing: "not known" must not read as + # "instrumental", or every untagged song would match a search for music without words. + music_vocals: Optional[bool] = None + music_energy: Optional[str] = None # low | medium | high + # ─── AI generation (M8) ────────────────────────────────────────────────── # What made this file, kept so it can be made again. Specified in # docs/m8-ai-generation.md. All null on anything that was not generated. diff --git a/backend/app/music.py b/backend/app/music.py new file mode 100644 index 0000000..5847cf0 --- /dev/null +++ b/backend/app/music.py @@ -0,0 +1,184 @@ +"""What a piece of music sounds like, so a video can find a soundtrack that fits it. + +Attribution says whose work a track is. This says what it is *for*: a tense, slow, +instrumental piano piece is the answer to "music under the reveal", and nothing in a +track's name or credit says any of that. Gecko Video Creator searches the library for +soundtracks, and without these fields it is choosing by title. + +The fields are separate columns rather than one JSON blob, for the same reason the +attribution fields are: `field_provenance` records who wrote each *column*, and a BPM +read out of the file's ID3 frame and a mood a person typed are different kinds of claim. +One blob would collapse them into a single stamp. + +Pure, like `attribution.py`: no session, no I/O, so the parsing rules can be tested +exhaustively without a database. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Iterable, Optional + +# Every music field, in one place, so the harvester, the schemas, the read model and the +# index builder cannot drift apart. +MUSIC_FIELDS: tuple[str, ...] = ( + "music_genres", + "music_moods", + "music_instruments", + "music_bpm", + "music_key", + "music_vocals", + "music_energy", +) + +# Stored as JSON-as-TEXT lists of short terms. +MUSIC_LIST_FIELDS: tuple[str, ...] = ("music_genres", "music_moods", "music_instruments") + +# Three levels rather than a 1-10 score: a person can tell "low" from "high" reliably by +# ear, and nobody can tell a 6 from a 7. A scale finer than anyone can judge would fill +# up with numbers that look precise and mean nothing. +ENERGY_LEVELS: tuple[str, ...] = ("low", "medium", "high") + +# Outside this range a "BPM" is a mis-tag (a track number, a year, a bitrate), not a +# tempo. Real music sits well inside it; the margins cover half- and double-time readings. +MIN_BPM = 20.0 +MAX_BPM = 300.0 + +MAX_TERMS = 20 +MAX_TERM_LENGTH = 60 +# Long enough for "F# minor", "Abm", Camelot "12B" or Open Key "10d"; anything longer is +# a comment that landed in the wrong frame. +MAX_KEY_LENGTH = 16 + +# ID3v2.3 writes a genre as a reference into the ID3v1 table, "(17)" or "(17)Rock". +# ffprobe resolves most of these to names, but a bare reference can still come through +# and "(17)" is not a genre anybody searches for. +_ID3V1_GENRE_REF = re.compile(r"^\(\d+\)") +# How tag writers separate several values in one frame: ID3v2.4 uses NUL, which ffprobe +# turns into ";", Vorbis comments and taggers commonly use ";" "/" or ",". +_TERM_SEPARATORS = re.compile(r"[;/,\x00]") +_NUMBER = re.compile(r"\d+(?:[.,]\d+)?") + + +def clean_terms(values: Iterable[Any]) -> list[str]: + """Whitespace collapsed, case-insensitive duplicates and over-long terms dropped. + + The same treatment `ingest/ytdlp.py` gives uploader tags, and for the same reason: + these come from many writers, and "Ambient" and "ambient " are one genre. + """ + seen: set[str] = set() + terms: list[str] = [] + for value in values: + term = " ".join(value.split()) if isinstance(value, str) else "" + if not term or len(term) > MAX_TERM_LENGTH or term.lower() in seen: + continue + seen.add(term.lower()) + terms.append(term) + if len(terms) >= MAX_TERMS: + break + return terms + + +def split_terms(raw: Any) -> list[str]: + """Several values out of one tag string: "Ambient; Cinematic" is two genres.""" + if not isinstance(raw, str): + return [] + parts = (_ID3V1_GENRE_REF.sub("", part).strip() for part in _TERM_SEPARATORS.split(raw)) + return clean_terms(parts) + + +def encode_terms(terms: Optional[Iterable[str]]) -> Optional[str]: + """The column value for a list: JSON, or NULL when there is nothing in it. + + NULL rather than "[]" so "never described" stays one value. The harvest only fills + blanks, and an empty list stored as text would read as an answer. + """ + cleaned = clean_terms(terms or []) + return json.dumps(cleaned) if cleaned else None + + +def decode_terms(stored: Optional[str]) -> list[str]: + """A list column as a list. A malformed value reads as empty rather than raising, + because one corrupt row must not break every listing that includes it.""" + if not stored: + return [] + try: + value = json.loads(stored) + except ValueError: + return [] + if not isinstance(value, list): + return [] + return [term for term in value if isinstance(term, str)] + + +def parse_bpm(raw: Any) -> Optional[float]: + """A tempo out of whatever a tag writer put there: "92", "92.5", "120 BPM", "128,0". + + None for anything outside a plausible tempo. A wrong BPM is worse than none, because + it ranks a 70 BPM ballad as a match for a 140 BPM cut. + """ + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + value = float(raw) + elif isinstance(raw, str): + match = _NUMBER.search(raw) + if not match: + return None + value = float(match.group().replace(",", ".")) + else: + return None + if not MIN_BPM <= value <= MAX_BPM: + return None + return round(value, 1) + + +def clean_key(raw: Any) -> Optional[str]: + """A musical key as written, trimmed. Not normalised to one notation. + + "Am", "A minor", Camelot "8A" and Open Key "1m" all name the same key, and mapping + between them is easy to get subtly wrong. Keeping the writer's own notation is + honest about what the file said; a person can still read and correct it. + """ + if not isinstance(raw, str): + return None + value = " ".join(raw.split()) + if not value or len(value) > MAX_KEY_LENGTH: + return None + return value + + +def to_columns(changes: dict) -> dict: + """An API payload's music fields in column form: lists become JSON text. + + Everything else passes through untouched, so a PATCH body can go through this whole. + """ + converted = dict(changes) + for name in MUSIC_LIST_FIELDS: + if name in converted: + converted[name] = encode_terms(converted[name]) + return converted + + +def index_text(asset: Any) -> str: + """The music words that go into `asset_fts` and the asset's embedding. + + BPM and key are left out, for the reason `attribution.ATTRIBUTION_TEXT_FIELDS` leaves + out dates: a bare number in a free-text index collides with titles ("Track 92") + instead of helping. Vocals and energy are written as the words a person would search + with, "instrumental" and "high energy", rather than as the stored values. + """ + parts: list[str] = [] + for name in MUSIC_LIST_FIELDS: + parts.extend(decode_terms(getattr(asset, name, None))) + + vocals = getattr(asset, "music_vocals", None) + if vocals is True: + parts.append("vocals") + elif vocals is False: + parts.append("instrumental") + + energy = getattr(asset, "music_energy", None) + if energy in ENERGY_LEVELS: + parts.append(f"{energy} energy") + + return " ".join(parts) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 498339a..5bb5bb0 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status from sqlmodel import Session, col, func, select +from app import music from app.auth import CurrentUser from app.database import get_session from app.ingest.filetypes import ASSET_TYPES, SOURCE_GVC, SOURCE_UPLOAD @@ -363,7 +364,8 @@ def update_asset( alone" — both arrive as a body without a useful value otherwise. """ asset = _owned(asset_id, user.id, session) - asset = service.apply_metadata(session, asset, payload.model_dump(exclude_unset=True)) + changes = music.to_columns(payload.model_dump(exclude_unset=True)) + asset = service.apply_metadata(session, asset, changes) # Same reason as the read above, and it bites harder here: the library store replaces # its copy with whatever this returns, so a response with empty tags makes an # asset's tags disappear from the grid after a rename until the page is reloaded. diff --git a/backend/app/schemas_assets.py b/backend/app/schemas_assets.py index 9a1931f..b245eaf 100644 --- a/backend/app/schemas_assets.py +++ b/backend/app/schemas_assets.py @@ -4,10 +4,12 @@ import re from datetime import datetime -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator +from app import music + # A whole year, a year and month, or a full date. Months 01-12 and days 01-31 are # enforced here so the string column cannot hold "2019-13" and sort between "2019-12" # and "2020-01" — the range filters rely on lexicographic order being chronological. @@ -71,6 +73,20 @@ class AssetRead(BaseModel): # and quietly break the link to the source. attribution_inherited: List[str] = Field(default_factory=list) + # ─── music profile ─────────────────────────────────────────────────────── + # Lists decoded from their JSON columns; empty when nothing is known. Not inherited + # by a clip, unlike attribution: thirty seconds cut from a song can be the quiet + # intro of a high-energy track, so the parent's profile would be a claim about the + # wrong music. + music_genres: List[str] = Field(default_factory=list) + music_moods: List[str] = Field(default_factory=list) + music_instruments: List[str] = Field(default_factory=list) + music_bpm: Optional[float] = None + music_key: Optional[str] = None + # Null is "not known", which is different from False, "instrumental". + music_vocals: Optional[bool] = None + music_energy: Optional[str] = None + # Batch-loaded for a listing, never per row: sixty assets a page each asking for # their own tags is sixty queries that grow with the page. tags: List["AssetTagRead"] = Field(default_factory=list) @@ -145,6 +161,33 @@ class AssetUpdate(BaseModel): license: Optional[str] = Field(default=None, max_length=500) credit_line: Optional[str] = Field(default=None, max_length=2_000) + # Music profile. A list replaces the stored one whole, and an empty list or null + # clears it. The router turns lists into their JSON columns (`music.to_columns`). + music_genres: Optional[List[str]] = Field(default=None, max_length=music.MAX_TERMS) + music_moods: Optional[List[str]] = Field(default=None, max_length=music.MAX_TERMS) + music_instruments: Optional[List[str]] = Field(default=None, max_length=music.MAX_TERMS) + music_bpm: Optional[float] = Field(default=None, ge=music.MIN_BPM, le=music.MAX_BPM) + music_key: Optional[str] = Field(default=None, max_length=music.MAX_KEY_LENGTH) + music_vocals: Optional[bool] = None + music_energy: Optional[Literal["low", "medium", "high"]] = None + + @field_validator("music_genres", "music_moods", "music_instruments") + @classmethod + def _terms_are_short(cls, value: Optional[List[str]]) -> Optional[List[str]]: + """Refuse an over-long term rather than letting `clean_terms` drop it silently: + a person who typed it should be told, where a tag writer's junk need not be.""" + if value is None: + return None + for term in value: + if len(term.strip()) > music.MAX_TERM_LENGTH: + raise ValueError(f"each term must be at most {music.MAX_TERM_LENGTH} characters") + return value + + @field_validator("music_key") + @classmethod + def _key_blank_is_none(cls, value: Optional[str]) -> Optional[str]: + return music.clean_key(value) if value is not None else None + @field_validator("published_date") @classmethod def _published_date_is_an_iso_partial(cls, value: Optional[str]) -> Optional[str]: diff --git a/backend/app/search/fts.py b/backend/app/search/fts.py index 54640d3..7cc10fc 100644 --- a/backend/app/search/fts.py +++ b/backend/app/search/fts.py @@ -50,7 +50,8 @@ # `attribution_text` is appended rather than slotted in beside `name`, and that position # is load-bearing: `search_assets` passes a *column index* to snippet() (2, for `name`), # so inserting a column anywhere before it would silently start excerpting the wrong -# field. Appending keeps every existing index valid. +# field. Appending keeps every existing index valid. `music_text` came later and is +# appended after it for the same reason. ASSET_FTS_DDL = """ CREATE VIRTUAL TABLE asset_fts USING fts5( asset_id UNINDEXED, @@ -60,6 +61,7 @@ summary, tags_text, attribution_text, + music_text, tokenize='porter unicode61' ) """ @@ -83,6 +85,7 @@ "summary", "tags_text", "attribution_text", + "music_text", ) SEGMENT_FTS_COLUMNS = ("segment_id", "asset_id", "user_id", "body", "start_time") @@ -134,21 +137,27 @@ class FtsHit: def index_asset( - session: Session, asset: Asset, tags_text: str = "", attribution_text: str = "" + session: Session, + asset: Asset, + tags_text: str = "", + attribution_text: str = "", + music_text: str = "", ) -> None: """Re-index one asset's own metadata. Safe to call repeatedly. - `attribution_text` is passed in rather than composed here for the same reason - `tags_text` is: this module knows about the index, not about what the application - considers worth indexing. `services/assets._reindex` builds both. + `attribution_text` and `music_text` are passed in rather than composed here for the + same reason `tags_text` is: this module knows about the index, not about what the + application considers worth indexing. `services/assets._reindex` builds all three. """ remove_asset(session, asset.id) session.execute( text( f"INSERT INTO {ASSET_FTS}" - " (asset_id, user_id, name, description, summary, tags_text, attribution_text)" + " (asset_id, user_id, name, description, summary, tags_text, attribution_text," + " music_text)" " VALUES" - " (:asset_id, :user_id, :name, :description, :summary, :tags_text, :attribution_text)" + " (:asset_id, :user_id, :name, :description, :summary, :tags_text," + " :attribution_text, :music_text)" ), { "asset_id": asset.id, @@ -158,6 +167,7 @@ def index_asset( "summary": asset.summary or "", "tags_text": tags_text, "attribution_text": attribution_text, + "music_text": music_text, }, ) session.commit() diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index b474b04..ae2c3f0 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import aliased from sqlmodel import Session, col, delete, select, update -from app import attribution +from app import attribution, music from app.auth import sign_media_key from app.clock import utcnow from app.config import settings @@ -240,11 +240,11 @@ def _describe(session: Session, storage: LocalStorage, asset: Asset) -> None: session.commit() session.refresh(asset) _reindex(session, asset) - apply_embedded_attribution(session, asset, embedded) + apply_embedded_fields(session, asset, embedded) -def apply_embedded_attribution(session: Session, asset: Asset, embedded: dict) -> None: - """Write harvested attribution into the fields that are still empty. +def apply_embedded_fields(session: Session, asset: Asset, embedded: dict) -> None: + """Write harvested attribution and music profile into the fields that are still empty. Filtering to empty fields is what makes this safe to run without asking, and what makes the library-wide re-harvest safe to run twice: an EXIF `Artist` is a fact about @@ -258,7 +258,7 @@ def apply_embedded_attribution(session: Session, asset: Asset, embedded: dict) - fillable = { name: value for name, value in embedded.items() - if name in attribution.ATTRIBUTION_FIELDS and _is_unset(getattr(asset, name, None)) + if name in _EMBEDDED_FIELDS and _is_unset(getattr(asset, name, None)) } if not fillable: return @@ -266,7 +266,11 @@ def apply_embedded_attribution(session: Session, asset: Asset, embedded: dict) - try: apply_embedded_metadata(session, asset, fillable) except Exception: # noqa: BLE001 - an upload that succeeded must stay succeeded - logger.warning("Could not store embedded attribution for %s", asset.id, exc_info=True) + logger.warning("Could not store embedded metadata for %s", asset.id, exc_info=True) + + +# What a harvest may fill. Not `name` or `description`: see ingest/embedded_metadata.py. +_EMBEDDED_FIELDS = frozenset(attribution.ATTRIBUTION_FIELDS) | frozenset(music.MUSIC_FIELDS) def _is_unset(value) -> bool: @@ -347,6 +351,7 @@ def _reindex(session: Session, asset: Asset) -> None: asset, tags_text=tags.tags_text_for(session, asset.id), attribution_text=attribution.index_text(attribution.resolve(asset, parent)), + music_text=music.index_text(asset), ) except Exception: # noqa: BLE001 - see above logger.warning("Could not index asset %s for search", asset.id, exc_info=True) @@ -902,6 +907,13 @@ def to_read_model( credit_line=resolved_attribution.credit_line, credit=resolved_attribution.credit, attribution_inherited=list(resolved_attribution.inherited), + music_genres=music.decode_terms(asset.music_genres), + music_moods=music.decode_terms(asset.music_moods), + music_instruments=music.decode_terms(asset.music_instruments), + music_bpm=asset.music_bpm, + music_key=asset.music_key, + music_vocals=asset.music_vocals, + music_energy=asset.music_energy, tags=[ AssetTagRead(id=t.id, name=t.name, category_id=t.category_id) for t in (asset_tags or []) diff --git a/backend/tests/fixtures/music_audio.flac b/backend/tests/fixtures/music_audio.flac new file mode 100644 index 0000000000000000000000000000000000000000..50f97b263322a3650e3a34ccddfc41bc598fba21 GIT binary patch literal 20523 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 Hv@ic(jBIex literal 0 HcmV?d00001 diff --git a/backend/tests/fixtures/music_audio.mp3 b/backend/tests/fixtures/music_audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..322c95bb586e43918dc16d8a053a6782883549ed GIT binary patch literal 8680 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 literal 0 HcmV?d00001 diff --git a/backend/tests/make_fixtures.py b/backend/tests/make_fixtures.py index cd55a70..c2ecb10 100644 --- a/backend/tests/make_fixtures.py +++ b/backend/tests/make_fixtures.py @@ -74,6 +74,7 @@ def main() -> None: _write_minimal_pdf(FIXTURES / "sample_document.pdf") _write_office_documents() _write_attributed_fixtures() + _write_music_fixtures() print("wrote:", ", ".join(sorted(p.name for p in FIXTURES.iterdir()))) @@ -188,6 +189,44 @@ def _write_attributed_pdf(target: Path) -> None: target.write_bytes(bytes(out)) +def _write_music_fixtures() -> None: + """Audio carrying a music profile in its tags, in the two tag systems that matter. + + ID3 and Vorbis comments spell every one of these differently (TBPM against BPM, TKEY + against INITIALKEY), and ffprobe passes the unknown ID3 frames through under their + four-letter ids. A mocked tag dict would encode a guess about all of that; these + files are what a tagger actually leaves. + """ + # ID3v2.4. ffmpeg writes a four-letter uppercase key as that frame, so TBPM, TKEY + # and TMOO are real frames here rather than TXXX entries. Two genres in one TCON, + # which is how v2.4 stores several. + run([ + "ffmpeg", "-y", "-nostdin", + "-f", "lavfi", "-i", "sine=frequency=220:duration=1", + "-c:a", "libmp3lame", "-id3v2_version", "4", + "-metadata", "artist=Jane Doe", + "-metadata", "genre=Ambient;Cinematic", + "-metadata", "TBPM=92", + "-metadata", "TKEY=Am", + "-metadata", "TMOO=Tense", + str(FIXTURES / "music_audio.mp3"), + ]) + + # FLAC, Vorbis comments. A lyrics comment, which is the one thing that says a track + # has vocals. + run([ + "ffmpeg", "-y", "-nostdin", + "-f", "lavfi", "-i", "sine=frequency=330:duration=1", + "-c:a", "flac", + "-metadata", "genre=Folk", + "-metadata", "BPM=128 BPM", + "-metadata", "MOOD=Uplifting", + "-metadata", "INITIALKEY=8A", + "-metadata", "LYRICS=La la la", + str(FIXTURES / "music_audio.flac"), + ]) + + def _write_office_documents() -> None: """The Office and plain-text fixtures `extract_text` reads. diff --git a/backend/tests/test_embedded_metadata.py b/backend/tests/test_embedded_metadata.py index 70f3d83..ba0ee34 100644 --- a/backend/tests/test_embedded_metadata.py +++ b/backend/tests/test_embedded_metadata.py @@ -209,7 +209,7 @@ def test_a_harvest_never_overwrites_a_value_already_there(library, session): stored = session.exec(select(Asset)).first() asset_service.apply_metadata(session, stored, {"creator": "The actual photographer"}) - asset_service.apply_embedded_attribution( + asset_service.apply_embedded_fields( session, stored, {"creator": "Jane Doe", "publisher": "BBC"} ) session.refresh(stored) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 8616ace..632b47a 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -444,3 +444,42 @@ def test_asset_fts_downgrade_also_repopulates(alembic_config): "SELECT asset_id FROM asset_fts WHERE asset_fts MATCH 'Giordano'" ).fetchone() assert hit is not None and hit[0] == "a1" + + +def test_asset_fts_rebuild_keeps_the_index_and_adds_music(alembic_config): + """Seeds an index entry and a profile at the revision before the rebuild, and + asserts both are searchable after: the only assertion that catches a missing + repopulate, which a column comparison passes.""" + config, db_path = alembic_config + command.upgrade(config, "c3a8f15e7b20") + + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO asset (id, user_id, name, asset_type, source, storage_key," + " size_bytes, field_provenance, upload_date, modified_date, metadata_modified_date," + " publisher, music_genres, music_moods, music_vocals, music_energy)" + " VALUES ('a1', 'u', 'Caravan theme', 'audio', 'local_upload', 'k1'," + " 100, '{}', '2026-01-01', '2026-01-01', '2026-01-01'," + " 'Modern Wisdom', '[\"Cinematic\"]', '[\"Tense\"]', 0, 'low')" + ) + conn.execute( + "INSERT INTO asset_fts (asset_id, user_id, name, description, summary, tags_text," + " attribution_text) VALUES ('a1', 'u', 'Caravan theme', '', '', '', 'Modern Wisdom')" + ) + conn.commit() + + command.upgrade(config, "head") + + with sqlite3.connect(db_path) as conn: + assert conn.execute("SELECT count(*) FROM asset_fts").fetchone()[0] == 1 + for term in ("Caravan", "Wisdom", "Cinematic", "Tense", "instrumental", "low"): + hit = conn.execute( + "SELECT asset_id FROM asset_fts WHERE asset_fts MATCH ?", (term,) + ).fetchone() + assert hit is not None and hit[0] == "a1", f"not findable by {term!r}" + + command.downgrade(config, "c3a8f15e7b20") + + with sqlite3.connect(db_path) as conn: + hit = conn.execute("SELECT asset_id FROM asset_fts WHERE asset_fts MATCH 'Caravan'").fetchone() + assert hit is not None and hit[0] == "a1" diff --git a/backend/tests/test_music.py b/backend/tests/test_music.py new file mode 100644 index 0000000..aadde75 --- /dev/null +++ b/backend/tests/test_music.py @@ -0,0 +1,288 @@ +"""The music profile: parsing, the harvest from real tags, the API, and search. + +The harvest tests run against real files built by `tests/make_fixtures.py`, for the +reason test_embedded_metadata.py gives: what a format stores and where is the thing most +likely to be wrong, and a mocked tag dict cannot be wrong about it. +""" + +import json +from pathlib import Path + +import pytest + +from app import music +from app.ingest import ytdlp +from app.ingest.embedded_metadata import harvest +from app.ingest.probe import probe +from app.models.asset import PROVENANCE_EMBEDDED, PROVENANCE_HUMAN, Asset + +FIXTURES = Path(__file__).parent / "fixtures" +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def _harvest(name: str, asset_type: str = "audio") -> dict: + path = FIXTURES / name + return harvest(path, asset_type, name, probe_tags=probe(path).tags) + + +def _upload(client, name: str) -> dict: + files = [("files", (name, (FIXTURES / name).read_bytes(), "application/octet-stream"))] + return client.post("/api/assets", files=files).json()["created"][0] + + +def _search_ids(client, q: str) -> list[str]: + body = client.get("/api/search", params={"q": q}).json() + return [hit["asset"]["id"] for hit in body["data"]] + + +# ─── parsing ───────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("Ambient;Cinematic", ["Ambient", "Cinematic"]), + ("Hip-Hop/Rap", ["Hip-Hop", "Rap"]), + ("(17)Rock", ["Rock"]), + ("(17)", []), + (" ambient ; Ambient ", ["ambient"]), + ("", []), + (None, []), + ], +) +def test_split_terms(raw, expected): + assert music.split_terms(raw) == expected + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("92", 92.0), + ("92.54", 92.5), + ("120 BPM", 120.0), + ("128,0", 128.0), + (140, 140.0), + # A track number or a year in the wrong frame is not a tempo. + ("3", None), + ("2019", None), + ("fast", None), + (True, None), + (None, None), + ], +) +def test_parse_bpm(raw, expected): + assert music.parse_bpm(raw) == expected + + +def test_a_key_is_kept_in_its_own_notation(): + assert music.clean_key(" A minor ") == "A minor" + assert music.clean_key("8A") == "8A" + assert music.clean_key("a comment that ended up in the key frame") is None + assert music.clean_key("") is None + + +def test_an_empty_list_is_stored_as_null_not_as_an_answer(): + assert music.encode_terms([]) is None + assert music.encode_terms([" "]) is None + assert json.loads(music.encode_terms(["Ambient", "ambient", "Drone"])) == ["Ambient", "Drone"] + + +def test_a_malformed_list_column_reads_as_empty(): + assert music.decode_terms("not json") == [] + assert music.decode_terms('{"a": 1}') == [] + assert music.decode_terms(None) == [] + + +def test_index_text_uses_the_words_people_search_with(): + asset = Asset( + user_id="u", + name="x", + asset_type="audio", + music_genres='["Ambient"]', + music_moods='["Tense"]', + music_instruments='["Piano"]', + music_bpm=92.0, + music_key="Am", + music_vocals=False, + music_energy="low", + ) + text = music.index_text(asset) + assert text == "Ambient Tense Piano instrumental low energy" + # A bare number collides with titles; the filters, not the text index, serve it. + assert "92" not in text + + +def test_unknown_vocals_say_nothing(): + """NULL must not index as "instrumental", or every untagged song would match it.""" + asset = Asset(user_id="u", name="x", asset_type="audio") + assert music.index_text(asset) == "" + + +# ─── the harvest, from real tags ───────────────────────────────────────────── + + +def test_id3_music_frames_are_read(): + found = _harvest("music_audio.mp3") + assert json.loads(found["music_genres"]) == ["Ambient", "Cinematic"] + assert json.loads(found["music_moods"]) == ["Tense"] + assert found["music_bpm"] == 92.0 + assert found["music_key"] == "Am" + # No lyrics frame, and no genre saying "Instrumental": not known either way. + assert "music_vocals" not in found + # The attribution beside it still comes through. + assert found["creator"] == "Jane Doe" + + +def test_vorbis_comments_are_read(): + found = _harvest("music_audio.flac") + assert json.loads(found["music_genres"]) == ["Folk"] + assert json.loads(found["music_moods"]) == ["Uplifting"] + assert found["music_bpm"] == 128.0 + assert found["music_key"] == "8A" + assert found["music_vocals"] is True + + +def test_a_genre_of_instrumental_means_no_vocals(): + from app.ingest.embedded_metadata import _music_from_tags + + assert _music_from_tags({"genre": "Instrumental"})["music_vocals"] is False + # A lyrics frame is the stronger evidence when a file carries both. + assert _music_from_tags({"genre": "Instrumental", "lyrics-eng": "la"})["music_vocals"] is True + + +def test_an_untagged_file_has_no_profile(): + found = _harvest("sample_audio.mp3") + assert not any(name in found for name in music.MUSIC_FIELDS) + + +def test_a_video_genre_is_not_read_as_music(): + """A video's genre is the programme's, not a description of its soundtrack.""" + from app.ingest.embedded_metadata import harvest as harvest_fn + + found = harvest_fn(Path("x.mp4"), "video", "x.mp4", probe_tags={"genre": "Documentary"}) + assert "music_genres" not in found + + +# ─── upload, API and search ────────────────────────────────────────────────── + + +def test_an_upload_carries_its_profile_through_the_api(library, session): + asset = _upload(library, "music_audio.mp3") + + assert asset["music_genres"] == ["Ambient", "Cinematic"] + assert asset["music_moods"] == ["Tense"] + assert asset["music_bpm"] == 92.0 + assert asset["music_key"] == "Am" + assert asset["music_vocals"] is None + + stored = session.get(Asset, asset["id"]) + provenance = json.loads(stored.field_provenance) + assert provenance["music_genres"] == PROVENANCE_EMBEDDED + assert provenance["music_bpm"] == PROVENANCE_EMBEDDED + + +def test_an_upload_is_findable_by_its_genre_and_mood(library): + asset = _upload(library, "music_audio.mp3") + assert asset["id"] in _search_ids(library, "cinematic") + assert asset["id"] in _search_ids(library, "tense") + + +def test_a_profile_can_be_written_by_hand(library, session): + asset = _upload(library, "sample_audio.mp3") + + response = library.patch( + f"/api/assets/{asset['id']}", + json={ + "music_genres": ["Orchestral", " orchestral ", "Score"], + "music_instruments": ["Strings", "Brass"], + "music_vocals": False, + "music_energy": "high", + "music_bpm": 140, + "music_key": "D minor", + }, + ) + assert response.status_code == 200 + data = response.json()["data"] + assert data["music_genres"] == ["Orchestral", "Score"] + assert data["music_instruments"] == ["Strings", "Brass"] + assert data["music_vocals"] is False + assert data["music_energy"] == "high" + assert data["music_bpm"] == 140.0 + assert data["music_key"] == "D minor" + + stored = session.get(Asset, asset["id"]) + assert json.loads(stored.field_provenance)["music_energy"] == PROVENANCE_HUMAN + + # Reindexed on the edit, in the words a search would use. + assert asset["id"] in _search_ids(library, "instrumental") + assert asset["id"] in _search_ids(library, "brass") + + +def test_an_empty_list_clears_a_profile_field(library, session): + asset = _upload(library, "music_audio.mp3") + + data = library.patch(f"/api/assets/{asset['id']}", json={"music_genres": []}).json()["data"] + assert data["music_genres"] == [] + assert session.get(Asset, asset["id"]).music_genres is None + + +@pytest.mark.parametrize( + "body", + [ + {"music_energy": "eleven"}, + {"music_bpm": 5}, + {"music_bpm": 900}, + {"music_genres": ["x" * 61]}, + {"music_moods": [f"m{i}" for i in range(21)]}, + {"music_key": "x" * 17}, + ], +) +def test_a_bad_profile_value_is_refused(library, body): + asset = _upload(library, "sample_audio.mp3") + assert library.patch(f"/api/assets/{asset['id']}", json=body).status_code == 422 + + +def test_a_harvest_never_overwrites_a_hand_written_profile(library, session): + from app.services import assets as asset_service + + asset = _upload(library, "music_audio.mp3") + library.patch(f"/api/assets/{asset['id']}", json={"music_moods": ["Melancholy"]}) + stored = session.get(Asset, asset["id"]) + + asset_service.apply_embedded_fields( + session, stored, {"music_moods": '["Tense"]', "music_energy": "low"} + ) + session.refresh(stored) + + assert music.decode_terms(stored.music_moods) == ["Melancholy"] + # The blank one still fills. + assert stored.music_energy == "low" + + +# ─── URL import ────────────────────────────────────────────────────────────── + + +def test_a_site_genre_is_carried_from_an_import(): + meta = ytdlp.metadata_from_info({"title": "Song", "genre": "Lo-fi; Chillhop"}) + assert json.loads(meta.music["music_genres"]) == ["Lo-fi", "Chillhop"] + + meta = ytdlp.metadata_from_info({"title": "Song", "genres": ["Folk", "Acoustic"]}) + assert json.loads(meta.music["music_genres"]) == ["Folk", "Acoustic"] + + assert ytdlp.metadata_from_info({"title": "Talk"}).music == {} + + +# ─── the migration ─────────────────────────────────────────────────────────── + + +def test_the_migration_indexes_the_same_music_fields_this_module_does(): + """`d71e4b9a2c86` carries a literal copy of the music_text expression; this is what + stops it drifting from `music.index_text`.""" + migration = ( + BACKEND_ROOT / "alembic" / "versions" / "20261026_0901_rebuild_asset_fts_with_music.py" + ).read_text() + for name in (*music.MUSIC_LIST_FIELDS, "music_vocals", "music_energy"): + assert f"a.{name}" in migration, f"the migration does not index {name}" + for name in ("music_bpm", "music_key"): + assert f"a.{name}" not in migration, f"the migration indexes {name}, music.py does not" + diff --git a/docs/music-profile.md b/docs/music-profile.md new file mode 100644 index 0000000..31d9d00 --- /dev/null +++ b/docs/music-profile.md @@ -0,0 +1,61 @@ +# Music profile + +**Status:** built. Step 1 of making the library useful as a soundtrack source for Gecko +Video Creator. GVC searches GAM for music, and until this an audio asset carried nothing +that said what it sounds like, so a search could only match a track's title. + +## Fields + +On `Asset`, all nullable. Parsing and the field list live in `backend/app/music.py`. + +| Field | Type | Notes | +|---|---|---| +| `music_genres` | JSON list | "Ambient", "Cinematic" | +| `music_moods` | JSON list | "Tense", "Uplifting" | +| `music_instruments` | JSON list | "Piano", "Strings" | +| `music_bpm` | float | 20–300. Outside that it is a track number or a year in the wrong frame | +| `music_key` | string | As written: "Am", "A minor" and Camelot "8A" are kept as they are, not converted | +| `music_vocals` | bool | **NULL is "not known"**, not instrumental | +| `music_energy` | `low` / `medium` / `high` | Three levels because people can't reliably tell a 6 from a 7 | + +## Where values come from + +**The file's own tags,** on upload and in the existing "re-read file metadata" job +(`harvest_attribution`), audio only. Read from the ffprobe tag block `probe.py` already +collects, with nothing new to install: + +| Field | ID3 (MP3) | Vorbis (FLAC/Ogg) | MP4 | +|---|---|---|---| +| genres | `TCON` (`genre`) | `GENRE` | `©gen` (`genre`) | +| moods | `TMOO` | `MOOD` | — | +| bpm | `TBPM` | `BPM` | `tmpo` | +| key | `TKEY` | `INITIALKEY` | — | +| vocals | `USLT` lyrics → yes; genre "Instrumental" → no | `LYRICS` | — | + +These values have the same standing as harvested attribution: they only fill blanks and +are stamped `embedded` in `field_provenance`, so a re-harvest never overwrites a +correction. Instruments and energy have no common tag, so they are typed by hand. + +**URL import** carries a site's genre (SoundCloud and Bandcamp report one) onto audio +imports. YouTube's `categories` are left out: they say "Music" for every song. + +**By hand,** in the asset's Music tab (audio assets only), or through `PATCH +/api/assets/{id}`, where a list replaces the stored one and `[]` clears it. + +## Search + +Genres, moods, instruments, "instrumental"/"vocals" and " energy" go into a new +`music_text` column of `asset_fts`, and into the text the asset's embedding is built +from. BPM and key are left out of the text: a bare number collides with titles. Adding +the column means rebuilding the FTS table, which migration `d71e4b9a2c86` does and then +repopulates, as `9c2e08b4a1f7` did for attribution. + +A clip does not inherit its parent's profile, unlike attribution. Thirty seconds cut +from a song can be its quiet intro. + +## Not done yet + +- GVC reading the profile: `search_assets` should return it to the assistant (step 2). +- Analysing the audio itself (BPM, energy, vocals, audio–text embeddings), for files + whose tags say nothing. +- Range filters (`bpm_min`/`bpm_max`) on `/api/assets`. diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts index f2cd8ba..bd18644 100644 --- a/frontend/src/api/assets.ts +++ b/frontend/src/api/assets.ts @@ -79,6 +79,20 @@ export interface Asset { credit: string attribution_inherited: string[] + /** What a track sounds like, so Video Creator can find music that fits a scene. Read + * from the file's own tags (ID3, Vorbis, MP4) where present, otherwise typed in the + * Music tab. Not inherited by a clip: an excerpt can be the quiet intro of a loud + * track. Lists are empty, and the rest null, when nothing is known. */ + music_genres: string[] + music_moods: string[] + music_instruments: string[] + music_bpm: number | null + /** In the notation the file or person wrote it: "Am", "A minor", Camelot "8A". */ + music_key: string | null + /** Null is "not known", which is not the same as false, "instrumental". */ + music_vocals: boolean | null + music_energy: MusicEnergy | null + /** M8. How a generated asset was made — enough to make it again. Null for anything * fal did not produce. The bases are named inside it, not as `parent_asset_id`: a * parent would lend this its attribution and put it in the base's Clip tab. */ @@ -152,8 +166,21 @@ export interface AssetUpdate { retrieved_at?: string | null license?: string | null credit_line?: string | null + + /** A list replaces the stored one whole; an empty list clears it. */ + music_genres?: string[] + music_moods?: string[] + music_instruments?: string[] + music_bpm?: number | null + music_key?: string | null + music_vocals?: boolean | null + music_energy?: MusicEnergy | null } +export const MUSIC_ENERGY_LEVELS = ['low', 'medium', 'high'] as const + +export type MusicEnergy = (typeof MUSIC_ENERGY_LEVELS)[number] + /** The attribution fields, in the order the panel shows them. */ export const ATTRIBUTION_FIELDS = [ 'creator', diff --git a/frontend/src/components/AssetDetail.tsx b/frontend/src/components/AssetDetail.tsx index 8e1c070..66c4e29 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -5,6 +5,7 @@ import { Info, Link2, Mic, + Music, Pencil, Quote, Scissors, @@ -33,6 +34,7 @@ import DocumentTextPanel from '@/components/DocumentTextPanel' import EmbedButton from '@/components/EmbedButton' import EnrichmentButton from '@/components/EnrichmentButton' import GenerateTab from '@/components/GenerateTab' +import MusicPanel from '@/components/MusicPanel' import SuggestionPanel from '@/components/SuggestionPanel' import TagInput from '@/components/TagInput' import Tabs, { type TabSpec } from '@/components/Tabs' @@ -684,6 +686,18 @@ export default function AssetDetail({ }, ] : []), + // Audio only. A video's soundtrack is not what a search for music wants back, and + // the profile describes a track, not a programme. + ...(asset.asset_type === 'audio' + ? [ + { + id: 'music', + label: 'Music', + icon: Music, + content: , + }, + ] + : []), { id: 'attribution', label: 'Source', diff --git a/frontend/src/components/MusicPanel.test.tsx b/frontend/src/components/MusicPanel.test.tsx new file mode 100644 index 0000000..3b0c0ed --- /dev/null +++ b/frontend/src/components/MusicPanel.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MusicPanel from '@/components/MusicPanel' +import { splitTerms } from '@/utils/terms' +import { useLibraryStore } from '@/stores/library' +import type { Asset } from '@/api/assets' +import { noAttribution } from '@/test-fixtures' + +function asset(overrides: Partial = {}): Asset { + return { + id: 'a1', + name: 'Caravan theme', + description: null, + summary: null, + ...noAttribution, + asset_type: 'audio', + source: 'local_upload', + parent_asset_id: null, + in_point: null, + out_point: null, + original_name: 'caravan.mp3', + mime_type: 'audio/mpeg', + file_format: 'mp3', + size_bytes: 100, + duration_seconds: 180, + width: null, + height: null, + codec: 'mp3', + file_url: null, + thumb_url: null, + missing: false, + tags: [], + upload_date: '2026-01-01T00:00:00', + modified_date: '2026-01-01T00:00:00', + metadata_modified_date: '2026-01-01T00:00:00', + ...overrides, + } +} + +const update = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + update.mockClear() + useLibraryStore.setState({ update }) +}) + +describe('splitTerms', () => { + it('splits on the separators tag writers use', () => { + expect(splitTerms('Ambient; Cinematic, Hip-Hop/Rap')).toEqual([ + 'Ambient', + 'Cinematic', + 'Hip-Hop', + 'Rap', + ]) + expect(splitTerms(' , ')).toEqual([]) + }) +}) + +describe('MusicPanel', () => { + it('shows what the file tags already said', () => { + render( + + ) + + expect(screen.getByLabelText('Genres')).toHaveValue('Ambient, Cinematic') + expect(screen.getByLabelText('Tempo (BPM)')).toHaveValue('92') + expect(screen.getByLabelText('Key')).toHaveValue('Am') + expect(screen.getByLabelText('Vocals')).toHaveValue('instrumental') + }) + + it('sends only the fields that changed', async () => { + // A BPM read from the file must keep its "embedded" stamp until someone edits it. + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Moods'), 'Tense, dark') + await user.selectOptions(screen.getByLabelText('Energy'), 'low') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(update).toHaveBeenCalledWith('a1', { + music_moods: ['Tense', 'dark'], + music_energy: 'low', + }) + }) + + it('clears a field back to not known', async () => { + const user = userEvent.setup() + render() + + await user.selectOptions(screen.getByLabelText('Vocals'), '') + await user.clear(screen.getByLabelText('Genres')) + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(update).toHaveBeenCalledWith('a1', { music_genres: [], music_vocals: null }) + }) + + it('will not save a tempo that is not one', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Tempo (BPM)'), '2019') + + expect(screen.getByText('A tempo between 20 and 300.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/MusicPanel.tsx b/frontend/src/components/MusicPanel.tsx new file mode 100644 index 0000000..bc0e7e7 --- /dev/null +++ b/frontend/src/components/MusicPanel.tsx @@ -0,0 +1,247 @@ +import { useEffect, useMemo, useState } from 'react' +import type { KeyboardEvent } from 'react' +import { Check } from 'lucide-react' +import { MUSIC_ENERGY_LEVELS } from '@/api/assets' +import type { Asset, AssetUpdate, MusicEnergy } from '@/api/assets' +import { useLibraryStore } from '@/stores/library' +import { splitTerms } from '@/utils/terms' +import { useSavedFlash } from '@/utils/useSavedFlash' + +/** + * What a track sounds like, so Video Creator can find music that fits a scene. + * + * Whatever the file's own tags said (ID3 genre, BPM, key, mood) is already filled in + * by the time this opens; this is where the rest gets typed and where a wrong tag gets + * corrected. Genres, moods and instruments are comma-separated text rather than chip + * inputs: a list of three short words is quicker to type than to click together, and a + * tag writer's "Ambient; Cinematic" pastes straight in. + */ + +const LIST_FIELDS = ['music_genres', 'music_moods', 'music_instruments'] as const +type ListField = (typeof LIST_FIELDS)[number] + +const LIST_LABELS: Record = { + music_genres: { label: 'Genres', placeholder: 'Ambient, cinematic, folk' }, + music_moods: { label: 'Moods', placeholder: 'Tense, uplifting, melancholy' }, + music_instruments: { label: 'Instruments', placeholder: 'Piano, strings, synth' }, +} + +/** The same bounds the server enforces (`app/music.py`). Outside them a "BPM" is a + * track number or a year in the wrong field, not a tempo. */ +const MIN_BPM = 20 +const MAX_BPM = 300 + +type Vocals = '' | 'vocals' | 'instrumental' + +interface Draft { + music_genres: string + music_moods: string + music_instruments: string + music_bpm: string + music_key: string + music_vocals: Vocals + music_energy: '' | MusicEnergy +} + +function draftFrom(asset: Asset): Draft { + return { + music_genres: asset.music_genres.join(', '), + music_moods: asset.music_moods.join(', '), + music_instruments: asset.music_instruments.join(', '), + music_bpm: asset.music_bpm === null ? '' : String(asset.music_bpm), + music_key: asset.music_key ?? '', + music_vocals: + asset.music_vocals === null ? '' : asset.music_vocals ? 'vocals' : 'instrumental', + music_energy: asset.music_energy ?? '', + } +} + +function bpmValid(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) return true + const bpm = Number(trimmed) + return Number.isFinite(bpm) && bpm >= MIN_BPM && bpm <= MAX_BPM +} + +interface Props { + asset: Asset +} + +export default function MusicPanel({ asset }: Props) { + const update = useLibraryStore((s) => s.update) + const [draft, setDraft] = useState(() => draftFrom(asset)) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [saved, flashSaved] = useSavedFlash() + + // Re-seed on a different asset or a server-side change to this one, not on every new + // object the store hands back — see AttributionPanel for why identity is the wrong key. + useEffect(() => { + setDraft(draftFrom(asset)) + setError(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [asset.id, asset.metadata_modified_date]) + + const dirty = useMemo(() => { + const current = draftFrom(asset) + return (Object.keys(current) as (keyof Draft)[]).some( + (key) => draft[key] !== current[key] + ) + }, [asset, draft]) + + const tempoValid = bpmValid(draft.music_bpm) + + const set = (name: K, value: Draft[K]) => + setDraft((previous) => ({ ...previous, [name]: value })) + + const save = async () => { + if (!dirty || !tempoValid) return + setSaving(true) + setError(null) + try { + // Only what changed: the API stamps every key it receives as human-written, and a + // BPM read from the file's ID3 frame should keep saying so until someone edits it. + const current = draftFrom(asset) + const changes: AssetUpdate = {} + for (const field of LIST_FIELDS) { + if (draft[field] !== current[field]) changes[field] = splitTerms(draft[field]) + } + if (draft.music_bpm !== current.music_bpm) { + changes.music_bpm = draft.music_bpm.trim() ? Number(draft.music_bpm) : null + } + if (draft.music_key !== current.music_key) { + changes.music_key = draft.music_key.trim() || null + } + if (draft.music_vocals !== current.music_vocals) { + changes.music_vocals = + draft.music_vocals === '' ? null : draft.music_vocals === 'vocals' + } + if (draft.music_energy !== current.music_energy) { + changes.music_energy = draft.music_energy || null + } + + await update(asset.id, changes) + flashSaved() + } catch { + setError('Could not save. Your changes are still here.') + } finally { + setSaving(false) + } + } + + // Escape must not reach DetailDock's window listener, which closes the whole panel. + const swallowEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') event.stopPropagation() + } + + return ( +
+

+ What this track sounds like, so Video Creator can find music that fits a scene. + Anything the file's own tags said is already filled in. +

+ + {LIST_FIELDS.map((field) => ( +
+ + set(field, e.target.value)} + /> +
+ ))} + +
+
+ + set('music_bpm', e.target.value)} + /> + {!tempoValid && ( +

+ A tempo between {MIN_BPM} and {MAX_BPM}. +

+ )} +
+
+ + set('music_key', e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + {error &&

{error}

} + +
+ + {saved && ( + + + Saved + + )} +
+
+ ) +} diff --git a/frontend/src/test-fixtures.ts b/frontend/src/test-fixtures.ts index a7aeb10..d6f22f6 100644 --- a/frontend/src/test-fixtures.ts +++ b/frontend/src/test-fixtures.ts @@ -8,7 +8,8 @@ import type { GenerationModel, GenerationOptions } from '@/api/generate' * Spreading this into each keeps them compiling without duplicating the list eight times — * and means the next field added to `Asset` is one edit here rather than eight. M8's * `generation` is that next field, so it rides along here despite the name: an asset no - * one generated is the default every one of those files wants. + * one generated is the default every one of those files wants. The empty music profile + * rides along for the same reason. * * Deliberately only these fields rather than a whole `makeAsset` factory: each of those * files varies a different part of the asset, and replacing their factories with one @@ -27,6 +28,13 @@ export const noAttribution: Pick< | 'credit' | 'attribution_inherited' | 'generation' + | 'music_genres' + | 'music_moods' + | 'music_instruments' + | 'music_bpm' + | 'music_key' + | 'music_vocals' + | 'music_energy' > = { source_url: null, creator: null, @@ -39,6 +47,13 @@ export const noAttribution: Pick< credit: '', attribution_inherited: [], generation: null, + music_genres: [], + music_moods: [], + music_instruments: [], + music_bpm: null, + music_key: null, + music_vocals: null, + music_energy: null, } /** diff --git a/frontend/src/utils/terms.ts b/frontend/src/utils/terms.ts new file mode 100644 index 0000000..6855237 --- /dev/null +++ b/frontend/src/utils/terms.ts @@ -0,0 +1,7 @@ +/** Separators a person or a tag writer uses between terms. */ +export function splitTerms(value: string): string[] { + return value + .split(/[;,/]/) + .map((term) => term.replace(/\s+/g, ' ').trim()) + .filter(Boolean) +}