python-musefs 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Conor Futro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-musefs
3
+ Version: 0.0.1
4
+ Summary: Shared musefs SQLite-store contract for the beets and Picard plugins
5
+ Author: Conor Futro
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Sohex/musefs
8
+ Project-URL: Repository, https://github.com/Sohex/musefs
9
+ Project-URL: Issues, https://github.com/Sohex/musefs/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Sound/Audio
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # python-musefs
23
+
24
+ The shared store-contract library behind the [beets](../beets/README.md),
25
+ [Picard](../picard/README.md), and [Lidarr](../lidarr/README.md) musefs
26
+ plugins. It is the single source of truth for how a plugin writes the musefs
27
+ SQLite store: the schema-version check, the `tags` / `art` / `track_art`
28
+ writes, sha256 art content-addressing, the `realpath_key` path normalization,
29
+ the `musefs scan` shell-out (`run_scan`), and the per-file sync write-loop
30
+ (`Record` / `sync_files`).
31
+
32
+ Field mapping stays in each plugin — beets expands multi-valued
33
+ `genres`/`composers` into one tag each, Picard takes the first value — so this
34
+ library deliberately does not own it.
35
+
36
+ ## Consumers
37
+
38
+ - **beets** depends on this package via pip (`contrib/beets/pyproject.toml`).
39
+ - **Picard** cannot pip-install plugin dependencies, so the package is
40
+ **vendored** into `contrib/picard/musefs/_common/` by
41
+ `vendor_to_picard.py`. After any change here, re-run:
42
+
43
+ ```bash
44
+ python contrib/python-musefs/vendor_to_picard.py
45
+ ```
46
+
47
+ The Picard test `tests/test_vendor_sync.py` fails if the committed copy drifts.
48
+ - **Lidarr** depends on this package via pip (`contrib/lidarr/pyproject.toml`).
49
+
50
+ ## Schema coupling
51
+
52
+ `musefs_common/schema.py` (`SCHEMA_SQL`, `USER_VERSION`) is **generated** from
53
+ the Rust migrations in `musefs-db/src/schema.rs` — do not edit it by hand.
54
+ `EXPECTED_USER_VERSION` (in `constants.py`) derives from it. When the Rust
55
+ schema bumps, regenerate and re-vendor:
56
+
57
+ ```bash
58
+ MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
59
+ python contrib/python-musefs/vendor_to_picard.py
60
+ ```
61
+
62
+ A `musefs-db` unit test fails if the generated file drifts. This is all
63
+ independent of the package's own `__version__` (its release SemVer).
64
+
65
+ ## Tests
66
+
67
+ ```bash
68
+ cd contrib/python-musefs
69
+ python -m venv .venv && source .venv/bin/activate
70
+ pip install -e ".[test]"
71
+ python -m pytest -v
72
+ ruff check . && ruff format --check .
73
+ ```
@@ -0,0 +1,52 @@
1
+ # python-musefs
2
+
3
+ The shared store-contract library behind the [beets](../beets/README.md),
4
+ [Picard](../picard/README.md), and [Lidarr](../lidarr/README.md) musefs
5
+ plugins. It is the single source of truth for how a plugin writes the musefs
6
+ SQLite store: the schema-version check, the `tags` / `art` / `track_art`
7
+ writes, sha256 art content-addressing, the `realpath_key` path normalization,
8
+ the `musefs scan` shell-out (`run_scan`), and the per-file sync write-loop
9
+ (`Record` / `sync_files`).
10
+
11
+ Field mapping stays in each plugin — beets expands multi-valued
12
+ `genres`/`composers` into one tag each, Picard takes the first value — so this
13
+ library deliberately does not own it.
14
+
15
+ ## Consumers
16
+
17
+ - **beets** depends on this package via pip (`contrib/beets/pyproject.toml`).
18
+ - **Picard** cannot pip-install plugin dependencies, so the package is
19
+ **vendored** into `contrib/picard/musefs/_common/` by
20
+ `vendor_to_picard.py`. After any change here, re-run:
21
+
22
+ ```bash
23
+ python contrib/python-musefs/vendor_to_picard.py
24
+ ```
25
+
26
+ The Picard test `tests/test_vendor_sync.py` fails if the committed copy drifts.
27
+ - **Lidarr** depends on this package via pip (`contrib/lidarr/pyproject.toml`).
28
+
29
+ ## Schema coupling
30
+
31
+ `musefs_common/schema.py` (`SCHEMA_SQL`, `USER_VERSION`) is **generated** from
32
+ the Rust migrations in `musefs-db/src/schema.rs` — do not edit it by hand.
33
+ `EXPECTED_USER_VERSION` (in `constants.py`) derives from it. When the Rust
34
+ schema bumps, regenerate and re-vendor:
35
+
36
+ ```bash
37
+ MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
38
+ python contrib/python-musefs/vendor_to_picard.py
39
+ ```
40
+
41
+ A `musefs-db` unit test fails if the generated file drifts. This is all
42
+ independent of the package's own `__version__` (its release SemVer).
43
+
44
+ ## Tests
45
+
46
+ ```bash
47
+ cd contrib/python-musefs
48
+ python -m venv .venv && source .venv/bin/activate
49
+ pip install -e ".[test]"
50
+ python -m pytest -v
51
+ ruff check . && ruff format --check .
52
+ ```
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-musefs"
7
+ version = "0.0.1"
8
+ description = "Shared musefs SQLite-store contract for the beets and Picard plugins"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Conor Futro" }]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: POSIX",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Sound/Audio",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/Sohex/musefs"
24
+ Repository = "https://github.com/Sohex/musefs"
25
+ Issues = "https://github.com/Sohex/musefs/issues"
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=7"]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ pythonpath = ["src"]
36
+ markers = [
37
+ "musefs_bin: tests that shell out to the real `musefs` Rust binary (opt-in)",
38
+ ]
39
+ addopts = "-m 'not musefs_bin'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,49 @@
1
+ """python-musefs: the shared musefs SQLite-store contract.
2
+
3
+ Single source of truth for the schema-version check, the tags/art/track_art
4
+ writes, art content-addressing, path-key normalization, the `musefs scan`
5
+ shell-out, and the per-file sync write-loop. Consumed by the beets plugin (as a
6
+ pip dependency) and by the Picard plugin (vendored into ``musefs/_common``).
7
+ """
8
+
9
+ from .constants import EXPECTED_USER_VERSION, MAX_ART_BYTES, SCAN_TIMEOUT_SECONDS
10
+ from .errors import ScanError, SchemaMismatch
11
+ from .paths import realpath_key
12
+ from .scan import run_scan
13
+ from .store import (
14
+ check_schema_version,
15
+ connect,
16
+ prune_missing,
17
+ replace_tags,
18
+ replace_track_art,
19
+ sniff_mime,
20
+ track_id_for_path,
21
+ upsert_art,
22
+ )
23
+ from .sync import ArtImage, Record, SyncStats, sync_files, sync_one
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "EXPECTED_USER_VERSION",
29
+ "MAX_ART_BYTES",
30
+ "SCAN_TIMEOUT_SECONDS",
31
+ "SchemaMismatch",
32
+ "ScanError",
33
+ "realpath_key",
34
+ "run_scan",
35
+ "connect",
36
+ "check_schema_version",
37
+ "track_id_for_path",
38
+ "prune_missing",
39
+ "replace_tags",
40
+ "upsert_art",
41
+ "replace_track_art",
42
+ "sniff_mime",
43
+ "ArtImage",
44
+ "Record",
45
+ "SyncStats",
46
+ "sync_one",
47
+ "sync_files",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,9 @@
1
+ from .schema import USER_VERSION
2
+
3
+ EXPECTED_USER_VERSION = USER_VERSION
4
+
5
+ MAX_ART_BYTES = 16 * 1024 * 1024 - 64 * 1024
6
+
7
+ # Wall-clock cap (seconds) for a single `musefs scan` shell-out; a wedged scan
8
+ # (stuck disk, DB lock) raises ScanError(kind="timeout") rather than hanging.
9
+ SCAN_TIMEOUT_SECONDS = 120
@@ -0,0 +1,44 @@
1
+ """Canonical tag-row contract both plugins must satisfy.
2
+
3
+ Each plugin's test builds an equivalent host object (a beets ``Item`` from the
4
+ list fields, a Picard ``Metadata`` from ``getall``) carrying ``CONTRACT_VALUES``
5
+ and asserts its ``map_fields`` output, normalized, equals
6
+ ``normalize_rows(CONTRACT_EXPECTED)``. This guards #84/#86 against future
7
+ divergence between the two mappers.
8
+
9
+ Scope: the genuinely-shared multi-value fields (``genre``, ``composer``). beets
10
+ has no multi-artist field, so ``artist``/``albumartist`` are single-valued here;
11
+ Picard's multi-artist expansion is tested in its own unit tests.
12
+ """
13
+
14
+ from collections import defaultdict
15
+
16
+ CONTRACT_VALUES = {
17
+ "title": "Song",
18
+ "artist": "Alice",
19
+ "albumartist": "Alice",
20
+ "album": "Greatest Hits",
21
+ "genre": ["Rock", "Pop"],
22
+ "composer": ["Carol", "Dave"],
23
+ }
24
+
25
+ CONTRACT_EXPECTED = [
26
+ ("title", "Song"),
27
+ ("artist", "Alice"),
28
+ ("albumartist", "Alice"),
29
+ ("album", "Greatest Hits"),
30
+ ("genre", "Rock"),
31
+ ("genre", "Pop"),
32
+ ("composer", "Carol"),
33
+ ("composer", "Dave"),
34
+ ]
35
+
36
+
37
+ def normalize_rows(rows):
38
+ """Group ``(key, value)`` rows by key into a comparison-stable dict. All
39
+ contract keys use set semantics (the store treats multi-values as a set), so
40
+ each key's values are returned sorted."""
41
+ grouped = defaultdict(list)
42
+ for key, value in rows:
43
+ grouped[key].append(value)
44
+ return {key: sorted(values) for key, values in grouped.items()}
@@ -0,0 +1,38 @@
1
+ from .constants import EXPECTED_USER_VERSION
2
+
3
+
4
+ class SchemaMismatch(Exception): # noqa: N818
5
+ """Raised when the musefs DB schema version differs from what this library
6
+ targets (``EXPECTED_USER_VERSION``)."""
7
+
8
+ def __init__(self, found):
9
+ self.found = found
10
+ super().__init__(
11
+ f"musefs DB user_version is {found}, plugin targets "
12
+ f"{EXPECTED_USER_VERSION}; the musefs and plugin versions have "
13
+ f"diverged."
14
+ )
15
+
16
+
17
+ class ScanError(Exception): # noqa: N818
18
+ """A `musefs scan` invocation failed. ``kind`` is one of ``"not_found"``,
19
+ ``"timeout"``, ``"failed"``; the remaining attributes carry enough context
20
+ for a host adapter to format its own user-facing message."""
21
+
22
+ def __init__(self, kind, *, binary, target, timeout=None, returncode=None, stderr=""):
23
+ self.kind = kind
24
+ self.binary = binary
25
+ self.target = target
26
+ self.timeout = timeout
27
+ self.returncode = returncode
28
+ self.stderr = stderr
29
+ super().__init__(self._default_message())
30
+
31
+ def _default_message(self):
32
+ if self.kind == "not_found":
33
+ return f"musefs binary '{self.binary}' not found"
34
+ if self.kind == "timeout":
35
+ return f"`{self.binary} scan` for {self.target} timed out after {self.timeout}s"
36
+ return (
37
+ f"`{self.binary} scan` failed for {self.target} (exit {self.returncode}): {self.stderr}"
38
+ )
@@ -0,0 +1,16 @@
1
+ import os
2
+
3
+
4
+ def realpath_key(path):
5
+ """Canonical absolute path string matching musefs scan's stored
6
+ ``backing_path`` (``std::fs::canonicalize`` + ``to_string_lossy``).
7
+
8
+ Accepts ``str`` or ``bytes`` and always returns ``str``.
9
+ """
10
+ real = os.path.realpath(path)
11
+ if isinstance(real, bytes):
12
+ real = os.fsdecode(real)
13
+ # os.fsdecode uses surrogateescape; Rust's to_string_lossy uses U+FFFD for
14
+ # undecodable bytes. Normalize so a non-UTF-8 path component produces the
15
+ # same key string on both sides instead of silently mismatching.
16
+ return real.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
@@ -0,0 +1,33 @@
1
+ import os
2
+ import subprocess
3
+
4
+ from .errors import ScanError
5
+
6
+
7
+ def run_scan(binary, db_path, target, *, timeout=None):
8
+ """Run ``<binary> scan <target...> --db <db_path>``. ``target`` is a single
9
+ path or an iterable of paths; all targets precede the ``--db`` flag and are
10
+ scanned under one process (one DB open). Creates the DB if absent and fills
11
+ the structural columns a plugin can't compute. Raises ``ScanError`` (with
12
+ ``kind`` in ``"not_found" | "timeout" | "failed"``) on failure; the caller
13
+ formats its own user-facing message from the exception attributes."""
14
+ if isinstance(target, (str, os.PathLike)):
15
+ targets = [target]
16
+ else:
17
+ targets = list(target)
18
+ display = str(targets[0]) if len(targets) == 1 else f"{len(targets)} target(s)"
19
+ argv = [binary, "scan", *(str(t) for t in targets), "--db", str(db_path)]
20
+ try:
21
+ result = subprocess.run(argv, capture_output=True, timeout=timeout)
22
+ except FileNotFoundError as exc:
23
+ raise ScanError("not_found", binary=binary, target=display) from exc
24
+ except subprocess.TimeoutExpired as exc:
25
+ raise ScanError("timeout", binary=binary, target=display, timeout=timeout) from exc
26
+ if result.returncode != 0:
27
+ raise ScanError(
28
+ "failed",
29
+ binary=binary,
30
+ target=display,
31
+ returncode=result.returncode,
32
+ stderr=result.stderr.decode(errors="replace").strip(),
33
+ )
@@ -0,0 +1,243 @@
1
+ # GENERATED from musefs-db/src/schema.rs — do not edit.
2
+ # Regenerate: MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
3
+ # Re-vendor: python contrib/python-musefs/vendor_to_picard.py
4
+
5
+ SCHEMA_SQL = """\
6
+ -- ── MIGRATION_V1 ──
7
+ CREATE TABLE tracks (
8
+ id INTEGER PRIMARY KEY,
9
+ backing_path TEXT NOT NULL UNIQUE,
10
+ format TEXT NOT NULL,
11
+ audio_offset INTEGER NOT NULL,
12
+ audio_length INTEGER NOT NULL,
13
+ backing_size INTEGER NOT NULL,
14
+ backing_mtime INTEGER NOT NULL,
15
+ content_version INTEGER NOT NULL DEFAULT 0,
16
+ updated_at INTEGER NOT NULL
17
+ );
18
+
19
+ CREATE TABLE tags (
20
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
21
+ key TEXT NOT NULL,
22
+ value TEXT NOT NULL,
23
+ ordinal INTEGER NOT NULL DEFAULT 0,
24
+ PRIMARY KEY (track_id, key, ordinal)
25
+ );
26
+
27
+ CREATE TABLE art (
28
+ id INTEGER PRIMARY KEY,
29
+ sha256 TEXT NOT NULL UNIQUE,
30
+ mime TEXT NOT NULL,
31
+ width INTEGER,
32
+ height INTEGER,
33
+ byte_len INTEGER NOT NULL,
34
+ data BLOB NOT NULL
35
+ );
36
+
37
+ CREATE TABLE track_art (
38
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
39
+ art_id INTEGER NOT NULL REFERENCES art(id),
40
+ picture_type INTEGER NOT NULL DEFAULT 3,
41
+ description TEXT NOT NULL DEFAULT '',
42
+ ordinal INTEGER NOT NULL DEFAULT 0,
43
+ PRIMARY KEY (track_id, ordinal)
44
+ );
45
+
46
+ CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
47
+ UPDATE tracks SET content_version = content_version + 1,
48
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
49
+ WHERE id = NEW.track_id;
50
+ END;
51
+ CREATE TRIGGER tags_au AFTER UPDATE ON tags BEGIN
52
+ UPDATE tracks SET content_version = content_version + 1,
53
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
54
+ WHERE id = NEW.track_id;
55
+ END;
56
+ CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
57
+ UPDATE tracks SET content_version = content_version + 1,
58
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
59
+ WHERE id = OLD.track_id;
60
+ END;
61
+
62
+ CREATE TRIGGER track_art_ai AFTER INSERT ON track_art BEGIN
63
+ UPDATE tracks SET content_version = content_version + 1,
64
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
65
+ WHERE id = NEW.track_id;
66
+ END;
67
+ CREATE TRIGGER track_art_au AFTER UPDATE ON track_art BEGIN
68
+ UPDATE tracks SET content_version = content_version + 1,
69
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
70
+ WHERE id = NEW.track_id;
71
+ END;
72
+ CREATE TRIGGER track_art_ad AFTER DELETE ON track_art BEGIN
73
+ UPDATE tracks SET content_version = content_version + 1,
74
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
75
+ WHERE id = OLD.track_id;
76
+ END;
77
+ PRAGMA user_version = 1;
78
+
79
+ -- ── MIGRATION_V2 ──
80
+ -- Binary tag payloads live alongside text tags. A row is binary iff
81
+ -- value_blob IS NOT NULL; binary rows store '' in value.
82
+ ALTER TABLE tags ADD COLUMN value_blob BLOB;
83
+
84
+ -- Read-only, derived-from-file structural metadata (FLAC STREAMINFO/SEEKTABLE).
85
+ -- NOT part of the editable `tags` contract: external tools never touch it.
86
+ CREATE TABLE structural_blocks (
87
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
88
+ kind TEXT NOT NULL,
89
+ ordinal INTEGER NOT NULL DEFAULT 0,
90
+ body BLOB NOT NULL,
91
+ PRIMARY KEY (track_id, kind, ordinal)
92
+ );
93
+ PRAGMA user_version = 2;
94
+
95
+ -- ── MIGRATION_V3 ──
96
+ -- Bounded changelog ring for O(changed) refresh. Every metadata edit funnels
97
+ -- through an UPDATE on the tracks row (the V1 tags/track_art triggers), so
98
+ -- triggers on tracks alone capture all writers. Relies on SQLite nested
99
+ -- trigger activation (on by default; distinct from PRAGMA recursive_triggers).
100
+ CREATE TABLE track_changes (
101
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ track_id INTEGER NOT NULL
103
+ );
104
+
105
+ CREATE TRIGGER tracks_changelog_ai AFTER INSERT ON tracks BEGIN
106
+ INSERT INTO track_changes (track_id) VALUES (NEW.id);
107
+ END;
108
+ CREATE TRIGGER tracks_changelog_au AFTER UPDATE ON tracks BEGIN
109
+ INSERT INTO track_changes (track_id) VALUES (NEW.id);
110
+ END;
111
+ CREATE TRIGGER tracks_changelog_ad AFTER DELETE ON tracks BEGIN
112
+ INSERT INTO track_changes (track_id) VALUES (OLD.id);
113
+ END;
114
+
115
+ -- Self-pruning ring: writers maintain it; the mount's read-only connections
116
+ -- never need to. Deletes only from the old end, so retained seqs stay contiguous.
117
+ CREATE TRIGGER track_changes_prune AFTER INSERT ON track_changes BEGIN
118
+ DELETE FROM track_changes WHERE seq <= NEW.seq - 8192;
119
+ END;
120
+ PRAGMA user_version = 3;
121
+
122
+ -- ── MIGRATION_V4 ──
123
+ CREATE TEMP TABLE _m4_tracks AS SELECT * FROM tracks;
124
+ CREATE TEMP TABLE _m4_tags AS SELECT * FROM tags;
125
+ CREATE TEMP TABLE _m4_art AS SELECT * FROM art;
126
+ CREATE TEMP TABLE _m4_track_art AS SELECT * FROM track_art;
127
+
128
+ DROP TABLE track_art;
129
+ DROP TABLE tags;
130
+ DROP TABLE art;
131
+ DROP TABLE tracks;
132
+
133
+ CREATE TABLE tracks (
134
+ id INTEGER PRIMARY KEY,
135
+ backing_path TEXT NOT NULL UNIQUE,
136
+ format TEXT NOT NULL,
137
+ audio_offset INTEGER NOT NULL,
138
+ audio_length INTEGER NOT NULL,
139
+ backing_size INTEGER NOT NULL,
140
+ backing_mtime INTEGER NOT NULL,
141
+ content_version INTEGER NOT NULL DEFAULT 0,
142
+ updated_at INTEGER NOT NULL,
143
+ CHECK (format IN ('flac','mp3','m4a','opus','vorbis','oggflac','wav')),
144
+ CHECK (audio_offset >= 0),
145
+ CHECK (audio_length >= 0),
146
+ CHECK (backing_size >= 0),
147
+ CHECK (backing_mtime >= 0),
148
+ CHECK (content_version >= 0),
149
+ CHECK (updated_at >= 0),
150
+ CHECK (audio_offset + audio_length <= backing_size)
151
+ );
152
+
153
+ CREATE TABLE tags (
154
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
155
+ key TEXT NOT NULL,
156
+ value TEXT NOT NULL,
157
+ ordinal INTEGER NOT NULL DEFAULT 0,
158
+ value_blob BLOB,
159
+ PRIMARY KEY (track_id, key, ordinal),
160
+ CHECK (ordinal >= 0),
161
+ CHECK (value_blob IS NULL OR value = '')
162
+ );
163
+
164
+ CREATE TABLE art (
165
+ id INTEGER PRIMARY KEY,
166
+ sha256 TEXT NOT NULL UNIQUE,
167
+ mime TEXT NOT NULL,
168
+ width INTEGER,
169
+ height INTEGER,
170
+ byte_len INTEGER NOT NULL,
171
+ data BLOB NOT NULL,
172
+ CHECK (byte_len = length(data)),
173
+ CHECK (length(sha256) = 64),
174
+ CHECK (width IS NULL OR width >= 0),
175
+ CHECK (height IS NULL OR height >= 0)
176
+ );
177
+
178
+ CREATE TABLE track_art (
179
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
180
+ art_id INTEGER NOT NULL REFERENCES art(id),
181
+ picture_type INTEGER NOT NULL DEFAULT 3,
182
+ description TEXT NOT NULL DEFAULT '',
183
+ ordinal INTEGER NOT NULL DEFAULT 0,
184
+ PRIMARY KEY (track_id, ordinal),
185
+ CHECK (picture_type BETWEEN 0 AND 20),
186
+ CHECK (ordinal >= 0)
187
+ );
188
+
189
+ INSERT INTO tracks SELECT * FROM _m4_tracks;
190
+ INSERT INTO art SELECT * FROM _m4_art;
191
+ INSERT INTO tags SELECT * FROM _m4_tags;
192
+ INSERT INTO track_art SELECT * FROM _m4_track_art;
193
+
194
+ DROP TABLE _m4_track_art;
195
+ DROP TABLE _m4_tags;
196
+ DROP TABLE _m4_art;
197
+ DROP TABLE _m4_tracks;
198
+
199
+ CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
200
+ UPDATE tracks SET content_version = content_version + 1,
201
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
202
+ WHERE id = NEW.track_id;
203
+ END;
204
+ CREATE TRIGGER tags_au AFTER UPDATE ON tags BEGIN
205
+ UPDATE tracks SET content_version = content_version + 1,
206
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
207
+ WHERE id = NEW.track_id;
208
+ END;
209
+ CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
210
+ UPDATE tracks SET content_version = content_version + 1,
211
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
212
+ WHERE id = OLD.track_id;
213
+ END;
214
+
215
+ CREATE TRIGGER track_art_ai AFTER INSERT ON track_art BEGIN
216
+ UPDATE tracks SET content_version = content_version + 1,
217
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
218
+ WHERE id = NEW.track_id;
219
+ END;
220
+ CREATE TRIGGER track_art_au AFTER UPDATE ON track_art BEGIN
221
+ UPDATE tracks SET content_version = content_version + 1,
222
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
223
+ WHERE id = NEW.track_id;
224
+ END;
225
+ CREATE TRIGGER track_art_ad AFTER DELETE ON track_art BEGIN
226
+ UPDATE tracks SET content_version = content_version + 1,
227
+ updated_at = CAST(strftime('%s','now') AS INTEGER)
228
+ WHERE id = OLD.track_id;
229
+ END;
230
+
231
+ CREATE TRIGGER tracks_changelog_ai AFTER INSERT ON tracks BEGIN
232
+ INSERT INTO track_changes (track_id) VALUES (NEW.id);
233
+ END;
234
+ CREATE TRIGGER tracks_changelog_au AFTER UPDATE ON tracks BEGIN
235
+ INSERT INTO track_changes (track_id) VALUES (NEW.id);
236
+ END;
237
+ CREATE TRIGGER tracks_changelog_ad AFTER DELETE ON tracks BEGIN
238
+ INSERT INTO track_changes (track_id) VALUES (OLD.id);
239
+ END;
240
+ PRAGMA user_version = 4;
241
+ """
242
+
243
+ USER_VERSION = 4