Ctrl K
ComunidadGuías…

frame_push.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Samsung Frame TV push output (Track: Integrations).

Renders the currently-selected artwork to a full-colour TV-resolution JPEG and pushes it into a
Samsung Frame TV's Art Mode over the LAN (Samsung's unofficial art WebSocket API via `samsungtvws`).
This is a third output target alongside the browser Canvas and the e-ink pull API.

Design note (no hardware here): the TV is hidden behind the small `FrameClient` interface so the only
hardware-dependent code is the thin `SamsungFrameClient` wrapper. Everything else — config, render,
push orchestration (dedupe / delete-old / persist), and the scheduler tick — is exercised in tests via
`FakeFrameClient` and an injected selector. See memory `no-frame-tv-for-testing`.
"""

from __future__ import annotations

import asyncio
import logging
import time
import traceback
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Awaitable, Callable, Optional, Tuple

from database import SessionLocal
from epaper import pick_crop_for_aspect, render_fullcolor
from models import SettingsModel

logger = logging.getLogger("artwork-display-api.frame")

# Token persists the TV pairing (first connect triggers a TV-side authorize prompt) across restarts.
# ./data is the app's persisted volume (same place as the SQLite DB).
DEFAULT_TOKEN_FILE = Path("data") / "frame_tv_token.json"

FRAME_SETTING_KEYS = (
    "frame_enabled", "frame_host", "frame_port", "frame_playlist",
    "frame_interval_sec", "frame_width", "frame_height", "frame_matte", "frame_display_id",
    # internal state written by the pusher:
    "frame_last_content_id", "frame_last_artwork_id", "frame_last_push_at",
)

_DEFAULTS = {
    "frame_enabled": "false",
    "frame_host": "",
    "frame_port": "8001",
    "frame_playlist": "",
    "frame_interval_sec": "900",
    "frame_width": "3840",
    "frame_height": "2160",
    "frame_matte": "none",
    "frame_display_id": "frame-tv",
}

# select_fn(playlist_name) -> (image_path, artwork_id, focal[, aspect_crops]) or None, where focal is the normalized
# (x, y) framing anchor. Injected by app.py so this module never imports app (avoids a circular
# import) and stays unit-testable.
SelectFn = Callable[[str], Awaitable[Optional[Tuple[Path, int, Tuple[float, float]]]]]

# --------------------------------------------------------------------------- config

_cache: dict = {"data": None, "ts": 0.0}
_CACHE_TTL = 30.0


def _read_rows() -> dict:
    db = SessionLocal()
    try:
        return {
            s.setting_key: s.setting_value
            for s in db.query(SettingsModel).filter(SettingsModel.setting_key.in_(FRAME_SETTING_KEYS)).all()
        }
    finally:
        db.close()


def _truthy(v: str) -> bool:
    return str(v).strip().lower() in ("true", "1", "yes", "on")


def _int(v, fallback: int) -> int:
    try:
        return int(str(v).strip())
    except (TypeError, ValueError):
        return fallback


def get_frame_config(force: bool = False) -> dict:
    """Resolve Frame settings (DB rows over defaults), 30s TTL cache like ai_client.get_ai_config."""
    now = time.monotonic()
    if not force and _cache["data"] is not None and (now - _cache["ts"]) < _CACHE_TTL:
        return _cache["data"]
    rows = _read_rows()

    def val(k):
        return rows.get(k, _DEFAULTS.get(k, ""))

    cfg = {
        "enabled": _truthy(val("frame_enabled")),
        "host": val("frame_host").strip(),
        "port": _int(val("frame_port"), 8001),
        "playlist": val("frame_playlist").strip(),
        "interval_sec": max(60, _int(val("frame_interval_sec"), 900)),
        "width": _int(val("frame_width"), 3840),
        "height": _int(val("frame_height"), 2160),
        "matte": (val("frame_matte").strip() or "none"),
        "display_id": (val("frame_display_id").strip() or "frame-tv"),
        "last_content_id": rows.get("frame_last_content_id") or None,
        "last_artwork_id": _int(rows.get("frame_last_artwork_id"), 0) or None,
        "last_push_at": float(rows.get("frame_last_push_at") or 0.0) or None,
    }
    _cache["data"] = cfg
    _cache["ts"] = now
    return cfg


def invalidate_frame_cache() -> None:
    _cache["data"] = None
    _cache["ts"] = 0.0


def _persist_state(content_id: str, artwork_id: int) -> None:
    """Record what we last pushed so the next cycle can dedupe + delete the prior upload."""
    db = SessionLocal()
    try:
        updates = {
            "frame_last_content_id": content_id,
            "frame_last_artwork_id": str(artwork_id),
            "frame_last_push_at": repr(time.time()),
        }
        for k, v in updates.items():
            row = db.query(SettingsModel).filter(SettingsModel.setting_key == k).first()
            if row:
                row.setting_value = v
            else:
                db.add(SettingsModel(setting_key=k, setting_value=v))
        db.commit()
    finally:
        db.close()
    invalidate_frame_cache()


# --------------------------------------------------------------------------- TV abstraction

class FrameClient(ABC):
    """Minimal interface over a Frame TV's Art Mode, so the pusher is hardware-agnostic + testable."""

    @abstractmethod
    async def push(self, image: bytes, file_type: str, matte: str) -> str:
        """Upload an image; return its content_id."""

    @abstractmethod
    async def show(self, content_id: str) -> None:
        """Select the uploaded image as the current art."""

    @abstractmethod
    async def ensure_artmode(self) -> None:
        """Make sure the TV is in Art Mode."""

    @abstractmethod
    async def delete(self, content_id: str) -> None:
        """Remove a previously-uploaded image (housekeeping)."""

    @abstractmethod
    async def test(self) -> dict:
        """Light connectivity check; raises on failure."""


