69804a9eb2
Playlist M3U regen now writes to a temp file and renames it into place, so a stray wrong-owner leftover file (root:root, from pre-migration host-cron runs) can't block nightly writes the way it did last night. Dashboard now lists tracks added to the beets library in the last 24h with playlist and format, sourced from a new beets_service.recently_added().
219 lines
7.5 KiB
Python
219 lines
7.5 KiB
Python
import sqlite3
|
|
|
|
from app.settings import settings
|
|
|
|
# Columns actually used by the app today. beets' `items` table has many more;
|
|
# add columns here as new features need them rather than SELECT *.
|
|
_ITEM_COLUMNS = ["id", "path", "title", "artist", "albumartist", "genres", "grouping", "isrc", "added", "format"]
|
|
|
|
|
|
def db_exists() -> bool:
|
|
return settings.beets_db_path.exists()
|
|
|
|
|
|
def _connect() -> sqlite3.Connection:
|
|
# mode=ro: alembic never writes through this connection. Mutations (tag
|
|
# edits, imports) go through the `beet` CLI or beets.library.Library
|
|
# in-process -- see services/library_edit.py (manual-fix feature).
|
|
conn = sqlite3.connect(f"file:{settings.beets_db_path}?mode=ro", uri=True)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _decode_path(value) -> str:
|
|
"""beets stores `path` as a BLOB (raw filesystem bytes), not TEXT."""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return value or ""
|
|
|
|
|
|
def _row_to_dict(row: sqlite3.Row) -> dict:
|
|
d = dict(row)
|
|
if "path" in d:
|
|
d["path"] = _decode_path(d["path"])
|
|
return d
|
|
|
|
|
|
def _grouping_clause(grouping: str) -> tuple[str, list]:
|
|
"""grouping is a single tag OR a "; "-joined set (e.g. "techno; djstuff"
|
|
for a track that's in two playlists' libraries). Matching only the exact
|
|
combined string would silently exclude combo-tagged tracks from a
|
|
single-tag filter -- match the token in any position instead."""
|
|
return (
|
|
"(grouping = ? OR grouping LIKE ? OR grouping LIKE ? OR grouping LIKE ?)",
|
|
[grouping, f"{grouping}; %", f"%; {grouping}", f"%; {grouping}; %"],
|
|
)
|
|
|
|
|
|
def _build_where(grouping: str | None, search: str | None, format: str | None) -> tuple[str, list]:
|
|
where = []
|
|
params: list = []
|
|
# Truthy checks, not `is not None`: a <select> filter left on its
|
|
# "(any)" option submits an EMPTY STRING, not an absent param -- every
|
|
# real form submission includes it. `is not None` treated that as "filter
|
|
# to grouping/format == ''", which matches nothing and made every filter
|
|
# combination except an all-fields-filled one look completely broken.
|
|
if grouping:
|
|
clause, clause_params = _grouping_clause(grouping)
|
|
where.append(clause)
|
|
params.extend(clause_params)
|
|
if format:
|
|
where.append("format = ?")
|
|
params.append(format)
|
|
if search:
|
|
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
|
|
like = f"%{search}%"
|
|
params.extend([like, like, like])
|
|
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
|
return where_sql, params
|
|
|
|
|
|
def query_items(
|
|
grouping: str | None = None,
|
|
search: str | None = None,
|
|
format: str | None = None,
|
|
limit: int | None = None,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
"""Items, optionally filtered by playlist grouping, a free-text search
|
|
over artist/title/album, and/or exact format match. limit/offset give
|
|
SQL-level pagination -- the library has thousands of tracks, so this
|
|
must not be a fetch-everything-then-slice-in-Python operation."""
|
|
if not db_exists():
|
|
return []
|
|
cols = ", ".join(_ITEM_COLUMNS)
|
|
where_sql, params = _build_where(grouping, search, format)
|
|
limit_sql = ""
|
|
if limit is not None:
|
|
limit_sql = "LIMIT ? OFFSET ?"
|
|
params = params + [limit, offset]
|
|
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.execute(
|
|
f"SELECT {cols} FROM items {where_sql} ORDER BY artist, album, track {limit_sql}",
|
|
params,
|
|
)
|
|
return [_row_to_dict(row) for row in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def count_items(grouping: str | None = None, search: str | None = None, format: str | None = None) -> int:
|
|
"""Matching row count for query_items()'s filters -- for pagination."""
|
|
if not db_exists():
|
|
return 0
|
|
where_sql, params = _build_where(grouping, search, format)
|
|
conn = _connect()
|
|
try:
|
|
return conn.execute(f"SELECT COUNT(*) FROM items {where_sql}", params).fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_item(item_id: int) -> dict | None:
|
|
if not db_exists():
|
|
return None
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.execute("SELECT * FROM items WHERE id = ?", (item_id,))
|
|
row = cur.fetchone()
|
|
return _row_to_dict(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def stats() -> dict:
|
|
"""Summary for the dashboard: total tracks, format breakdown, storage
|
|
estimate (bitrate*length/8, same approximation `beet stats` itself
|
|
uses -- not a filesystem stat() pass over every file), and groupings."""
|
|
if not db_exists():
|
|
return {
|
|
"db_exists": False,
|
|
"total_tracks": 0,
|
|
"groupings": [],
|
|
"formats": [],
|
|
"total_bytes": 0,
|
|
}
|
|
conn = _connect()
|
|
try:
|
|
total = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]
|
|
|
|
format_rows = conn.execute(
|
|
"SELECT format, COUNT(*) as n FROM items "
|
|
"WHERE format IS NOT NULL AND format != '' "
|
|
"GROUP BY format ORDER BY n DESC"
|
|
).fetchall()
|
|
|
|
total_bytes = conn.execute(
|
|
"SELECT SUM(length * bitrate) / 8 FROM items WHERE length IS NOT NULL AND bitrate IS NOT NULL"
|
|
).fetchone()[0] or 0
|
|
|
|
return {
|
|
"db_exists": True,
|
|
"total_tracks": total,
|
|
"groupings": distinct_groupings(conn),
|
|
"formats": [{"format": r["format"], "count": r["n"]} for r in format_rows],
|
|
"total_bytes": int(total_bytes),
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def distinct_groupings(conn: sqlite3.Connection | None = None) -> list[str]:
|
|
"""Individual grouping tokens for the library page's filter dropdown --
|
|
split on "; " so a combo value like "techno; djstuff" contributes
|
|
"techno" and "djstuff" separately rather than cluttering the dropdown
|
|
with every raw combination that happens to exist in the DB."""
|
|
if not db_exists():
|
|
return []
|
|
owns_conn = conn is None
|
|
conn = conn or _connect()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT DISTINCT grouping FROM items WHERE grouping IS NOT NULL AND grouping != ''"
|
|
).fetchall()
|
|
tokens = set()
|
|
for row in rows:
|
|
for part in row[0].split(";"):
|
|
part = part.strip()
|
|
if part:
|
|
tokens.add(part)
|
|
return sorted(tokens)
|
|
finally:
|
|
if owns_conn:
|
|
conn.close()
|
|
|
|
|
|
def recently_added(since: float, limit: int = 200) -> list[dict]:
|
|
"""Tracks whose beets `added` timestamp falls after `since` (epoch
|
|
seconds) -- drives the dashboard's "downloaded recently" list. Capped
|
|
at `limit`: a normal nightly batch is tens of tracks, this is just a
|
|
guard against one huge backfill import flooding the dashboard."""
|
|
if not db_exists():
|
|
return []
|
|
cols = ", ".join(_ITEM_COLUMNS)
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.execute(
|
|
f"SELECT {cols} FROM items WHERE added >= ? ORDER BY added DESC LIMIT ?",
|
|
(since, limit),
|
|
)
|
|
return [_row_to_dict(row) for row in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def distinct_formats() -> list[str]:
|
|
"""For the library page's format filter dropdown."""
|
|
if not db_exists():
|
|
return []
|
|
conn = _connect()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT DISTINCT format FROM items WHERE format IS NOT NULL AND format != '' ORDER BY format"
|
|
).fetchall()
|
|
return [r[0] for r in rows]
|
|
finally:
|
|
conn.close()
|