import time from datetime import datetime, timezone from typing import Literal from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import Field, field_validator from ..auth import get_current_user, require_admin from ..runtime import get_runtime_settings from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)]) class Settings(StrictPayload): enabled: bool weekday: int = Field(ge=0, le=6) hour: int = Field(ge=0, le=23) limit_titles: int = Field(ge=1, le=24) public_url: str = Field(max_length=500) intro: str = Field(default='', max_length=2000) revision: int = Field(ge=1) _url = field_validator('public_url')(RecapSettings.origin_only.__func__) class NewDraft(StrictPayload): days: Literal[7, 14, 30] = 7 class Selection(StrictPayload): id: str = Field(pattern=r'^[a-f0-9]{32}$') selected: bool featured: bool class Version(StrictPayload): revision: int = Field(ge=1) class EditionUpdate(Version): subject: str = Field(min_length=1, max_length=150) intro: str = Field(default='', max_length=2000) titles: list[Selection] = Field(max_length=60) @field_validator('subject') @classmethod def subject_line(cls, value): value = value.strip() if not value or any(ord(char) < 32 or ord(char) == 127 for char in value): raise ValueError('Use a single, non-empty subject line.') return value class Test(Version): request_id: UUID class Publish(Version): send_at: datetime | None = None def fail(exc): if isinstance(exc, service.NewsletterError): raise HTTPException(exc.status, exc.detail) from exc if isinstance(exc, store.Conflict): raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc @router.get('/profile/newsletters') def preference(user: dict = Depends(get_current_user)): try: return service.preferences(user) except service.NewsletterError as exc: fail(exc) @router.put('/profile/newsletters') async def set_preference(payload: Preference, user: dict = Depends(get_current_user)): try: if payload.enabled: return await service.subscribe(user) store.disable(service.account_for(user)['id']) return service.preferences(user) except service.NewsletterError as exc: fail(exc) @router.post('/newsletter-subscription/check') def check_token(payload: TokenAction): try: return service.token_action(payload.token, payload.action) except service.NewsletterError as exc: fail(exc) @router.post('/newsletter-subscription/confirm') def confirm_token(payload: TokenAction): try: return service.token_action(payload.token, payload.action, apply=True) except service.NewsletterError as exc: fail(exc) @router.get('/admin/newsletters') def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)): ready, detail = service.delivery_ready() return {'settings': store.public_settings(), 'ready': ready, 'detail': detail, 'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)} @router.put('/admin/newsletters') def settings(payload: Settings, user: dict = Depends(require_admin)): try: ready, detail = service.delivery_ready(payload.public_url) if payload.enabled and not ready: raise service.NewsletterError(detail) return store.save_settings(payload.model_dump(), datetime.now(timezone.utc)) except (service.NewsletterError, store.Conflict) as exc: fail(exc) @router.post('/admin/newsletters/drafts', status_code=201) async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)): try: return await service.create_draft(user, payload.days) except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc: fail(exc) @router.get('/admin/newsletters/editions/{identity}') def edition(identity: UUID, user: dict = Depends(require_admin)): try: return service.require_edition(identity.hex) except service.NewsletterError as exc: fail(exc) @router.put('/admin/newsletters/editions/{identity}') def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)): try: return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro, [entry.model_dump() for entry in payload.titles], time.time()) except store.Conflict as exc: fail(exc) @router.post('/admin/newsletters/editions/{identity}/preview') async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)): try: return await service.preview(identity.hex, payload.revision) except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc: fail(exc) @router.post('/admin/newsletters/editions/{identity}/test', status_code=202) def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)): try: return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id)) except (service.NewsletterError, store.Conflict) as exc: fail(exc) @router.post('/admin/newsletters/editions/{identity}/publish', status_code=202) def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)): try: return service.publish(identity.hex, payload.revision, payload.send_at) except (service.NewsletterError, store.Conflict) as exc: fail(exc) @router.post('/admin/newsletters/editions/{identity}/cancel') def cancel(identity: UUID, user: dict = Depends(require_admin)): try: service.require_edition(identity.hex) return store.cancel(identity.hex, time.time()) except service.NewsletterError as exc: fail(exc) @router.get('/admin/newsletters/artwork/{identity}') async def artwork(identity: UUID, user: dict = Depends(require_admin)): runtime = get_runtime_settings() if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key: raise HTTPException(404, 'Artwork unavailable') content = await catalog.poster(runtime, identity.hex) if not content: raise HTTPException(404, 'Artwork unavailable') return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})