# medias
A TV media player that runs directly on DRM/KMS, without X11 or Wayland.
libmpv decodes video into one KMS plane. A Vulkan-rendered UI sits on a
second plane, and both are composited in one atomic page flip. On the
Raspberry Pi, hardware-decoded frames are scanned out directly, with HDR10
output. Input comes
from an HDMI-CEC remote, evdev keyboards and remotes, or the terminal.
Targets are the Raspberry Pi and desktop GPUs (e.g. AMD RX 6800); see Hardware.
## Features
- **Local library:** video and music folders with thumbnails (a frame at 15%,
embedded or folder cover art). Per-file resume; search-as-you-type filter
with an on-screen keyboard.
- **UPnP/DLNA:** discovers media servers via SSDP (e.g. Fritz!Box, minidlna)
and browses their ContentDirectory. Album art is shown; items play from
their HTTP resources.
- **Folder playback:** at the end of a file the next one in its folder plays
(on by default, togglable). Next/Previous-track keys skip.
- **YouTube:** search, trending, subscriptions, playlists, liked videos (via
YouTube Data API v3 sign-in), and "Like your watched" (videos related to
watched ones). Streams play directly
from InnerTube, with yt-dlp as fallback. The original audio track plays
first; dubs in your languages are offered as named tracks.
- **Refresh-rate matching:** during playback the display switches to a
same-size mode at a multiple of the video frame rate (23.976 fps → 23.976 Hz,
25 → 50), and back in the library.
- **Direct scanout and HDR** (Raspberry Pi 4/5 planes): hardware-decoded frames
go to their own KMS plane without a GL pass; 4K HEVC Main 10 plays at 24 fps
on the Pi 5. PQ and HLG video switch an HDR TV to BT.2020 HDR with the
file's static metadata; the UI is drawn at SDR brightness on top.
Dolby Vision profile 5 (no HDR10 base layer) is converted from the file's
Dolby metadata with direct scanout: to HDR10 on an HDR TV, to SDR otherwise
or with `MEDIAS_HDR=0` (`MEDIAS_TONEMAP=0` disables; colours are then wrong
and a warning is shown).
- **Views:** text list, thumbnail list, or a wide thumbnail grid
(Yellow/Info toggles).
- **Black screen:** Yellow/Info during playback cycles info shown → hidden →
black screen. Audio keeps playing, which suits music on OLED TVs.
## Requirements
- Linux with a KMS driver and an EGL/GLES driver (video).
- Optional: a Vulkan driver with dmabuf import (e.g. Mesa RADV or V3DV, or NVIDIA)
for the UI. Without one (e.g. Mali Midgard on RK3399) a CPU rasterizer draws
the UI; `MEDIAS_UI=cpu` forces it.
- `libmpv` (package `mpv` or `libmpv-dev`). `libgbm`, `libEGL` and the Vulkan
loader are loaded or linked from the system.
- Access to `/dev/dri/card*`, `/dev/dri/renderD*`, `/dev/input/event*`, and to
`/dev/cec*` for HDMI-CEC (groups `video`, `render`, `input`).
- No desktop or display manager holding the display (DRM master); otherwise
startup fails with `acquire DRM master ... Permission denied`.
`sudo fuser -v /dev/dri/card*` shows the holder. Stop it with
`sudo systemctl stop display-manager`, or boot to a console with
`sudo systemctl set-default multi-user.target`.
- Optional: `yt-dlp` for the playback fallback and anonymous YouTube search.
## Hardware
Software-decoded video renders through mpv's OpenGL path at up to 1080p; the
display scales it. GPU fill rate limits that path more than decoding does.
Hardware-decoded video on the Pi bypasses GL (direct scanout).
Measurements: PERFORMANCE.md.
| Tested | Raspberry Pi 5 | Vulkan UI (V3DV). HEVC hwdec with direct scanout: 4K Main 10 at 24 fps, HDR10 (LG TV). H.264 1080p25 software. |
| Tested | Desktop: AMD (RADV), NVIDIA | Vulkan UI. |
| Expected | Raspberry Pi 4 | Vulkan UI (V3DV); H.264 and HEVC hwdec, direct scanout. Untested. |
| Expected | RK3588/RK3588S (e.g. Radxa Rock 5C) | Mali-G610: PanVK Vulkan, Panthor kernel driver (recent mainline). 4x A76 decode 1080p in software. Untested. Not RK3582 (Rock 5C Lite): its GPU is disabled. |
| Best effort | Allwinner H618 (Orange Pi Zero3) | Mali-G31, no Vulkan: CPU UI. 1080p24 H.264 needs `profile=fast` (`MEDIAS_MPV_CONF`); 30 ms of a 41.7 ms frame. Set `MEDIAS_AUDIO_DEVICE` (e.g. `alsa/plughw:CARD=HDMI,DEV=0`). |
| Best effort | RK3399 (e.g. Asus Tinker Edge R) | Mali-T860, no Vulkan: CPU UI. Too slow on vendor kernels; mainline untested. |
For a new 1080p box, buy a Raspberry Pi 4 or 5, or an RK3588 board.
## Install
```sh
cargo install medias
# with the "For you" feed (YouTube's TV OAuth client, see below):
cargo install medias --features youtube-tv
```
Release builds for Linux x86_64 and aarch64 (tar.gz and Debian 13 trixie
`.deb`, in `dist/`) need cargo-make, cargo-zigbuild, zig, binutils, and the
host's libmpv and libgbm (their symbols form the link stubs):
```sh
cargo make dist # all targets; cargo make dist --help
sudo apt install ./dist/medias_*_arm64.deb
```
Apt repository: `cargo make repo-key` creates the signing key once (ed25519,
passphrase). `cargo make repo` adds `dist/*.deb` to `dist/debian/pool` and
writes the signed indexes, `medias.asc` and `medias.sources` (key embedded).
Both tasks rewrite `dist/index.html`, the download page (`cargo make
dist-index` alone; template `packaging/index.html.in`).
The pool keeps every version; upload without `--delete`:
```sh
cargo make dist && cargo make repo
```
On a Debian 13 trixie as root:
```sh
curl -fsSL https://www.creative-memory.ai/andy/medias/dist/debian/medias.sources -o /etc/apt/sources.list.d/medias.sources
apt update && apt install medias
```
## Service
`medias@.service` runs medias on tty1 as the instance's user, with a logind
session (PipeWire, device access) and groups `video render input audio`.
Options go in `/etc/default/medias` (`MEDIAS_ARGS`, `MEDIAS_*` variables).
```sh
# Edit /etc/default/medias to configure.
# Replace USER with the user you want medias (and e.g. pipewire) to run as.
sudo systemctl enable --now medias@$USER.service
```
The .deb installs the unit to `/usr/lib/systemd/system`. From the tar.gz, copy
`medias` to `/usr/bin`, `medias@.service` to `/etc/systemd/system` and
`medias.default` to `/etc/default/medias`.
## Run
```sh
medias # ~/medias/Videos and ~/medias/Music
medias --videos=Movies=/mnt/movies --videos=Series=/mnt/series --music=/mnt/music
medias metadata movie.mkv # codecs, resolution, fps, bit depth, channels, bitrates
medias planes # TV HDR capabilities, KMS planes and their formats
medias --help # all options, keys and environment variables
```
The same command works from a Linux console, SSH or a terminal multiplexer.
`scripts/medias-vt` optionally starts it on a separate VT (see
`scripts/README.md`).
## Environment variables
Set them in the shell, or in `/etc/default/medias` for the service.
Display and video:
| `MEDIAS_DIRECT=0` | Render hardware-decoded frames through GL. Default: direct scanout where the planes take the Pi decoder's 10-bit format (P030); `1` tries it on other hardware. |
| `MEDIAS_HDR=0` | No HDR signalling. Default with direct scanout: PQ/HLG video switches the display to BT.2020 HDR when the TV's EDID lists the transfer function. |
| `MEDIAS_HDR_WHITE=<cd/m2>` | Brightness of the UI's white in HDR mode (default 100). Lower it if the UI looks brighter than in SDR; 25 matched SDR on an LG TV. |
| `MEDIAS_TONEMAP=1` | Experimental, with direct scanout: PQ (HDR10) or HLG video on a display without HDR signalling is tone-mapped to SDR by a Vulkan pass over the decoder's frames. When the pass does not keep a file's frame rate (3840x2160 above 30 fps on the Pi 5), that file is shown without tone mapping. Default: no tone mapping. |
| `MEDIAS_TONEMAP=0` | Disables the pass also for Dolby Vision profile 5, which uses it by default (polynomial reshaping only; half size when a file is too slow at full size). |
| `MEDIAS_TONEMAP=half` | As `1`, but such a file is tone-mapped at half its size and scaled up by the display plane. |
| `MEDIAS_MATCH_RATE=0` | Keep the display refresh rate during playback. |
| `MEDIAS_MATCH_RATE_DELAY=<s>` | Pause after a refresh or HDR switch while the TV resyncs (default 3; 0: none). |
| `MEDIAS_HWDEC=auto\|no\|...` | mpv hardware decoding (default `auto`; `no`: software). |
| `MEDIAS_UI=cpu` | Draw the UI with the CPU rasterizer instead of Vulkan (automatic without a Vulkan device that imports dmabufs). |
| `MEDIAS_MPV_CONF=<path>` | mpv options, one `key=value` per line, applied last (e.g. `profile=fast` for slow GPUs). |
Audio:
| `MEDIAS_AO=<list>` | mpv audio output list (default `pipewire,alsa,null`). |
| `MEDIAS_AUDIO_DEVICE=<dev>` | mpv audio device (`aplay -L` lists names, e.g. `alsa/sysdefault:CARD=vc4hdmi0`). |
YouTube:
| `MEDIAS_YT_MAX_HEIGHT=<px>` | Stream height cap (default 1080). |
| `MEDIAS_YT_CODECS=<list>` | Allowed video codecs, preferred first (default `av01,vp9,avc1`; Pi: `avc1`). |
| `MEDIAS_YT_AUDIO_LANGS=<list>` | Languages whose dubs are offered beside the original audio, e.g. `de,en` (default: from the locale). |
| `MEDIAS_YT_SUB_LANGS=<list>` | Caption languages offered (default: `MEDIAS_YT_AUDIO_LANGS`). |
| `MEDIAS_YT_SYNC=1` | Report playback to the YouTube TV account's watch history (`youtube-tv` builds). |
| `MEDIAS_YT_CLIENT_ID`, `MEDIAS_YT_CLIENT_SECRET`, `MEDIAS_YT_API_KEY` | Data API credentials, instead of `youtube_oauth.json`. |
| `MEDIAS_YTDL_PATH=<path>` | yt-dlp binary (default: found in `/usr/bin` etc.). |
| `MEDIAS_YTDL_FORMAT=<format>` | yt-dlp format for the playback fallback (default `bestvideo[height<=?1080]+bestaudio/best`). |
Diagnosis:
| `MEDIAS_STATS=1` | Print per-second playback stats: frames presented, render times, drops, decoder. |
| `MEDIAS_MPV_LOG=<path>` | Write mpv's verbose log. |
| `MEDIAS_GL_SYNC=flush` | `glFlush` instead of `glFinish` after each GL video frame. |
| `MEDIAS_YT_DEBUG=1` | Dump InnerTube browse responses to `~/medias/innertube-*.json`. |
`MEDIAS_ARGS` in `/etc/default/medias` holds the command-line options of the
service (split at whitespace).
## Files under `~/medias`
| `Videos/`, `Music/` | default library roots |
| `upnp.txt` | UPnP server description URLs outside multicast reach, one per line |
| `youtube.txt` | YouTube sources, `Label \| query` per line (yt-dlp query or URL) |
| `youtube_oauth.json` | Data API sign-in: `{"client_id", "client_secret", "api_key"?}` |
| `.history` | resume positions and last-played dates |
| `.youtube_auth`, `.youtube_tv_auth` | stored sign-in sessions (mode 0600) |
| `.view`, `.autoplay` | list/grid view (default grid); play-next-in-folder on/off |
| `.cache/thumbs/` | thumbnail cache |
## YouTube sign-in
- **Data API** (subscriptions, playlists, liked videos): create a Google Cloud
OAuth client of type "TVs and Limited Input devices", enable YouTube Data
API v3, and put its ID and secret in `~/medias/youtube_oauth.json`. "Sign in"
then shows a code and a QR code. In the consent screen's "Testing" state,
Google expires the refresh token after 7 days.
- **For you** (build feature `youtube-tv`): a second sign-in with YouTube's own
TV client (as the Kodi YouTube plugin does). No configuration is needed.
The feed loads up to 36 videos. The same sign-in adds "Watched" and
"Playlists" (own and saved, up to 200 entries each). `MEDIAS_YT_SYNC=1` adds videos played in
medias to that account's YouTube watch history (off by default).
- Without either, search uses yt-dlp anonymously.
## Remote keys (summary)
| Up/Down | move | volume |
| Left/Right | page (grid: move) | seek ±10 s |
| OK | open / play | playback menu |
| Back | up a level | back to the library (paused) |
| Rewind/Fast-forward, Channel ± | page | seek ±60 s |
| Forward/Backward (Next/Previous track) | page | next/previous file in folder |
| Red / Green | — | subtitles / audio track |
| Blue | on-screen keyboard | playback menu |
| Yellow | list/grid view | info → hidden → black screen |
## License
GPL-2.0-or-later (`LICENSE`). The embedded Liberation Sans font is under the
SIL Open Font License 1.1 (`assets/LiberationSans-OFL.txt`).