Compare commits

..
20 Commits
Author SHA1 Message Date
bart 49ad12ff14 fix(metadata): clear stream_title when DJ is talking 2026-11-07 17:21:24 +01:00
bart 9da6d3db27 fix(mqtt): decouple eventloop from status watcher and publish playlist to state and subtopic 2026-11-07 16:33:53 +01:00
bart 4bb6fe007e feat: Add playlist and genre separation to MQTT playback state and discovery 2026-11-07 12:50:58 +01:00
bart a795c3d024 feat(kiosk): bump v0.1.5 with Yatse artwork proxy, smart track selection, and HA buttons 2026-11-06 18:20:20 +01:00
bart 423bc75c94 fix(kiosk): auto-hide OSD, prioritize hardware-decoded formats, clean YouTube share URLs 2026-11-05 22:20:20 +01:00
bart 289f4a4ff5 feat(yatse): support Addons.ExecuteAddon YouTube sharing and Playlist endpoints 2026-11-05 19:12:04 +01:00
bart 888996f4f3 fix(gui): hide mouse cursor in kiosk mode 2026-11-05 18:58:19 +01:00
bart 3a407f90f7 feat(kiosk): bump v0.1.2 with video library support for movies, tv shows, and seasons 2026-11-05 18:46:32 +01:00
bart 4f2cdbd817 feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback 2026-11-04 23:26:08 +01:00
bart d77cdfedab new 2026-11-04 19:41:25 +01:00
bart 99cbc00357 eerste versie 2026-11-03 22:21:03 +01:00
bart 8a08620866 feat(eventserver): send UDP ping ack reply to Yatse client 2026-11-02 22:46:33 +01:00
bart 91657aad56 feat(jellyfin): support Jellyfin 12.x authentication headers and capabilities schema 2026-11-02 22:14:13 +01:00
bart 9fe1c66a31 fix(jellyfin): gracefully skip connection when password is empty to avoid log spam 2026-11-02 22:09:20 +01:00
bart 5de0425b57 fix(kodi_rpc): fix Application.SetMute error handling and correct platform booleans and system labels 2026-11-02 22:04:07 +01:00
bart ba5feaf3b2 feat(kodi_rpc): implement Settings.GetSettingValue, Settings.SetSettingValue, and library stubs 2026-11-02 22:01:14 +01:00
bart 935225b7bb feat(eventserver): implement Kodi UDP EventServer on port 9777 for Yatse 2026-11-02 21:52:14 +01:00
bart 3a0ea206a2 fix(kodi_rpc): support HTTP GET query param request and raw TCP JSON-RPC socket on 9090 for Yatse 2026-11-02 21:48:49 +01:00
bart 0aed952728 feat(docker): add Dockerfile, docker-compose and headless fallback for local testing 2026-11-02 21:42:26 +01:00
bart f66f874bae feat(client): initial implementation of jellyfin htpc client with yatse remote and youtube playback 2026-11-02 21:33:42 +01:00
6324 changed files with 35069 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
config.yaml
/tmp/mpv-*.sock
dist/
build/
*.egg-info/
.pytest_cache/
jellyfin-kiosk-rs/target*.log
+42
View File
@@ -0,0 +1,42 @@
# AI Agent & Contributor Guidelines: Rust Dependency & Feature Minimization
## Core Principle: Minimal Features & Fast Compilation
To ensure fast compilation times and prevent binary and dependency bloat on development and embedded/kiosk hardware:
1. **Always use `default-features = false` by default** on all external crates unless explicitly proven unnecessary.
2. **Never enable meta-features or "all" feature flags**:
- ❌ Never use `tokio = { features = ["full"] }`.
- ❌ Never use `egui_extras = { features = ["all_loaders"] }`.
- ❌ Never enable default formats in the `image` crate (pulls in `rayon`, `exr`, `tiff`, `dds`, `hdr`, etc.).
3. **Avoid heavy rasterization / rendering stacks**:
- Do **NOT** enable `svg` or `resvg` in `egui_extras`. The `resvg`/`usvg`/`tiny-skia` dependency tree adds dozens of crates and heavily impacts compilation time.
- Do **NOT** enable `wgpu` in `eframe`. Use `glow` (OpenGL) only.
4. **Iterate with `cargo check`**:
- Always run `cargo check` instead of `cargo build` during development cycles to test type checking and borrow checking in seconds.
---
## Approved Dependency Feature Matrix
When editing or updating `jellyfin-kiosk-rs/Cargo.toml`, adhere strictly to the following configurations:
| Crate | Approved Configuration | Rationale / Prohibited Features |
| :--- | :--- | :--- |
| `eframe` | `default-features = false, features = ["default_fonts", "glow", "wayland", "x11"]` | Keep `wgpu` disabled. Only build necessary windowing and OpenGL backends. |
| `egui_extras` | `default-features = false, features = ["http", "image"]` | Only needed for fetching/rendering HTTP posters & backdrops. **Never enable `all_loaders` or `svg`**. |
| `image` | `default-features = false, features = ["jpeg", "png"]` | Jellyfin images are JPEGs/PNGs. Disabling default features avoids `rayon` and heavy decoders. |
| `tokio` | `default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"]` | Never use `"full"`. `fs`, `process`, and `signal` from Tokio are not used (standard library equivalents are used). |
| `axum` | `default-features = false, features = ["http1", "json", "tokio"]` | The REST API only handles JSON POST/GET requests and Tokio serving. No `form` or `query` parsers needed. |
| `reqwest` | `default-features = false, features = ["json", "default-tls", "http2"]` | Minimal HTTP client features required for communication with the Jellyfin server. |
| `tracing-subscriber`| `features = ["env-filter"]` | Standard lightweight log filtering and formatting. |
| `rumqttc` | `default-features = false` | MQTT client for Home Assistant discovery and state reporting. Avoids rustls/aws-lc-rs crypto bloat. |
---
## Verification Before Committing
Whenever dependencies or features are changed:
1. Run `cargo check` in `jellyfin-kiosk-rs` to ensure compilation without warnings/errors.
2. Check dependency count in `Cargo.lock` (`grep "name = " Cargo.lock | wc -l`) to confirm no unexpected transitive bloat was introduced.
3. Verify that `cargo build` links successfully.
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.12-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
# Install mpv, ffmpeg and system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
mpv \
ffmpeg \
curl \
ca-certificates \
libgl1 \
libegl1 \
libx11-6 \
libxext6 \
libasound2 \
pulseaudio-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install python dependencies and latest yt-dlp
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt yt-dlp
COPY src/ ./src/
COPY config.example.yaml ./config.example.yaml
# If no config.yaml exists at runtime, fallback to config.example.yaml
RUN cp config.example.yaml config.yaml
EXPOSE 8080 9090 8097 9777/udp
ENTRYPOINT ["python3", "-m", "src.main"]
CMD ["-c", "config.yaml"]
+197
View File
@@ -0,0 +1,197 @@
# Jellyfin HTPC Client & YouTube Receiver
Een snelle, lichtgewicht HTPC client ontworpen voor Linux mini-PC's (x86_64). Integreert met **Jellyfin** voor het streamen van films/series en biedt volledige ondersteuning voor **Yatse** en het delen van **YouTube** video's rechtstreeks vanuit de YouTube mobiele app.
---
## 🌟 Belangrijkste Functies
1. **Jellyfin Remote Player**:
- Registreert zich via WebSocket direct bij de Jellyfin server (`https://thuus.bnhmeijer.nl/jellyfin` of lokaal IP).
- Verschijnt automatisch als "Cast / Remote Speler" in de Jellyfin app en Yatse.
- Stuurt realtime voortgang, speelduur en hervatting terug naar Jellyfin.
2. **Yatse & Kodi Remote Ondersteuning**:
- Bevat een geëmuleerde **Kodi JSON-RPC server** (standaard HTTP poort `8080` & WebSocket `9090`).
- Yatse kan de HTPC toevoegen als Kodi mediacenter voor volledige controle: Play/Pause, Seek, Volume, Mute, OSD notificaties.
3. **YouTube Delen vanuit de YouTube App**:
- **Via Yatse**: Tik in de YouTube app op *Delen* $\rightarrow$ *Yatse* $\rightarrow$ de video speelt direct af op het grote scherm!
- **Via Web Interface / API**: Open `http://<htpc-ip>:8097/` op je telefoon om een link te plakken of de speler te bedienen.
- Automatische metadata-extractie (titel, uploader, speelduur) en stream extractie via `yt-dlp`.
4. **Hardware-versnelde MPV Video Engine**:
- Gestuurd via MPV IPC Unix socket.
- Automatische GPU decodering (`--hwdec=auto` voor Intel QuickSync / AMD VA-API / Nvidia NVDEC).
- Minimale geheugenvoetprint (< 40MB RAM).
---
## 🏗️ Architectuur
```mermaid
flowchart TD
subgraph Mobile ["Telefoon / Tablet"]
YT[YouTube App] -->|Delen naar Yatse| Yatse[Yatse Remote App]
JFApp[Jellyfin Mobiel] -->|Cast naar HTPC| JFSrv[Jellyfin Server]
Browser[Mobiele Browser] -->|Plak YouTube Link| WebAPI[Web Remote :8097]
end
subgraph HTPC ["HTPC (x86_64 Linux)"]
subgraph Remotes
KodiRPC["Kodi JSON-RPC Server (:8080 / :9090)"]
WebAPI
end
subgraph Core ["jellyfin-htpc-client (Python 3.12)"]
JFClient["Jellyfin Client Session (WebSocket)"]
YTParser["YouTube Parser & yt-dlp"]
MPVControl["MPV IPC Controller"]
end
subgraph Playback ["Media Speler"]
MPV["MPV Video Player (GPU / VA-API)"]
end
end
Yatse -->|JSON-RPC| KodiRPC
JFSrv -->|WebSocket Remote Control| JFClient
KodiRPC --> MPVControl
WebAPI --> MPVControl
JFClient --> MPVControl
YTParser --> MPVControl
MPVControl --> MPV
```
---
## 🐳 Lokaal Testen met Docker
Je kunt de client direct lokaal testen met Docker en Docker Compose:
```bash
# 1. Start de container (lokaal)
docker compose up -d
# 2. Bekijk de logs
docker compose logs -f
# 3. Open de Web Remote in je browser:
# http://localhost:8097/
#
# 4. Of test Yatse vanaf je telefoon op hetzelfde Wi-Fi netwerk:
# Voeg Kodi toe met het IP-adres van je laptop op poort 8080!
```
### Display / X11 Doorkoppeling (Optioneel):
Als je wilt dat het MPV-videovenster op het scherm van je laptop verschijnt, geef Docker dan eenmalig toegang tot X11:
```bash
xhost +local:root
docker compose up -d
```
*Draai je zonder beeldscherm of op een headless server? De client detecteert dit automatisch en valt terug op headless mode (`--vo=null --ao=null`), of start handmatig met `MPV_VO=null docker compose up -d`.*
---
## 📦 Installatie op de HTPC Bare-Metal (Aanbevolen voor TV)
```bash
git clone https://git.bnhmeijer.nl/bart/jellyfin-client.git
cd jellyfin-client
chmod +x install.sh
sudo ./install.sh
```
Het script installeert automatisch de benodigde pakketten (`mpv`, `yt-dlp`, `python3-venv`), maakt `/opt/jellyfin-client` aan met een virtuele omgeving en installeert de systemd service.
---
## ⚙️ Configuratie
Bewerk `/etc/jellyfin-client/config.yaml` (of `config.yaml` in de projectmap):
```yaml
jellyfin:
server_url: "https://thuus.bnhmeijer.nl/jellyfin" # of lokaal: http://10.2.0.4:8096/jellyfin
username: "bart"
password: "jouw_wachtwoord"
client_name: "Jellyfin HTPC Client"
device_name: "HTPC Woonkamer"
device_id: "htpc-woonkamer-01"
player:
mpv_path: "mpv"
ipc_socket: "/tmp/mpv-jellyfin.sock"
hwdec: "auto" # 'auto', 'vaapi' (Intel/AMD) of 'nvdec' (Nvidia)
vo: "gpu"
fullscreen: true
audio_device: "auto"
kodi_rpc:
enabled: true
http_port: 8080 # Poort voor Yatse Kodi-verbinding
ws_port: 9090
web_api:
enabled: true
host: "0.0.0.0"
port: 8097 # Web Remote UI & REST API
```
---
## 📱 Yatse Verbinden & Gebruiken
### Optie 1: Als Kodi Speler (Beste ervaring voor YouTube delen)
1. Open **Yatse** op je Android telefoon.
2. Ga naar **Instellingen** $\rightarrow$ **Hosts beheren** $\rightarrow$ **+** (Host toevoegen).
3. Kies **Kodi**.
4. Vul het IP-adres van je HTPC in en poort `8080`.
5. Yatse detecteert direct de speler.
6. **YouTube afspelen**: Ga in de officiële YouTube app naar een video $\rightarrow$ druk op **Delen** $\rightarrow$ selecteer **Yatse** $\rightarrow$ de video start direct op je HTPC!
### Optie 2: Als Jellyfin Speler
1. Voeg in Yatse je **Jellyfin server** toe (`https://thuus.bnhmeijer.nl/jellyfin`).
2. Tik rechtsboven op het Cast-icoon.
3. Selecteer **HTPC Woonkamer** als afspeeldoel.
4. Elke film of serie die je nu kiest in Yatse speelt synchroon af via MPV op de HTPC.
---
## 🌐 Web Remote & Directe API
Open op elk apparaat in je netwerk de browser:
```
http://<htpc-ip>:8097/
```
Hier kun je een YouTube URL invoeren, op "Play on TV" drukken en speelbediening uitvoeren (Play/Pause, -15s/+15s, Volume).
### REST Endpoints
- `POST /api/play` - `{"url": "https://www.youtube.com/watch?v=..."}`
- `POST /api/playpause`
- `POST /api/stop`
- `POST /api/seek` - `{"seconds": 15}`
- `POST /api/volume` - `{"volume": 80}`
- `GET /api/status`
---
## 🛠️ Service Beheer
```bash
# Service starten en automatisch inschakelen bij opstarten
sudo systemctl enable --now jellyfin-client
# Status en logs bekijken
sudo systemctl status jellyfin-client
sudo journalctl -u jellyfin-client -f
```
---
## 🧪 Tests Uitvoeren
```bash
.venv/bin/pytest -v
```
+67
View File
@@ -0,0 +1,67 @@
# Jellyfin HTPC Client Configuration
jellyfin:
# Base URL of your Jellyfin server (can be internal IP or external domain with HTTPS)
server_url: "https://thuus.bnhmeijer.nl/jellyfin"
# Login credentials
username: "bart"
password: ""
# Device identification
client_name: "Jellyfin HTPC Client"
device_name: "HTPC Living Room"
device_id: "htpc-living-room-01"
player:
# Path to mpv binary
mpv_path: "mpv"
# MPV IPC socket location
ipc_socket: "/tmp/mpv-jellyfin.sock"
# Video output & hardware decoding
# Options for hwdec: auto, vaapi (Intel/AMD), nvdec (Nvidia), no
hwdec: "auto"
vo: "gpu"
fullscreen: true
# Audio device (use `mpv --audio-device=help` to list devices)
audio_device: "auto"
# Custom MPV arguments
extra_args:
- "--keep-open=yes"
- "--force-window=immediate"
- "--osd-font=sans-serif"
- "--osd-font-size=28"
kodi_rpc:
# Emulated Kodi JSON-RPC interface for Yatse and other Kodi remotes
enabled: true
http_port: 8080
ws_port: 9090
web_api:
# Direct REST API and web sharing page
enabled: true
host: "0.0.0.0"
port: 8097
youtube:
# Maximum resolution preference for YouTube streaming via yt-dlp
max_height: 1080
# Automatically extract metadata (title, uploader) using yt-dlp before playback
fetch_metadata: true
# Google OAuth 2.0 Client ID (Google Cloud Console -> APIs -> Credentials, type: TVs and Limited Input / Desktop)
client_id: ""
client_secret: ""
# Pad voor het opslaan van de ingelogde YouTube sessie
token_path: "youtube_token.json"
mqtt:
# Enable MQTT client and Home Assistant auto-discovery
enabled: false
# MQTT broker host/IP
host: "localhost"
port: 1883
username: ""
password: ""
# Home Assistant MQTT discovery prefix (default: homeassistant)
discovery_prefix: "homeassistant"
# Base topic prefix for state updates and commands
topic_prefix: "jellyfin-kiosk"
+24
View File
@@ -0,0 +1,24 @@
services:
jellyfin-client:
build:
context: .
dockerfile: Dockerfile
container_name: jellyfin-client
restart: unless-stopped
network_mode: host
environment:
- DISPLAY=${DISPLAY:-:0}
- MPV_VO=${MPV_VO:-gpu} # 'gpu' for video window, 'null' for headless
- MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no'
- MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing
volumes:
# Optional: Mount custom config.yaml if present
- ./config.yaml:/app/config.yaml:ro
# X11 Display passthrough to show video window on host screen
- /tmp/.X11-unix:/tmp/.X11-unix:ro
# Hardware acceleration devices
devices:
- /dev/dri:/dev/dri
- /dev/snd:/dev/snd
Executable
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Installer script for Jellyfin HTPC Client on Ubuntu/Debian
INSTALL_DIR="/opt/jellyfin-client"
CONFIG_DIR="/etc/jellyfin-client"
SYSTEMD_SERVICE_FILE="/etc/systemd/system/jellyfin-client.service"
echo "=========================================================="
echo " Jellyfin HTPC Client & YouTube Receiver Installer "
echo "=========================================================="
if [ "$EUID" -ne 0 ]; then
echo "[-] Please run as root (e.g. sudo ./install.sh)"
exit 1
fi
CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[*] Step 1: Installing system dependencies (mpv, yt-dlp, python3)..."
apt-get update -qq
apt-get install -y -qq mpv yt-dlp python3 python3-venv curl
echo "[*] Step 2: Setting up installation directory at ${INSTALL_DIR}..."
mkdir -p "${INSTALL_DIR}"
cp -ru "${CURRENT_DIR}/src" "${INSTALL_DIR}/"
cp -u "${CURRENT_DIR}/requirements.txt" "${INSTALL_DIR}/"
cp -u "${CURRENT_DIR}/config.example.yaml" "${INSTALL_DIR}/"
echo "[*] Step 3: Creating Python virtual environment..."
python3 -m venv "${INSTALL_DIR}/.venv"
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q
echo "[*] Step 4: Configuring /etc/jellyfin-client..."
mkdir -p "${CONFIG_DIR}"
if [ ! -f "${CONFIG_DIR}/config.yaml" ]; then
cp "${CURRENT_DIR}/config.example.yaml" "${CONFIG_DIR}/config.yaml"
echo "[+] Created default config at ${CONFIG_DIR}/config.yaml"
else
echo "[i] Existing configuration found at ${CONFIG_DIR}/config.yaml (preserving)"
fi
echo "[*] Step 5: Installing systemd service..."
cp "${CURRENT_DIR}/systemd/jellyfin-client.service" "${SYSTEMD_SERVICE_FILE}"
systemctl daemon-reload
echo ""
echo "=========================================================="
echo " Installation Complete! "
echo "=========================================================="
echo "Next steps:"
echo "1. Edit configuration in: ${CONFIG_DIR}/config.yaml"
echo " (Set your Jellyfin URL, username, and password)"
echo ""
echo "2. Enable and start the service:"
echo " sudo systemctl enable --now jellyfin-client"
echo ""
echo "3. Check logs:"
echo " sudo journalctl -u jellyfin-client -f"
echo ""
echo "4. In Yatse:"
echo " - Add Media Center -> Kodi -> Enter HTPC IP on port 8080 (for direct YouTube shares & remote)"
echo " - Or add your Jellyfin server directly in Yatse and select '${CONFIG_DIR}' as remote player!"
echo "=========================================================="
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+4201
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "jellyfin-kiosk-rs"
version = "0.1.5"
edition = "2021"
[dependencies]
eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
egui_extras = { version = "0.29", default-features = false, features = ["http", "image"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
tokio = { version = "1.40", default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls", "http2"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
raw-window-handle = "0.6"
rumqttc = { version = "0.25", default-features = false }
libc = "0.2"
libmpv-sys = { version = "3.1", default-features = false }
+31
View File
@@ -0,0 +1,31 @@
use std::path::Path;
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let lib_dir = Path::new(&out_dir).join("libmpv_link");
let _ = std::fs::create_dir_all(&lib_dir);
let candidates = [
"/lib/x86_64-linux-gnu/libmpv.so.2",
"/usr/lib/x86_64-linux-gnu/libmpv.so.2",
"/usr/lib64/libmpv.so.2",
"/lib/x86_64-linux-gnu/libmpv.so.1",
"/usr/lib/x86_64-linux-gnu/libmpv.so.1",
"/usr/lib/libmpv.so.2",
"/usr/lib/libmpv.so.1",
];
let dest = lib_dir.join("libmpv.so");
for candidate in &candidates {
if Path::new(candidate).exists() {
let _ = std::fs::remove_file(&dest);
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(candidate, &dest);
println!("cargo:rustc-link-search=native={}", lib_dir.display());
break;
}
}
println!("cargo:rustc-link-lib=mpv");
println!("cargo:rerun-if-changed=build.rs");
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub jellyfin: JellyfinConfig,
#[serde(default)]
pub player: PlayerConfig,
#[serde(default)]
pub kodi_rpc: KodiRpcConfig,
#[serde(default)]
pub mqtt: MqttConfig,
#[serde(default)]
pub youtube: YouTubeConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JellyfinConfig {
pub server_url: String,
pub username: String,
pub password: Option<String>,
#[serde(default = "default_client_name")]
pub client_name: String,
#[serde(default = "default_device_name")]
pub device_name: String,
#[serde(default = "default_device_id")]
pub device_id: String,
}
fn default_client_name() -> String {
"Jellyfin HTPC Kiosk".to_string()
}
fn default_device_name() -> String {
"HTPC Kiosk".to_string()
}
fn default_device_id() -> String {
"htpc-kiosk-01".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerConfig {
#[serde(default = "default_mpv_path")]
pub mpv_path: String,
#[serde(default = "default_ipc_socket")]
pub ipc_socket: String,
#[serde(default = "default_hwdec")]
pub hwdec: String,
#[serde(default = "default_vo")]
pub vo: String,
#[serde(default = "default_fullscreen")]
pub fullscreen: bool,
}
fn default_mpv_path() -> String {
"mpv".to_string()
}
fn default_ipc_socket() -> String {
"/tmp/mpv-kiosk.sock".to_string()
}
fn default_hwdec() -> String {
"auto".to_string()
}
fn default_vo() -> String {
"gpu".to_string()
}
fn default_fullscreen() -> bool {
true
}
impl Default for PlayerConfig {
fn default() -> Self {
Self {
mpv_path: default_mpv_path(),
ipc_socket: default_ipc_socket(),
hwdec: default_hwdec(),
vo: default_vo(),
fullscreen: default_fullscreen(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct KodiRpcConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_http_port")]
pub http_port: u16,
#[serde(default = "default_ws_port")]
pub ws_port: u16,
}
fn default_true() -> bool {
true
}
fn default_http_port() -> u16 {
8080
}
fn default_ws_port() -> u16 {
9090
}
impl Default for KodiRpcConfig {
fn default() -> Self {
Self {
enabled: default_true(),
http_port: default_http_port(),
ws_port: default_ws_port(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MqttConfig {
#[serde(default = "default_false")]
pub enabled: bool,
#[serde(default = "default_mqtt_host")]
pub host: String,
#[serde(default = "default_mqtt_port")]
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default = "default_discovery_prefix")]
pub discovery_prefix: String,
#[serde(default = "default_topic_prefix")]
pub topic_prefix: String,
pub client_id: Option<String>,
}
fn default_false() -> bool {
false
}
fn default_mqtt_host() -> String {
"localhost".to_string()
}
fn default_mqtt_port() -> u16 {
1883
}
fn default_discovery_prefix() -> String {
"homeassistant".to_string()
}
fn default_topic_prefix() -> String {
"jellyfin-kiosk".to_string()
}
impl Default for MqttConfig {
fn default() -> Self {
Self {
enabled: default_false(),
host: default_mqtt_host(),
port: default_mqtt_port(),
username: None,
password: None,
discovery_prefix: default_discovery_prefix(),
topic_prefix: default_topic_prefix(),
client_id: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct YouTubeConfig {
#[serde(default = "default_max_height")]
pub max_height: u32,
#[serde(default = "default_true")]
pub fetch_metadata: bool,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default = "default_youtube_token_path")]
pub token_path: String,
}
fn default_max_height() -> u32 {
1080
}
fn default_youtube_token_path() -> String {
"youtube_token.json".to_string()
}
impl Default for YouTubeConfig {
fn default() -> Self {
Self {
max_height: default_max_height(),
fetch_metadata: default_true(),
client_id: String::new(),
client_secret: None,
token_path: default_youtube_token_path(),
}
}
}
impl AppConfig {
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let candidates = [
PathBuf::from("config.yaml"),
PathBuf::from("../config.yaml"),
PathBuf::from("/etc/jellyfin-client/config.yaml"),
PathBuf::from("config.example.yaml"),
PathBuf::from("../config.example.yaml"),
];
for path in &candidates {
if path.exists() {
let content = std::fs::read_to_string(path)?;
let cfg: AppConfig = serde_yaml::from_str(&content)?;
tracing::info!("Loaded config from {}", path.display());
return Ok(cfg);
}
}
Err("No config.yaml found in search paths".into())
}
}
+757
View File
@@ -0,0 +1,757 @@
use std::sync::Arc;
use tokio::net::UdpSocket;
use crate::player::PlayerController;
use crate::yatse::{RemoteNavAction, RemoteNavSender};
// Official Kodi EventServer Packet Types
pub const PT_HELO: u16 = 0x01;
pub const PT_BYE: u16 = 0x02;
pub const PT_BUTTON: u16 = 0x03;
pub const _PT_MOUSE: u16 = 0x04;
pub const PT_PING: u16 = 0x05;
pub const _PT_BROADCAST: u16 = 0x06;
pub const _PT_NOTIFICATION: u16 = 0x07;
pub const _PT_BLOB: u16 = 0x08;
pub const _PT_LOG: u16 = 0x09;
pub const PT_ACTION: u16 = 0x0A; // 10 in decimal!
// Button packet flags
pub const _PTB_USE_NAME: u16 = 0x01;
pub const PTB_DOWN: u16 = 0x02;
pub const PTB_UP: u16 = 0x04;
pub const HEADER_SIZE: usize = 32;
pub struct KodiEventServer {
port: u16,
player: Arc<PlayerController>,
nav_tx: RemoteNavSender,
}
impl KodiEventServer {
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: impl Into<RemoteNavSender>) -> Self {
Self { port, player, nav_tx: nav_tx.into() }
}
pub async fn start(&self) -> Result<u16, std::io::Error> {
let addr = format!("0.0.0.0:{}", self.port);
let socket = match UdpSocket::bind(&addr).await {
Ok(s) => {
let bound_port = s.local_addr()?.port();
tracing::info!("Kodi EventServer listening on UDP 0.0.0.0:{}", bound_port);
(Arc::new(s), bound_port)
}
Err(e) => {
tracing::warn!("Failed to bind Kodi EventServer on UDP {}: {}", addr, e);
return Err(e);
}
};
let (socket, bound_port) = socket;
let player = self.player.clone();
let nav_tx = self.nav_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
loop {
let (len, peer) = match socket.recv_from(&mut buf).await {
Ok(res) => res,
Err(e) => {
tracing::warn!("EventServer recv error: {}", e);
continue;
}
};
if len < HEADER_SIZE {
continue;
}
if &buf[0..4] != b"XBMC" {
continue;
}
let majver = buf[4];
let minver = buf[5];
let pkttype = u16::from_be_bytes([buf[6], buf[7]]);
let _seq = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
let _maxseq = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]);
let paylen = u16::from_be_bytes([buf[16], buf[17]]) as usize;
let client_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
let payload = if len >= HEADER_SIZE + paylen {
&buf[HEADER_SIZE..HEADER_SIZE + paylen]
} else {
&buf[HEADER_SIZE..len]
};
tracing::info!(
"EventServer packet from {}: ver={}.{}, type=0x{:02X}, paylen={}, token={}",
peer, majver, minver, pkttype, paylen, client_token
);
match pkttype {
PT_HELO | PT_PING => {
let dev_name = payload
.split(|&b| b == 0)
.next()
.map(|s| String::from_utf8_lossy(s).to_string())
.unwrap_or_default();
tracing::info!("EventServer HELO/PING from {} (device: '{}')", peer, dev_name);
// Echo ping ack back to client (Yatse expects this handshake)
let mut ack = [0u8; HEADER_SIZE];
ack[0..4].copy_from_slice(b"XBMC");
ack[4] = 2; // majver
ack[5] = 0; // minver
ack[6..8].copy_from_slice(&PT_PING.to_be_bytes());
ack[8..12].copy_from_slice(&1u32.to_be_bytes());
ack[12..16].copy_from_slice(&1u32.to_be_bytes());
ack[16..18].copy_from_slice(&0u16.to_be_bytes());
ack[18..22].copy_from_slice(&client_token.to_be_bytes());
let _ = socket.send_to(&ack, peer).await;
tracing::info!("EventServer sent PING-ACK to {}", peer);
}
PT_BUTTON => {
if payload.len() >= 6 {
// Format: code(2B) + flags(2B) + amount(2B) + map_name(null-term) + button_name(null-term)
let bcode = u16::from_be_bytes([payload[0], payload[1]]);
let flags = u16::from_be_bytes([payload[2], payload[3]]);
let _amount = u16::from_be_bytes([payload[4], payload[5]]);
let is_down = (flags & PTB_DOWN) != 0 || (flags & PTB_UP) == 0;
if !is_down {
// Ignore key release
continue;
}
let str_data = &payload[6..];
let mut parts = str_data.split(|&b| b == 0);
let map_name = parts
.next()
.map(|s| String::from_utf8_lossy(s).to_string())
.unwrap_or_default();
let btn_name = parts
.next()
.map(|s| String::from_utf8_lossy(s).to_lowercase())
.unwrap_or_default();
tracing::info!(
"EventServer BUTTON: code=0x{:04X}, flags=0x{:04X}, map='{}', btn='{}'",
bcode, flags, map_name, btn_name
);
dispatch_button_or_code(&btn_name, bcode, &nav_tx, &player);
}
}
PT_ACTION => {
if payload.len() >= 2 {
// Format: action_type(1B) + action_string(null-terminated string)
let action_type = payload[0];
let action_str = String::from_utf8_lossy(&payload[1..])
.trim_matches('\0')
.trim()
.to_string();
tracing::info!("EventServer ACTION: type={}, action='{}'", action_type, action_str);
dispatch_action(&action_str, &nav_tx, &player);
}
}
PT_BYE => {
tracing::info!("EventServer client BYE from {}", peer);
}
_ => {
tracing::debug!("EventServer unhandled packet type: 0x{:02X}", pkttype);
}
}
}
});
Ok(bound_port)
}
}
#[allow(dead_code)]
pub fn build_packet_header(pkttype: u16, paylen: u16, client_token: u32) -> [u8; HEADER_SIZE] {
let mut header = [0u8; HEADER_SIZE];
header[0..4].copy_from_slice(b"XBMC");
header[4] = 2; // majver
header[5] = 0; // minver
header[6..8].copy_from_slice(&pkttype.to_be_bytes());
header[8..12].copy_from_slice(&1u32.to_be_bytes());
header[12..16].copy_from_slice(&1u32.to_be_bytes());
header[16..18].copy_from_slice(&paylen.to_be_bytes());
header[18..22].copy_from_slice(&client_token.to_be_bytes());
header
}
#[allow(dead_code)]
pub fn build_helo_packet(device_name: &str, token: u32) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(device_name.as_bytes());
payload.push(0);
// icon_type (0 = none), port (0), reserved (8)
payload.push(0);
payload.extend_from_slice(&0u16.to_be_bytes());
payload.extend_from_slice(&[0u8; 8]);
let header = build_packet_header(PT_HELO, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
#[allow(dead_code)]
pub fn build_ping_packet(token: u32) -> Vec<u8> {
let header = build_packet_header(PT_PING, 0, token);
header.to_vec()
}
#[allow(dead_code)]
pub fn build_button_packet(
code: u16,
flags: u16,
map_name: &str,
button_name: &str,
token: u32,
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&code.to_be_bytes());
payload.extend_from_slice(&flags.to_be_bytes());
payload.extend_from_slice(&0u16.to_be_bytes());
payload.extend_from_slice(map_name.as_bytes());
payload.push(0);
payload.extend_from_slice(button_name.as_bytes());
payload.push(0);
let header = build_packet_header(PT_BUTTON, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
#[allow(dead_code)]
pub fn build_action_packet(action_str: &str, token: u32) -> Vec<u8> {
let mut payload = Vec::new();
payload.push(1); // ACTION_EXECBUILTIN
payload.extend_from_slice(action_str.as_bytes());
payload.push(0);
let header = build_packet_header(PT_ACTION, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
pub fn dispatch_button_or_code(
btn: &str,
code: u16,
nav_tx: &RemoteNavSender,
player: &Arc<PlayerController>,
) {
let lower = btn.to_lowercase();
match lower.as_str() {
"up" | "bigstepforward" | "cursorup" => {
let _ = nav_tx.send(RemoteNavAction::Up);
}
"down" | "bigstepback" | "cursordown" => {
let _ = nav_tx.send(RemoteNavAction::Down);
}
"left" | "stepback" | "cursorleft" => {
let _ = nav_tx.send(RemoteNavAction::Left);
}
"right" | "stepforward" | "cursorright" => {
let _ = nav_tx.send(RemoteNavAction::Right);
}
"select" | "enter" | "ok" => {
let _ = nav_tx.send(RemoteNavAction::Select);
}
"back" | "parentdir" | "previousmenu" | "escape" => {
let _ = nav_tx.send(RemoteNavAction::Back);
}
"title" | "topmenu" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"menu" | "contextmenu" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"togglefullscreen" | "fullscreen" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"osd" | "showosd" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"subtitles" | "nextsubtitle" => {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
}
"audionextlanguage" | "nextaudio" | "cycleaudio" => {
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
}
"play" | "pause" | "playpause" => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
"stop" => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
"volumeup" | "vol_up" => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "vol_down" => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"mute" | "volumemute" | "togglemute" => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
"info" | "showcodecinfo" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
_ => {
// Fallback to raw keycode if button string was not matched
match code {
0xF010 | 0xF050 | 273 => { let _ = nav_tx.send(RemoteNavAction::Up); }
0xF011 | 0xF051 | 274 => { let _ = nav_tx.send(RemoteNavAction::Down); }
0xF012 | 0xF052 | 276 => { let _ = nav_tx.send(RemoteNavAction::Left); }
0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); }
0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); }
0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); }
0xF020 | 0xF022 => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
0xF021 => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
0xF02B => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
0xF02C => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
0xF02D => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
_ => {}
}
}
}
}
pub fn normalize_action(action: &str) -> String {
let mut lower = action.trim().to_lowercase();
if lower.starts_with("action(") && lower.ends_with(')') {
lower = lower[7..lower.len() - 1].trim().to_string();
}
if lower.ends_with("()") {
lower = lower[..lower.len() - 2].trim().to_string();
}
lower
}
pub fn dispatch_action(
action: &str,
nav_tx: &RemoteNavSender,
player: &Arc<PlayerController>,
) {
let lower = action.trim().to_lowercase();
// Check window activation first
if lower.starts_with("activatewindow") {
if lower.contains("tvshowtitles") || lower.contains("tvshows") || lower.contains("shows") || lower.contains("series") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
} else if lower.contains("movietitles") || lower.contains("videos") || lower.contains("movies") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
} else if lower.contains("music") || lower.contains("songs") || lower.contains("artists") || lower.contains("albums") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
} else if lower.contains("pictures") || lower.contains("photos") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(5));
} else if lower.contains("livetv") || lower.contains("channels") || lower.contains("tv") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
} else if lower.contains("home") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
}
return;
}
let norm = normalize_action(action);
match norm.as_str() {
"up" | "cursorup" | "3" => {
let _ = nav_tx.send(RemoteNavAction::Up);
}
"down" | "cursordown" | "4" => {
let _ = nav_tx.send(RemoteNavAction::Down);
}
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => {
let _ = nav_tx.send(RemoteNavAction::Left);
}
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => {
let _ = nav_tx.send(RemoteNavAction::Right);
}
"bigstepforward" => {
let p = player.clone();
tokio::spawn(async move { p.seek(60.0).await; });
}
"bigstepback" => {
let p = player.clone();
tokio::spawn(async move { p.seek(-60.0).await; });
}
"select" | "enter" | "7" | "selectitem" => {
let _ = nav_tx.send(RemoteNavAction::Select);
}
"back" | "parentdir" | "previousmenu" | "close" | "9" | "10" | "escape" => {
let _ = nav_tx.send(RemoteNavAction::Back);
}
"title" | "topmenu" | "11" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"menu" | "contextmenu" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"togglefullscreen" | "fullscreen" | "12" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"osd" | "showosd" | "114" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"nextsubtitle" | "subtitles" | "showsubtitles" | "122" => {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
}
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
}
"play" | "pause" | "playpause" | "15" => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
"stop" | "14" => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
"volumeup" | "vol_up" | "88" => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "vol_down" | "89" => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"mute" | "togglemute" | "volumemute" | "13" => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
"info" | "showcodecinfo" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"skipprevious" | "prev" => {
let p = player.clone();
tokio::spawn(async move { p.seek_time(0.0).await; });
}
"skipnext" | "next" => {
let p = player.clone();
tokio::spawn(async move { p.seek(60.0).await; });
}
other => {
tracing::info!("EventServer unhandled action: '{}' (norm: '{}')", action, other);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PlayerConfig;
use tokio::sync::mpsc;
use tokio::time::{timeout, Duration};
#[test]
fn test_build_packet_header() {
let hdr = build_packet_header(PT_PING, 10, 0xAABBCCDD);
assert_eq!(&hdr[0..4], b"XBMC");
assert_eq!(hdr[4], 2);
assert_eq!(hdr[5], 0);
assert_eq!(u16::from_be_bytes([hdr[6], hdr[7]]), PT_PING);
assert_eq!(u32::from_be_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]), 1);
assert_eq!(u32::from_be_bytes([hdr[12], hdr[13], hdr[14], hdr[15]]), 1);
assert_eq!(u16::from_be_bytes([hdr[16], hdr[17]]), 10);
assert_eq!(u32::from_be_bytes([hdr[18], hdr[19], hdr[20], hdr[21]]), 0xAABBCCDD);
}
#[tokio::test]
async fn test_dispatch_button_navigation() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
("up", RemoteNavAction::Up),
("bigstepforward", RemoteNavAction::Up),
("cursorup", RemoteNavAction::Up),
("down", RemoteNavAction::Down),
("bigstepback", RemoteNavAction::Down),
("cursordown", RemoteNavAction::Down),
("left", RemoteNavAction::Left),
("stepback", RemoteNavAction::Left),
("cursorleft", RemoteNavAction::Left),
("right", RemoteNavAction::Right),
("stepforward", RemoteNavAction::Right),
("cursorright", RemoteNavAction::Right),
("select", RemoteNavAction::Select),
("enter", RemoteNavAction::Select),
("ok", RemoteNavAction::Select),
("back", RemoteNavAction::Back),
("parentdir", RemoteNavAction::Back),
("previousmenu", RemoteNavAction::Back),
("escape", RemoteNavAction::Back),
];
for (btn, expected) in cases {
dispatch_button_or_code(btn, 0, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action");
assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected);
}
}
#[tokio::test]
async fn test_dispatch_button_raw_keycodes() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
(0xF010, RemoteNavAction::Up),
(0xF050, RemoteNavAction::Up),
(273, RemoteNavAction::Up),
(0xF011, RemoteNavAction::Down),
(0xF051, RemoteNavAction::Down),
(274, RemoteNavAction::Down),
(0xF012, RemoteNavAction::Left),
(0xF052, RemoteNavAction::Left),
(276, RemoteNavAction::Left),
(0xF013, RemoteNavAction::Right),
(0xF053, RemoteNavAction::Right),
(275, RemoteNavAction::Right),
(0xF001, RemoteNavAction::Select),
(0xF014, RemoteNavAction::Select),
(13, RemoteNavAction::Select),
(0xF016, RemoteNavAction::Back),
(27, RemoteNavAction::Back),
(8, RemoteNavAction::Back),
];
for (code, expected) in cases {
dispatch_button_or_code("unknown_btn", code, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action for keycode");
assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected);
}
}
#[tokio::test]
async fn test_dispatch_action_navigation() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
("up", RemoteNavAction::Up),
("action(3)", RemoteNavAction::Up),
("cursorup", RemoteNavAction::Up),
("down", RemoteNavAction::Down),
("action(4)", RemoteNavAction::Down),
("cursordown", RemoteNavAction::Down),
("left", RemoteNavAction::Left),
("action(1)", RemoteNavAction::Left),
("cursorleft", RemoteNavAction::Left),
("right", RemoteNavAction::Right),
("action(2)", RemoteNavAction::Right),
("cursorright", RemoteNavAction::Right),
("select", RemoteNavAction::Select),
("enter", RemoteNavAction::Select),
("action(7)", RemoteNavAction::Select),
("selectitem", RemoteNavAction::Select),
("back", RemoteNavAction::Back),
("action(9)", RemoteNavAction::Back),
("action(10)", RemoteNavAction::Back),
("previousmenu", RemoteNavAction::Back),
("parentdir", RemoteNavAction::Back),
("close", RemoteNavAction::Back),
("ActivateWindow(Videos,TvShowTitles)", RemoteNavAction::ActivateSection(3)),
("ActivateWindow(Videos,MovieTitles)", RemoteNavAction::ActivateSection(2)),
("ActivateWindow(Music)", RemoteNavAction::ActivateSection(4)),
("ActivateWindow(Pictures)", RemoteNavAction::ActivateSection(5)),
("ActivateWindow(Home)", RemoteNavAction::ActivateSection(0)),
];
for (act, expected) in cases {
dispatch_action(act, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action for action string");
assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected);
}
}
#[tokio::test]
async fn test_udp_helo_and_ping_ack() {
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// 1. Send HELO
let token = 0x12345678;
let helo_pkt = build_helo_packet("Test Yatse Device", token);
client.send_to(&helo_pkt, &server_addr).await.expect("Send HELO");
// Expect PING ACK reply
let mut buf = [0u8; 128];
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
.await
.expect("Timeout waiting for HELO ack")
.expect("Recv HELO ack");
assert!(len >= HEADER_SIZE);
assert_eq!(&buf[0..4], b"XBMC");
let resp_type = u16::from_be_bytes([buf[6], buf[7]]);
assert_eq!(resp_type, PT_PING);
let resp_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
assert_eq!(resp_token, token);
// 2. Send PING
let ping_token = 0x87654321;
let ping_pkt = build_ping_packet(ping_token);
client.send_to(&ping_pkt, &server_addr).await.expect("Send PING");
let (len2, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
.await
.expect("Timeout waiting for PING ack")
.expect("Recv PING ack");
assert!(len2 >= HEADER_SIZE);
assert_eq!(&buf[0..4], b"XBMC");
let resp_type2 = u16::from_be_bytes([buf[6], buf[7]]);
assert_eq!(resp_type2, PT_PING);
let resp_token2 = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
assert_eq!(resp_token2, ping_token);
}
#[tokio::test]
async fn test_udp_button_events_over_socket() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Send 'Up' button (DOWN flag)
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for button up")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Up);
// Send 'Select' button (DOWN flag)
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "select", 1);
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for button select")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Select);
// Send key release ONLY (PTB_UP flag, without PTB_DOWN) -> Should be ignored
let release_pkt = build_button_packet(0, PTB_UP, "R1", "up", 1);
client.send_to(&release_pkt, &server_addr).await.expect("Send button release");
// Wait short time and ensure no action was dispatched
let res = timeout(Duration::from_millis(100), nav_rx.recv()).await;
assert!(res.is_err(), "Key release should be ignored and not trigger an action");
}
#[tokio::test]
async fn test_udp_action_events_over_socket() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Send ACTION "Action(1)" which is Left
let action_pkt = build_action_packet("Action(1)", 1);
client.send_to(&action_pkt, &server_addr).await.expect("Send action");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for action Left")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Left);
}
#[tokio::test]
async fn test_udp_malformed_packets_gracefully_ignored() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Packet smaller than header size
client.send_to(b"too small", &server_addr).await.expect("Send small");
// Packet with invalid signature
let mut bad_sig = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
bad_sig[0..4].copy_from_slice(b"NOPE");
client.send_to(&bad_sig, &server_addr).await.expect("Send bad sig");
// Immediately following with a valid packet must still succeed
let valid_pkt = build_button_packet(0, PTB_DOWN, "R1", "right", 1);
client.send_to(&valid_pkt, &server_addr).await.expect("Send valid");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for action Right")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Right);
}
#[tokio::test]
async fn test_udp_action_mute() {
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player.clone(), nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
assert!(!player.status.lock().await.is_muted);
// Yatse sends "Mute()" action over EventServer
let mute_pkt = build_action_packet("Mute()", 1);
client.send_to(&mute_pkt, &server_addr).await.expect("Send Mute()");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(player.status.lock().await.is_muted);
// Send second "Mute()" to unmute
client.send_to(&mute_pkt, &server_addr).await.expect("Send second Mute()");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!player.status.lock().await.is_muted);
}
}
+183
View File
@@ -0,0 +1,183 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, RwLock, Semaphore};
use eframe::egui::{ColorImage, Context, TextureHandle, TextureOptions};
#[derive(Clone)]
pub enum ImageStatus {
Loaded(TextureHandle),
Loading,
Failed,
}
fn hash_url(url: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
hasher.finish()
}
struct CacheInner {
textures: HashMap<String, (u64, TextureHandle)>,
in_flight: HashSet<String>,
failed: HashSet<String>,
access_counter: u64,
}
pub struct AsyncImageCache {
inner: Mutex<CacheInner>,
tx: mpsc::UnboundedSender<(String, Option<ColorImage>)>,
rx: Mutex<mpsc::UnboundedReceiver<(String, Option<ColorImage>)>>,
http_client: reqwest::Client,
token: Arc<RwLock<Option<String>>>,
semaphore: Arc<Semaphore>,
}
impl AsyncImageCache {
pub fn new(token: Arc<RwLock<Option<String>>>) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
Self {
inner: Mutex::new(CacheInner {
textures: HashMap::new(),
in_flight: HashSet::new(),
failed: HashSet::new(),
access_counter: 0,
}),
tx,
rx: Mutex::new(rx),
http_client: reqwest::Client::new(),
token,
semaphore: Arc::new(Semaphore::new(10)),
}
}
pub fn update(&self, ctx: &Context) {
let mut any_new = false;
if let Ok(mut rx_guard) = self.rx.try_lock() {
while let Ok((url, opt_img)) = rx_guard.try_recv() {
if let Ok(mut inner) = self.inner.lock() {
inner.in_flight.remove(&url);
match opt_img {
Some(color_image) => {
inner.access_counter += 1;
let counter = inner.access_counter;
let handle = ctx.load_texture(&url, color_image, TextureOptions::LINEAR);
inner.textures.insert(url, (counter, handle));
any_new = true;
}
None => {
inner.failed.insert(url);
}
}
}
}
}
// Limit cached textures to 500
if let Ok(mut inner) = self.inner.lock() {
if inner.textures.len() > 500 {
let mut entries: Vec<(String, u64)> = inner
.textures
.iter()
.map(|(k, (c, _))| (k.clone(), *c))
.collect();
entries.sort_by_key(|(_, c)| *c);
let to_remove = entries.len() - 400;
for (k, _) in entries.into_iter().take(to_remove) {
inner.textures.remove(&k);
}
}
}
if any_new {
ctx.request_repaint();
}
}
pub fn get_or_load(&self, ctx: &Context, url: &str) -> ImageStatus {
if url.is_empty() {
return ImageStatus::Failed;
}
if let Ok(mut inner) = self.inner.lock() {
inner.access_counter += 1;
let current_count = inner.access_counter;
if let Some((counter, handle)) = inner.textures.get_mut(url) {
*counter = current_count;
return ImageStatus::Loaded(handle.clone());
}
if inner.failed.contains(url) {
return ImageStatus::Failed;
}
if inner.in_flight.contains(url) {
return ImageStatus::Loading;
}
inner.in_flight.insert(url.to_string());
} else {
return ImageStatus::Loading;
}
let url_clone = url.to_string();
let tx = self.tx.clone();
let client = self.http_client.clone();
let sem = self.semaphore.clone();
let ctx_clone = ctx.clone();
let token_opt = self.token.try_read().ok().and_then(|g| g.clone());
tokio::spawn(async move {
let _permit = sem.acquire().await;
let cache_dir = std::path::Path::new("/tmp/jellyfin-cache");
let file_name = format!("{:016x}.cache", hash_url(&url_clone));
let cache_file = cache_dir.join(file_name);
let mut bytes_opt = if let Ok(data) = std::fs::read(&cache_file) {
Some(data)
} else {
None
};
if bytes_opt.is_none() {
let mut req = client.get(&url_clone);
if let Some(ref t) = token_opt {
req = req.header("X-Emby-Token", t);
req = req.header("Authorization", format!("MediaBrowser Token=\"{}\"", t));
}
if let Ok(resp) = req.send().await {
if resp.status().is_success() {
if let Ok(b) = resp.bytes().await {
let vec_bytes = b.to_vec();
let _ = std::fs::create_dir_all(cache_dir);
let _ = std::fs::write(&cache_file, &vec_bytes);
bytes_opt = Some(vec_bytes);
}
}
}
}
let color_img = if let Some(bytes) = bytes_opt {
tokio::task::spawn_blocking(move || {
if let Ok(dyn_img) = image::load_from_memory(&bytes) {
let rgba = dyn_img.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let pixels = rgba.into_raw();
Some(ColorImage::from_rgba_unmultiplied(size, &pixels))
} else {
None
}
})
.await
.unwrap_or(None)
} else {
None
};
let _ = tx.send((url_clone, color_img));
ctx_clone.request_repaint();
});
ImageStatus::Loading
}
}
+789
View File
@@ -0,0 +1,789 @@
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::JellyfinConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardLayout {
Poster, // 2:3 ratio (Movies, TV Shows) - wat kleinere plaatjes (135 x 200)
Square, // 1:1 ratio (Music Albums, Artists) - vierkant (160 x 160)
LandscapeThumb,// 16:9 ratio (Verder kijken, Music Videos) - liggend (250 x 140)
PhotoLarge, // Foto albums - wat grotere plaatjes (280 x 190)
}
#[derive(Debug, Clone)]
pub enum PlayableTarget {
Stream {
url: String,
title: String,
media_type: String,
artist: Option<String>,
genre: Option<String>,
},
Photo { url: String, title: String },
OpenFolder { folder_id: String, name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MediaItem {
pub id: String,
pub name: String,
pub sort_name: Option<String>,
pub item_type: String,
pub series_name: Option<String>,
pub artist_name: Option<String>,
pub genre: Option<String>,
pub index_number: Option<u32>,
pub parent_index_number: Option<u32>,
pub season_name: Option<String>,
pub year: Option<u32>,
pub overview: Option<String>,
pub rating: Option<f32>,
pub runtime_ticks: Option<u64>,
pub played_percentage: Option<f32>,
pub is_folder: bool,
pub has_primary_image: bool,
pub has_backdrop_image: bool,
pub parent_primary_image_item_id: Option<String>,
pub parent_backdrop_item_id: Option<String>,
pub recursive_item_count: Option<u32>,
pub child_count: Option<u32>,
pub series_id: Option<String>,
pub album_id: Option<String>,
pub album: Option<String>,
}
impl MediaItem {
pub fn primary_image_id(&self) -> &str {
if let Some(ref p_id) = self.parent_primary_image_item_id {
if p_id.starts_with("http://") || p_id.starts_with("https://") {
return p_id;
}
}
if let Some(ref b_id) = self.parent_backdrop_item_id {
if b_id.starts_with("http://") || b_id.starts_with("https://") {
return b_id;
}
}
if self.has_primary_image {
&self.id
} else if let Some(ref p_id) = self.parent_primary_image_item_id {
p_id
} else if let Some(ref b_id) = self.parent_backdrop_item_id {
b_id
} else {
&self.id
}
}
pub fn poster_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=320&fillHeight=480&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn thumb_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=480&fillHeight=270&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn square_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=300&fillHeight=300&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn photo_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=1280&quality=90", server_url.trim_end_matches('/'), img)
}
pub fn backdrop_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
let base = server_url.trim_end_matches('/');
if self.has_backdrop_image {
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, self.id)
} else if let Some(ref p_id) = self.parent_backdrop_item_id {
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, p_id)
} else {
format!("{}/Items/{}/Images/Primary?maxWidth=1280&quality=85", base, self.id)
}
}
pub fn stream_url(&self, server_url: &str, token: &str) -> String {
let base = server_url.trim_end_matches('/');
if self.item_type == "Audio" || self.item_type == "MusicAlbum" {
format!("{}/Audio/{}/stream?static=true&api_key={}", base, self.id, token)
} else {
format!("{}/Videos/{}/stream?static=true&api_key={}", base, self.id, token)
}
}
pub fn formatted_runtime(&self) -> String {
if let Some(ticks) = self.runtime_ticks {
let total_mins = ticks / (10_000_000 * 60);
let hours = total_mins / 60;
let mins = total_mins % 60;
if hours > 0 {
format!("{}u {}m", hours, mins)
} else {
format!("{}m", mins)
}
} else {
String::new()
}
}
pub fn sort_letter(&self) -> char {
let text = self.sort_name.as_deref().unwrap_or(&self.name);
let trimmed = text.trim().trim_start_matches(|c: char| !c.is_alphanumeric());
for c in trimmed.chars() {
if c.is_ascii_digit() {
return '#';
}
if c.is_ascii_alphabetic() {
return c.to_ascii_uppercase();
}
}
'#'
}
}
pub fn sort_media_items_alphabetically(items: &mut [MediaItem]) {
items.sort_by(|a, b| {
let la = a.sort_letter();
let lb = b.sort_letter();
let order_a = if la == '#' { 0 } else { (la as u32) + 1 };
let order_b = if lb == '#' { 0 } else { (lb as u32) + 1 };
order_a.cmp(&order_b).then_with(|| {
let na = a.sort_name.as_deref().unwrap_or(&a.name).trim_start_matches(|c: char| !c.is_alphanumeric());
let nb = b.sort_name.as_deref().unwrap_or(&b.name).trim_start_matches(|c: char| !c.is_alphanumeric());
na.to_lowercase().cmp(&nb.to_lowercase())
})
});
}
#[derive(Debug, Clone, Default)]
pub struct MediaLibrary {
// Dashboard (Home)
pub resume_items: Vec<MediaItem>,
pub latest_movies: Vec<MediaItem>,
pub latest_shows: Vec<MediaItem>,
pub latest_music: Vec<MediaItem>,
// Libraries
pub all_movies: Vec<MediaItem>,
pub all_shows: Vec<MediaItem>,
pub music_artists: Vec<MediaItem>,
pub music_albums: Vec<MediaItem>,
pub music_videos: Vec<MediaItem>,
pub photos: Vec<MediaItem>,
pub live_channels: Vec<MediaItem>,
pub favorites: Vec<MediaItem>,
}
#[derive(Clone)]
pub struct JellyfinClient {
pub config: JellyfinConfig,
client: reqwest::Client,
pub token: Arc<RwLock<Option<String>>>,
pub user_id: Arc<RwLock<Option<String>>>,
pub library: Arc<RwLock<MediaLibrary>>,
}
impl JellyfinClient {
pub fn new(config: JellyfinConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
token: Arc::new(RwLock::new(None)),
user_id: Arc::new(RwLock::new(None)),
library: Arc::new(RwLock::new(MediaLibrary::default())),
}
}
fn auth_header_value(&self, token: Option<&str>) -> String {
let base = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"1.0.0\"",
self.config.client_name, self.config.device_name, self.config.device_id
);
if let Some(t) = token {
format!("{}, Token=\"{}\"", base, t)
} else {
base
}
}
pub async fn authenticate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/Users/AuthenticateByName", self.config.server_url.trim_end_matches('/'));
let auth_val = self.auth_header_value(None);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
let payload = serde_json::json!({
"Username": self.config.username,
"Pw": self.config.password.clone().unwrap_or_default(),
});
let resp = self.client.post(&url)
.headers(headers)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
return Err(format!("Jellyfin authentication failed: {}", err).into());
}
let data: serde_json::Value = resp.json().await?;
let token = data["AccessToken"].as_str().ok_or("No AccessToken in response")?.to_string();
let user_id = data["User"]["Id"].as_str().ok_or("No User.Id in response")?.to_string();
*self.token.write().await = Some(token);
*self.user_id.write().await = Some(user_id);
tracing::info!("Authenticated successfully with Jellyfin as {}", self.config.username);
Ok(())
}
pub async fn ensure_authenticated(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.token.read().await.is_none() {
self.authenticate().await?;
}
Ok(())
}
pub async fn get_token(&self) -> Option<String> {
self.token.read().await.clone()
}
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
let token_guard = self.token.read().await;
let token = token_guard.as_deref().ok_or("Not authenticated")?;
let auth_val = self.auth_header_value(Some(token));
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
headers.insert("X-Emby-Token", HeaderValue::from_str(token)?);
Ok(headers)
}
pub async fn fetch_folder_items(&self, folder_id: &str) -> Vec<MediaItem> {
let base_url = self.config.server_url.trim_end_matches('/');
if let Ok(headers) = self.get_authed_headers().await {
let user_id = self.user_id.read().await.clone().unwrap_or_default();
// 1. Try standard folder items with parentId
let url = format!(
"{}/Users/{}/Items?parentId={}&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
let mut items = fetch_endpoint(&self.client, &url, &headers).await;
// 2. If empty, maybe this is a TV Show (Series): fetch seasons
if items.is_empty() {
let url_seasons = format!(
"{}/Shows/{}/Seasons?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, folder_id, user_id
);
items = fetch_endpoint(&self.client, &url_seasons, &headers).await;
}
// 3. If empty, maybe this is a Season: fetch episodes
if items.is_empty() {
let url_episodes = format!(
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, folder_id, user_id
);
items = fetch_endpoint(&self.client, &url_episodes, &headers).await;
}
// 4. If empty, maybe this is an artist: try fetching albums via albumArtistIds
if items.is_empty() {
let url_albums = format!(
"{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_albums, &headers).await;
}
// 5. If still empty, try artistIds for albums
if items.is_empty() {
let url_albums2 = format!(
"{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_albums2, &headers).await;
}
// 6. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly
if items.is_empty() {
let url_songs = format!(
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_songs, &headers).await;
}
// If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number
if items.iter().any(|i| i.item_type == "Audio" || i.item_type == "Season" || i.item_type == "Episode") {
items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX));
} else {
sort_media_items_alphabetically(&mut items);
}
return items;
}
Vec::new()
}
pub async fn resolve_playable_item(&self, item: &MediaItem) -> Result<PlayableTarget, Box<dyn std::error::Error + Send + Sync>> {
let base_url = self.config.server_url.trim_end_matches('/');
let token = {
let guard = self.token.read().await;
guard.clone().unwrap_or_default()
};
// 0. YouTube Video or direct stream URL
if item.item_type == "YouTubeVideo"
|| item.id.starts_with("https://www.youtube.com")
|| item.id.starts_with("https://youtu.be")
{
return Ok(PlayableTarget::Stream {
url: item.id.clone(),
title: item.name.clone(),
media_type: "stream".to_string(),
artist: item.series_name.clone(),
genre: item.genre.clone(),
});
}
// 1. Photo item -> view photo
if item.item_type == "Photo" {
return Ok(PlayableTarget::Photo {
url: item.photo_url(base_url),
title: item.name.clone(),
});
}
// 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
if item.item_type == "Series"
|| item.item_type == "Season"
|| item.item_type == "PhotoAlbum"
|| item.item_type == "MusicArtist"
|| item.item_type == "MusicAlbum"
|| item.is_folder
{
return Ok(PlayableTarget::OpenFolder {
folder_id: item.id.clone(),
name: item.name.clone(),
});
}
// 5. Standard playable stream (Movie, Episode, Video, MusicVideo, Audio, TvChannel)
let (media_type, artist) = match item.item_type.as_str() {
"Audio" => ("music".to_string(), item.artist_name.clone()),
"TvChannel" => ("stream".to_string(), None),
"MusicVideo" => ("video".to_string(), item.artist_name.clone()),
_ => ("video".to_string(), None),
};
Ok(PlayableTarget::Stream {
url: item.stream_url(base_url, &token),
title: item.name.clone(),
media_type,
artist,
genre: item.genre.clone(),
})
}
pub async fn fetch_all_media(&self) -> Result<MediaLibrary, Box<dyn std::error::Error + Send + Sync>> {
if self.token.read().await.is_none() {
self.authenticate().await?;
}
let user_id = {
let guard = self.user_id.read().await;
guard.clone().ok_or("Missing user_id")?
};
let headers = self.get_authed_headers().await?;
let base_url = self.config.server_url.trim_end_matches('/');
// 1. Fetch Views to discover all library categories
let views_url = format!("{}/Users/{}/Views", base_url, user_id);
let views_resp: serde_json::Value = self.client.get(&views_url)
.headers(headers.clone())
.send()
.await?
.json()
.await?;
let mut movies_id: Option<String> = None;
let mut shows_id: Option<String> = None;
let mut music_id: Option<String> = None;
let mut photo_id: Option<String> = None;
let mut music_video_id: Option<String> = None;
if let Some(items) = views_resp["Items"].as_array() {
for v in items {
let col_type = v["CollectionType"].as_str().unwrap_or("");
let name = v["Name"].as_str().unwrap_or("").to_lowercase();
let id = v["Id"].as_str().unwrap_or("").to_string();
if col_type == "movies" || name.contains("movie") || name.contains("film") {
if movies_id.is_none() { movies_id = Some(id); }
} else if col_type == "tvshows" || name.contains("show") || name.contains("serie") {
if shows_id.is_none() { shows_id = Some(id); }
} else if col_type == "music" && !name.contains("video") {
if music_id.is_none() { music_id = Some(id); }
} else if col_type == "musicvideos" || name.contains("music video") {
if music_video_id.is_none() { music_video_id = Some(id); }
} else if col_type == "homevideos" || col_type == "photos" || name.contains("foto") || name.contains("photo") {
if photo_id.is_none() { photo_id = Some(id); }
}
}
}
// 2. Fetch Resume / Next Up
let resume_url = format!(
"{}/UserItems/Resume?userId={}&limit=16&fields=Overview,PrimaryImageAspectRatio,SeriesName,ParentBackdropItemId,UserData",
base_url, user_id
);
let resume_items = fetch_endpoint(&self.client, &resume_url, &headers).await;
// 3. Fetch Latest & All Movies
let mut latest_movies = Vec::new();
let mut all_movies = Vec::new();
if let Some(ref m_id) = movies_id {
let url_latest = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, m_id
);
latest_movies = fetch_endpoint(&self.client, &url_latest, &headers).await;
let url_all = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, m_id
);
all_movies = fetch_endpoint(&self.client, &url_all, &headers).await;
sort_media_items_alphabetically(&mut all_movies);
}
// 4. Fetch Latest & All Shows
let mut latest_shows = Vec::new();
let mut all_shows = Vec::new();
if let Some(ref s_id) = shows_id {
let url_latest = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, s_id
);
latest_shows = fetch_endpoint(&self.client, &url_latest, &headers).await;
let url_all = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=SortName&sortOrder=Ascending&limit=1000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, s_id
);
all_shows = fetch_endpoint(&self.client, &url_all, &headers).await;
sort_media_items_alphabetically(&mut all_shows);
}
// 5. Fetch Music (Latest Albums, Artists, and All Albums)
let mut latest_music = Vec::new();
let mut music_artists = Vec::new();
let mut music_albums = Vec::new();
if let Some(ref mus_id) = music_id {
let url_latest_mus = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,AlbumArtist,SortName",
base_url, user_id, mus_id
);
latest_music = fetch_endpoint(&self.client, &url_latest_mus, &headers).await;
// Fetch artists using dedicated /Artists endpoint first
let url_artists = format!(
"{}/Artists?userId={}&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
base_url, user_id
);
music_artists = fetch_endpoint(&self.client, &url_artists, &headers).await;
if music_artists.is_empty() {
let url_artists_fb = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicArtist&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
base_url, user_id, mus_id
);
music_artists = fetch_endpoint(&self.client, &url_artists_fb, &headers).await;
}
sort_media_items_alphabetically(&mut music_artists);
let url_albums = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,AlbumArtist,SortName",
base_url, user_id, mus_id
);
music_albums = fetch_endpoint(&self.client, &url_albums, &headers).await;
sort_media_items_alphabetically(&mut music_albums);
}
// 6. Fetch Music Videos
let mut music_videos = Vec::new();
if let Some(ref mv_id) = music_video_id {
let url_mv = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicVideo&sortBy=DateCreated&sortOrder=Descending&limit=200&fields=Overview,Artist,SortName",
base_url, user_id, mv_id
);
music_videos = fetch_endpoint(&self.client, &url_mv, &headers).await;
sort_media_items_alphabetically(&mut music_videos);
}
// 7. Fetch Photos / Home Videos
let mut photos = Vec::new();
if let Some(ref p_id) = photo_id {
let url_photo = format!(
"{}/Users/{}/Items?parentId={}&sortBy=DateCreated&sortOrder=Descending&limit=500&fields=Overview,SortName",
base_url, user_id, p_id
);
photos = fetch_endpoint(&self.client, &url_photo, &headers).await;
sort_media_items_alphabetically(&mut photos);
}
// 8. Fetch Live TV Channels
let url_live = format!("{}/LiveTv/Channels?userId={}&limit=100", base_url, user_id);
let live_channels = fetch_endpoint(&self.client, &url_live, &headers).await;
// 9. Fetch Favorites
let url_fav = format!(
"{}/Users/{}/Items?userId={}&isFavorite=true&recursive=true&limit=200&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,SortName",
base_url, user_id, user_id
);
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
sort_media_items_alphabetically(&mut favorites);
let lib = MediaLibrary {
resume_items,
latest_movies,
latest_shows,
latest_music,
all_movies,
all_shows,
music_artists,
music_albums,
music_videos,
photos,
live_channels,
favorites,
};
*self.library.write().await = lib.clone();
Ok(lib)
}
pub async fn get_library(&self) -> MediaLibrary {
{
let guard = self.library.read().await;
if !guard.all_movies.is_empty() || !guard.all_shows.is_empty() {
return guard.clone();
}
}
if let Ok(lib) = self.fetch_all_media().await {
return lib;
}
self.library.read().await.clone()
}
pub async fn fetch_show_episodes(&self, show_id: &str) -> Vec<MediaItem> {
let base_url = self.config.server_url.trim_end_matches('/');
if let Ok(headers) = self.get_authed_headers().await {
let user_id = self.user_id.read().await.clone().unwrap_or_default();
let url = format!(
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
base_url, show_id, user_id
);
return fetch_endpoint(&self.client, &url, &headers).await;
}
Vec::new()
}
pub async fn fetch_episodes_paged(
&self,
show_id: Option<&str>,
season: Option<u32>,
start: usize,
limit: usize,
) -> (usize, Vec<MediaItem>) {
let base_url = self.config.server_url.trim_end_matches('/');
if let Ok(headers) = self.get_authed_headers().await {
let user_id = self.user_id.read().await.clone().unwrap_or_default();
let url = if let Some(sid) = show_id {
if let Some(s_num) = season {
format!(
"{}/Shows/{}/Episodes?userId={}&season={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
base_url, sid, user_id, s_num, start, limit
)
} else {
format!(
"{}/Shows/{}/Episodes?userId={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
base_url, sid, user_id, start, limit
)
}
} else {
format!(
"{}/Users/{}/Items?includeItemTypes=Episode&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
base_url, user_id, start, limit
)
};
return fetch_endpoint_paged(&self.client, &url, &headers).await;
}
(0, Vec::new())
}
pub async fn fetch_songs_paged(
&self,
album_id: Option<&str>,
artist_id: Option<&str>,
start: usize,
limit: usize,
) -> (usize, Vec<MediaItem>) {
let base_url = self.config.server_url.trim_end_matches('/');
if let Ok(headers) = self.get_authed_headers().await {
let user_id = self.user_id.read().await.clone().unwrap_or_default();
let url = if let Some(aid) = album_id {
format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Audio&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
base_url, user_id, aid, start, limit
)
} else if let Some(arid) = artist_id {
format!(
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
base_url, user_id, arid, start, limit
)
} else {
format!(
"{}/Users/{}/Items?includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
base_url, user_id, start, limit
)
};
return fetch_endpoint_paged(&self.client, &url, &headers).await;
}
(0, Vec::new())
}
}
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
fetch_endpoint_paged(client, url, headers).await.1
}
async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec<MediaItem>) {
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let items = parse_items(&json);
let total = json["TotalRecordCount"]
.as_u64()
.map(|n| n as usize)
.unwrap_or(items.len());
return (total, items);
}
}
(0, Vec::new())
}
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
let mut out = Vec::new();
if let Some(items) = json["Items"].as_array() {
for it in items {
let id = it["Id"].as_str().unwrap_or("").to_string();
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
let artist_name = it["AlbumArtist"]
.as_str()
.or_else(|| it["Artist"].as_str())
.map(|s| s.to_string())
.or_else(|| {
it["Artists"].as_array().and_then(|arr| arr.first().and_then(|a| a.as_str())).map(|s| s.to_string())
})
.or_else(|| {
it["ArtistItems"].as_array().and_then(|arr| arr.first().and_then(|a| a["Name"].as_str())).map(|s| s.to_string())
});
let genre = it["Genres"].as_array().and_then(|arr| {
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
if list.is_empty() {
None
} else {
Some(list.join(", "))
}
}).or_else(|| it["Genre"].as_str().map(|s| s.to_string()));
let year = it["ProductionYear"].as_u64().map(|y| y as u32);
let overview = it["Overview"].as_str().map(|s| s.to_string());
let rating = it["CommunityRating"].as_f64().map(|r| r as f32);
let runtime_ticks = it["RunTimeTicks"].as_u64();
let played_percentage = it["UserData"]["PlayedPercentage"].as_f64().map(|p| p as f32);
let is_folder = it["IsFolder"].as_bool().unwrap_or(false);
let has_primary_image = it["ImageTags"]["Primary"].is_string();
let has_backdrop_image = it["BackdropImageTags"].as_array().map_or(false, |b| !b.is_empty());
let parent_primary_image_item_id = it["ParentPrimaryImageItemId"]
.as_str()
.or_else(|| it["AlbumId"].as_str())
.or_else(|| it["SeriesId"].as_str())
.map(|s| s.to_string());
let parent_backdrop_item_id = it["ParentBackdropItemId"]
.as_str()
.or_else(|| it["SeriesId"].as_str())
.map(|s| s.to_string());
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32);
let season_name = it["SeasonName"].as_str().map(|s| s.to_string());
let recursive_item_count = it["RecursiveItemCount"].as_u64().map(|n| n as u32);
let child_count = it["ChildCount"].as_u64().map(|n| n as u32);
let series_id = it["SeriesId"].as_str().map(|s| s.to_string());
let album_id = it["AlbumId"].as_str().map(|s| s.to_string());
let album = it["Album"].as_str().map(|s| s.to_string());
if !id.is_empty() {
out.push(MediaItem {
id,
name,
sort_name,
item_type,
series_name,
artist_name,
genre,
index_number,
parent_index_number,
season_name,
year,
overview,
rating,
runtime_ticks,
played_percentage,
is_folder,
has_primary_image,
has_backdrop_image,
parent_primary_image_item_id,
parent_backdrop_item_id,
recursive_item_count,
child_count,
series_id,
album_id,
album,
});
}
}
}
out
}
+160
View File
@@ -0,0 +1,160 @@
use eframe::egui::Vec2;
use tokio::sync::mpsc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod app;
mod config;
mod eventserver;
mod image_cache;
mod jellyfin;
mod mqtt;
mod player;
mod yatse;
mod youtube;
use app::KioskApp;
use config::AppConfig;
use eventserver::KodiEventServer;
use jellyfin::JellyfinClient;
use player::PlayerController;
use yatse::{create_yatse_router, YatseServerState};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!("Starting Jellyfin HTPC Kiosk (Rust)...");
// 1. Load Configuration
let config = AppConfig::load()?;
// 2. Initialize Media Player (MPV Controller)
let player = PlayerController::new(config.player.clone());
// 3. Navigation channel (Yatse Remote -> UI) with immediate egui repaint trigger
let (nav_tx_raw, nav_rx) = mpsc::unbounded_channel();
let egui_ctx_holder: std::sync::Arc<std::sync::Mutex<Option<eframe::egui::Context>>> =
std::sync::Arc::new(std::sync::Mutex::new(None));
let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone());
// 4. Initialize Jellyfin Client
let client = JellyfinClient::new(config.jellyfin.clone());
// 5. Start Yatse Kodi JSON-RPC Server & EventServer
if config.kodi_rpc.enabled {
let yatse_state = YatseServerState::new(
player.clone(),
nav_tx.clone(),
client.clone(),
);
let app_router = create_yatse_router(yatse_state.clone());
let port = config.kodi_rpc.http_port;
tokio::spawn(async move {
let addr = format!("0.0.0.0:{}", port);
tracing::info!("Yatse Kodi JSON-RPC server listening on http://{}", addr);
if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await {
let _ = axum::serve(listener, app_router).await;
}
});
// Start Kodi TCP JSON-RPC socket server on port 9090 for Yatse
let ws_port = config.kodi_rpc.ws_port;
crate::yatse::start_tcp_jsonrpc_server(ws_port, yatse_state).await;
// Start Kodi EventServer (UDP 9777) for Yatse
let event_server = KodiEventServer::new(9777, player.clone(), nav_tx);
let _ = event_server.start().await;
// Zeroconf / mDNS Advertisement for Yatse automatic discovery
tokio::spawn(async move {
let hostname = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| std::env::var("HOSTNAME").unwrap_or_else(|_| "kiosk".into()));
let service_name = format!("Kodi ({})", hostname);
// Announce JSON-RPC HTTP on port 8080
let _http_child = std::process::Command::new("avahi-publish-service")
.args(["-s", &service_name, "_xbmc-jsonrpc-h._tcp", &port.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
// Announce EventServer UDP on port 9777
let _event_child = std::process::Command::new("avahi-publish-service")
.args(["-s", &service_name, "_xbmc-events._udp", "9777"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
tracing::info!("Published Zeroconf services for Yatse discovery: '{}'", service_name);
});
}
// 5. Start MQTT Service & Home Assistant Discovery if enabled
if config.mqtt.enabled {
mqtt::MqttService::start(config.clone(), player.clone());
}
// 7. Launch Native GUI via eframe (runs on main thread)
let native_options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("Jellyfin HTPC Kiosk")
.with_inner_size(Vec2::new(1280.0, 720.0))
.with_fullscreen(config.player.fullscreen),
..Default::default()
};
let app_config = config.clone();
let app_client = client.clone();
let app_player = player.clone();
let egui_ctx_for_nav = egui_ctx_holder.clone();
// Run eframe app (blocks until window is closed)
tokio::task::block_in_place(|| {
eframe::run_native(
"Jellyfin HTPC Kiosk",
native_options,
Box::new(move |cc| {
*egui_ctx_for_nav.lock().unwrap() = Some(cc.egui_ctx.clone());
// Install egui image loaders (http, png, jpg)
egui_extras::install_image_loaders(&cc.egui_ctx);
let mut window_id: Option<u64> = None;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = cc.window_handle() {
match handle.as_raw() {
RawWindowHandle::Xlib(h) => {
window_id = Some(h.window as u64);
}
RawWindowHandle::Xcb(h) => {
window_id = Some(h.window.get() as u64);
}
_ => {}
}
}
if let Some(wid) = window_id {
let p = app_player.clone();
tokio::spawn(async move {
p.set_window_id(Some(wid)).await;
});
}
Ok(Box::new(KioskApp::new(
app_config,
app_client,
app_player,
nav_rx,
window_id,
)))
}),
)
})
.map_err(|e| format!("GUI runtime error: {}", e))?;
Ok(())
}
+576
View File
@@ -0,0 +1,576 @@
use std::sync::Arc;
use std::time::Duration;
use rumqttc::{AsyncClient, LastWill, MqttOptions, Packet, QoS};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::config::{AppConfig, MqttConfig};
use crate::player::{PlaybackState, PlayerController, PlayerStatus};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MqttPlaybackState {
pub state: String,
pub media_type: String,
pub title: String,
pub artist: String,
pub playlist: String,
pub genre: String,
pub stream_title: String,
}
impl MqttPlaybackState {
pub fn from_status(st: &PlayerStatus) -> Self {
if st.state == PlaybackState::Stopped {
Self {
state: "stopped".to_string(),
media_type: String::new(),
title: String::new(),
artist: String::new(),
playlist: String::new(),
genre: String::new(),
stream_title: String::new(),
}
} else {
let state_str = match st.state {
PlaybackState::Playing => "playing",
PlaybackState::Paused => "paused",
PlaybackState::Stopped => "stopped",
};
Self {
state: state_str.to_string(),
media_type: st.media_type.clone(),
title: st.title.clone(),
artist: st.artist.clone(),
playlist: st.playlist.clone(),
genre: st.genre.clone(),
stream_title: if st.artist == "Radio Chicken DJ" || st.stream_title.contains("Radio Chicken DJ") {
String::new()
} else {
st.stream_title.clone()
},
}
}
}
}
pub struct MqttService;
impl MqttService {
pub fn start(config: AppConfig, player: Arc<PlayerController>) {
tokio::spawn(async move {
run_mqtt_loop(config, player).await;
});
}
}
async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
let mqtt_cfg = &config.mqtt;
let fallback_hostname = crate::config::get_system_hostname();
let device_id = if config.jellyfin.device_id.is_empty()
|| config.jellyfin.device_id == "htpc-kiosk-01"
|| config.jellyfin.device_id == "htpc-living-room-01"
{
&fallback_hostname
} else {
&config.jellyfin.device_id
};
let device_name = if config.jellyfin.device_name.is_empty()
|| config.jellyfin.device_name == "HTPC Kiosk"
|| config.jellyfin.device_name == "HTPC Living Room"
{
device_id
} else {
&config.jellyfin.device_name
};
let client_id = mqtt_cfg.client_id.clone().unwrap_or_else(|| {
format!("{}-{}", mqtt_cfg.topic_prefix, device_id)
});
let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id);
let command_topic = format!("{}/{}/command", mqtt_cfg.topic_prefix, device_id);
let mut mqtt_opts = MqttOptions::new(&client_id, &mqtt_cfg.host, mqtt_cfg.port);
mqtt_opts.set_keep_alive(Duration::from_secs(15));
if let (Some(u), Some(p)) = (&mqtt_cfg.username, &mqtt_cfg.password) {
if !u.is_empty() {
mqtt_opts.set_credentials(u, p);
}
}
mqtt_opts.set_last_will(LastWill::new(
&avail_topic,
"offline",
QoS::AtLeastOnce,
true,
));
tracing::info!(
"Starting MQTT Service: broker={}:{}, client_id='{}', device='{}'",
mqtt_cfg.host,
mqtt_cfg.port,
client_id,
device_name
);
let is_connected = Arc::new(std::sync::atomic::AtomicBool::new(false));
loop {
let (client, mut eventloop) = AsyncClient::new(mqtt_opts.clone(), 100);
is_connected.store(false, std::sync::atomic::Ordering::SeqCst);
let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel::<()>();
let client_clone = client.clone();
let mqtt_cfg_clone = mqtt_cfg.clone();
let device_id_string = device_id.to_string();
let is_connected_clone = is_connected.clone();
let mut status_rx_clone = player.status_watch_rx();
let watcher_handle = tokio::spawn(async move {
let mut last_published: Option<MqttPlaybackState> = None;
loop {
tokio::select! {
_ = &mut cancel_rx => break,
res = status_rx_clone.changed() => {
if res.is_err() {
break;
}
if is_connected_clone.load(std::sync::atomic::Ordering::SeqCst) {
let st = status_rx_clone.borrow().clone();
let new_state = MqttPlaybackState::from_status(&st);
if last_published.as_ref() != Some(&new_state) {
publish_playback_state(&client_clone, &mqtt_cfg_clone, &device_id_string, &new_state).await;
last_published = Some(new_state);
}
}
}
}
}
});
loop {
match eventloop.poll().await {
Ok(rumqttc::Event::Incoming(Packet::ConnAck(_))) => {
is_connected.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!("MQTT broker connected ({}:{})", mqtt_cfg.host, mqtt_cfg.port);
// 1. Announce online availability
let _ = client.publish(&avail_topic, QoS::AtLeastOnce, true, "online").await;
// 2. Subscribe to control commands
let _ = client.subscribe(&command_topic, QoS::AtLeastOnce).await;
// 3. Publish Home Assistant Discovery configs
publish_homeassistant_discovery(&client, mqtt_cfg, device_id, device_name).await;
// 4. Publish current playback state
let current_st = {
let guard = player.status.lock().await;
MqttPlaybackState::from_status(&guard)
};
publish_playback_state(&client, mqtt_cfg, device_id, &current_st).await;
}
Ok(rumqttc::Event::Incoming(Packet::Publish(p))) => {
if p.topic == command_topic {
if let Ok(cmd_str) = std::str::from_utf8(&p.payload) {
handle_remote_command(&player, cmd_str.trim()).await;
}
}
}
Ok(_) => {}
Err(e) => {
is_connected.store(false, std::sync::atomic::Ordering::SeqCst);
tracing::warn!("MQTT connection lost or error: {}. Reconnecting...", e);
let _ = cancel_tx.send(());
watcher_handle.abort();
tokio::time::sleep(Duration::from_secs(3)).await;
break; // break inner loop to re-create client/eventloop
}
}
}
}
}
pub async fn publish_homeassistant_discovery(
client: &AsyncClient,
mqtt_cfg: &MqttConfig,
device_id: &str,
device_name: &str,
) {
let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id);
let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id);
let command_topic = format!("{}/{}/command", mqtt_cfg.topic_prefix, device_id);
let device_obj = json!({
"identifiers": [device_id],
"name": device_name,
"model": "Jellyfin HTPC Kiosk",
"manufacturer": "Jellyfin",
"sw_version": env!("CARGO_PKG_VERSION")
});
struct SensorConfig<'a> {
object_id: &'a str,
name: &'a str,
value_template: &'a str,
icon: &'a str,
}
let sensors = [
SensorConfig {
object_id: "media_type",
name: "Media Type",
value_template: "{{ value_json.media_type }}",
icon: "mdi:play-box",
},
SensorConfig {
object_id: "title",
name: "Title",
value_template: "{{ value_json.title }}",
icon: "mdi:movie-open",
},
SensorConfig {
object_id: "artist",
name: "Artist",
value_template: "{{ value_json.artist }}",
icon: "mdi:account-music",
},
SensorConfig {
object_id: "playlist",
name: "Playlist",
value_template: "{{ value_json.playlist }}",
icon: "mdi:playlist-music",
},
SensorConfig {
object_id: "genre",
name: "Genre",
value_template: "{{ value_json.genre }}",
icon: "mdi:tag-text-outline",
},
SensorConfig {
object_id: "stream_title",
name: "Stream Title",
value_template: "{{ value_json.stream_title }}",
icon: "mdi:radio",
},
SensorConfig {
object_id: "playback_state",
name: "Playback State",
value_template: "{{ value_json.state }}",
icon: "mdi:play-pause",
},
];
for s in &sensors {
let disc_topic = format!(
"{}/sensor/{}/{}/config",
mqtt_cfg.discovery_prefix, device_id, s.object_id
);
let payload = json!({
"name": s.name,
"has_entity_name": true,
"unique_id": format!("{}_{}", device_id, s.object_id),
"state_topic": state_topic,
"value_template": s.value_template,
"json_attributes_topic": state_topic,
"icon": s.icon,
"device": device_obj,
"availability_topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
});
if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await {
tracing::warn!("Failed to publish Home Assistant discovery for sensor {}: {}", s.object_id, e);
} else {
tracing::info!("Published HA sensor discovery: {}", disc_topic);
}
}
struct ButtonConfig<'a> {
object_id: &'a str,
name: &'a str,
payload_press: &'a str,
icon: &'a str,
}
let buttons = [
ButtonConfig {
object_id: "play_pause",
name: "Play / Pause",
payload_press: "play_pause",
icon: "mdi:play-pause",
},
ButtonConfig {
object_id: "stop",
name: "Stop",
payload_press: "stop",
icon: "mdi:stop",
},
ButtonConfig {
object_id: "mute",
name: "Mute",
payload_press: "toggle_mute",
icon: "mdi:volume-mute",
},
];
for b in &buttons {
let disc_topic = format!(
"{}/button/{}/{}/config",
mqtt_cfg.discovery_prefix, device_id, b.object_id
);
let payload = json!({
"name": b.name,
"has_entity_name": true,
"unique_id": format!("{}_{}", device_id, b.object_id),
"command_topic": command_topic,
"payload_press": b.payload_press,
"icon": b.icon,
"device": device_obj,
"availability_topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
});
if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await {
tracing::warn!("Failed to publish Home Assistant discovery for button {}: {}", b.object_id, e);
} else {
tracing::info!("Published HA button discovery: {}", disc_topic);
}
}
tracing::info!("Published Home Assistant MQTT discovery for device '{}'", device_id);
}
pub async fn publish_playback_state(
client: &AsyncClient,
mqtt_cfg: &MqttConfig,
device_id: &str,
state: &MqttPlaybackState,
) {
let payload = json!({
"state": state.state,
"media_type": state.media_type,
"title": state.title,
"artist": state.artist,
"playlist": state.playlist,
"genre": state.genre,
"stream_title": state.stream_title,
});
let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id);
let _ = client.publish(&state_topic, QoS::AtLeastOnce, true, payload.to_string()).await;
// Subtopics for direct subscriptions
let _ = client.publish(format!("{}/{}/media_type", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.media_type.as_str()).await;
let _ = client.publish(format!("{}/{}/title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.title.as_str()).await;
let _ = client.publish(format!("{}/{}/artist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.artist.as_str()).await;
let _ = client.publish(format!("{}/{}/playlist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.playlist.as_str()).await;
let _ = client.publish(format!("{}/{}/genre", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.genre.as_str()).await;
let _ = client.publish(format!("{}/{}/stream_title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.stream_title.as_str()).await;
let _ = client.publish(format!("{}/{}/playback_state", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.state.as_str()).await;
tracing::info!(
"MQTT state published: media_type='{}', title='{}', artist='{}', playlist='{}', genre='{}', stream_title='{}', state='{}'",
state.media_type,
state.title,
state.artist,
state.playlist,
state.genre,
state.stream_title,
state.state
);
}
async fn handle_remote_command(player: &Arc<PlayerController>, cmd: &str) {
tracing::info!("Received MQTT remote command: '{}'", cmd);
match cmd {
"play_pause" | "toggle" => player.play_pause().await,
"pause" => {
let st = player.status.lock().await;
if st.state == PlaybackState::Playing {
drop(st);
player.play_pause().await;
}
}
"play" => {
let st = player.status.lock().await;
if st.state == PlaybackState::Paused {
drop(st);
player.play_pause().await;
}
}
"stop" => player.stop().await,
"toggle_mute" | "mute" => player.toggle_mute().await,
s if s.starts_with("volume:") => {
if let Ok(vol) = s["volume:".len()..].trim().parse::<f64>() {
player.set_volume(vol).await;
}
}
_ => {
tracing::warn!("Unrecognized MQTT command: '{}'", cmd);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::PlaybackState;
#[test]
fn test_stopped_state_has_empty_media_type_and_metadata() {
let st = PlayerStatus {
state: PlaybackState::Stopped,
media_type: "music".to_string(),
title: "Previous Track".to_string(),
artist: "Previous Artist".to_string(),
genre: "Rock".to_string(),
stream_title: "".to_string(),
time_pos: 0.0,
duration: 0.0,
volume: 100.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "stopped");
assert_eq!(mqtt_state.media_type, "");
assert_eq!(mqtt_state.title, "");
assert_eq!(mqtt_state.artist, "");
assert_eq!(mqtt_state.playlist, "");
assert_eq!(mqtt_state.genre, "");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_playing_music_state() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "music".to_string(),
title: "Bohemian Rhapsody".to_string(),
artist: "Queen".to_string(),
playlist: "Favorites".to_string(),
genre: "Rock".to_string(),
stream_title: "".to_string(),
time_pos: 42.0,
duration: 354.0,
volume: 85.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "music");
assert_eq!(mqtt_state.title, "Bohemian Rhapsody");
assert_eq!(mqtt_state.artist, "Queen");
assert_eq!(mqtt_state.playlist, "Favorites");
assert_eq!(mqtt_state.genre, "Rock");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_playing_stream_with_icy_metadata() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "stream".to_string(),
title: "Radio Veronica".to_string(),
artist: "Pink Floyd".to_string(),
playlist: "Radio".to_string(),
genre: "Classic Rock".to_string(),
stream_title: "Pink Floyd - Comfortably Numb".to_string(),
time_pos: 120.0,
duration: 0.0,
volume: 90.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "stream");
assert_eq!(mqtt_state.title, "Radio Veronica");
assert_eq!(mqtt_state.artist, "Pink Floyd");
assert_eq!(mqtt_state.playlist, "Radio");
assert_eq!(mqtt_state.genre, "Classic Rock");
assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb");
}
#[test]
fn test_playing_video_state() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "video".to_string(),
title: "Interstellar".to_string(),
artist: "".to_string(),
genre: "Sci-Fi".to_string(),
stream_title: "".to_string(),
time_pos: 1000.0,
duration: 10140.0,
volume: 100.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "video");
assert_eq!(mqtt_state.title, "Interstellar");
assert_eq!(mqtt_state.artist, "");
assert_eq!(mqtt_state.genre, "Sci-Fi");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_dj_announcement_stream_title_empty() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "video".to_string(),
title: "Radio Chicken TV: Up next: Iron Maiden - The Trooper".to_string(),
artist: "Radio Chicken DJ".to_string(),
playlist: "HEUL_HARD".to_string(),
genre: "DJ".to_string(),
stream_title: "Radio Chicken DJ - Up next: Iron Maiden - The Trooper".to_string(),
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.artist, "Radio Chicken DJ");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_yaml_config_deserialization() {
let yaml_str = r#"
jellyfin:
server_url: "https://jellyfin.example.com"
username: "testuser"
device_id: "living-room-01"
device_name: "Living Room Kiosk"
mqtt:
enabled: true
host: "192.168.1.50"
port: 1883
username: "mqtt_user"
password: "mqtt_password"
discovery_prefix: "homeassistant"
topic_prefix: "kiosk"
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml_str).expect("Failed to deserialize AppConfig");
assert!(cfg.mqtt.enabled);
assert_eq!(cfg.mqtt.host, "192.168.1.50");
assert_eq!(cfg.mqtt.port, 1883);
assert_eq!(cfg.mqtt.username.as_deref(), Some("mqtt_user"));
assert_eq!(cfg.mqtt.password.as_deref(), Some("mqtt_password"));
assert_eq!(cfg.mqtt.discovery_prefix, "homeassistant");
assert_eq!(cfg.mqtt.topic_prefix, "kiosk");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+689
View File
@@ -0,0 +1,689 @@
use reqwest::header::AUTHORIZATION;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::YouTubeConfig;
use crate::jellyfin::MediaItem;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_url: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub token_type: String,
}
#[derive(Debug, Clone)]
pub enum DevicePollResult {
Pending,
SlowDown,
Success,
Error(String),
}
#[derive(Debug, Clone)]
pub struct YouTubeClient {
config: YouTubeConfig,
client: reqwest::Client,
token: Arc<RwLock<Option<StoredToken>>>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionsResponse {
items: Option<Vec<SubscriptionItem>>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionItem {
snippet: Option<SubscriptionSnippet>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionSnippet {
title: String,
description: Option<String>,
#[serde(rename = "resourceId")]
resource_id: Option<ResourceId>,
thumbnails: Option<Thumbnails>,
}
#[derive(Debug, Deserialize)]
struct ResourceId {
#[serde(rename = "channelId")]
channel_id: Option<String>,
#[serde(rename = "videoId")]
video_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Thumbnails {
default: Option<ThumbnailItem>,
medium: Option<ThumbnailItem>,
high: Option<ThumbnailItem>,
}
#[derive(Debug, Deserialize)]
struct ThumbnailItem {
url: String,
}
#[derive(Debug, Deserialize)]
struct PlaylistItemsResponse {
items: Option<Vec<PlaylistItem>>,
}
#[derive(Debug, Deserialize)]
struct PlaylistItem {
snippet: Option<PlaylistItemSnippet>,
}
#[derive(Debug, Deserialize)]
struct PlaylistItemSnippet {
title: String,
description: Option<String>,
#[serde(rename = "publishedAt")]
published_at: Option<String>,
#[serde(rename = "channelTitle")]
channel_title: Option<String>,
#[serde(rename = "resourceId")]
resource_id: Option<ResourceId>,
thumbnails: Option<Thumbnails>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
items: Option<Vec<SearchItem>>,
}
#[derive(Debug, Deserialize)]
struct SearchItem {
id: Option<ResourceId>,
snippet: Option<PlaylistItemSnippet>,
}
fn best_thumbnail(t: &Option<Thumbnails>) -> Option<String> {
if let Some(t) = t {
if let Some(h) = &t.high {
return Some(h.url.clone());
}
if let Some(m) = &t.medium {
return Some(m.url.clone());
}
if let Some(d) = &t.default {
return Some(d.url.clone());
}
}
None
}
fn resolve_token_file(path_str: &str) -> PathBuf {
if path_str.starts_with("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(&path_str[2..]);
}
}
PathBuf::from(path_str)
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl YouTubeClient {
pub fn new(config: YouTubeConfig) -> Self {
let mut initial_token = None;
let token_path = resolve_token_file(&config.token_path);
if token_path.exists() {
if let Ok(data) = std::fs::read_to_string(&token_path) {
if let Ok(t) = serde_json::from_str::<StoredToken>(&data) {
tracing::info!("Loaded cached YouTube token from {}", token_path.display());
initial_token = Some(t);
}
}
}
Self {
config,
client: reqwest::Client::builder().build().unwrap_or_default(),
token: Arc::new(RwLock::new(initial_token)),
}
}
pub fn is_configured(&self) -> bool {
!self.config.client_id.trim().is_empty()
}
pub async fn is_logged_in(&self) -> bool {
let guard = self.token.read().await;
if let Some(t) = guard.as_ref() {
t.expires_at > current_timestamp() || t.refresh_token.is_some()
} else {
false
}
}
pub async fn logout(&self) {
{
let mut guard = self.token.write().await;
*guard = None;
}
let token_path = resolve_token_file(&self.config.token_path);
if token_path.exists() {
let _ = std::fs::remove_file(token_path);
}
}
fn save_token(&self, token: &StoredToken) {
let token_path = resolve_token_file(&self.config.token_path);
if let Some(parent) = token_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(data) = serde_json::to_string_pretty(token) {
let _ = std::fs::write(&token_path, data);
}
}
pub async fn start_device_auth(&self) -> Result<DeviceCodeResponse, String> {
if !self.is_configured() {
return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string());
}
tracing::info!("Starting YouTube OAuth Device Code flow for client_id: {}...", self.config.client_id);
let params = [
("client_id", self.config.client_id.as_str()),
("scope", "https://www.googleapis.com/auth/youtube.readonly"),
];
let res = self
.client
.post("https://oauth2.googleapis.com/device/code")
.form(&params)
.send()
.await
.map_err(|e| {
tracing::error!("Network error starting YouTube device auth: {}", e);
format!("Netwerkfout bij starten authenticatie: {}", e)
})?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
tracing::error!("Google OAuth device/code error (status {}): {}", status, text);
if text.contains("Invalid client type") {
return Err("Ongeldig OAuth Client ID type. Kies in Google Cloud Console bij het aanmaken van het Client ID het type: 'Tv's en apparaten met beperkte invoer' (TVs and Limited Input devices).".to_string());
}
return Err(format!("Google API fout ({}): {}", status, text));
}
let resp: DeviceCodeResponse = res
.json()
.await
.map_err(|e| {
tracing::error!("Failed to parse Google response: {}", e);
format!("Ongeldig antwoord van Google: {}", e)
})?;
tracing::info!(
"YouTube Device Code generated: user_code='{}', url='{}'",
resp.user_code,
resp.verification_url
);
Ok(resp)
}
pub async fn poll_device_token(&self, device_code: &str) -> DevicePollResult {
let mut params = vec![
("client_id", self.config.client_id.clone()),
("device_code", device_code.to_string()),
(
"grant_type",
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
),
];
if let Some(secret) = &self.config.client_secret {
if !secret.trim().is_empty() {
params.push(("client_secret", secret.clone()));
}
}
let res = match self
.client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
{
Ok(r) => r,
Err(e) => return DevicePollResult::Error(format!("Netwerkfout: {}", e)),
};
let status = res.status();
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(e) => return DevicePollResult::Error(format!("JSON fout: {}", e)),
};
if status.is_success() {
let access_token = body
.get("access_token")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let refresh_token = body
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expires_in = body
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let token_type = body
.get("token_type")
.and_then(|v| v.as_str())
.unwrap_or("Bearer")
.to_string();
let stored = StoredToken {
access_token,
refresh_token,
expires_at: current_timestamp() + expires_in,
token_type,
};
self.save_token(&stored);
{
let mut guard = self.token.write().await;
*guard = Some(stored);
}
return DevicePollResult::Success;
}
if let Some(err) = body.get("error").and_then(|e| e.as_str()) {
match err {
"authorization_pending" => DevicePollResult::Pending,
"slow_down" => DevicePollResult::SlowDown,
"access_denied" => {
DevicePollResult::Error("Toestemming geweigerd door gebruiker.".to_string())
}
"expired_token" => {
DevicePollResult::Error("De koppelcode is verlopen. Probeer opnieuw.".to_string())
}
other => DevicePollResult::Error(format!("Google fout: {}", other)),
}
} else {
DevicePollResult::Error(format!("Onbekende fout van Google (status {})", status))
}
}
pub async fn get_valid_token(&self) -> Result<String, String> {
let (current_access, refresh_opt, is_expired) = {
let guard = self.token.read().await;
if let Some(t) = guard.as_ref() {
let now = current_timestamp();
(t.access_token.clone(), t.refresh_token.clone(), t.expires_at <= now + 60)
} else {
return Err("Niet ingelogd op YouTube".to_string());
}
};
if !is_expired {
return Ok(current_access);
}
// Need to refresh
let refresh_token = match refresh_opt {
Some(rt) => rt,
None => return Err("Geen refresh token aanwezig. Log opnieuw in.".to_string()),
};
let mut params = vec![
("client_id", self.config.client_id.clone()),
("refresh_token", refresh_token.clone()),
("grant_type", "refresh_token".to_string()),
];
if let Some(secret) = &self.config.client_secret {
if !secret.trim().is_empty() {
params.push(("client_secret", secret.clone()));
}
}
let res = self
.client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.map_err(|e| format!("Netwerkfout bij vernieuwen YouTube token: {}", e))?;
if !res.status().is_success() {
return Err("Kon YouTube sessie niet vernieuwen. Log opnieuw in.".to_string());
}
let body: serde_json::Value = res
.json()
.await
.map_err(|e| format!("JSON fout bij token vernieuwen: {}", e))?;
let new_access = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "Geen access_token in antwoord".to_string())?
.to_string();
let expires_in = body
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let updated = StoredToken {
access_token: new_access.clone(),
refresh_token: Some(refresh_token),
expires_at: current_timestamp() + expires_in,
token_type: "Bearer".to_string(),
};
self.save_token(&updated);
{
let mut guard = self.token.write().await;
*guard = Some(updated);
}
Ok(new_access)
}
pub async fn fetch_subscriptions(&self) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
let url = "https://www.googleapis.com/youtube/v3/subscriptions?part=snippet&mine=true&maxResults=50&order=alphabetical";
let res = self
.client
.get(url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij ophalen abonnementen: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: SubscriptionsResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
if let Some(snip) = item.snippet {
let channel_id = snip
.resource_id
.and_then(|r| r.channel_id)
.unwrap_or_default();
if channel_id.is_empty() {
continue;
}
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
items.push(MediaItem {
id: channel_id,
name: title.clone(),
sort_name: Some(title),
item_type: "YouTubeChannel".to_string(),
series_name: None,
artist_name: None,
genre: None,
index_number: None,
parent_index_number: None,
season_name: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: true,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
recursive_item_count: None,
child_count: None,
series_id: None,
album_id: None,
album: None,
});
}
}
}
Ok(items)
}
pub async fn fetch_channel_videos(&self, channel_id: &str) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
// Channel upload playlist ID is channel ID with "UU" instead of "UC"
let playlist_id = if channel_id.starts_with("UC") {
format!("UU{}", &channel_id[2..])
} else {
channel_id.to_string()
};
let url = format!(
"https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId={}&maxResults=30",
playlist_id
);
let res = self
.client
.get(&url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij ophalen uploads van kanaal: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: PlaylistItemsResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
if let Some(snip) = item.snippet {
let video_id = snip
.resource_id
.and_then(|r| r.video_id)
.unwrap_or_default();
if video_id.is_empty() {
continue;
}
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
let video_url = format!("https://www.youtube.com/watch?v={}", video_id);
let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect());
items.push(MediaItem {
id: video_url,
name: title.clone(),
sort_name: snip.published_at,
item_type: "YouTubeVideo".to_string(),
series_name: snip.channel_title,
artist_name: None,
genre: date_str,
index_number: None,
parent_index_number: None,
season_name: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: false,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
recursive_item_count: None,
child_count: None,
series_id: None,
album_id: None,
album: None,
});
}
}
}
Ok(items)
}
pub async fn fetch_feed(&self) -> Result<Vec<MediaItem>, String> {
let subs = self.fetch_subscriptions().await?;
if subs.is_empty() {
return Ok(Vec::new());
}
// Concurrently fetch the latest 3 videos from up to top 15 subscriptions
let top_channels: Vec<String> = subs.into_iter().take(15).map(|c| c.id).collect();
let mut handles = Vec::new();
for channel_id in top_channels {
let client = self.clone();
handles.push(tokio::spawn(async move {
client.fetch_channel_videos(&channel_id).await.unwrap_or_default()
}));
}
let mut all_videos = Vec::new();
for h in handles {
if let Ok(videos) = h.await {
// Take top 3 of each channel
all_videos.extend(videos.into_iter().take(3));
}
}
// Sort descending by published_at (stored in sort_name)
all_videos.sort_by(|a, b| b.sort_name.cmp(&a.sort_name));
Ok(all_videos)
}
pub async fn search(&self, query: &str) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
let encoded_q = urlencoding_simple(query);
let url = format!(
"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={}&maxResults=25",
encoded_q
);
let res = self
.client
.get(&url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij zoeken: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: SearchResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
let video_id = item
.id
.and_then(|r| r.video_id)
.unwrap_or_default();
if video_id.is_empty() {
continue;
}
if let Some(snip) = item.snippet {
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
let video_url = format!("https://www.youtube.com/watch?v={}", video_id);
let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect());
items.push(MediaItem {
id: video_url,
name: title.clone(),
sort_name: snip.published_at,
item_type: "YouTubeVideo".to_string(),
series_name: snip.channel_title,
artist_name: None,
genre: date_str,
index_number: None,
parent_index_number: None,
season_name: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: false,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
recursive_item_count: None,
child_count: None,
series_id: None,
album_id: None,
album: None,
});
}
}
}
Ok(items)
}
}
fn urlencoding_simple(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
out.push(c);
} else if c == ' ' {
out.push('+');
} else {
for b in c.to_string().as_bytes() {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
@@ -0,0 +1 @@
{"rustc_fingerprint":14753936209423482788,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/bart/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
3ac0e9726997097b
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"gvar-alloc\", \"std\", \"variable-fonts\"]","declared_features":"[\"default\", \"gvar-alloc\", \"libm\", \"std\", \"variable-fonts\"]","target":11794240345726188307,"profile":2241668132362809309,"path":5649524171242691192,"deps":[[4945662571602681759,"ab_glyph_rasterizer",false,2687199477971215692],[5327495677235252177,"owned_ttf_parser",false,13558008051659722080]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph-0217f9255a6590dd/dep-lib-ab_glyph","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
5d1ec60264864a72
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"gvar-alloc\", \"std\", \"variable-fonts\"]","declared_features":"[\"default\", \"gvar-alloc\", \"libm\", \"std\", \"variable-fonts\"]","target":11794240345726188307,"profile":15657897354478470176,"path":5649524171242691192,"deps":[[4945662571602681759,"ab_glyph_rasterizer",false,15637680591314221340],[5327495677235252177,"owned_ttf_parser",false,9976818268436013839]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph-351e6496e093a624/dep-lib-ab_glyph","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"libm\", \"std\"]","target":4335109392423587462,"profile":2241668132362809309,"path":13818387167479311948,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph_rasterizer-34531215e91a4783/dep-lib-ab_glyph_rasterizer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"libm\", \"std\"]","target":4335109392423587462,"profile":15657897354478470176,"path":13818387167479311948,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph_rasterizer-e4817a80f37d7712/dep-lib-ab_glyph_rasterizer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
699c6ac28aa5af89
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core\", \"default\", \"rustc-dep-of-std\", \"std\"]","target":6569825234462323107,"profile":15657897354478470176,"path":4529919677372112567,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/adler2-5305f511e1c31af3/dep-lib-adler2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
892547091c8cf972
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core\", \"default\", \"rustc-dep-of-std\", \"std\"]","target":6569825234462323107,"profile":2241668132362809309,"path":4529919677372112567,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/adler2-b5185ec3be97cc68/dep-lib-adler2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
5068cea52b295da9
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":15657897354478470176,"path":3288015079357928609,"deps":[[966925859616469517,"build_script_build",false,9203697050838375196],[4321869508056025743,"zerocopy",false,2467696241657189483],[5855319743879205494,"once_cell",false,12280998255132305995],[15482175856213997617,"cfg_if",false,14176503484701607164],[18408407127522236545,"getrandom",false,2733066888145013121]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-2b16cca8b306abc9/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
842172d1ed01f74c
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":2241668132362809309,"path":3288015079357928609,"deps":[[966925859616469517,"build_script_build",false,9203697050838375196],[4321869508056025743,"zerocopy",false,15765362044039701013],[5855319743879205494,"once_cell",false,11850536697227471877],[15482175856213997617,"cfg_if",false,14735624068320645977],[18408407127522236545,"getrandom",false,15986369605517680036]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-a3e6e2b710a4e0bc/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[966925859616469517,"build_script_build",false,17893360845909163615]],"local":[{"RerunIfChanged":{"output":"debug/build/ahash-a57948a68d40e0a7/output","paths":["build.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":17883862002600103897,"profile":2225463790103693989,"path":4299570802035626402,"deps":[[5398981501050481332,"version_check",false,14943717254255103075]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-d6dd36af1ebc73ff/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
181ee43004d5c945
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core-graphics\", \"default\", \"image\", \"image-data\", \"wayland-data-control\", \"windows-sys\", \"wl-clipboard-rs\"]","target":1337616771932055151,"profile":2241668132362809309,"path":17166859248196286572,"deps":[[6803352382179706244,"percent_encoding",false,7822495553327927090],[7264839661287455353,"log",false,12557639338793902813],[12459942763388630573,"parking_lot",false,17310600078098908667],[15803581142294733505,"x11rb",false,15864593136328662872]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arboard-4d99d111aeeb9d85/dep-lib-arboard","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
941081f2db9ef8de
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core-graphics\", \"default\", \"image\", \"image-data\", \"wayland-data-control\", \"windows-sys\", \"wl-clipboard-rs\"]","target":1337616771932055151,"profile":15657897354478470176,"path":17166859248196286572,"deps":[[6803352382179706244,"percent_encoding",false,10204264024909671241],[7264839661287455353,"log",false,10968825162537122904],[12459942763388630573,"parking_lot",false,7493661607571755906],[15803581142294733505,"x11rb",false,16408250448247954789]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arboard-941d3c3f22ae1264/dep-lib-arboard","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"alloc\", \"default\"]","declared_features":"[\"alloc\", \"default\"]","target":8427501830925002634,"profile":2241668132362809309,"path":11315664722530468864,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/as-raw-xcb-connection-1b429b60928621fa/dep-lib-as_raw_xcb_connection","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"alloc\", \"default\"]","declared_features":"[\"alloc\", \"default\"]","target":8427501830925002634,"profile":15657897354478470176,"path":11315664722530468864,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/as-raw-xcb-connection-9d9025af637da936/dep-lib-as_raw_xcb_connection","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[]","target":5116616278641129243,"profile":2225463790103693989,"path":8469429350005984183,"deps":[[8711674966389384079,"syn",false,12098035866327759596],[8949245912927223590,"quote",false,6701396820762924173],[16346726298725429545,"proc_macro2",false,12397529350165103128]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/async-trait-b09e65b0c30ab584/dep-lib-async_trait","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":551700150563500949,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-199214763a0024c7/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2241668132362809309,"path":551700150563500949,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-96e688c59e310096/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
1fd5c4355ae3a430
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":6403542389053020766,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/autocfg-374b6208e55aaac6/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
ef0aa26d005fff9d
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"http1\", \"json\", \"tokio\"]","declared_features":"[\"__private_docs\", \"default\", \"form\", \"http1\", \"http2\", \"json\", \"macros\", \"matched-path\", \"multipart\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\", \"ws\"]","target":13920321295547257648,"profile":15657897354478470176,"path":5480532092245258048,"deps":[[784494742817713399,"tower_service",false,7096277064673234537],[927329442006724342,"http_body_util",false,10169796446186527904],[2251399859588827949,"pin_project_lite",false,16496052122936131536],[2517136641825875337,"sync_wrapper",false,5993311283381840508],[3632162862999675140,"tower",false,5005464751011667245],[4359148418957042248,"axum_core",false,8050097667477050567],[5532778797167691009,"itoa",false,12625258790664933256],[6444209561448300374,"futures_util",false,2711510235064628060],[6557439603276904804,"serde",false,10117669846973571935],[6803352382179706244,"percent_encoding",false,10204264024909671241],[7712452662827335977,"tower_layer",false,1094941957052667426],[8160210889872729633,"serde_json",false,1366118035012243637],[9678799920983747518,"matchit",false,466840185268934315],[10229185211513642314,"mime",false,3488359487242993514],[10260941683582100114,"async_trait",false,15969331622671879673],[11926622812581095017,"bytes",false,14261233726640694094],[12328341851100645683,"http",false,3354019498251889263],[12613788554453945248,"memchr",false,14549996354126691281],[14092367075979712649,"hyper",false,11409751076288742659],[14814583949208169760,"serde_path_to_error",false,3598317744880925089],[15618961772992676818,"hyper_util",false,5909608442022453208],[16991438365634268121,"rustversion",false,3477745542916629179],[17866805608252134857,"tokio",false,10735189983294870288],[17905774625381964326,"http_body",false,8880207948320647891]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-05a9f8609164393f/dep-lib-axum","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}

Some files were not shown because too many files have changed in this diff Show More