Files
musicgrabber/library_index.py
g33kphr33k f7574d4347 M3U files get an editor, and the Watched tab gets pills
The Watched tab's new M3U Files pill lists every playlist file on disk and
opens an editor: drag to reorder, remove, prune dead entries, add tracks from
the library index, create, and bin. Untouched entries go back byte for byte,
saves are atomic, and a file a download touched mid-edit refuses the save
rather than losing the track. Watched-playlist M3Us are rebuilt on every
refresh, so they open read-only with Save as Copy.

The same library picker gives missing watched tracks a From Library button,
recording the chosen file as the track's resolved path so it joins the M3U.

Playlists, Upgrades, Audit and M3U Files are now pills, each loading only
when opened.

Built by a human with opinions and an AI that was told about em dashes.
2026-10-05 19:22:13 +01:00

1219 lines
48 KiB
Python

"""
MusicGrabber - Persistent library index
A disposable, rebuildable cache of facts about every audio file beneath a
library root: size, mtime, inode, codec, bitrate, duration, artist, title, and
whether the file carries our SOURCE tag.
Four things in MusicGrabber used to walk the library independently and open the
same files to learn overlapping subsets of one mutagen parse. This is that parse,
done once, written down.
Design rules, in descending order of how cross you should be if one is broken:
1. **It is a cache.** The filesystem is the source of truth. A missing,
locked, corrupt or never-completed index is an unavailable optional hint, never an
error. Nothing in here may raise into a caller, delay a download, or fail an
API request. Every public function returns a "cannot help you" value instead.
2. **It lives in its own database file.** Its own connections, no ATTACH, no
shared lock domain with the primary database. A reconciliation write cannot
block a download, and the recovery procedure for a corrupt index is to delete
the file and let it rebuild.
3. **It is keyed on the library root, not on the user.** A file's size, codec and
tags are facts about the file; they do not change depending on who is asking.
Users sharing a root share the rows. Isolation is enforced where it always
was, by `authoritative_audio_file` checking containment on the way out.
4. **Files MusicGrabber did not download get a row too**, with `source` NULL.
That negative cache is most of the point. On a real library 98% of the files
are not ours, and without a row saying so we open every one of them on every
scan, forever, to rediscover it.
"""
import os
import sqlite3
import threading
import time
from contextlib import ExitStack, contextmanager
from datetime import datetime, timezone
from pathlib import Path
from audio_probe import probe_file
from constants import (
AUDIO_EXTENSIONS,
LIBRARY_INDEX_BUSY_TIMEOUT_MS,
LIBRARY_INDEX_DB_PATH,
LIBRARY_INDEX_DELETE_FLOOR,
LIBRARY_INDEX_PROGRESS_SECONDS,
LIBRARY_INDEX_RECONCILE_HOURS,
LIBRARY_INDEX_START_DELAY,
LIBRARY_INDEX_WRITE_BATCH,
MUSIC_DIR,
)
from settings import get_setting
from utils import iter_library_audio_files, path_has_excluded_scan_dir
# 3: duplicate keys now keep version brackets ("(Instrumental)", "(Live at
# Wembley)") and drop the rest. Same table shape, new key formula, so version 2
# is re-keyed in place rather than rebuilt (see _rekey_tag_identities).
SCHEMA_VERSION = 3
# Versions whose rows only need their artist/title keys recomputing from the
# tags they already store. Anything else is a shape change and starts again.
_REKEYABLE_VERSIONS = {2}
_SCHEMA = """
CREATE TABLE IF NOT EXISTS library_roots (
root_path TEXT PRIMARY KEY,
last_complete_scan_at TEXT,
last_attempt_at TEXT,
last_error TEXT,
file_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS library_files (
root_path TEXT NOT NULL,
relative_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
mtime_ns INTEGER NOT NULL,
dev INTEGER,
ino INTEGER,
artist TEXT,
title TEXT,
artist_key TEXT,
title_key TEXT,
album TEXT,
codec TEXT,
bitrate_kbps INTEGER,
duration REAL,
source TEXT,
source_quality TEXT,
source_codec TEXT,
source_bitrate_kbps INTEGER,
file_id TEXT,
indexed_at TEXT NOT NULL,
PRIMARY KEY (root_path, relative_path)
);
CREATE INDEX IF NOT EXISTS library_files_identity
ON library_files (root_path, artist_key, title_key);
CREATE INDEX IF NOT EXISTS library_files_fingerprint
ON library_files (root_path, size_bytes, mtime_ns);
CREATE INDEX IF NOT EXISTS library_files_file_id
ON library_files (root_path, file_id);
"""
_PARSED_COLUMNS = (
"artist", "title", "artist_key", "title_key", "album", "codec",
"bitrate_kbps", "duration", "source", "source_quality", "source_codec",
"source_bitrate_kbps", "file_id",
)
_init_lock = threading.Lock()
_initialised = False
_unavailable_logged = False
_scan_state_lock = threading.Lock()
_scans_running: set[str] = set()
_scheduler_running = False
_scheduler_thread: threading.Thread | None = None
# =============================================================================
# Plumbing
# =============================================================================
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@contextmanager
def index_conn(*, read_only: bool = False):
"""A short-lived connection to the index database.
No pool. Index reads are small and infrequent next to the primary database's
traffic, and opening a SQLite connection costs a fraction of a millisecond;
a second pool would be a second thing to get subtly wrong for no measurable
gain. Writers hold one of these for a whole batch, not for a whole scan.
Download-facing reads use read_only=True: no database creation or lock wait.
"""
busy_timeout = 0 if read_only else LIBRARY_INDEX_BUSY_TIMEOUT_MS
database = (
Path(LIBRARY_INDEX_DB_PATH).absolute().as_uri() + "?mode=ro"
if read_only else str(LIBRARY_INDEX_DB_PATH)
)
conn = sqlite3.connect(database, timeout=busy_timeout / 1000, uri=read_only)
try:
conn.execute(f"PRAGMA busy_timeout = {busy_timeout}")
yield conn
finally:
try:
conn.close()
except Exception:
pass
def init_index() -> bool:
"""Create the index database if needed. False means carry on without one."""
global _initialised, _unavailable_logged
if _initialised:
return True
with _init_lock:
if _initialised:
return True
try:
Path(LIBRARY_INDEX_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with index_conn() as conn:
conn.execute("PRAGMA journal_mode=WAL")
found = conn.execute("PRAGMA user_version").fetchone()[0]
if found in _REKEYABLE_VERSIONS:
changed = _rekey_tag_identities(conn)
print(f"Library index schema {found} re-keyed to {SCHEMA_VERSION} ({changed} rows changed)")
elif found and found != SCHEMA_VERSION:
# A disposable cache does not get migration scripts. If the
# shape has moved on, throw the contents away and let the
# next reconciliation rebuild them from the disk, which is
# the only source of truth any of this ever had. Re-keying
# above is the one exception, because it never reads the
# disk; a rebuild leaves duplicate checks half-blind for
# as long as a cold build takes, which on a big library is
# long enough for a watched playlist to re-download things.
print(
f"Library index schema {found} is not {SCHEMA_VERSION}; "
"discarding and rebuilding from disk"
)
conn.executescript(
"DROP TABLE IF EXISTS library_files;"
"DROP TABLE IF EXISTS library_roots;"
)
conn.executescript(_SCHEMA)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
_initialised = True
_unavailable_logged = False
return True
except Exception as exc:
if not _unavailable_logged:
print(f"Library index unavailable, carrying on without it: {exc}")
_unavailable_logged = True
return False
def reset_for_tests() -> None:
"""Forget the "already initialised" flag. Tests only."""
global _initialised, _unavailable_logged
with _init_lock:
_initialised = False
_unavailable_logged = False
with _scan_state_lock:
_scans_running.clear()
def _rekey_tag_identities(conn) -> int:
"""Recompute every row's artist/title keys from the tags it already stores.
For when only the normalisation rules changed. Seconds rather than a cold
rebuild's tens of minutes, no file is touched, and rows whose keys come
out the same are not written. Returns how many rows changed.
"""
updates = []
for root, rel, artist, title, artist_key, title_key in conn.execute(
"SELECT root_path, relative_path, artist, title, artist_key, title_key FROM library_files"
):
new_artist_key, new_title_key = _tag_keys(artist, title)
if (new_artist_key, new_title_key) != (artist_key, title_key):
updates.append((new_artist_key, new_title_key, root, rel))
conn.executemany(
"UPDATE library_files SET artist_key = ?, title_key = ? WHERE root_path = ? AND relative_path = ?",
updates,
)
return len(updates)
def _tag_keys(artist: str | None, title: str | None) -> tuple[str | None, str | None]:
"""Normalised Artist/Title keys, using the same rules as duplicate checks.
Imported late: utils will import this module back once duplicate detection
starts reading the index, and circular imports at module scope are a
memorable way to break start-up.
"""
from utils import _tag_identity
if not artist or not title:
return None, None
key = _tag_identity(artist, title)
if not all(key):
return None, None
return key
def _relative_to(root: str, path: Path | str) -> str | None:
"""`path` expressed beneath `root`, or None if it does not live there."""
try:
rel = Path(path).relative_to(root)
except (ValueError, TypeError):
return None
return rel.as_posix()
def _signed64(value):
"""Reinterpret an unsigned 64-bit stat field as the signed one SQLite stores.
SQLite's INTEGER is signed 64-bit. Most filesystems hand out inode numbers
that fit comfortably, but CIFS synthesises them by hashing the file handle
when the server will not supply real ones, and those use the full unsigned
range. About one file in 150 on such a mount then overflows, which used to
take the entire reconciliation down with it, every day, forever.
Storing the same 64 bits read as signed keeps every value distinct, which is
the only thing the hardlink grouping in `storage_totals` ever asks of them.
Found on Karl's VM, where the mount hashes; invisible on the host beside it,
where the very same NAS hands over real inode numbers. Filesystems, eh.
"""
if value is None:
return None
value &= 0xFFFFFFFFFFFFFFFF
return value - 0x10000000000000000 if value >= 0x8000000000000000 else value
def _prefix_range(prefix: str) -> tuple[str, str]:
"""Bounds for "every relative path inside this subdirectory".
A range scan on the primary key, which beats LIKE both for speed and for not
having to think about what a '%' in somebody's album title would do.
"""
start = prefix if prefix.endswith("/") else prefix + "/"
# '/' is 0x2F, so 0x30 ('0') is the first string that sorts past the subtree.
return start, start[:-1] + "0"
# =============================================================================
# Reading
# =============================================================================
def root_state(root: Path | str) -> dict | None:
"""What the index knows about a root, or None if it knows nothing."""
if not init_index():
return None
try:
with index_conn() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM library_roots WHERE root_path = ?", (str(root),)
).fetchone()
return dict(row) if row else None
except Exception:
return None
def is_ready(root: Path | str) -> bool:
"""True once a complete walk of this root has been published."""
state = root_state(root)
return bool(state and state.get("last_complete_scan_at"))
def reconcile_in_progress(root: Path | str) -> bool:
"""True while a reconciliation walk owns this root.
A cheap in-memory check, no disk I/O, so callers can ask on every request
without needing a backoff of their own. Exists so the legacy full-library
fallback (`utils.build_library_tag_index`) can decline to start a second
walk that would only fight the first one for the same disk: see #93, where
the two starved each other badly enough that the first build never finished.
"""
with _scan_state_lock:
return str(root) in _scans_running
def storage_totals(directories) -> dict | None:
"""`{storage_bytes, file_count}` for these directories, or None if we cannot.
None means "ask the disk yourself": no index, no complete scan yet, or a
directory that sits outside every root we have indexed. The caller keeps its
existing walk for exactly that case.
"""
if not init_index():
return None
try:
roots = _indexed_roots_ready()
if not roots:
return None
clauses, params = [], []
for directory in directories:
if directory is None:
continue
target = str(directory)
owner = _owning_root(roots, target)
if owner is None:
return None # Not covered by any index; the walk is still correct.
rel = _relative_to(owner, target)
if rel is None or rel == ".":
clauses.append("root_path = ?")
params.append(owner)
else:
start, end = _prefix_range(rel)
clauses.append(
"(root_path = ? AND ((relative_path >= ? AND relative_path < ?)))"
)
params.extend([owner, start, end])
if not clauses:
return {"storage_bytes": 0, "file_count": 0}
# Hardlinks are counted once, matching the walk this replaces. Rows with
# no inode recorded fall back to their own path, so they can never
# collapse into each other by accident.
sql = f"""
SELECT COUNT(*) AS files, COALESCE(SUM(size_bytes), 0) AS total FROM (
SELECT size_bytes FROM library_files
WHERE {' OR '.join(clauses)}
GROUP BY CASE
WHEN dev IS NULL OR ino IS NULL THEN root_path || '/' || relative_path
ELSE dev || ':' || ino
END
)
"""
with index_conn() as conn:
row = conn.execute(sql, params).fetchone()
if row is None:
return None
return {"storage_bytes": int(row[1] or 0), "file_count": int(row[0] or 0)}
except Exception as exc:
print(f"Library index storage lookup failed, falling back to a walk: {exc}")
return None
def _indexed_roots_ready() -> list[str]:
"""Roots with a complete published scan, longest first for owner matching."""
with index_conn() as conn:
rows = conn.execute(
"SELECT root_path FROM library_roots WHERE last_complete_scan_at IS NOT NULL"
).fetchall()
return sorted((r[0] for r in rows), key=len, reverse=True)
def _owning_root(roots: list[str], target: str) -> str | None:
"""The indexed root that contains `target`, preferring the most specific."""
for root in roots:
if target == root or _relative_to(root, target) is not None:
return root
return None
def find_by_tags(root: Path | str, artist: str | list[str], title: str) -> list[Path] | None:
"""Matching candidates in credit preference order, or None if unavailable.
An empty list is a completed index's honest miss. Age and an active refresh
do not invalidate its answers. Callers must validate paths before reuse.
"""
try:
variants = [artist] if isinstance(artist, str) else artist
identities = [_tag_keys(variant, title) for variant in variants]
keys = list(dict.fromkeys(key[0] for key in identities if key[0]))
title_key = next((key[1] for key in identities if key[1]), None)
# The download path must neither initialise/migrate the cache nor wait
# behind an exclusive lock. Startup and the scheduler own that work.
with index_conn(read_only=True) as conn:
if conn.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
return None
state = conn.execute(
"SELECT last_complete_scan_at FROM library_roots WHERE root_path = ?",
(str(root),),
).fetchone()
if not state or not state[0]:
return None
if not keys or not title_key:
return []
placeholders = ','.join('?' for _ in keys)
rows = conn.execute(
f"""SELECT artist_key, relative_path FROM library_files
WHERE root_path = ? AND artist_key IN ({placeholders}) AND title_key = ?""",
(str(root), *keys, title_key),
).fetchall()
priority = {key: rank for rank, key in enumerate(keys)}
# SQL ORDER BY relative_path tempts SQLite to scan the whole root's
# primary key instead of seeking by identity. Sort only the few hits.
rows.sort(key=lambda row: (priority[row[0]], row[1]))
return [Path(root) / row[1] for row in rows]
except Exception:
return None
_HINT_SQL = """
SELECT size_bytes, mtime_ns, artist, title, album, codec, bitrate_kbps,
duration, source, source_quality, source_codec, source_bitrate_kbps,
file_id
FROM library_files WHERE root_path = ? AND relative_path = ?
"""
def known_artists(root: Path | str) -> set[str] | None:
"""Every artist identity the library holds under `root`, or None if we cannot say.
Returned as `_tag_keys` identities rather than display names, so callers can
ask "do they own this?" about a name spelled differently somewhere else. None
means the index cannot answer and the caller should not pretend the library is
empty; an empty set would be a claim, and a wrong one.
This exists for discovery, which is useless without it: suggesting artists
somebody already owns is the single fastest way to make a recommendation
feature look stupid. On a real library 61% of raw suggestions were already on
the shelf.
"""
# Same late import as _tag_keys, for the same reason: utils imports us back.
from utils import artist_credit_lookup_variants
if not init_index():
return None
try:
target = str(root)
if target not in _indexed_roots_ready():
return None
with index_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT artist FROM library_files "
"WHERE root_path = ? AND artist IS NOT NULL AND artist != ''",
(target,),
).fetchall()
# Expanded through the credit variants, because the tag on disk is a
# credit and not an identity. A library holding "Pendulum feat. Jasmine
# Yee" does own Pendulum, and without this we would cheerfully offer them
# Pendulum as a discovery. Worth 307 extra keys on a real library, found
# Duke Dumont and The Qemists hiding inside collaborations, and costs a
# tenth of a second once an hour.
artists: set[str] = set()
for (credit,) in rows:
for variant in (artist_credit_lookup_variants(credit) or [credit]):
key = _tag_keys(variant, "x")[0]
if key:
artists.add(key)
return artists
except Exception as exc:
print(f"Library index could not list artists: {exc}")
return None
def _like_escape(text: str) -> str:
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def search_files(root: Path | str, query: str, limit: int = 50) -> list[dict] | None:
"""Library files whose tags or path contain every word of `query`, or None.
The library-file picker's engine: the M3U editor's "add a track" and the
missing-track "I already have this one" both ask it. Every word has to turn
up somewhere among artist, title, album and path, so "bowie heroes" finds
the song without you having to remember which of the three it was filed by.
A LIKE scan over one root is a few milliseconds on twenty thousand rows,
which is not worth an FTS table to beat. Callers vet paths before use.
"""
words = [w for w in (query or "").split() if w][:8]
if not words:
return []
if not init_index():
return None
try:
target = str(root)
if target not in _indexed_roots_ready():
return None
clauses = []
params: list = [target]
for word in words:
pattern = f"%{_like_escape(word)}%"
clauses.append(
"(artist LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\' "
"OR album LIKE ? ESCAPE '\\' OR relative_path LIKE ? ESCAPE '\\')"
)
params.extend([pattern] * 4)
params.append(int(limit))
with index_conn(read_only=True) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""SELECT relative_path, artist, title, album, codec, duration
FROM library_files
WHERE root_path = ? AND {' AND '.join(clauses)}
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE
LIMIT ?""",
params,
).fetchall()
return [{
"path": str(Path(target) / row["relative_path"]),
"relative_path": row["relative_path"],
"artist": row["artist"],
"title": row["title"],
"album": row["album"],
"codec": row["codec"],
"duration": float(row["duration"] or 0.0),
} for row in rows]
except Exception as exc:
print(f"Library index search failed: {exc}")
return None
def describe_files(paths) -> dict[str, dict]:
"""Artist, title, album and duration for each path the index holds.
Keyed by the path string given. Display only, so unlike `probe_hints` it
does not insist the file is unchanged since it was read: a playlist row
showing last week's tags beats one showing a bare filename. Paths the index
has never seen are simply absent, and an unavailable index is an empty dict.
"""
found: dict[str, dict] = {}
try:
if not init_index():
return found
roots = _indexed_roots_ready()
if not roots:
return found
with index_conn(read_only=True) as conn:
conn.row_factory = sqlite3.Row
for path in paths:
owner = _owning_root(roots, str(path))
rel = _relative_to(owner, path) if owner else None
if rel is None:
continue
row = conn.execute(
"SELECT artist, title, album, duration FROM library_files "
"WHERE root_path = ? AND relative_path = ?",
(owner, rel),
).fetchone()
if row:
found[str(path)] = {
"artist": row["artist"],
"title": row["title"],
"album": row["album"],
"duration": float(row["duration"] or 0.0),
}
except Exception as exc:
print(f"Library index could not describe playlist files: {exc}")
return found
def _probe_shaped(row) -> dict:
"""One indexed row wearing `probe_file`'s clothes.
Deliberately not every key `probe_file` returns: the index has never stored
sample rate, bit depth or channels because nothing that reads it has ever
asked for them. Anybody who needs those wants a real probe, and should say
so by calling one.
The numeric coercions are fidelity, not fudging. `probe_file` hands back
integers and floats here rather than None, so a hint that returned NULL
would be a subtly different shape for callers to trip over later.
"""
return {
"artist": row["artist"],
"title": row["title"],
"album": row["album"],
"codec": row["codec"],
"bitrate_kbps": int(row["bitrate_kbps"] or 0),
"duration": float(row["duration"] or 0.0),
"source": row["source"],
"source_quality": row["source_quality"],
"source_codec": row["source_codec"],
"source_bitrate_kbps": int(row["source_bitrate_kbps"] or 0),
"file_id": row["file_id"],
}
def _hint_reader(conn, roots: list[str]):
"""Build the per-file lookup used by `probe_hints`."""
def hint(path, st):
try:
owner = _owning_root(roots, str(path))
if owner is None:
return None
rel = _relative_to(owner, path)
if rel is None:
return None
row = conn.execute(_HINT_SQL, (owner, rel)).fetchone()
if row is None:
return None
# The same fingerprint the reconciliation itself trusts, and a
# stricter one than the callers' own caches use: nanoseconds, not
# whole seconds. A file that has moved on since the last pass simply
# gets no answer, and the caller opens it exactly as it always did.
if row["size_bytes"] != st.st_size or row["mtime_ns"] != st.st_mtime_ns:
return None
return _probe_shaped(row)
except Exception:
return None
return hint
@contextmanager
def probe_hints():
"""One held-open reader answering "do I really need to open this file?".
Yields a callable `hint(path, stat)` returning a probe-shaped dict, or None
meaning "no idea, open it yourself". None is always a safe answer, so a
missing, locked or half-migrated index costs the caller nothing but the
speed it had before.
This is the negative cache finally being spent. On a real library 98% of the
files are not ours (19,874 of 20,371 on Karl's) and can never become upgrade
candidates, yet every scan opened all the ones in its scope to rediscover
that, at roughly 24 ms a file over SMB, to learn nothing whatsoever. For the
upgrade scan that was 1,287 of the 1,673 files it walks, and 31.4 of the
33.6 seconds it took.
The connection is opened once and reused, because at twenty thousand files a
connection per lookup would have quietly eaten a good chunk of the winnings.
The lookups themselves are read-only, so they cannot wait behind anybody's
lock; unlike the download path this will happily create the database on a
fresh install first, because background housekeeping can afford to.
A returned dict with `codec` None is how the index records "mutagen could not
read this one either". The row exists precisely so that nobody tries again.
"""
def unavailable(path, st):
return None
hint = unavailable
with ExitStack() as stack:
try:
if init_index():
conn = stack.enter_context(index_conn(read_only=True))
conn.row_factory = sqlite3.Row
if conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION:
roots = [
r[0] for r in conn.execute(
"SELECT root_path FROM library_roots "
"WHERE last_complete_scan_at IS NOT NULL"
).fetchall()
]
if roots:
hint = _hint_reader(conn, sorted(roots, key=len, reverse=True))
except Exception:
hint = unavailable
yield hint
def paths_under(directory: Path | str) -> list[Path] | None:
"""Every indexed audio file beneath a directory, or None if we cannot say.
None means "walk it yourself": no index, no completed scan, or a directory
that sits outside every root we hold. An empty list is therefore always an
honest "that folder has no audio in it", never a shrug, which matters
because the caller creates database rows from what comes back.
"""
if not init_index():
return None
try:
roots = _indexed_roots_ready()
if not roots:
return None
target = str(directory)
owner = _owning_root(roots, target)
if owner is None:
return None
rel = _relative_to(owner, target)
with index_conn() as conn:
if rel is None or rel == ".":
rows = conn.execute(
"SELECT relative_path FROM library_files WHERE root_path = ?",
(owner,),
).fetchall()
else:
start, end = _prefix_range(rel)
rows = conn.execute(
"SELECT relative_path FROM library_files WHERE root_path = ? "
"AND relative_path >= ? AND relative_path < ?",
(owner, start, end),
).fetchall()
base = Path(owner)
return [base / row[0] for row in rows]
except Exception as exc:
print(f"Library index path listing failed, falling back to a walk: {exc}")
return None
def file_count(root: Path | str) -> int:
"""Rows held for a root. Diagnostics and tests."""
if not init_index():
return 0
try:
with index_conn() as conn:
return int(
conn.execute(
"SELECT COUNT(*) FROM library_files WHERE root_path = ?", (str(root),)
).fetchone()[0]
)
except Exception:
return 0
# =============================================================================
# Reconciliation
# =============================================================================
def _row_values(info: dict | None) -> tuple:
"""The parsed columns, in `_PARSED_COLUMNS` order, from one probe result.
`info is None` means mutagen could not read the file. That still earns a
row: remembering "this one is not worth opening" is the entire point of the
negative cache, and a file we cannot parse is the clearest case of it.
"""
if info is None:
return (None,) * len(_PARSED_COLUMNS)
artist = info.get("artist")
title = info.get("title")
artist_key, title_key = _tag_keys(artist, title)
return (
artist, title, artist_key, title_key, info.get("album"), info.get("codec"),
info.get("bitrate_kbps"), info.get("duration"), info.get("source"),
info.get("source_quality"), info.get("source_codec"),
info.get("source_bitrate_kbps"), info.get("file_id"),
)
_UPSERT = f"""
INSERT INTO library_files
(root_path, relative_path, size_bytes, mtime_ns, dev, ino,
{', '.join(_PARSED_COLUMNS)}, indexed_at)
VALUES ({', '.join('?' * (6 + len(_PARSED_COLUMNS) + 1))})
ON CONFLICT(root_path, relative_path) DO UPDATE SET
size_bytes = excluded.size_bytes,
mtime_ns = excluded.mtime_ns,
dev = excluded.dev,
ino = excluded.ino,
{', '.join(f'{c} = excluded.{c}' for c in _PARSED_COLUMNS)},
indexed_at = excluded.indexed_at
"""
def reconcile(root: Path | str, *, walker=None, probe=None) -> dict:
"""Bring one library root's index back in line with what is on disk.
Returns a summary dict. Never raises; a failure is reported in `error` and
leaves every previously published row exactly where it was.
"""
summary = {
"root": str(root), "walked": 0, "unchanged": 0, "renamed": 0, "probed": 0,
"deleted": 0, "delete_skipped": False, "ran": False, "error": None,
}
if not init_index():
summary["error"] = "index unavailable"
return summary
root_str = str(root)
try:
if not Path(root_str).is_dir():
summary["error"] = f"root is not a directory: {root_str}"
return summary
except OSError as exc:
summary["error"] = f"root unavailable: {exc}"
return summary
with _scan_state_lock:
if root_str in _scans_running:
summary["error"] = "a reconciliation is already running for this root"
return summary
_scans_running.add(root_str)
try:
summary["ran"] = True
return _reconcile_locked(root_str, summary, walker=walker, probe=probe)
except Exception as exc:
summary["error"] = str(exc)
_record_failure(root_str, str(exc))
print(f"Library index reconciliation failed for {root_str}: {exc}")
return summary
finally:
with _scan_state_lock:
_scans_running.discard(root_str)
def _reconcile_locked(root: str, summary: dict, *, walker=None, probe=None) -> dict:
walk = walker or iter_library_audio_files
probe_fn = probe or probe_file
started = _now()
with index_conn() as conn:
conn.row_factory = sqlite3.Row
existing = {
r["relative_path"]: r
for r in conn.execute(
"SELECT * FROM library_files WHERE root_path = ?", (root,)
).fetchall()
}
previous = conn.execute(
"SELECT file_count FROM library_roots WHERE root_path = ?", (root,)
).fetchone()
previous_count = int(previous["file_count"]) if previous else 0
# Fingerprint index for rename detection: an organiser that moved a file
# without touching it leaves size and mtime exactly where they were.
by_fingerprint: dict[tuple[int, int], list[str]] = {}
for rel, row in existing.items():
by_fingerprint.setdefault((row["size_bytes"], row["mtime_ns"]), []).append(rel)
upserts: list[tuple] = []
seen: set[str] = set()
# Orphan rows already claimed by a rename. Kept separate from `seen`, which
# means "still on disk at this path": a claimed orphan is neither, and it
# still needs pruning at the end.
consumed: set[str] = set()
def flush():
if not upserts:
return
with index_conn() as conn:
conn.executemany(_UPSERT, upserts)
conn.commit()
upserts.clear()
# Progress reporting, so a twenty minute cold build is distinguishable from
# a twenty minute hang. Borrowed from Michael Strong's persistent-library-index
# branch, which had the good manners to say something while it worked.
last_progress = time.monotonic()
for entry in walk(Path(root), strict=True):
path, st = entry.path, entry.stat
rel = _relative_to(root, path)
if rel is None:
continue
summary["walked"] += 1
seen.add(rel)
cached = existing.get(rel)
if (
cached is not None
and cached["size_bytes"] == st.st_size
and cached["mtime_ns"] == st.st_mtime_ns
):
# Nothing to say and nobody to say it to. A row that still matches
# the disk needs no write: the prune below works from `seen`, which
# is already in hand, so recording "still here" 20,000 times would
# be 20,000 writes nothing ever reads.
summary["unchanged"] += 1
else:
parsed = None
if cached is None:
parsed = _rename_of(root, existing, by_fingerprint, seen, consumed, st)
if parsed is not None:
summary["renamed"] += 1
else:
parsed = _row_values(probe_fn(path))
summary["probed"] += 1
upserts.append(
(root, rel, st.st_size, st.st_mtime_ns,
_signed64(st.st_dev), _signed64(st.st_ino))
+ tuple(parsed) + (started,)
)
if len(upserts) >= LIBRARY_INDEX_WRITE_BATCH:
flush()
now = time.monotonic()
if now - last_progress >= LIBRARY_INDEX_PROGRESS_SECONDS:
last_progress = now
print(
f"Library index: {root}: {summary['walked']} files seen, "
f"{summary['probed']} read, {summary['renamed']} moved, "
f"{summary['unchanged']} unchanged so far",
flush=True,
)
flush()
# The delete guard. Everything above degrades to "slower than it could be";
# this is the one step that can quietly leave the user without an index.
stale = [rel for rel in existing if rel not in seen]
floor = previous_count * LIBRARY_INDEX_DELETE_FLOOR
if stale and previous_count > 0 and summary["walked"] < floor:
summary["delete_skipped"] = True
message = (
f"refused to prune {len(stale)} rows: the walk saw {summary['walked']} "
f"files where the last complete scan saw {previous_count}. The index is "
f"unchanged; check the mount."
)
print(f"Library index: {message}")
_record_failure(root, message, attempted_at=started)
return summary
if stale:
with index_conn() as conn:
conn.executemany(
"DELETE FROM library_files WHERE root_path = ? AND relative_path = ?",
[(root, rel) for rel in stale],
)
conn.commit()
summary["deleted"] = len(stale)
with index_conn() as conn:
conn.execute(
"""INSERT INTO library_roots
(root_path, last_complete_scan_at, last_attempt_at, last_error, file_count)
VALUES (?, ?, ?, NULL, ?)
ON CONFLICT(root_path) DO UPDATE SET
last_complete_scan_at = excluded.last_complete_scan_at,
last_attempt_at = excluded.last_attempt_at,
last_error = NULL,
file_count = excluded.file_count""",
(root, _now(), started, summary["walked"]),
)
conn.commit()
return summary
def _rename_of(root, existing, by_fingerprint, seen, consumed, st) -> tuple | None:
"""Parsed columns inherited from a file that merely moved, if this is one.
A new path whose size and mtime match a row whose own file has since
vanished is an organiser's rename, not a new file, so its tags are already
known and there is nothing to open. Confirming the old path really is gone
costs one stat, only ever on a genuinely new path, and it is what stops two
coincidentally identical files swapping identities.
"""
for candidate in by_fingerprint.get((st.st_size, st.st_mtime_ns), []):
if candidate in seen or candidate in consumed:
continue
try:
if (Path(root) / candidate).exists():
continue
except OSError:
continue
consumed.add(candidate)
row = existing[candidate]
return tuple(row[column] for column in _PARSED_COLUMNS)
return None
def _record_failure(root: str, message: str, attempted_at: str | None = None) -> None:
"""Note that a pass did not publish. Published rows are left alone."""
try:
with index_conn() as conn:
conn.execute(
"""INSERT INTO library_roots (root_path, last_attempt_at, last_error)
VALUES (?, ?, ?)
ON CONFLICT(root_path) DO UPDATE SET
last_attempt_at = excluded.last_attempt_at,
last_error = excluded.last_error""",
(root, attempted_at or _now(), message[:500]),
)
conn.commit()
except Exception:
pass
# =============================================================================
# Hooks. Called the moment we move one of our own files, so the index does not
# spend a day believing yesterday's arrangement.
# =============================================================================
def _indexable(rel: str) -> bool:
"""Whether the walker would ever hand us this file.
Not everything a completed job points at is audio. A watched playlist records
its .m3u as the job's final path, and the download plumbing shuffles .lrc
sidecars about with the same helpers as the music. The index holds rows for
audio, so anything else is declined here rather than at every call site.
The directory check is for libraries with a bin inside the share: MusicGrabber
keeps its own trash and quarantine under /data, but Synology, QNAP and Windows
all park deletions in a hidden folder within the share itself, and the walker
skips those. A row for a file no scan will ever see again is a row the next
reconciliation would only delete.
Judged on the root-relative path deliberately. A share mounted beneath
something like /mnt/@recycle is nobody's business but the mount's.
"""
path = Path(rel)
if path.suffix.lower() not in AUDIO_EXTENSIONS:
return False
return not path_has_excluded_scan_dir(path)
def index_file(path: Path | str) -> bool:
"""Index one file immediately, so our own downloads do not wait for a scan.
True means the index now describes this file, which includes the case where
it already did and nothing needed reading. False means it does not, and the
daily reconciliation will sort it out: no root owns the path, the path is not
something a scan would ever yield, or the index is simply unavailable.
"""
if not init_index():
return False
try:
root = _owning_root(_known_roots(), str(path))
if root is None:
return False
rel = _relative_to(root, path)
if rel is None or not _indexable(rel):
return False
st = os.stat(path)
with index_conn() as conn:
known = conn.execute(
"SELECT size_bytes, mtime_ns FROM library_files "
"WHERE root_path = ? AND relative_path = ?",
(root, rel),
).fetchone()
# The same fingerprint gate the reconciliation and the upgrade hint both
# use. A download that turned out to be a duplicate still records a
# completed job, and reopening a file we already hold current facts about
# would be exactly the work this whole index exists to stop; a bulk import
# of mostly-owned tracks would pay 24ms a piece over SMB for nothing.
# Anything that genuinely changed the file moves one of these two numbers,
# and callers that changed a file on purpose remove the row first.
if known and known[0] == st.st_size and known[1] == st.st_mtime_ns:
return True
values = (
(root, rel, st.st_size, st.st_mtime_ns,
_signed64(st.st_dev), _signed64(st.st_ino))
+ _row_values(probe_file(Path(path))) + (_now(),)
)
with index_conn() as conn:
conn.execute(_UPSERT, values)
conn.commit()
return True
except Exception as exc:
print(f"Library index could not record {path}: {exc}")
return False
def remove_file(path: Path | str) -> bool:
"""Forget one file. A miss is a success; the row was already gone."""
if not init_index():
return False
try:
root = _owning_root(_known_roots(), str(path))
rel = _relative_to(root, path) if root else None
if rel is None:
return False
with index_conn() as conn:
conn.execute(
"DELETE FROM library_files WHERE root_path = ? AND relative_path = ?",
(root, rel),
)
conn.commit()
return True
except Exception as exc:
print(f"Library index could not forget {path}: {exc}")
return False
def move_file(old_path: Path | str, new_path: Path | str) -> bool:
"""Follow one of our own moves without reopening the file."""
if not init_index():
return False
try:
roots = _known_roots()
old_root = _owning_root(roots, str(old_path))
new_root = _owning_root(roots, str(new_path))
old_rel = _relative_to(old_root, old_path) if old_root else None
new_rel = _relative_to(new_root, new_path) if new_root else None
# Not indexable is not indexed, which for a move means the same thing as
# being outside the roots entirely, so a file moved into a bin the scans
# skip is a departure rather than a rename.
old_rel = old_rel if old_rel and _indexable(old_rel) else None
new_rel = new_rel if new_rel and _indexable(new_rel) else None
if old_rel is None or new_rel is None:
# Moved in or out of the indexed world entirely; index the arrival
# and forget the departure, rather than pretending it was a rename.
removed = remove_file(old_path) if old_rel else False
return index_file(new_path) if new_rel else removed
with index_conn() as conn:
conn.execute(
"DELETE FROM library_files WHERE root_path = ? AND relative_path = ?",
(new_root, new_rel),
)
conn.execute(
"""UPDATE library_files SET root_path = ?, relative_path = ?, indexed_at = ?
WHERE root_path = ? AND relative_path = ?""",
(new_root, new_rel, _now(), old_root, old_rel),
)
conn.commit()
return True
except Exception as exc:
print(f"Library index could not follow {old_path} to {new_path}: {exc}")
return False
def _known_roots() -> list[str]:
"""Every root the index holds rows for, most specific first."""
try:
with index_conn() as conn:
rows = conn.execute("SELECT root_path FROM library_roots").fetchall()
return sorted((r[0] for r in rows), key=len, reverse=True)
except Exception:
return []
# =============================================================================
# Scheduling
# =============================================================================
def configured_roots() -> list[str]:
"""Every distinct music root across the global and per-user settings.
Distinct, because the index is keyed on the root: three users pointed at the
same library is one set of rows, not three identical ones.
"""
roots = {str(Path(get_setting("music_dir", str(MUSIC_DIR))))}
try:
from db import db_conn
with db_conn() as conn:
user_ids = [r[0] for r in conn.execute("SELECT id FROM users").fetchall()]
for user_id in user_ids:
roots.add(str(Path(get_setting("music_dir", str(MUSIC_DIR), user_id=user_id))))
except Exception as exc:
print(f"Library index could not read per-user music roots: {exc}")
return sorted(roots)
def reconcile_all() -> list[dict]:
"""One reconciliation per configured root."""
return [reconcile(root) for root in configured_roots()]
def library_index_scheduler():
"""Background loop: reconcile each root against the disk, once a day."""
print(
f"Library index scheduler started (every {LIBRARY_INDEX_RECONCILE_HOURS}h, "
f"first pass in {LIBRARY_INDEX_START_DELAY}s)"
)
slept = 0
while slept < LIBRARY_INDEX_START_DELAY and _scheduler_running:
time.sleep(5)
slept += 5
while _scheduler_running:
try:
for summary in reconcile_all():
if summary["error"]:
print(f"Library index: {summary['root']}: {summary['error']}")
elif summary["ran"]:
print(
f"Library index: {summary['root']}: {summary['walked']} files "
f"({summary['unchanged']} unchanged, {summary['renamed']} moved, "
f"{summary['probed']} read, {summary['deleted']} gone)"
)
except Exception as exc:
print(f"Library index scheduler error: {exc}")
sleep_seconds = max(3600, LIBRARY_INDEX_RECONCILE_HOURS * 3600)
elapsed = 0
while elapsed < sleep_seconds and _scheduler_running:
time.sleep(60)
elapsed += 60
def start_library_index_scheduler() -> None:
"""Start the reconciliation loop once. Safe to call more than once."""
global _scheduler_running, _scheduler_thread
if _scheduler_running:
return
if not init_index():
return
_scheduler_running = True
_scheduler_thread = threading.Thread(target=library_index_scheduler, daemon=True)
_scheduler_thread.start()
def stop_library_index_scheduler() -> None:
global _scheduler_running
_scheduler_running = False