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:
commit
b8cd65b8ca
21 changed files with 1294 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
120
README.md
Normal 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
24
mediapi/__init__.py
Normal 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
26
mediapi/auth.py
Normal 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
31
mediapi/config.py
Normal 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
70
mediapi/media.py
Normal 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
85
mediapi/mpv_ipc.py
Normal 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
183
mediapi/player.py
Normal 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)
|
||||
0
mediapi/routes/__init__.py
Normal file
0
mediapi/routes/__init__.py
Normal file
99
mediapi/routes/api_routes.py
Normal file
99
mediapi/routes/api_routes.py
Normal 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())
|
||||
28
mediapi/routes/auth_routes.py
Normal file
28
mediapi/routes/auth_routes.py
Normal 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
8
mediapi/routes/pages.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
165
mediapi/static/css/style.css
Normal file
165
mediapi/static/css/style.css
Normal 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
152
mediapi/static/js/app.js
Normal 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);
|
||||
})();
|
||||
52
mediapi/templates/index.html
Normal file
52
mediapi/templates/index.html
Normal 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>← 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">—</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">« 30</button>
|
||||
<button id="btn-playpause">Play/Pause</button>
|
||||
<button id="btn-fwd30">30 »</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>
|
||||
18
mediapi/templates/login.html
Normal file
18
mediapi/templates/login.html
Normal 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
8
pyproject.toml
Normal 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
6
run.py
Normal 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)
|
||||
15
systemd/mediapi-app.service
Normal file
15
systemd/mediapi-app.service
Normal 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
|
||||
24
systemd/mediapi-mpv.service
Normal file
24
systemd/mediapi-mpv.service
Normal 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
168
uv.lock
Normal 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" },
|
||||
]
|
||||
Loading…
Reference in a new issue