Files
musicseerr/backend/infrastructure/persistence/request_history.py
T
Harvey 343bafd7f4 feat: Requests / Add to Library Rework - Unmonitored album default + … (#25)
* feat: Requests / Add to Library Rework - Unmonitored album default + Resilience

* checking for source + refresh album logic

* artist monitoring + auto downloading + various request fixes

* synchronous album requests

* format
2026-04-06 23:08:58 +01:00

358 lines
14 KiB
Python

import asyncio
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
import msgspec
logger = logging.getLogger(__name__)
class RequestHistoryRecord(msgspec.Struct):
musicbrainz_id: str
artist_name: str
album_title: str
requested_at: str
status: str
artist_mbid: str | None = None
year: int | None = None
cover_url: str | None = None
completed_at: str | None = None
lidarr_album_id: int | None = None
monitor_artist: bool = False
auto_download_artist: bool = False
class RequestHistoryStore:
_ACTIVE_STATUSES = ("pending", "downloading")
def __init__(self, db_path: Path, write_lock: threading.Lock | None = None):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._write_lock = write_lock or threading.Lock()
with self._write_lock:
self._ensure_tables()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def _ensure_tables(self) -> None:
conn = self._connect()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS request_history (
musicbrainz_id_lower TEXT PRIMARY KEY,
musicbrainz_id TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_title TEXT NOT NULL,
artist_mbid TEXT,
year INTEGER,
cover_url TEXT,
requested_at TEXT NOT NULL,
completed_at TEXT,
status TEXT NOT NULL,
lidarr_album_id INTEGER,
monitor_artist INTEGER NOT NULL DEFAULT 0,
auto_download_artist INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_history_status_requested_at ON request_history(status, requested_at DESC)"
)
# Migrate existing tables missing the monitoring columns
for col in ("monitor_artist", "auto_download_artist"):
try:
conn.execute(f"ALTER TABLE request_history ADD COLUMN {col} INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e).lower():
logger.warning("Unexpected error adding column %s: %s", col, e)
conn.commit()
finally:
conn.close()
def _execute(self, operation, write: bool):
if write:
with self._write_lock:
conn = self._connect()
try:
result = operation(conn)
conn.commit()
return result
finally:
conn.close()
conn = self._connect()
try:
return operation(conn)
finally:
conn.close()
async def _read(self, operation):
return await asyncio.to_thread(self._execute, operation, False)
async def _write(self, operation):
return await asyncio.to_thread(self._execute, operation, True)
@staticmethod
def _row_to_record(row: sqlite3.Row | None) -> RequestHistoryRecord | None:
if row is None:
return None
return RequestHistoryRecord(
musicbrainz_id=row["musicbrainz_id"],
artist_name=row["artist_name"],
album_title=row["album_title"],
artist_mbid=row["artist_mbid"],
year=row["year"],
cover_url=row["cover_url"],
requested_at=row["requested_at"],
completed_at=row["completed_at"],
status=row["status"],
lidarr_album_id=row["lidarr_album_id"],
monitor_artist=bool(row["monitor_artist"]) if row["monitor_artist"] is not None else False,
auto_download_artist=bool(row["auto_download_artist"]) if row["auto_download_artist"] is not None else False,
)
async def async_record_request(
self,
musicbrainz_id: str,
artist_name: str,
album_title: str,
year: int | None = None,
cover_url: str | None = None,
artist_mbid: str | None = None,
lidarr_album_id: int | None = None,
monitor_artist: bool = False,
auto_download_artist: bool = False,
) -> None:
requested_at = datetime.now(timezone.utc).isoformat()
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"""
INSERT INTO request_history (
musicbrainz_id_lower, musicbrainz_id, artist_name, album_title,
artist_mbid, year, cover_url, requested_at, completed_at, status, lidarr_album_id,
monitor_artist, auto_download_artist
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)
ON CONFLICT(musicbrainz_id_lower) DO UPDATE SET
musicbrainz_id = excluded.musicbrainz_id,
artist_name = excluded.artist_name,
album_title = excluded.album_title,
artist_mbid = excluded.artist_mbid,
year = excluded.year,
cover_url = COALESCE(excluded.cover_url, request_history.cover_url),
requested_at = excluded.requested_at,
completed_at = NULL,
status = 'pending',
lidarr_album_id = COALESCE(excluded.lidarr_album_id, request_history.lidarr_album_id),
monitor_artist = excluded.monitor_artist,
auto_download_artist = excluded.auto_download_artist
""",
(
normalized_mbid,
musicbrainz_id,
artist_name,
album_title,
artist_mbid,
year,
cover_url,
requested_at,
lidarr_album_id,
int(monitor_artist),
int(auto_download_artist),
),
)
await self._write(operation)
async def async_get_record(self, musicbrainz_id: str) -> RequestHistoryRecord | None:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> RequestHistoryRecord | None:
row = conn.execute(
"SELECT * FROM request_history WHERE musicbrainz_id_lower = ?",
(normalized_mbid,),
).fetchone()
return self._row_to_record(row)
return await self._read(operation)
async def async_update_monitoring_flags(
self, musicbrainz_id: str, *, monitor_artist: bool, auto_download_artist: bool,
) -> None:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"UPDATE request_history SET monitor_artist = ?, auto_download_artist = ? WHERE musicbrainz_id_lower = ?",
(int(monitor_artist), int(auto_download_artist), normalized_mbid),
)
await self._write(operation)
async def async_get_active_mbids(self) -> set[str]:
"""Return the set of MBIDs with active (pending/downloading) requests."""
def operation(conn: sqlite3.Connection) -> set[str]:
rows = conn.execute(
"SELECT musicbrainz_id_lower FROM request_history WHERE status IN (?, ?)",
self._ACTIVE_STATUSES,
).fetchall()
return {row["musicbrainz_id_lower"] for row in rows}
return await self._read(operation)
async def async_get_active_requests(self) -> list[RequestHistoryRecord]:
def operation(conn: sqlite3.Connection) -> list[RequestHistoryRecord]:
rows = conn.execute(
"SELECT * FROM request_history WHERE status IN (?, ?) ORDER BY requested_at DESC",
self._ACTIVE_STATUSES,
).fetchall()
return [record for row in rows if (record := self._row_to_record(row)) is not None]
return await self._read(operation)
async def async_get_active_count(self) -> int:
def operation(conn: sqlite3.Connection) -> int:
row = conn.execute(
"SELECT COUNT(*) AS count FROM request_history WHERE status IN (?, ?)",
self._ACTIVE_STATUSES,
).fetchone()
return int(row["count"] if row is not None else 0)
return await self._read(operation)
async def async_get_history(
self,
page: int = 1,
page_size: int = 20,
status_filter: str | None = None,
sort: str | None = None,
) -> tuple[list[RequestHistoryRecord], int]:
safe_page = max(page, 1)
safe_page_size = max(page_size, 1)
offset = (safe_page - 1) * safe_page_size
_SORT_MAP = {
"newest": "requested_at DESC",
"oldest": "requested_at ASC",
"status": "status ASC, requested_at DESC",
}
order_clause = _SORT_MAP.get(sort or "", "requested_at DESC")
def operation(conn: sqlite3.Connection) -> tuple[list[RequestHistoryRecord], int]:
params: tuple[object, ...]
where_clause = ""
if status_filter:
where_clause = "WHERE status = ?"
params = (status_filter,)
else:
params = ()
total_row = conn.execute(
f"SELECT COUNT(*) AS count FROM request_history {where_clause}",
params,
).fetchone()
rows = conn.execute(
f"SELECT * FROM request_history {where_clause} ORDER BY {order_clause} LIMIT ? OFFSET ?",
params + (safe_page_size, offset),
).fetchall()
records = [record for row in rows if (record := self._row_to_record(row)) is not None]
total = int(total_row["count"] if total_row is not None else 0)
return records, total
return await self._read(operation)
async def async_update_status(
self,
musicbrainz_id: str,
status: str,
completed_at: str | None = None,
) -> None:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
if status in self._ACTIVE_STATUSES and completed_at is None:
conn.execute(
"UPDATE request_history SET status = ?, completed_at = NULL WHERE musicbrainz_id_lower = ?",
(status, normalized_mbid),
)
return
conn.execute(
"UPDATE request_history SET status = ?, completed_at = COALESCE(?, completed_at) WHERE musicbrainz_id_lower = ?",
(status, completed_at, normalized_mbid),
)
await self._write(operation)
async def async_update_cover_url(self, musicbrainz_id: str, cover_url: str) -> None:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"UPDATE request_history SET cover_url = ? WHERE musicbrainz_id_lower = ?",
(cover_url, normalized_mbid),
)
await self._write(operation)
async def async_update_lidarr_album_id(self, musicbrainz_id: str, lidarr_album_id: int) -> None:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"UPDATE request_history SET lidarr_album_id = ? WHERE musicbrainz_id_lower = ?",
(lidarr_album_id, normalized_mbid),
)
await self._write(operation)
async def async_update_artist_mbid(self, musicbrainz_id: str, artist_mbid: str) -> None:
"""Backfill the artist MBID without resetting other fields."""
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"UPDATE request_history SET artist_mbid = ? WHERE musicbrainz_id_lower = ? AND (artist_mbid IS NULL OR artist_mbid = '')",
(artist_mbid, normalized_mbid),
)
await self._write(operation)
async def async_delete_record(self, musicbrainz_id: str) -> bool:
normalized_mbid = musicbrainz_id.lower()
def operation(conn: sqlite3.Connection) -> bool:
cursor = conn.execute(
"DELETE FROM request_history WHERE musicbrainz_id_lower = ?",
(normalized_mbid,),
)
return cursor.rowcount > 0
return await self._write(operation)
async def prune_old_terminal_requests(self, days: int) -> int:
"""Delete terminal requests older than `days` days. Active requests are never touched."""
import time as _time
from datetime import timezone
cutoff_iso = datetime.fromtimestamp(_time.time() - days * 86400, tz=timezone.utc).isoformat()
terminal_statuses = ("imported", "failed", "cancelled", "incomplete")
def operation(conn: sqlite3.Connection) -> int:
cursor = conn.execute(
f"DELETE FROM request_history WHERE status IN ({','.join('?' for _ in terminal_statuses)}) "
"AND COALESCE(completed_at, requested_at) < ?",
(*terminal_statuses, cutoff_iso),
)
return cursor.rowcount
return await self._write(operation)