class SamsungFrameClient(FrameClient):
    """Thin wrapper over the synchronous `samsungtvws` library, called via asyncio.to_thread.
    THE ONLY HARDWARE-DEPENDENT CODE. One connection is reused across the calls within a single push
    cycle; the scheduler builds a fresh client each tick (see _run_one_cycle), so there is no
    long-lived socket to reset. `tv_factory` is injectable purely so tests can verify the mapping
    without the real library or a TV."""

    def __init__(self, host: str, port: int = 8001, token_file: Optional[str] = None, tv_factory=None):
        self.host = host
        self.port = port
        self.token_file = token_file or str(DEFAULT_TOKEN_FILE)
        self._tv_factory = tv_factory or self._default_factory
        self._tv = None

    def _default_factory(self):
        from samsungtvws import SamsungTVWS  # lazy: optional dep, only needed when enabled
        DEFAULT_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
        return SamsungTVWS(host=self.host, port=self.port, token_file=self.token_file)

    def _conn(self):
        if self._tv is None:
            self._tv = self._tv_factory()
        return self._tv

    async def push(self, image: bytes, file_type: str, matte: str) -> str:
        return await asyncio.to_thread(
            lambda: self._conn().art().upload(image, file_type=file_type, matte=matte)
        )

    async def show(self, content_id: str) -> None:
        await asyncio.to_thread(lambda: self._conn().art().select_image(content_id, show=True))

    async def ensure_artmode(self) -> None:
        await asyncio.to_thread(lambda: self._conn().art().set_artmode(True))

    async def delete(self, content_id: str) -> None:
        await asyncio.to_thread(lambda: self._conn().art().delete(content_id))

    async def test(self) -> dict:
        return await asyncio.to_thread(lambda: {"artmode": self._conn().art().get_artmode()})


class FakeFrameClient(FrameClient):
    """In-memory FrameClient for tests: records calls, returns deterministic content_ids."""

    def __init__(self, fail_on: Optional[str] = None):
        self.calls: list = []
        self.uploaded: list = []
        self.deleted: list = []
        self.artmode = False
        self._n = 0
        self._fail_on = fail_on  # method name to raise in, to exercise error paths

    def _maybe_fail(self, name):
        if self._fail_on == name:
            raise RuntimeError(f"simulated failure in {name}")

    async def push(self, image: bytes, file_type: str, matte: str) -> str:
        self._maybe_fail("push")
        self._n += 1
        cid = f"MY-F{self._n:04d}"
        self.calls.append(("push", file_type, matte, len(image)))
        self.uploaded.append(cid)
        return cid

    async def show(self, content_id: str) -> None:
        self._maybe_fail("show")
        self.calls.append(("show", content_id))

    async def ensure_artmode(self) -> None:
        self._maybe_fail("artmode")
        self.artmode = True
        self.calls.append(("artmode",))

    async def delete(self, content_id: str) -> None:
        self._maybe_fail("delete")
        self.deleted.append(content_id)
        self.calls.append(("delete", content_id))

    async def test(self) -> dict:
        self._maybe_fail("test")
        return {"ok": True}


