Initial mediapi: WiFi AP + media browser with HDMI playback control

Raspberry Pi 4 appliance that boots as a WiFi access point and serves a
phone-controlled web app for browsing /localmedia and driving video
playback out the Pi's HDMI port.

- NetworkManager-based AP setup (documented in README)
- Flask app + vanilla HTML/JS single-page UI (Control / Playback modes)
- mpv persistent daemon (DRM/KMS HDMI output) controlled over JSON IPC
- long-lived session-cookie auth
- "keep playing" folder auto-advance, including when started mid-folder
- systemd units for both mpv and the app, autostart on boot

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-07-05 21:45:57 -04:00
commit b8cd65b8ca
21 changed files with 1294 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Python
__pycache__/
*.py[cod]
# uv / virtualenv
.venv/
# Flask session signing key -- generated per-deployment, must stay secret
instance/secret_key
# Local Claude Code settings
.claude/

120
README.md Normal file
View file

@ -0,0 +1,120 @@
hardware: raspberry pi 4 4GB ram
os: raspberry pi os lite (64 bits)
use-cas: almost always offline. except when doing maintenance
# AP on the raspberry pi
* must have access point that boots up on system boot
* ssid: mediapipi, pw: mediapipi
* fine if it's slow, fine if it does not boot right away (wait for other services)
* (I will connect to the AP from my phone and control what the pi plays)
## Setup (Raspberry Pi OS Bookworm / NetworkManager)
```bash
# set the wifi regulatory domain (required, affects allowed channels/power)
sudo raspi-config nonint do_wifi_country US
# create the AP connection profile on wlan0
# ipv4.method=shared makes NetworkManager run its own DHCP server (dnsmasq)
# on wlan0 for the phone to get an address from -- no internet sharing/NAT
# involved since there's no other upstream connection active
sudo nmcli connection add type wifi ifname wlan0 con-name mediapi-ap \
autoconnect yes connection.autoconnect-priority 100 save yes \
802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.ssid mediapipi \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk mediapipi \
ipv4.method shared
# bring it up now (it will also auto-start on every boot from here on)
sudo nmcli connection up mediapi-ap
# verify
nmcli connection show mediapi-ap
ip addr show wlan0
```
To remove it later: `sudo nmcli connection delete mediapi-ap`
# app to browse media
media will be stored at
* /localmedia
* more TBD
I want an app that runs on the raspbery pi with two modes:
* playback
* control
the app should have two buttons up top that show the two modes all the time. the manager will switch back and forth between the two. the rest of the screen will be used to show the content. Must fit on one phone screen
## control mode
* shows the media stores
* the user can click on any one of them to "go" inside the folder
* at any point in time the user can chose to "play" a folder, or a file (how TBD)
* radio buttons up top of that panel (below and separately from the two global modes)
* keep playing
* more TBD
* has basic navigation (pretty buch go back one level at the time)
## playback mode:
* display the file being played
* displays playback info (current time, total video time), volume control
* pause/play, back and fast forward 30 seconds (or whatever you can get on the framework you use)
* plays on both HDMI mirrored if connected.
## basic auth
user: jujualexevan
pw: bambas
fine to keep known devices logged in forever pretty much
easy to run ... I don't want to have to download a gazillion things
## Setup (mediapi app)
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
player controlled over its JSON IPC socket. mpv renders video to HDMI
directly (DRM/KMS, no desktop needed); the phone browser only shows
metadata/controls, never the video image itself.
```bash
# system deps: mpv for HDMI/DRM playback
sudo apt update
sudo apt install -y mpv
# mpv needs access to the GPU/DRM devices to render to HDMI
sudo usermod -aG video,render jm
# log out/in (or reboot) for the new group membership to take effect
# install uv (Python package/venv manager) if not already present
curl -LsSf https://astral.sh/uv/install.sh | sh
# installs to ~/.local/bin/uv -- confirm with `which uv`; if it differs,
# update the ExecStart path in systemd/mediapi-app.service to match
cd /home/jm/mediapi
uv sync # creates .venv and installs dependencies from pyproject.toml
# install the two systemd services (mpv player + the web app)
sudo cp systemd/mediapi-mpv.service systemd/mediapi-app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mediapi-mpv mediapi-app
# verify
systemctl status mediapi-mpv mediapi-app
ls -l /run/mediapi/mpv.sock
```
Then, from a phone connected to the `mediapipi` AP, browse to
`http://<pi-ap-ip>:8080/` (the AP's gateway address, typically `10.42.0.1`
confirm with `ip addr show wlan0` on the pi) and log in with the
`jujualexevan` / `bambas` credentials above.
Notes / things to double check on the actual hardware (couldn't be verified
from a dev machine):
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt`
on current Bookworm Pi4 images (needed for DRM output) — worth a quick check.
* If HDMI isn't picked automatically, `mpv --drm-connector=help` (with a
display attached) lists connectors to pin one explicitly via the
`ExecStart` line in `systemd/mediapi-mpv.service`.
* If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA
device name (usually `vc4-hdmi`) and add e.g.
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit, or run
`sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`

24
mediapi/__init__.py Normal file
View file

@ -0,0 +1,24 @@
from flask import Flask
from .auth import register_auth_gate
from .config import Config
from .player import PlayerStateManager
from .routes.api_routes import bp as api_bp
from .routes.auth_routes import bp as auth_bp
from .routes.pages import bp as pages_bp
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
app.register_blueprint(auth_bp)
app.register_blueprint(pages_bp)
app.register_blueprint(api_bp)
register_auth_gate(app)
app.player = PlayerStateManager(app.config["MPV_SOCKET"], app.config["VIDEO_EXTENSIONS"])
app.player.start()
return app

26
mediapi/auth.py Normal file
View file

@ -0,0 +1,26 @@
import hmac
from flask import current_app, redirect, request, session, url_for
EXEMPT_ENDPOINTS = {"auth.login", "static"}
def check_credentials(username, password):
cfg = current_app.config
return hmac.compare_digest(username, cfg["USERNAME"]) and hmac.compare_digest(
password, cfg["PASSWORD"]
)
def is_authenticated():
return bool(session.get("authenticated"))
def register_auth_gate(app):
@app.before_request
def _require_login():
if request.endpoint in EXEMPT_ENDPOINTS or request.endpoint is None:
return None
if not is_authenticated():
return redirect(url_for("auth.login"))
return None

31
mediapi/config.py Normal file
View file

@ -0,0 +1,31 @@
import os
from datetime import timedelta
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
SECRET_KEY_PATH = os.path.join(INSTANCE_DIR, "secret_key")
def load_secret_key():
os.makedirs(INSTANCE_DIR, exist_ok=True)
if not os.path.exists(SECRET_KEY_PATH):
with open(SECRET_KEY_PATH, "w") as f:
f.write(os.urandom(32).hex())
with open(SECRET_KEY_PATH) as f:
return f.read().strip()
class Config:
SECRET_KEY = load_secret_key()
PERMANENT_SESSION_LIFETIME = timedelta(days=3650)
SESSION_COOKIE_SAMESITE = "Lax"
USERNAME = "jujualexevan"
PASSWORD = "bambas"
MEDIA_ROOTS = ["/localmedia"]
MPV_SOCKET = "/run/mediapi/mpv.sock"
VIDEO_EXTENSIONS = {
".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".ts", ".flv", ".wmv",
}

70
mediapi/media.py Normal file
View file

@ -0,0 +1,70 @@
import os
class PathError(Exception):
pass
def _resolved_roots(media_roots):
return [os.path.realpath(r) for r in media_roots]
def resolve_path(requested_path, media_roots):
"""Resolve requested_path (absolute or relative to a root) and ensure it
stays within one of the configured media roots. Raises PathError otherwise."""
roots = _resolved_roots(media_roots)
if not requested_path:
# no path given -> the roots themselves are the top-level listing
return None
real = os.path.realpath(requested_path)
for root in roots:
if real == root or real.startswith(root + os.sep):
return real
raise PathError(f"path escapes configured media roots: {requested_path}")
def list_directory(path, media_roots, video_extensions):
"""Return {"folders": [...], "files": [...]} for the given resolved path,
or for the top-level roots themselves when path is None."""
if path is None:
folders = []
for root in media_roots:
real = os.path.realpath(root)
if os.path.isdir(real):
folders.append({"name": os.path.basename(real) or real, "path": real})
folders.sort(key=lambda e: e["name"].lower())
return {"folders": folders, "files": []}
folders = []
files = []
with os.scandir(path) as it:
for entry in it:
if entry.name.startswith("."):
continue
if entry.is_dir(follow_symlinks=True):
folders.append({"name": entry.name, "path": entry.path})
elif entry.is_file(follow_symlinks=True):
ext = os.path.splitext(entry.name)[1].lower()
if ext in video_extensions:
files.append({"name": entry.name, "path": entry.path})
folders.sort(key=lambda e: e["name"].lower())
files.sort(key=lambda e: e["name"].lower())
return {"folders": folders, "files": files}
def list_video_files(path, video_extensions):
"""Sorted list of video file paths directly inside `path` (no recursion)."""
files = []
with os.scandir(path) as it:
for entry in it:
if entry.name.startswith("."):
continue
if entry.is_file(follow_symlinks=True):
ext = os.path.splitext(entry.name)[1].lower()
if ext in video_extensions:
files.append(entry.path)
files.sort(key=lambda p: os.path.basename(p).lower())
return files

85
mediapi/mpv_ipc.py Normal file
View file

@ -0,0 +1,85 @@
import json
import socket
import threading
class MpvIPCError(Exception):
"""Base class for all mpv IPC errors."""
class MpvConnectionError(MpvIPCError):
"""The underlying socket is broken; caller should reconnect."""
class MpvCommandError(MpvIPCError):
"""mpv responded but rejected the command (e.g. a property that's
legitimately unavailable while idle, like time-pos with nothing loaded).
The connection itself is fine."""
class MpvIPCClient:
"""Minimal client for mpv's JSON IPC protocol over a unix domain socket.
One request in flight at a time (guarded by a lock) since responses on
the socket aren't tagged with a request id in a way we bother matching --
we just send a command and read the next response line.
"""
def __init__(self, socket_path):
self.socket_path = socket_path
self._sock = None
self._file = None
self._lock = threading.Lock()
def connect(self):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(2)
sock.connect(self.socket_path)
self._sock = sock
self._file = sock.makefile("rwb")
def close(self):
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._file = None
@property
def connected(self):
return self._sock is not None
def command(self, *args):
"""Send an mpv command, return its "data" field. Raises MpvIPCError
on failure or if not connected -- caller (PlayerStateManager) is
responsible for reconnect logic."""
if not self.connected:
raise MpvConnectionError("not connected")
payload = json.dumps({"command": list(args)}) + "\n"
with self._lock:
try:
self._file.write(payload.encode("utf-8"))
self._file.flush()
while True:
line = self._file.readline()
if not line:
raise MpvConnectionError("socket closed")
msg = json.loads(line)
# skip async event notifications, wait for the command reply
if "event" in msg:
continue
if msg.get("error") != "success":
raise MpvCommandError(msg.get("error", "unknown error"))
return msg.get("data")
except (OSError, json.JSONDecodeError) as exc:
self.close()
raise MpvConnectionError(str(exc)) from exc
def get_property(self, name):
return self.command("get_property", name)
def set_property(self, name, value):
return self.command("set_property", name, value)

183
mediapi/player.py Normal file
View file

@ -0,0 +1,183 @@
import logging
import os
import threading
import time
from .media import list_video_files
from .mpv_ipc import MpvCommandError, MpvConnectionError, MpvIPCClient, MpvIPCError
log = logging.getLogger(__name__)
POLL_INTERVAL = 1.0
RECONNECT_INTERVAL = 2.0
class PlayerStateManager:
"""Owns the single connection to mpv's IPC socket. A background thread
polls mpv for playback state and drives "keep playing" auto-advance;
Flask request handlers only ever read the cached snapshot or send a
command through this class -- they never touch the socket directly."""
def __init__(self, socket_path, video_extensions):
self.socket_path = socket_path
self.video_extensions = video_extensions
self._client = MpvIPCClient(socket_path)
self._lock = threading.Lock()
self._stop = threading.Event()
self._state = {
"connected": False,
"filename": None,
"position": None,
"duration": None,
"paused": None,
"volume": None,
"keep_playing": False,
}
self._queue_folder = None
self._queue_files = []
self._queue_index = -1
self._prev_idle = True
def start(self):
thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True)
thread.start()
def stop(self):
self._stop.set()
# -- background loop -------------------------------------------------
def _run(self):
while not self._stop.is_set():
if not self._client.connected:
try:
self._client.connect()
log.info("connected to mpv socket at %s", self.socket_path)
except OSError:
self._set_disconnected()
time.sleep(RECONNECT_INTERVAL)
continue
try:
self._poll_once()
except MpvConnectionError as exc:
log.warning("lost connection to mpv: %s", exc)
self._set_disconnected()
time.sleep(RECONNECT_INTERVAL)
continue
time.sleep(POLL_INTERVAL)
def _set_disconnected(self):
with self._lock:
self._state["connected"] = False
def _get_property_safe(self, name):
"""Like client.get_property, but treats "property unavailable"
(normal for time-pos/duration/filename while mpv is idle) as None
instead of a fatal error -- only a real MpvConnectionError should
tear down the connection."""
try:
return self._client.get_property(name)
except MpvCommandError:
return None
def _poll_once(self):
idle = bool(self._get_property_safe("idle-active"))
filename = self._get_property_safe("filename")
position = self._get_property_safe("time-pos")
duration = self._get_property_safe("duration")
paused = self._get_property_safe("pause")
volume = self._get_property_safe("volume")
if idle and not self._prev_idle:
self._maybe_advance()
idle = self._get_property_safe("idle-active")
self._prev_idle = bool(idle)
with self._lock:
self._state.update({
"connected": True,
"filename": filename,
"position": position,
"duration": duration,
"paused": paused,
"volume": volume,
})
def _maybe_advance(self):
"""Called from the poll loop when mpv just went idle. If keep-playing
is on and there's a next file in the current folder queue, load it."""
with self._lock:
keep_playing = self._state["keep_playing"]
has_next = (
self._queue_files
and 0 <= self._queue_index + 1 < len(self._queue_files)
)
if keep_playing and has_next:
self._queue_index += 1
next_file = self._queue_files[self._queue_index]
else:
next_file = None
if next_file:
try:
self._client.command("loadfile", next_file, "replace")
except MpvIPCError as exc:
log.warning("auto-advance loadfile failed: %s", exc)
# -- public read API ---------------------------------------------------
def get_status(self):
with self._lock:
state = dict(self._state)
state["keep_playing"] = self._state["keep_playing"]
return state
# -- public command API (called from Flask routes) ---------------------
def play_file(self, path):
self._client.command("loadfile", path, "replace")
# Build the auto-advance queue from the containing folder, positioned
# at the file just started -- so "keep playing" continues through the
# rest of the folder even when you start from the middle.
folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions)
try:
index = files.index(path)
except ValueError:
# played file isn't in the folder listing (unusual) -- queue just it
files = [path]
index = 0
with self._lock:
self._queue_folder = folder
self._queue_files = files
self._queue_index = index
def play_folder(self, folder_path):
files = list_video_files(folder_path, self.video_extensions)
if not files:
raise ValueError(f"no video files in {folder_path}")
self._client.command("loadfile", files[0], "replace")
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self):
self._client.command("cycle", "pause")
def seek(self, offset_seconds):
self._client.command("seek", offset_seconds, "relative")
def set_volume(self, value):
value = max(0, min(100, value))
self._client.set_property("volume", value)
def set_keep_playing(self, enabled):
with self._lock:
self._state["keep_playing"] = bool(enabled)

View file

View file

@ -0,0 +1,99 @@
from flask import Blueprint, current_app, jsonify, request
from ..media import PathError, list_directory, resolve_path
from ..mpv_ipc import MpvIPCError
bp = Blueprint("api", __name__, url_prefix="/api")
@bp.route("/browse")
def browse():
cfg = current_app.config
requested = request.args.get("path") or None
try:
resolved = resolve_path(requested, cfg["MEDIA_ROOTS"])
except PathError as exc:
return jsonify({"error": str(exc)}), 400
listing = list_directory(resolved, cfg["MEDIA_ROOTS"], cfg["VIDEO_EXTENSIONS"])
return jsonify({"path": resolved, **listing})
@bp.route("/play", methods=["POST"])
def play():
cfg = current_app.config
body = request.get_json(force=True, silent=True) or {}
requested = body.get("path")
mode = body.get("mode")
if not requested or mode not in ("file", "folder"):
return jsonify({"error": "expected {path, mode: 'file'|'folder'}"}), 400
try:
resolved = resolve_path(requested, cfg["MEDIA_ROOTS"])
except PathError as exc:
return jsonify({"error": str(exc)}), 400
player = current_app.player
try:
if mode == "file":
player.play_file(resolved)
else:
player.play_folder(resolved)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/playpause", methods=["POST"])
def playpause():
try:
current_app.player.playpause()
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/seek", methods=["POST"])
def seek():
body = request.get_json(force=True, silent=True) or {}
try:
offset = float(body.get("offset"))
except (TypeError, ValueError):
return jsonify({"error": "expected {offset: number}"}), 400
try:
current_app.player.seek(offset)
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/volume", methods=["POST"])
def volume():
body = request.get_json(force=True, silent=True) or {}
try:
value = int(body.get("value"))
except (TypeError, ValueError):
return jsonify({"error": "expected {value: 0-100}"}), 400
try:
current_app.player.set_volume(value)
except MpvIPCError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/keep-playing", methods=["POST"])
def keep_playing():
body = request.get_json(force=True, silent=True) or {}
current_app.player.set_keep_playing(bool(body.get("enabled")))
return jsonify({"ok": True})
@bp.route("/status")
def status():
return jsonify(current_app.player.get_status())

View file

@ -0,0 +1,28 @@
from flask import Blueprint, redirect, render_template, request, session, url_for
from ..auth import check_credentials, is_authenticated
bp = Blueprint("auth", __name__)
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
if is_authenticated():
return redirect(url_for("pages.index"))
return render_template("login.html", error=None)
username = request.form.get("username", "")
password = request.form.get("password", "")
if check_credentials(username, password):
session.permanent = True
session["authenticated"] = True
return redirect(url_for("pages.index"))
return render_template("login.html", error="Invalid username or password"), 401
@bp.route("/logout", methods=["POST"])
def logout():
session.clear()
return redirect(url_for("auth.login"))

8
mediapi/routes/pages.py Normal file
View file

@ -0,0 +1,8 @@
from flask import Blueprint, render_template
bp = Blueprint("pages", __name__)
@bp.route("/")
def index():
return render_template("index.html")

View file

@ -0,0 +1,165 @@
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #111;
color: #eee;
}
.login-body {
display: flex;
align-items: center;
justify-content: center;
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 80%;
max-width: 320px;
}
.login-form input, .login-form button {
padding: 0.75rem;
font-size: 1rem;
border-radius: 6px;
border: 1px solid #444;
}
.login-form button {
background: #2a6df4;
color: white;
border: none;
}
.error { color: #ff6b6b; }
.app {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mode-bar {
display: flex;
flex-shrink: 0;
}
.mode-btn {
flex: 1;
padding: 1rem;
font-size: 1.1rem;
background: #1c1c1c;
color: #888;
border: none;
border-bottom: 3px solid transparent;
}
.mode-btn.active {
color: #fff;
border-bottom-color: #2a6df4;
}
.panel {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.panel.hidden { display: none; }
.browse-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.browse-toolbar button {
flex: 1;
padding: 0.6rem;
border-radius: 6px;
border: 1px solid #444;
background: #1c1c1c;
color: #eee;
}
.browse-toolbar button:disabled { opacity: 0.4; }
.listing .entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0.4rem;
border-bottom: 1px solid #262626;
}
.listing .entry-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 0.5rem;
}
.listing .entry.folder .entry-name { cursor: pointer; }
.listing button {
padding: 0.4rem 0.7rem;
border-radius: 6px;
border: 1px solid #444;
background: #2a6df4;
color: white;
}
.offline { color: #ff6b6b; text-align: center; margin-top: 2rem; }
.hidden { display: none; }
.filename {
text-align: center;
font-size: 1.1rem;
margin: 0.5rem 0 1rem;
word-break: break-word;
}
.progress-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-row input[type="range"] { flex: 1; }
.transport-row {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.transport-row button {
flex: 1;
padding: 0.8rem;
border-radius: 6px;
border: 1px solid #444;
background: #1c1c1c;
color: #eee;
font-size: 1rem;
}
.volume-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-row input[type="range"] { flex: 1; }
.keep-playing-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}

152
mediapi/static/js/app.js Normal file
View file

@ -0,0 +1,152 @@
(function () {
"use strict";
let currentPath = null; // null = top-level media roots
let volumeDebounce = null;
const el = (id) => document.getElementById(id);
function api(path, options) {
return fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, options))
.then((r) => r.json().then((data) => ({ ok: r.ok, data })));
}
function formatTime(seconds) {
if (seconds === null || seconds === undefined || isNaN(seconds)) return "0:00";
seconds = Math.max(0, Math.floor(seconds));
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m + ":" + String(s).padStart(2, "0");
}
// -- mode switching -------------------------------------------------
function switchMode(mode) {
const isControl = mode === "control";
el("mode-control").classList.toggle("active", isControl);
el("mode-playback").classList.toggle("active", !isControl);
el("panel-control").classList.toggle("hidden", !isControl);
el("panel-playback").classList.toggle("hidden", isControl);
}
el("mode-control").addEventListener("click", () => switchMode("control"));
el("mode-playback").addEventListener("click", () => switchMode("playback"));
// -- browsing ---------------------------------------------------------
function loadBrowse(path) {
const url = path ? "/api/browse?path=" + encodeURIComponent(path) : "/api/browse";
api(url).then(({ ok, data }) => {
if (!ok) return;
currentPath = data.path;
renderListing(data);
});
}
function renderListing(data) {
el("btn-up").disabled = !currentPath;
el("btn-play-folder").disabled = !currentPath || data.files.length === 0;
const listing = el("listing");
listing.innerHTML = "";
data.folders.forEach((f) => {
const row = document.createElement("div");
row.className = "entry folder";
row.innerHTML = '<span class="entry-name">📁 ' + escapeHtml(f.name) + "</span>";
row.querySelector(".entry-name").addEventListener("click", () => loadBrowse(f.path));
listing.appendChild(row);
});
data.files.forEach((f) => {
const row = document.createElement("div");
row.className = "entry file";
row.innerHTML =
'<span class="entry-name">' + escapeHtml(f.name) + '</span><button>Play</button>';
row.querySelector("button").addEventListener("click", () => playPath(f.path, "file"));
listing.appendChild(row);
});
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
function playPath(path, mode) {
api("/api/play", { method: "POST", body: JSON.stringify({ path: path, mode: mode }) }).then(
({ ok }) => {
if (ok) switchMode("playback");
}
);
}
el("btn-up").addEventListener("click", () => {
if (!currentPath) return;
const parent = currentPath.split("/").slice(0, -1).join("/");
loadBrowse(parent || null);
});
el("btn-play-folder").addEventListener("click", () => {
if (currentPath) playPath(currentPath, "folder");
});
// -- playback status polling ------------------------------------------
function pollStatus() {
api("/api/status").then(({ data }) => {
const online = data.connected;
el("player-offline").classList.toggle("hidden", online);
el("player-online").classList.toggle("hidden", !online);
if (!online) return;
el("now-playing").textContent = data.filename || "—";
el("time-pos").textContent = formatTime(data.position);
el("time-dur").textContent = formatTime(data.duration);
const slider = el("seek-slider");
slider.max = data.duration || 0;
slider.value = data.position || 0;
if (document.activeElement !== el("volume-slider")) {
el("volume-slider").value = data.volume || 0;
}
el("keep-playing-checkbox").checked = !!data.keep_playing;
});
}
el("btn-playpause").addEventListener("click", () => {
api("/api/control/playpause", { method: "POST" });
});
el("btn-back30").addEventListener("click", () => {
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: -30 }) });
});
el("btn-fwd30").addEventListener("click", () => {
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: 30 }) });
});
el("volume-slider").addEventListener("input", (e) => {
clearTimeout(volumeDebounce);
const value = e.target.value;
volumeDebounce = setTimeout(() => {
api("/api/control/volume", { method: "POST", body: JSON.stringify({ value: Number(value) }) });
}, 150);
});
el("keep-playing-checkbox").addEventListener("change", (e) => {
api("/api/control/keep-playing", {
method: "POST",
body: JSON.stringify({ enabled: e.target.checked }),
});
});
// -- init ---------------------------------------------------------
loadBrowse(null);
switchMode("control");
pollStatus();
setInterval(pollStatus, 1500);
})();

View file

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>mediapi</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="app">
<div class="mode-bar">
<button id="mode-control" class="mode-btn active">Control</button>
<button id="mode-playback" class="mode-btn">Playback</button>
</div>
<div id="panel-control" class="panel">
<div class="browse-toolbar">
<button id="btn-up" disabled>&larr; Up</button>
<button id="btn-play-folder" disabled>Play this folder</button>
</div>
<div id="listing" class="listing"></div>
</div>
<div id="panel-playback" class="panel hidden">
<p id="player-offline" class="offline hidden">Player offline</p>
<div id="player-online">
<p id="now-playing" class="filename">&mdash;</p>
<div class="progress-row">
<span id="time-pos">0:00</span>
<input id="seek-slider" type="range" min="0" max="0" step="1" disabled>
<span id="time-dur">0:00</span>
</div>
<div class="transport-row">
<button id="btn-back30">&laquo; 30</button>
<button id="btn-playpause">Play/Pause</button>
<button id="btn-fwd30">30 &raquo;</button>
</div>
<div class="volume-row">
<span>Vol</span>
<input id="volume-slider" type="range" min="0" max="100" step="1">
</div>
<label class="keep-playing-row">
<input type="checkbox" id="keep-playing-checkbox">
Keep playing
</label>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>mediapi</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-body">
<form class="login-form" method="post" action="{{ url_for('auth.login') }}">
<h1>mediapi</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<input type="text" name="username" placeholder="username" autocomplete="username" required autofocus>
<input type="password" name="password" placeholder="password" autocomplete="current-password" required>
<button type="submit">Log in</button>
</form>
</body>
</html>

8
pyproject.toml Normal file
View file

@ -0,0 +1,8 @@
[project]
name = "mediapi"
version = "0.1.0"
description = "Local media browser + HDMI playback remote control"
requires-python = ">=3.11"
dependencies = [
"flask>=3",
]

6
run.py Normal file
View file

@ -0,0 +1,6 @@
from mediapi import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, threaded=True)

View file

@ -0,0 +1,15 @@
[Unit]
Description=MediaPi Flask app
After=network-online.target mediapi-mpv.service
Wants=network-online.target mediapi-mpv.service
[Service]
Type=simple
User=jm
WorkingDirectory=/home/jm/mediapi
ExecStart=/home/jm/.local/bin/uv run run.py
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,24 @@
[Unit]
Description=mpv persistent player (DRM/HDMI output, JSON IPC)
After=local-fs.target
[Service]
Type=simple
User=jm
SupplementaryGroups=video render
RuntimeDirectory=mediapi
RuntimeDirectoryMode=0770
ExecStart=/usr/bin/mpv \
--idle=yes \
--input-ipc-server=/run/mediapi/mpv.sock \
--vo=gpu-next --gpu-context=drm \
--hwdec=auto-safe \
--fullscreen \
--no-terminal \
--keep-open=no \
--ao=alsa
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target

168
uv.lock Normal file
View file

@ -0,0 +1,168 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mediapi"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
]
[package.metadata]
requires-dist = [{ name = "flask", specifier = ">=3" }]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]