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
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from infrastructure.queue.queue_store import QueueStore
|
||||
from infrastructure.persistence.request_history import RequestHistoryStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,7 +32,7 @@ class QueueInterface(ABC):
|
||||
|
||||
|
||||
class QueuedRequest:
|
||||
__slots__ = ('album_mbid', 'future', 'job_id', 'retry_count', 'recovered')
|
||||
__slots__ = ('album_mbid', 'future', 'job_id', 'retry_count', 'recovered', 'enqueued_at')
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -43,6 +46,7 @@ class QueuedRequest:
|
||||
self.job_id = job_id or str(uuid.uuid4())
|
||||
self.retry_count = 0
|
||||
self.recovered = recovered
|
||||
self.enqueued_at = time.monotonic()
|
||||
|
||||
|
||||
class RequestQueue(QueueInterface):
|
||||
@@ -52,42 +56,85 @@ class RequestQueue(QueueInterface):
|
||||
maxsize: int = 200,
|
||||
store: "QueueStore | None" = None,
|
||||
max_retries: int = 3,
|
||||
request_history: "RequestHistoryStore | None" = None,
|
||||
concurrency: int = 2,
|
||||
on_import_callback: Callable | None = None,
|
||||
):
|
||||
self._queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
|
||||
self._processor = processor
|
||||
self._processor_task: Optional[asyncio.Task] = None
|
||||
self._processing = False
|
||||
self._worker_tasks: list[asyncio.Task] = []
|
||||
self._active_workers = 0
|
||||
self._maxsize = maxsize
|
||||
self._store = store
|
||||
self._max_retries = max_retries
|
||||
self._request_history = request_history
|
||||
self._concurrency = max(1, min(concurrency, 5))
|
||||
self._cancelled_mbids: set[str] = set()
|
||||
self._enqueue_lock = asyncio.Lock()
|
||||
self._recovered = False
|
||||
self._on_import_callback = on_import_callback
|
||||
|
||||
async def add(self, album_mbid: str) -> dict:
|
||||
"""Blocking enqueue — waits for the result."""
|
||||
await self.start()
|
||||
|
||||
request = QueuedRequest(album_mbid)
|
||||
await self._queue.put(request)
|
||||
if self._store:
|
||||
self._store.enqueue(request.job_id, album_mbid)
|
||||
await self._queue.put(request)
|
||||
|
||||
result = await request.future
|
||||
return result
|
||||
|
||||
|
||||
async def enqueue(self, album_mbid: str) -> bool:
|
||||
"""Fire-and-forget enqueue. Returns True if enqueued, False if duplicate."""
|
||||
async with self._enqueue_lock:
|
||||
if self._store and self._store.has_active_mbid(album_mbid):
|
||||
logger.info("Duplicate request rejected for %s — already active", album_mbid[:8])
|
||||
return False
|
||||
|
||||
# Clear any prior cancellation so re-requests aren't silently dropped
|
||||
self._cancelled_mbids.discard(album_mbid.lower())
|
||||
|
||||
await self.start()
|
||||
request = QueuedRequest(album_mbid)
|
||||
if self._store:
|
||||
self._store.enqueue(request.job_id, album_mbid)
|
||||
await self._queue.put(request)
|
||||
logger.info("Enqueued request for album %s (job %s)", album_mbid[:8], request.job_id[:8])
|
||||
return True
|
||||
|
||||
async def cancel(self, album_mbid: str) -> bool:
|
||||
"""Remove a pending job from the queue. Returns True if removed."""
|
||||
removed = False
|
||||
if self._store:
|
||||
removed = self._store.remove_by_mbid(album_mbid)
|
||||
# Mark for skip - items already in the asyncio.Queue can't be removed,
|
||||
# so workers check this set before processing.
|
||||
self._cancelled_mbids.add(album_mbid.lower())
|
||||
if removed:
|
||||
logger.info("Cancelled pending queue job for %s", album_mbid[:8])
|
||||
return removed
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._processor_task is None or self._processor_task.done():
|
||||
self._processor_task = asyncio.create_task(self._process_queue())
|
||||
logger.info("Queue processor started")
|
||||
self._recover_pending()
|
||||
alive = [t for t in self._worker_tasks if not t.done()]
|
||||
if len(alive) < self._concurrency:
|
||||
for _ in range(self._concurrency - len(alive)):
|
||||
task = asyncio.create_task(self._process_queue())
|
||||
self._worker_tasks.append(task)
|
||||
logger.info("Queue processor started (%d workers)", self._concurrency)
|
||||
if not self._recovered:
|
||||
self._recovered = True
|
||||
self._recover_pending()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._processor_task and not self._processor_task.done():
|
||||
alive = [t for t in self._worker_tasks if not t.done()]
|
||||
if alive:
|
||||
await self.drain()
|
||||
|
||||
self._processor_task.cancel()
|
||||
try:
|
||||
await self._processor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._processor_task = None
|
||||
for t in alive:
|
||||
t.cancel()
|
||||
await asyncio.gather(*alive, return_exceptions=True)
|
||||
self._worker_tasks.clear()
|
||||
logger.info("Queue processor stopped")
|
||||
|
||||
async def drain(self, timeout: float = 30.0) -> None:
|
||||
@@ -102,7 +149,9 @@ class RequestQueue(QueueInterface):
|
||||
status = {
|
||||
"queue_size": self._queue.qsize(),
|
||||
"max_size": self._maxsize,
|
||||
"processing": self._processing,
|
||||
"processing": self._active_workers > 0,
|
||||
"active_workers": self._active_workers,
|
||||
"max_workers": self._concurrency,
|
||||
}
|
||||
if self._store:
|
||||
status["dead_letter_count"] = self._store.get_dead_letter_count()
|
||||
@@ -124,6 +173,12 @@ class RequestQueue(QueueInterface):
|
||||
try:
|
||||
self._queue.put_nowait(request)
|
||||
recovered += 1
|
||||
if self._request_history:
|
||||
task = asyncio.ensure_future(self._backfill_history(row["album_mbid"]))
|
||||
task.add_done_callback(
|
||||
lambda t: t.exception() and logger.error("Backfill failed: %s", t.exception())
|
||||
if not t.cancelled() and t.exception() else None
|
||||
)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Queue full during recovery, %d items deferred to next restart",
|
||||
len(pending) - recovered)
|
||||
@@ -133,6 +188,88 @@ class RequestQueue(QueueInterface):
|
||||
|
||||
self._retry_dead_letters()
|
||||
|
||||
async def _backfill_history(self, album_mbid: str) -> None:
|
||||
"""Create a minimal history record for recovered jobs that lack one."""
|
||||
if not self._request_history:
|
||||
return
|
||||
try:
|
||||
existing = await self._request_history.async_get_record(album_mbid)
|
||||
if not existing:
|
||||
await self._request_history.async_record_request(
|
||||
musicbrainz_id=album_mbid,
|
||||
artist_name="Unknown",
|
||||
album_title="Unknown",
|
||||
)
|
||||
logger.info("Backfilled history record for recovered job %s", album_mbid[:8])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Failed to backfill history for %s: %s", album_mbid[:8], e)
|
||||
|
||||
async def _update_history_on_result(self, album_mbid: str, result: dict) -> None:
|
||||
if not self._request_history:
|
||||
return
|
||||
try:
|
||||
from services.request_utils import extract_cover_url
|
||||
|
||||
# Don't overwrite a user-initiated cancellation
|
||||
existing = await self._request_history.async_get_record(album_mbid)
|
||||
if existing and existing.status == "cancelled":
|
||||
logger.info("Skipping history update for %s — already cancelled", album_mbid[:8])
|
||||
return
|
||||
|
||||
payload = result.get("payload", {})
|
||||
if not payload or not isinstance(payload, dict):
|
||||
await self._request_history.async_update_status(album_mbid, "downloading")
|
||||
return
|
||||
|
||||
lidarr_album_id = payload.get("id")
|
||||
cover_url = extract_cover_url(payload)
|
||||
artist_mbid = None
|
||||
artist_data = payload.get("artist", {})
|
||||
if artist_data:
|
||||
artist_mbid = artist_data.get("foreignArtistId")
|
||||
|
||||
statistics = payload.get("statistics", {})
|
||||
has_files = statistics.get("trackFileCount", 0) > 0
|
||||
|
||||
# Persist metadata fields BEFORE status update / callback so the
|
||||
# record is complete when the import callback reads it.
|
||||
if lidarr_album_id:
|
||||
await self._request_history.async_update_lidarr_album_id(album_mbid, lidarr_album_id)
|
||||
if cover_url:
|
||||
await self._request_history.async_update_cover_url(album_mbid, cover_url)
|
||||
if artist_mbid:
|
||||
await self._request_history.async_update_artist_mbid(album_mbid, artist_mbid)
|
||||
|
||||
if has_files:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
await self._request_history.async_update_status(
|
||||
album_mbid, "imported", completed_at=now_iso
|
||||
)
|
||||
# Invalidate caches so the album immediately appears as "In Library"
|
||||
if self._on_import_callback:
|
||||
try:
|
||||
enriched = await self._request_history.async_get_record(album_mbid)
|
||||
if enriched:
|
||||
await self._on_import_callback(enriched)
|
||||
except Exception as cb_err: # noqa: BLE001
|
||||
logger.warning("Import callback failed for %s: %s", album_mbid[:8], cb_err)
|
||||
else:
|
||||
await self._request_history.async_update_status(album_mbid, "downloading")
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Failed to update history after processing %s: %s", album_mbid[:8], e)
|
||||
|
||||
async def _update_history_on_failure(self, album_mbid: str, error: Exception) -> None:
|
||||
if not self._request_history:
|
||||
return
|
||||
try:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
await self._request_history.async_update_status(
|
||||
album_mbid, "failed", completed_at=now_iso
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Failed to update history on failure for %s: %s", album_mbid[:8], e)
|
||||
|
||||
def _retry_dead_letters(self) -> None:
|
||||
if not self._store:
|
||||
return
|
||||
@@ -142,10 +279,6 @@ class RequestQueue(QueueInterface):
|
||||
if self._store.has_pending_mbid(row["album_mbid"]):
|
||||
self._store.remove_dead_letter(row["id"])
|
||||
continue
|
||||
self._store.remove_dead_letter(row["id"])
|
||||
inserted = self._store.enqueue(row["id"], row["album_mbid"])
|
||||
if not inserted:
|
||||
continue
|
||||
request = QueuedRequest(
|
||||
album_mbid=row["album_mbid"],
|
||||
job_id=row["id"],
|
||||
@@ -154,10 +287,13 @@ class RequestQueue(QueueInterface):
|
||||
request.retry_count = row["retry_count"]
|
||||
try:
|
||||
self._queue.put_nowait(request)
|
||||
enqueued += 1
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Queue full during dead-letter retry, remaining deferred")
|
||||
break
|
||||
# Only remove dead letter + persist to pending AFTER successful in-memory enqueue
|
||||
self._store.remove_dead_letter(row["id"])
|
||||
self._store.enqueue(row["id"], row["album_mbid"])
|
||||
enqueued += 1
|
||||
if enqueued:
|
||||
logger.info("Re-enqueued %d dead-letter jobs for retry", enqueued)
|
||||
|
||||
@@ -165,11 +301,29 @@ class RequestQueue(QueueInterface):
|
||||
while True:
|
||||
try:
|
||||
request: QueuedRequest = await self._queue.get()
|
||||
self._processing = True
|
||||
|
||||
|
||||
# Skip items cancelled while sitting in the asyncio.Queue
|
||||
if request.album_mbid.lower() in self._cancelled_mbids:
|
||||
self._cancelled_mbids.discard(request.album_mbid.lower())
|
||||
logger.info("Skipping cancelled request %s", request.album_mbid[:8])
|
||||
if not request.future.done():
|
||||
request.future.cancel()
|
||||
self._queue.task_done()
|
||||
continue
|
||||
|
||||
# Prevent unbounded growth from orphaned cancel entries
|
||||
if len(self._cancelled_mbids) > 200:
|
||||
self._cancelled_mbids.clear()
|
||||
|
||||
self._active_workers += 1
|
||||
if self._store:
|
||||
self._store.mark_processing(request.job_id)
|
||||
|
||||
queue_wait_ms = int((time.monotonic() - request.enqueued_at) * 1000)
|
||||
logger.info(
|
||||
"Processing request %s (queue_wait=%dms)", request.album_mbid[:8], queue_wait_ms
|
||||
)
|
||||
|
||||
try:
|
||||
if request.recovered:
|
||||
logger.info("Processing recovered job %s for album %s", request.job_id[:8], request.album_mbid[:8])
|
||||
@@ -178,6 +332,7 @@ class RequestQueue(QueueInterface):
|
||||
request.future.set_result(result)
|
||||
if self._store:
|
||||
self._store.dequeue(request.job_id)
|
||||
await self._update_history_on_result(request.album_mbid, result)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Error processing request for %s (attempt %d/%d): %s",
|
||||
request.album_mbid[:8], request.retry_count + 1, self._max_retries, e)
|
||||
@@ -192,13 +347,13 @@ class RequestQueue(QueueInterface):
|
||||
retry_count=request.retry_count + 1,
|
||||
max_retries=self._max_retries,
|
||||
)
|
||||
await self._update_history_on_failure(request.album_mbid, e)
|
||||
finally:
|
||||
self._active_workers -= 1
|
||||
self._queue.task_done()
|
||||
self._processing = False
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Queue processor cancelled")
|
||||
logger.info("Queue worker cancelled")
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Queue processor error: %s", e)
|
||||
self._processing = False
|
||||
logger.error("Queue worker error: %s", e)
|
||||
|
||||
Reference in New Issue
Block a user