def _default_client_factory(cfg: dict) -> FrameClient:
    return SamsungFrameClient(cfg["host"], cfg["port"], token_file=str(DEFAULT_TOKEN_FILE))


# --------------------------------------------------------------------------- orchestration

async def push_once(
    cfg: dict,
    select_fn: SelectFn,
    client: FrameClient,
    *,
    force: bool = False,
    persist: Optional[Callable[[str, int], None]] = None,
) -> dict:
    """Render + push the current artwork to the Frame. Returns a status dict; never raises for
    expected conditions (no artwork, unchanged). Caller decides how to surface client errors."""
    sel = await select_fn(cfg.get("playlist", ""))
    if not sel:
        return {"status": "skipped", "reason": "no artwork available"}
    # 4th element (per-shape crop presets) is optional: a selector that predates them — or any
    # third-party one — still returns a 3-tuple and simply gets the focal cover.
    path, artwork_id, focal = sel[0], sel[1], sel[2]
    aspect_crops = sel[3] if len(sel) > 3 else None

    if not force and artwork_id == cfg.get("last_artwork_id") and cfg.get("last_content_id"):
        return {"status": "unchanged", "artwork_id": artwork_id}

    # A Frame can't Ken Burns its way out of a bad crop any more than e-ink can, so it takes the
    # authored box too — picked for the Frame's own panel size.
    crop_box = pick_crop_for_aspect(aspect_crops, cfg["width"], cfg["height"])
    image = await asyncio.to_thread(
        render_fullcolor, Path(path), cfg["width"], cfg["height"], "cover", focal, 90, crop_box
    )
    content_id = await client.push(image, "jpg", cfg.get("matte", "none"))
    await client.show(content_id)
    await client.ensure_artmode()

    old = cfg.get("last_content_id")
    if old and old != content_id:
        try:
            await client.delete(old)
        except Exception as e:  # housekeeping only — don't fail the push
            logger.warning(f"[Frame] could not delete prior upload {old}: {e}")

    (persist or _persist_state)(content_id, artwork_id)
    return {"status": "pushed", "content_id": content_id, "artwork_id": artwork_id, "bytes": len(image)}


async def run_test_push(select_fn: SelectFn, client_factory=_default_client_factory) -> dict:
    """One-shot 'Test / Push now' for the settings endpoint. Returns a structured result instead of
    raising, so the GUI can show a clean message with or without a TV present."""
    cfg = get_frame_config(force=True)
    if not cfg["host"]:
        return {"status": "error", "reason": "No Frame TV host/IP configured."}
    try:
        client = client_factory(cfg)
        return await push_once(cfg, select_fn, client, force=True)
    except Exception as e:
        logger.warning(f"[Frame] test push failed: {e}")
        return {"status": "error", "reason": str(e) or e.__class__.__name__}


async def _run_one_cycle(select_fn: SelectFn, client_factory=_default_client_factory) -> dict:
    """A single scheduler tick (factored out so it's testable without the infinite loop)."""
    cfg = get_frame_config(force=True)
    if not cfg["enabled"] or not cfg["host"]:
        return {"status": "idle"}
    client = client_factory(cfg)
    return await push_once(cfg, select_fn, client)


async def frame_push_loop(select_fn: SelectFn, client_factory=_default_client_factory) -> None:
    """Periodic pusher. Started once (leader worker only) from app.py's lifespan."""
    logger.info("[Frame] push loop started")
    while True:
        delay = 900
        try:
            delay = get_frame_config(force=True)["interval_sec"]
            res = await _run_one_cycle(select_fn, client_factory)
            if res.get("status") not in ("idle", "unchanged"):
                logger.info(f"[Frame] cycle: {res}")
        except Exception:
            logger.error(f"[Frame] loop error: {traceback.format_exc()}")
            delay = max(60, delay)
        await asyncio.sleep(delay)