95cfdff97d
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""FC-3a: CRUD over Source rows. FC-3c adds POST /<id>/check."""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from ..extensions import get_session
|
|
from ..models import DownloadEvent, Source
|
|
from ..services.source_service import (
|
|
KNOWN_PLATFORMS,
|
|
ArtistNotFoundError,
|
|
DuplicateSourceError,
|
|
EmptyUrlError,
|
|
InvalidConfigError,
|
|
SourceService,
|
|
UnknownPlatformError,
|
|
)
|
|
|
|
sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources")
|
|
|
|
|
|
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
|
|
body = {"error": error}
|
|
if detail is not None:
|
|
body["detail"] = detail
|
|
body.update(extra)
|
|
return jsonify(body), status
|
|
|
|
|
|
@sources_bp.route("", methods=["GET"])
|
|
async def list_sources():
|
|
artist_id_raw = request.args.get("artist_id")
|
|
artist_id = None
|
|
if artist_id_raw is not None:
|
|
try:
|
|
artist_id = int(artist_id_raw)
|
|
except ValueError:
|
|
return _bad("invalid_artist_id", detail="artist_id must be an integer")
|
|
async with get_session() as session:
|
|
records = await SourceService(session).list(artist_id=artist_id)
|
|
return jsonify([r.to_dict() for r in records])
|
|
|
|
|
|
@sources_bp.route("/<int:source_id>", methods=["GET"])
|
|
async def get_source(source_id: int):
|
|
async with get_session() as session:
|
|
record = await SourceService(session).get(source_id)
|
|
if record is None:
|
|
return _bad("not_found", status=404, detail=f"source id={source_id}")
|
|
return jsonify(record.to_dict())
|
|
|
|
|
|
@sources_bp.route("", methods=["POST"])
|
|
async def create_source():
|
|
body = await request.get_json()
|
|
if not isinstance(body, dict):
|
|
return _bad("invalid_body", detail="body must be a JSON object")
|
|
try:
|
|
artist_id = int(body["artist_id"])
|
|
platform = body["platform"]
|
|
url = body["url"]
|
|
except (KeyError, TypeError, ValueError):
|
|
return _bad("invalid_body", detail="artist_id, platform, url are required")
|
|
|
|
optional = {
|
|
k: body[k]
|
|
for k in ("enabled", "config_overrides", "check_interval_override")
|
|
if k in body
|
|
}
|
|
|
|
async with get_session() as session:
|
|
svc = SourceService(session)
|
|
try:
|
|
record = await svc.create(
|
|
artist_id=artist_id, platform=platform, url=url, **optional,
|
|
)
|
|
except ArtistNotFoundError:
|
|
return _bad("artist_not_found", status=404, detail=f"artist id={artist_id}")
|
|
except UnknownPlatformError as exc:
|
|
return _bad("unknown_platform", detail=str(exc), known=sorted(KNOWN_PLATFORMS))
|
|
except InvalidConfigError as exc:
|
|
return _bad("invalid_config", detail=str(exc))
|
|
except EmptyUrlError as exc:
|
|
return _bad("empty_url", detail=str(exc))
|
|
except DuplicateSourceError as exc:
|
|
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
|
return jsonify(record.to_dict()), 201
|
|
|
|
|
|
@sources_bp.route("/<int:source_id>", methods=["PATCH"])
|
|
async def patch_source(source_id: int):
|
|
body = await request.get_json()
|
|
if not isinstance(body, dict):
|
|
return _bad("invalid_body", detail="body must be a JSON object")
|
|
async with get_session() as session:
|
|
svc = SourceService(session)
|
|
try:
|
|
record = await svc.update(source_id, **body)
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
except UnknownPlatformError as exc:
|
|
return _bad("unknown_platform", detail=str(exc), known=sorted(KNOWN_PLATFORMS))
|
|
except InvalidConfigError as exc:
|
|
return _bad("invalid_config", detail=str(exc))
|
|
except EmptyUrlError as exc:
|
|
return _bad("empty_url", detail=str(exc))
|
|
except DuplicateSourceError as exc:
|
|
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
|
return jsonify(record.to_dict())
|
|
|
|
|
|
@sources_bp.route("/<int:source_id>", methods=["DELETE"])
|
|
async def delete_source(source_id: int):
|
|
async with get_session() as session:
|
|
try:
|
|
await SourceService(session).delete(source_id)
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
return "", 204
|
|
|
|
|
|
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
|
async def check_source(source_id: int):
|
|
"""FC-3c: enqueue a download for this source.
|
|
|
|
Returns 202 with the new DownloadEvent id. If a pending/running
|
|
event already exists for this source, returns 409 with that id."""
|
|
async with get_session() as session:
|
|
source = (await session.execute(
|
|
select(Source).where(Source.id == source_id)
|
|
)).scalar_one_or_none()
|
|
if source is None:
|
|
return _bad("not_found", status=404, detail=f"source id={source_id}")
|
|
if not source.enabled:
|
|
return _bad("source_disabled", detail="enable the source first")
|
|
|
|
in_flight = (await session.execute(
|
|
select(DownloadEvent.id).where(
|
|
DownloadEvent.source_id == source_id,
|
|
DownloadEvent.status.in_(["pending", "running"]),
|
|
).order_by(DownloadEvent.id.desc()).limit(1)
|
|
)).scalar_one_or_none()
|
|
if in_flight is not None:
|
|
return jsonify(
|
|
{"download_event_id": in_flight, "status": "already_running"}
|
|
), 409
|
|
|
|
event = DownloadEvent(source_id=source_id, status="pending")
|
|
session.add(event)
|
|
await session.commit()
|
|
await session.refresh(event)
|
|
event_id = event.id
|
|
|
|
from ..tasks.download import download_source
|
|
download_source.delay(source_id)
|
|
|
|
return jsonify({"download_event_id": event_id, "status": "pending"}), 202
|