Add Grizzlyflix newsletters with curated editions and weekly delivery
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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'})
|
||||
Reference in New Issue
Block a user