feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..runtime import get_runtime_settings
|
||||
from . import recap_email as mail, recap_store as store
|
||||
from .invite_email import smtp_email_config_ready
|
||||
from .jellyfin_identity import linked_user_id, source_key
|
||||
from .monthly_reports import get_monthly_report, month_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecapError(Exception):
|
||||
def __init__(self, detail: str, status: int = 409):
|
||||
self.detail, self.status = detail, status
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
def worker_enabled() -> bool:
|
||||
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
|
||||
|
||||
|
||||
def delivery_ready() -> tuple[bool, str]:
|
||||
config = store.settings()
|
||||
if not config["public_url"]:
|
||||
return False, "Set the application URL in Hosting & proxy for email links."
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
return False, detail
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
||||
return False, "Connect Jellystat to generate viewing recaps."
|
||||
if not worker_enabled():
|
||||
return False, "Background automation is paused on this server."
|
||||
return True, "Email delivery is configured."
|
||||
|
||||
|
||||
def current_account(user: dict) -> dict:
|
||||
account = db.get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
raise RecapError("This account cannot receive viewing recaps.", 403)
|
||||
return account
|
||||
|
||||
|
||||
def binding_matches(sub: dict, account: dict) -> bool:
|
||||
runtime = get_runtime_settings()
|
||||
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
|
||||
and mail.valid_email(account.get("email"))
|
||||
and account["email"].strip().casefold() == sub["email"].strip().casefold()
|
||||
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
|
||||
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
|
||||
|
||||
|
||||
def active_subscription(account: dict) -> dict | None:
|
||||
sub = store.subscription(account["id"])
|
||||
if sub and sub["state"] != "off" and not binding_matches(sub, account):
|
||||
store.disable(account["id"])
|
||||
sub = store.subscription(account["id"])
|
||||
return sub
|
||||
|
||||
|
||||
def preferences(user: dict) -> dict:
|
||||
account = current_account(user)
|
||||
sub = active_subscription(account)
|
||||
config = store.settings()
|
||||
ready, detail = delivery_ready()
|
||||
runtime = get_runtime_settings()
|
||||
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
|
||||
email = mail.valid_email(account.get("email"))
|
||||
state = sub["state"] if sub else "off"
|
||||
if state == "pending" and sub["confirmation_expires"] <= time.time():
|
||||
state = "expired"
|
||||
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
|
||||
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
|
||||
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
|
||||
"automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
|
||||
"can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
|
||||
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
|
||||
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
|
||||
"resend_after": (sub["requested_at"] + 300) if sub else None}
|
||||
|
||||
|
||||
async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
|
||||
account = current_account(user)
|
||||
preference = preferences(user)
|
||||
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
|
||||
if preference["state"] == "enabled":
|
||||
store.set_automatic(account['id'], automatic)
|
||||
return preferences(user)
|
||||
if not preference["can_subscribe"]:
|
||||
raise RecapError(preference["detail"])
|
||||
config = store.settings()
|
||||
runtime = get_runtime_settings()
|
||||
try:
|
||||
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
|
||||
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
|
||||
rendered = mail.render_confirmation(account["username"], url)
|
||||
try:
|
||||
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
|
||||
mail.message_id(uuid.uuid4().hex, config["public_url"]))
|
||||
except mail.DeliveryError as exc:
|
||||
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
|
||||
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
|
||||
|
||||
|
||||
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
|
||||
sub = store.token_subscription(token, action)
|
||||
if not sub:
|
||||
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
|
||||
if action == "unsubscribe":
|
||||
if apply:
|
||||
store.disable(sub["user_id"])
|
||||
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
|
||||
account = db.get_user_by_id(sub["user_id"])
|
||||
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
|
||||
or not binding_matches(sub, account)):
|
||||
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
|
||||
if apply and not store.confirm(sub, time.time()):
|
||||
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
|
||||
return {"action": action, "state": "enabled" if apply else "ready"}
|
||||
|
||||
|
||||
def completed_month(month: str | None) -> str:
|
||||
try:
|
||||
period = month_periods(month, datetime.now(timezone.utc))
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 422) from exc
|
||||
if period["is_partial"]:
|
||||
raise RecapError("Choose a completed month for an email recap.", 422)
|
||||
return period["month"]
|
||||
|
||||
|
||||
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
|
||||
"""Embed only signed artwork from this account's report; missing art is optional."""
|
||||
import base64
|
||||
import re
|
||||
from .insights_artwork import get_artwork
|
||||
runtime = get_runtime_settings()
|
||||
images = []
|
||||
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
|
||||
|
||||
async def picture(index, row):
|
||||
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
|
||||
if not match:
|
||||
return
|
||||
try:
|
||||
data, mime = await get_artwork(account, runtime, *match.groups())
|
||||
cid = f"recap-title-{index}@magent"
|
||||
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
|
||||
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
|
||||
except Exception:
|
||||
pass # An unavailable poster must never prevent a personal report.
|
||||
|
||||
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
|
||||
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
|
||||
if not preview:
|
||||
rendered["inline_images"] = images
|
||||
return rendered
|
||||
|
||||
|
||||
async def preview(user: dict, month: str | None) -> dict:
|
||||
account = current_account(user)
|
||||
selected = completed_month(month)
|
||||
config = store.settings()
|
||||
if not config["public_url"]:
|
||||
raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
|
||||
try:
|
||||
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
|
||||
except HistoryLimitError as exc:
|
||||
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
|
||||
except (JellystatError, TimeoutError) as exc:
|
||||
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
||||
if report["state"] != "ready":
|
||||
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
||||
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
|
||||
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
|
||||
|
||||
|
||||
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
||||
account = current_account(user)
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise RecapError(detail)
|
||||
sub = active_subscription(account)
|
||||
if not sub or sub["state"] != "enabled":
|
||||
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
|
||||
selected = completed_month(month)
|
||||
try:
|
||||
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
|
||||
|
||||
|
||||
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
|
||||
account = db.get_user_by_id(delivery["user_id"])
|
||||
from ..feature_access import permissions
|
||||
if not account or not permissions(account)["stats"]:
|
||||
raise mail.DeliveryCancelled()
|
||||
sub = active_subscription(account) if account else None
|
||||
config = store.settings()
|
||||
ready, _ = delivery_ready()
|
||||
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
|
||||
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
|
||||
or config["public_url"] != delivery["public_url"]
|
||||
or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
|
||||
raise mail.DeliveryCancelled()
|
||||
return account, sub
|
||||
|
||||
|
||||
async def process_delivery(delivery: dict) -> None:
|
||||
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
|
||||
try:
|
||||
account, sub = eligible_delivery(delivery)
|
||||
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
|
||||
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
|
||||
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
||||
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
||||
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
||||
|
||||
def before_data():
|
||||
eligible_delivery(delivery)
|
||||
if not store.begin_sending(delivery, time.time()):
|
||||
raise mail.DeliveryCancelled()
|
||||
|
||||
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
|
||||
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
|
||||
state, detail = "sent", "Accepted by the mail server."
|
||||
except mail.DeliveryCancelled:
|
||||
state, detail = "cancelled", "Consent, account details or email configuration changed."
|
||||
except HistoryLimitError:
|
||||
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
|
||||
except (JellystatError, TimeoutError):
|
||||
state, detail = "retry", "Viewing history is temporarily unavailable."
|
||||
except mail.DeliveryError as exc:
|
||||
state, detail = exc.state, exc.detail
|
||||
except Exception as exc:
|
||||
# Do not expose provider errors or private report content in history/logs.
|
||||
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
|
||||
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
|
||||
if row and row["state"] == "sending":
|
||||
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
|
||||
if state == "retry":
|
||||
if delivery["attempts"] >= 3:
|
||||
state, detail = "failed", detail + " Stopped after three attempts."
|
||||
else:
|
||||
delay = 300 if delivery["attempts"] == 1 else 1800
|
||||
store.finish(delivery, state, detail, time.time(), delay)
|
||||
|
||||
|
||||
async def run_once() -> None:
|
||||
store.enqueue_due(datetime.now(timezone.utc))
|
||||
for _ in range(10):
|
||||
delivery = store.claim_delivery(time.time())
|
||||
if not delivery:
|
||||
break
|
||||
await process_delivery(delivery)
|
||||
|
||||
|
||||
async def run_email_recap_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await run_once()
|
||||
except Exception as exc:
|
||||
logger.error("email recap worker failed type=%s", type(exc).__name__)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
|
||||
account = current_account(user)
|
||||
ready, detail = delivery_ready()
|
||||
if not ready:
|
||||
raise RecapError(detail)
|
||||
sub = active_subscription(account)
|
||||
if not sub or sub['state'] != 'enabled':
|
||||
raise RecapError('Confirm your profile email in email preferences before emailing a report.')
|
||||
try:
|
||||
selected = month_periods(month, datetime.now(timezone.utc))['month']
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 422) from exc
|
||||
try:
|
||||
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
|
||||
except ValueError as exc:
|
||||
raise RecapError(str(exc), 429) from exc
|
||||
return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
|
||||
Reference in New Issue
Block a user