Ubuntu & Jellyfin Project Guide
A Car TV Jellyfin Box for the Honda Odyssey, Toyota Sienna, and Any HDMI Rear Screen
A small Ubuntu Desktop PC that boots straight into Jellyfin, fullscreen, for the rear seat. Built so kids in a Honda Odyssey, a Toyota Sienna, or any other car with an HDMI screen get a library that is already up when the car starts. The PC is weak on purpose. It direct-plays. It does not transcode video.
What This Box Is For
The rear screen in a minivan is HDMI video and audio only. Nothing on the Honda Odyssey or Toyota Sienna head unit clicks back at the computer, and a generic car TV is the same. The player has to already be in TV mode when the screen lights up. Someone in the back still picks what to watch. This page is the machine that gets there. It is not a catalog.
The library folders are /srv/media/movies and /srv/media/tv.
Minimum PC that works
I recommend a used Intel NUC with a Core i3 from the 6th generation or newer. A newer NUC is a comfortable buy, and you follow the same steps. HDMI, 4 GB of RAM, and a 128 GB SSD are enough, because the PC plays the video as-is instead of re-encoding it. The table is the shopping list.
| Piece | Minimum |
|---|---|
| Computer | Intel NUC, or the same small shape from another brand. Intel Core i3, 6th generation or newer. |
| Graphics | Intel HD Graphics that come with that CPU (HD 520 class or newer). Not a separate video card, and not a CPU with no graphics. |
| Video out | HDMI. DisplayPort plus an HDMI adapter is fine. The car screen is HDMI in. |
| Memory | 4 GB. More is unused. This PC does not transcode. |
| Storage | SSD, 128 GB. The drive is what wears out when the ignition cuts power. Keep a spare. |
| Firmware | A setting named After Power Loss, AC Power Recovery, or Restore on AC Power Loss. Set it to Power On. |
| Network | Ethernet, for copying files at home. Wi-Fi can stay off. The car has no network. |
| CPU type | x86-64. This guide is Ubuntu Desktop plus the Jellyfin Media Player package. |
On eBay, search “Intel NUC i3 HDMI”, “NUC 6th gen”, or a newer NUC generation. Read the processor line so you know which generation you are getting. A 7th-generation or newer NUC uses a newer graphics driver, and the rest of this guide stays the same.
A 6th-generation Core i3 is the one to choose when you cannot check the graphics first. A Raspberry Pi will not run these steps, and neither will a Chromebox that cannot install Ubuntu or a PC with no Intel graphics. If a listing is older than 6th generation, pick a newer one so H.264 can play on the graphics chip.
Updated November 1, 2026. Config examples use the account name car. That name is a placeholder. Create your own account and use it everywhere this page says car. Keep the password off this page and off any note you share. The PC’s address on your home network stays there too.
Vehicles · Install · Playback · Copy files · Player · Autostart · What to buy · FAQ
What “Good” Looks Like
After boot, with no keyboard, on a PC that meets the minimum above:
- The desktop user is logged in.
- Jellyfin is Healthy on port 8096.
- Jellyfin Media Player is fullscreen in TV mode, pointed at
http://127.0.0.1:8096. - A baseline file plays and the Jellyfin dashboard says Direct Play, not Transcode.
- A file copied from a Mac or a Windows PC is readable by the
jellyfinuser. - Boot reaches the desktop without a multi-minute wait for the internet.
- System volume and the Jellyfin slider are both at 100% when the car screen is connected, and sound comes out of that screen.
The file that counts as working
Keep the library in this shape. It hardware-decodes on every PC in the minimum, including a 6th-gen NUC. Newer NUCs play the same files. You do not need a second format.
| Baseline that plays well | |
|---|---|
| Video | H.264 High, 8-bit, yuv420p |
| Size | 720p or 1080p |
| Audio | AAC, stereo |
| Subtitles | Off, or text such as SRT. Not picture subtitles (PGS or VobSub). |
| Container | MP4 or MKV. A remux that only changes the container is fine. |
| Proof | Dashboard says Direct Play. The server does not start a video transcode. |
Honda Odyssey, Toyota Sienna, and a Generic Car TV
The software does not care which minivan it is in. It cares about the wire. A Honda Odyssey rear entertainment screen, a Toyota Sienna rear screen, or a generic car TV all work when they accept HDMI picture and sound and send nothing back. There is no CEC remote path to rely on, and there is no touchscreen click from the head unit. TV layout has to be on before the display connects.
The car cuts power with the ignition. In the PC firmware, set After Power Loss (wording varies: AC Power Recovery, Restore on AC Power Loss) to Power On. When accessory power returns, the PC starts by itself. That setting lives in the firmware setup screen. Linux cannot read it or change it. Leave the BIOS and UEFI alone, including over SSH. If fwupdmgr reports no update, or a firmware update fails, leave the firmware as it is.
A hard ignition cut can still corrupt the SSD. A supply that shuts the PC down cleanly is safer than pulling power every time. The drive is the part that wears out over years in a car, not the software. Keep a spare SSD. An old Intel GPU keeps the driver it shipped with. Buying a newer Intel chip is how you get more decode profiles.
Install Ubuntu Desktop
Install the current Ubuntu Desktop LTS from the USB installer. As of this guide that is 26.04. Desktop is easier to keep working for years than Ubuntu Server or a hand-built kiosk. A clean start is Erase disk and install Ubuntu.
After the first boot:
- Install updates and reboot once.
- Create the desktop user that will auto-login. The examples below call that user
car. - Use one password for the Linux account, Samba, and the Jellyfin admin. Keep that password off anything you publish.
If the disk is LVM and the installer left free space in the volume group, give it all to root:
sudo lvextend -l +100%FREE --resizefs /dev/ubuntu-vg/ubuntu-lv
Confirm with df -h / and sudo lvs.
Install Jellyfin from the Official Repo
Match the Jellyfin apt suite to the Ubuntu codename (VERSION_CODENAME in /etc/os-release). Jellyfin runs as a native systemd service. Docker is the wrong tool for this job.
| Install this | Codename | Jellyfin suite |
|---|---|---|
| Current Ubuntu Desktop LTS (26.04) | resolute | resolute |
/etc/apt/sources.list.d/jellyfin.sources:
Types: deb
URIs: https://repo.jellyfin.org/ubuntu
Suites: resolute
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/jellyfin.gpg
Enabled: yes
Set Suites to the codename in /etc/os-release. The file below is for 26.04. If the newest LTS has moved on, use that codename instead.
sudo apt update
sudo apt install jellyfin jellyfin-server jellyfin-web jellyfin-ffmpeg8
sudo systemctl enable --now jellyfin
Health check. Right after boot the service can take about 10 seconds. Expect Healthy.
curl -sf http://127.0.0.1:8096/health
Mark the packages so a later cleanup does not remove them. Use intel-media-va-driver in that list instead of i965-va-driver if vainfo shows the iHD driver.
sudo apt-mark manual jellyfin jellyfin-server jellyfin-web jellyfin-ffmpeg8 \
jellyfin-media-player i965-va-driver intel-microcode libmpv2
Turn on unattended security updates. Leave Remove-Unused-Dependencies off. Skip apt autoremove. On a desktop install that list can include Xorg and other packages the player still wants.
Driver and Codecs for the Minimum PC
The pass line is H.264 decode in hardware. After Ubuntu is installed, vainfo has to list VAProfileH264High with VAEntrypointVLD. If that line is missing, the PC is below the minimum. A video transcode will not make it a car player.
vainfo
ls -l /dev/dri/renderD128
Install one driver. Match it to what vainfo prints, not to the sticker on the case.
| PC | Driver | What the GPU will decode |
|---|---|---|
| 6th gen (the minimum) | i965-va-driver plus the drop-in below | H.264, and 8-bit HEVC (HEVC Main). Not 10-bit. |
| 7th gen and newer | intel-media-va-driver. No driver-name drop-in. | H.264 and 8-bit HEVC. 10-bit only if vainfo also lists HEVC Main 10. |
The iHD driver is the wrong package on a 6th-gen chip. The i965 driver is the wrong package once vainfo is already using iHD.
i965-only drop-in, /etc/systemd/system/jellyfin.service.d/vaapi.conf, then sudo systemctl daemon-reload && sudo systemctl restart jellyfin. Skip this file when vainfo already shows iHD:
[Service]
Environment=LIBVA_DRIVER_NAME=i965
Trust vainfo, not the generation printed on the case.
encoding.xml on a weak box
These values in /etc/jellyfin/encoding.xml are a backstop. Video transcoding is disabled on the user, so they are not the normal path.
| Key | Value | Why |
|---|---|---|
| HardwareAccelerationType | vaapi | GPU when a transcode really happens |
| VaapiDevice | /dev/dri/renderD128 | The render node |
| EnableHardwareEncoding | true | Keep encoding off the CPU if something must transcode |
| EnableTonemapping | false | Tonemap is expensive, and this box should not be doing HDR conversion |
| EncodingThreadCount | 2 | Enough for a small used PC |
| AllowHevcEncoding | false | Playback should be a direct play |
| AllowAv1Encoding | false | No AV1 encode |
| EnableThrottling | false | Leave the package default |
User policy for the admin account
Set this in the dashboard, or with the user policy API.
| Policy | Value |
|---|---|
| EnableVideoPlaybackTranscoding | false |
| EnableSyncTranscoding | false |
| EnablePlaybackRemuxing | true |
| EnableAudioPlaybackTranscoding | true |
| ForceRemoteSourceTranscoding | false |
| RemoteClientBitrateLimit | 0 |
Remux only changes the container. It does not re-encode the picture. Audio conversion is cheap and keeps odd sound tracks audible. A bitrate limit of 0 means the stream is not squeezed.
Codecs that play well
Standardize on the baseline. It is the format a 6th-gen NUC and every newer Intel NUC both direct-play. 8-bit HEVC also direct-plays on that floor when vainfo lists HEVC Main. You do not need it.
| Plays well on the minimum | Leave it off this PC | |
|---|---|---|
| Video | H.264 High, 8-bit, yuv420p, 720p or 1080p. 8-bit HEVC (HEVC Main) at 1080p is also fine. | 10-bit HEVC, 4K, HDR, Dolby Vision, AV1. These stutter or never use the GPU on the minimum. Re-encode them on another computer. |
| Audio | AAC, stereo. The car screen wants normal PCM, so passthrough stays off. | Bitstream passthrough. That is how the head unit stays silent. |
| Subtitles | Off, or text (SRT). | Picture subtitles (PGS, VobSub). Those ask the server for a video transcode. |
| Container | MP4 or MKV. | A video transcode to “make it smaller.” Remux is allowed. Re-encoding on this PC is not. |
When a file is outside that table, re-encode it with HandBrake on another computer before you copy it: H.264, 1080p, 8-bit, yuv420p, AAC stereo. Then the dashboard should say Direct Play.
Three failures that look the same from the back seat
| What you see | Log | Cause |
|---|---|---|
| Play and transcode both fail immediately | FFmpeg exit 243, Permission denied, probe says streams and format are both null | The file is mode 700. jellyfin cannot open it. Fix permissions and Samba. The GPU is fine. |
| Fullscreen poster, app frozen, close and retry | Player log: Command loadfile: argument index has incompatible type | Current Ubuntu’s libmpv expects a playlist index. The published player build may still omit it. FFmpeg never starts. Load the shim below. |
| Poster, then a long wait, then maybe stutter | FFmpeg.Remux-*.log with -codec:v copy | Remux, not a re-encode. 10-bit HEVC still has to be decoded by the player. Turning video transcoding back on does not fix it. |
Jellyfin’s own ffmpeg is /usr/lib/jellyfin-ffmpeg/ffmpeg, from the jellyfin-ffmpeg8 package. Leave the service pointed at that binary, not the distro ffmpeg. Playback logs are /var/log/jellyfin/FFmpeg.*.log plus jellyfinYYYYMMDD.log.
Libraries and the First User
Open http://127.0.0.1:8096 once and finish the wizard with a real username and password. This setup creates the two folders and turns on metadata and the realtime monitor, so a copy from your Mac or Windows PC shows up without a manual scan.
- Movies library, folder
/srv/media/movies, collection type movies - Shows library, folder
/srv/media/tv, collection type tvshows - Internet metadata providers on, for when the box is on a network that has internet
- Realtime monitor on
If you finish the wizard with the API, call GET /Startup/User before POST /Startup/User. On Jellyfin 12 the POST returns 404 until that GET creates the empty first user. POST /Startup/Complete with no user marks the wizard done, and then you cannot create the admin. Recovery is setting IsStartupWizardCompleted back to false in /etc/jellyfin/system.xml, restarting, then GET, POST user, POST complete. Leave that flag alone once a real admin exists. Jellyfin 12 will not replay the wizard without authentication after that.
Media Folders and Permissions
This was the actual playback bug. It looked like a driver problem and a transcode problem. It was file mode. macOS, copying over SMB, created files as mode 700 and set the ACL mask to ---. Jellyfin runs as user jellyfin. Direct play and transcode both need that user to open the file. FFmpeg exited 243 with Permission denied. ffprobe as root worked. ffprobe as jellyfin failed. Logs said “streams and format are both null” for the same reason.
sudo groupadd --system media || true
sudo usermod -aG media,video,render "$USER"
sudo usermod -aG media,video,render jellyfin
sudo mkdir -p /srv/media/movies /srv/media/tv
sudo chown -R "$USER":media /srv/media
sudo chmod 2775 /srv/media /srv/media/movies /srv/media/tv
sudo setfacl -m u:jellyfin:rx,g:media:rx /srv/media /srv/media/movies /srv/media/tv
sudo setfacl -d -m u:jellyfin:rx,g:media:rx,o::rx /srv/media /srv/media/movies /srv/media/tv
The 2 in 2775 is setgid, so new files stay in group media. The Samba settings in the next section keep new copies readable, including files a Mac would otherwise save as mode 700. After any batch of copies, check:
sudo -u jellyfin find /srv/media -type f ! -readable -print
Run that from /tmp. Running it from the desktop user’s home prints “Failed to restore initial working directory” because jellyfin cannot enter that home directory. That warning is not a media failure. No printed paths means every file is readable. /srv itself can stay 755 root:root. Jellyfin only needs to traverse it.
Copy Files from a Mac or a Windows PC
At home, plug the car PC into your network and open a shared folder. Drag files in from a Mac or a Windows PC, then take the car PC out to the car. Playback in the car does not need a network.
sudo apt install samba avahi-daemon
sudo smbpasswd -a "$USER"
sudo smbpasswd -e "$USER"
Under [global] in /etc/samba/smb.conf, this line has to be present. It keeps a Mac from saving files as mode 700, which Jellyfin then cannot open. Windows copies are fine with it too. If the line is missing, playback fails with FFmpeg exit 243.
unix extensions = no
Append the share. The examples use the account name car. force create mode and unix extensions = no keep new files readable. fruit:nfs_aces = no stops a Mac from writing an ACL mask of ---, which would hide the file from Jellyfin. Windows can use this same share.
[media]
comment = Jellyfin libraries
path = /srv/media
browseable = yes
read only = no
guest ok = no
valid users = car
force user = car
force group = media
create mask = 0664
directory mask = 2775
force create mode = 0664
force directory mode = 2775
inherit permissions = yes
vfs objects = fruit streams_xattr
fruit:metadata = stream
fruit:posix_rename = yes
fruit:nfs_aces = no
testparm should be clean except a warning that some shares use vfs_fruit and the printer shares do not. That warning is fine.
sudo systemctl enable --now smbd
sudo systemctl reload smbd
Both Mac and Windows use port 445, so NetBIOS can stay off. Current Ubuntu already sets disable netbios = yes. This optional Avahi file lets the share show up under the car PC’s name:
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_smb._tcp</type>
<port>445</port>
</service>
</service-group>
From a Mac
- In Finder, choose Go, then Connect to Server.
- Enter the address below. If
car.localdoes not resolve, use the address your router shows for the car PC. - Sign in with the desktop username and the Samba password.
- Drag files into
moviesortv.
smb://car.local/media
From a Windows PC
- Open File Explorer.
- Click the address bar and enter the path below. If the name does not resolve, use the address your router shows for the car PC, in the form
\\that-address\media. - Sign in with the desktop username and the Samba password. If Windows asks for a domain, use the car PC’s name, or just the username.
- Drag files into
moviesortv.
\\car.local\media
Jellyfin Media Player
Install the player from the Jellyfin Media Player releases. Trust dpkg for the version. jellyfinmediaplayer --version can report an older number than the package.
The published deb may still depend on the old t64 library names. Current Ubuntu dropped that suffix, so apt refuses the package. The unmet names are libqt6core6t64, libqt6dbus6t64, libqt6gui6t64, libqt6network6t64, libqt6opengl6t64, libqt6widgets6t64, and libqt6xml6t64. If the deb installs cleanly, skip the rewrite. If it does not, rewrite and install:
dpkg-deb -R jellyfin-media-player.deb /tmp/jmp-unpack
sed -i \
-e 's/libqt6core6t64/libqt6core6/g' \
-e 's/libqt6dbus6t64/libqt6dbus6/g' \
-e 's/libqt6gui6t64/libqt6gui6/g' \
-e 's/libqt6network6t64/libqt6network6/g' \
-e 's/libqt6opengl6t64/libqt6opengl6/g' \
-e 's/libqt6widgets6t64/libqt6widgets6/g' \
-e 's/libqt6xml6t64/libqt6xml6/g' \
/tmp/jmp-unpack/DEBIAN/control
dpkg-deb -b /tmp/jmp-unpack /tmp/jellyfin-media-player-resolute.deb
sudo apt-get install /tmp/jellyfin-media-player-resolute.deb
Other dependencies (libmpv2, Qt Quick, WebEngine, SDL2, libcec) are already in current Ubuntu. After install, confirm /usr/bin/jellyfinmediaplayer exists.
Current Ubuntu’s libmpv expects a playlist index in front of the loadfile options. The published player may still call it the old way, so every play click fails with Command loadfile: argument index has incompatible type. The poster goes fullscreen and the app looks frozen. FFmpeg is not involved. Zero-copy VA-API also fails in this player’s OpenGL path (VAAPI hwdec only works with OpenGL or Vulkan backends), so the same library switches hwdec=auto to hwdec=auto-copy. H.264 and 8-bit HEVC can use the GPU when vainfo lists them. 10-bit HEVC uses the GPU only when Main 10 is listed, and otherwise falls back to software.
Build the shim on the PC:
sudo apt install gcc libmpv-dev
Save this as /tmp/jmp-mpv-loadfile.c:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <mpv/client.h>
static int (*real_command_node)(mpv_handle *, mpv_node *, mpv_node *);
static int (*real_command_node_async)(mpv_handle *, uint64_t, mpv_node *);
static int (*real_set_property)(mpv_handle *, const char *, mpv_format, void *);
static int (*real_set_property_async)(mpv_handle *, uint64_t, const char *, mpv_format, void *);
static void resolve(void) __attribute__((constructor));
static void resolve(void) {
real_command_node = dlsym(RTLD_NEXT, "mpv_command_node");
real_command_node_async = dlsym(RTLD_NEXT, "mpv_command_node_async");
real_set_property = dlsym(RTLD_NEXT, "mpv_set_property");
real_set_property_async = dlsym(RTLD_NEXT, "mpv_set_property_async");
}
static int needs_index(const mpv_node *args) {
if (!args || args->format != MPV_FORMAT_NODE_ARRAY || !args->u.list)
return 0;
const mpv_node_list *list = args->u.list;
if (list->num < 4 || !list->values)
return 0;
if (list->values[0].format != MPV_FORMAT_STRING || !list->values[0].u.string)
return 0;
if (strcmp(list->values[0].u.string, "loadfile") != 0 &&
strcmp(list->values[0].u.string, "loadlist") != 0)
return 0;
if (list->values[2].format != MPV_FORMAT_STRING)
return 0;
if (list->values[3].format == MPV_FORMAT_INT64)
return 0;
return 1;
}
static void call_rewritten(mpv_handle *ctx, mpv_node *args, mpv_node *result,
int async, uint64_t reply, int *rc) {
mpv_node_list *old = args->u.list;
mpv_node *values = calloc((size_t)old->num + 1, sizeof(mpv_node));
mpv_node_list rewritten = *old;
mpv_node saved = *args;
if (!values) {
*rc = real_command_node ? real_command_node(ctx, args, result) : -1;
return;
}
values[0] = old->values[0];
values[1] = old->values[1];
values[2] = old->values[2];
values[3].format = MPV_FORMAT_INT64;
values[3].u.int64 = -1;
for (int i = 3; i < old->num; i++)
values[i + 1] = old->values[i];
rewritten.num = old->num + 1;
rewritten.values = values;
args->u.list = &rewritten;
if (async)
*rc = real_command_node_async(ctx, reply, args);
else
*rc = real_command_node(ctx, args, result);
*args = saved;
free(values);
}
int mpv_command_node(mpv_handle *ctx, mpv_node *args, mpv_node *result) {
if (!real_command_node)
resolve();
if (needs_index(args)) {
int rc = 0;
call_rewritten(ctx, args, result, 0, 0, &rc);
return rc;
}
return real_command_node(ctx, args, result);
}
int mpv_command_node_async(mpv_handle *ctx, uint64_t reply_userdata, mpv_node *args) {
if (!real_command_node_async)
resolve();
if (needs_index(args)) {
int rc = 0;
call_rewritten(ctx, args, NULL, 1, reply_userdata, &rc);
return rc;
}
return real_command_node_async(ctx, reply_userdata, args);
}
static int rewrite_hwdec(mpv_handle *ctx, const char *name, mpv_format format, void *data,
int async, uint64_t reply) {
if (name && data && format == MPV_FORMAT_STRING && strcmp(name, "hwdec") == 0) {
char **value = data;
if (value && *value && strcmp(*value, "auto") == 0) {
char *copy = "auto-copy";
if (async)
return real_set_property_async(ctx, reply, name, format, ©);
return real_set_property(ctx, name, format, ©);
}
}
if (async)
return real_set_property_async(ctx, reply, name, format, data);
return real_set_property(ctx, name, format, data);
}
int mpv_set_property(mpv_handle *ctx, const char *name, mpv_format format, void *data) {
if (!real_set_property)
resolve();
return rewrite_hwdec(ctx, name, format, data, 0, 0);
}
int mpv_set_property_async(mpv_handle *ctx, uint64_t reply_userdata, const char *name,
mpv_format format, void *data) {
if (!real_set_property_async)
resolve();
return rewrite_hwdec(ctx, name, format, data, 1, reply_userdata);
}
sudo gcc -shared -fPIC -O2 -o /usr/local/lib/libjmp-mpv-loadfile.so /tmp/jmp-mpv-loadfile.c -ldl
sudo tee /usr/local/bin/jellyfinmediaplayer >/dev/null << 'EOF'
#!/bin/bash
export LD_PRELOAD="/usr/local/lib/libjmp-mpv-loadfile.so${LD_PRELOAD:+:$LD_PRELOAD}"
exec /usr/bin/jellyfinmediaplayer "$@"
EOF
sudo chmod 755 /usr/local/bin/jellyfinmediaplayer
Start the player through /usr/local/bin/jellyfinmediaplayer so the shim is loaded. Starting /usr/bin/jellyfinmediaplayer directly skips it. Without the shim, a loadfile probe returns loadfile_rc=-4 (invalid parameter). With LD_PRELOAD it returns loadfile_rc=0 and event=playback-restart.
Confirm the running player actually loaded the library:
grep libjmp-mpv-loadfile /proc/$(pgrep -n -f jellyfinmediaplayer)/maps
Player config
File: ~/.local/share/jellyfinmediaplayer/jellyfinmediaplayer.conf. The player reads it at launch. Editing it while the player is running does nothing until the process is restarted.
Section video:
hardwareDecoding: enabled- Every force-transcode flag false, including
force_transcode_4k,force_transcode_hevc,force_transcode_hi10p,force_transcode_hdr,force_transcode_av1, andforce_transcode_dovi allow_transcode_to_hevcandprefer_transcode_to_h265false
Section main: layout = tv, fullscreen = true, enableMPV = true, userWebClient = http://127.0.0.1:8096.
Section audio: devicetype = hdmi, every passthrough flag false. A car head unit wants normal PCM. Bitstream passthrough is how you get silence. The web client’s saved volume key is 1 (100%).
The autostart script uses exec, so killing the player does not relaunch it. Start it again inside the logged-in session, through the wrapper. id -u car is the uid to put in the runtime path. A kill -9 is safer than a normal quit if you just wrote the config, because a graceful quit can save the old in-memory settings back over the file.
sudo -u car env \
DISPLAY=:0 \
WAYLAND_DISPLAY=wayland-0 \
XDG_RUNTIME_DIR=/run/user/$(id -u car) \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u car)/bus \
/usr/local/bin/jellyfinmediaplayer --fullscreen --tv
Boot Straight into the Player
Auto-login
/etc/gdm3/custom.conf, once each, under [daemon]. One pair is enough.
AutomaticLoginEnable=true
AutomaticLogin=car
/var/lib/AccountsService/users/car:
[User]
Session=ubuntu
XSession=ubuntu
SystemAccount=false
On current Ubuntu Desktop the session name is ubuntu on Wayland. Write the name of a session that is actually installed. Check the session files on the machine before you save AccountsService.
Stay awake
/etc/systemd/logind.conf.d/car-kiosk.conf:
[Login]
HandleLidSwitch=ignore
IdleAction=ignore
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
sudo systemctl restart systemd-logind
Skip the internet wait
In the car there is no network. Two services otherwise hold the desktop for several minutes. systemd-networkd-wait-online.service waits about two minutes, and longer when the ethernet port has no cable. NetworkManager is what actually configures this box, and networkd is not managing the links. NetworkManager-wait-online.service waits until NetworkManager finishes startup. With no known wifi and no ethernet carrier, that was about four minutes. Jellyfin is local. The desktop does not need a route.
sudo systemctl mask systemd-networkd-wait-online.service
sudo systemctl disable NetworkManager-wait-online.service
Also mark the ethernet port optional so a missing cable is not required for boot. /etc/netplan/60-optional-ethernet.yaml:
network:
version: 2
ethernets:
enp0s0:
optional: true
Replace enp0s0 with the name from ip -br link. Cap NetworkManager’s wait in case the service gets enabled again. /etc/systemd/system/NetworkManager-wait-online.service.d/timeout.conf, then sudo systemctl daemon-reload:
[Service]
ExecStart=
ExecStart=/usr/bin/nm-online -s -q -t 10
TimeoutStartSec=15
Volume at 100%
A new HDMI screen used to come up near half volume. WirePlumber’s factory device.routes.default-sink-volume is 0.064. On the cubic scale the GNOME slider uses, that is about 40%. Jellyfin’s own slider is separate. The web client reads local storage key volume on a 0–1 scale and defaults to 1 if it is missing. On this box that saved value is 1.00.
Set the default for every new screen, as the desktop user inside the graphical session:
wpctl settings device.routes.default-sink-volume 1.0 --save
Also drop this in /etc/wireplumber/wireplumber.conf.d/99-full-volume.conf:
wireplumber.settings = {
device.routes.default-sink-volume = 1.0
}
Max the hardware mixer and save it across boots. Passthrough stays off. The head unit gets normal PCM at full level.
amixer -q -c 0 sset Master 100% unmute
amixer -q -c 0 sset PCM 100% unmute
sudo alsactl store
Autostart script
/home/car/.local/bin/start-jellyfin.sh, mode 755. It belongs to the desktop user. It turns off the screensaver and idle sleep, waits until Jellyfin answers, waits until a screen is connected, sets the output to 100%, then replaces itself with the wrapped player. With the HDMI cable unplugged the script sits in the display loop and the player is not running. That is what you want. Opening fullscreen with no monitor crashed GNOME Shell (meta_window_get_work_area_for_logical_monitor) and dumped the session back to the login screen.
#!/bin/bash
gsettings set org.gnome.desktop.screensaver lock-enabled false || true
gsettings set org.gnome.desktop.screensaver ubuntu-lock-on-suspend false || true
gsettings set org.gnome.desktop.session idle-delay 0 || true
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing' || true
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-battery-type 'nothing' || true
xset s off || true
xset -dpms || true
xset s noblank || true
until curl -sf -o /dev/null --max-time 2 http://127.0.0.1:8096/health; do
sleep 1
done
sleep 2
# A fullscreen window with no monitor crashes GNOME Shell on this NUC.
until grep -h -q '^connected$' /sys/class/drm/card*-*/status 2>/dev/null; do
sleep 1
done
# HDMI just appeared. Pin the system output at 100% before the player opens.
for _ in $(seq 1 30); do
if wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 && wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.0 --limit 1.0; then
break
fi
sleep 0.5
done
amixer -q -c 0 sset Master 100% unmute || true
amixer -q -c 0 sset PCM 100% unmute || true
exec /usr/local/bin/jellyfinmediaplayer --fullscreen --tv
/home/car/.config/autostart/jellyfin.desktop:
[Desktop Entry]
Type=Application
Name=Jellyfin
Exec=/home/car/.local/bin/start-jellyfin.sh
X-GNOME-Autostart-enabled=true
webMode in the player config stayed desktop while the launch flags were --fullscreen --tv. Leave the flags. They are what put TV layout on the HDMI screen. The player also signs into the local server. On the original box the admin password was the same as the desktop user, and the player kept the session.
Checks Before You Call It Done
curl -sf http://127.0.0.1:8096/health
systemctl is-active jellyfin smbd
systemctl is-enabled systemd-networkd-wait-online.service NetworkManager-wait-online.service
pgrep -a -f jellyfinmediaplayer
sudo -u jellyfin find /srv/media -type f ! -readable -print
vainfo | head -40
amixer -c 0 sget Master
wpctl settings device.routes.default-sink-volume
Run the find from /tmp. You want:
Healthy- jellyfin and smbd active
systemd-networkd-wait-onlinemasked,NetworkManager-wait-onlinedisabled- A process line of
jellyfinmediaplayer --fullscreen --tvonce HDMI is connected, andlibjmp-mpv-loadfile.soin that process’s maps - No unreadable files
- VA-API profiles that include H.264 decode, and HEVC Main if you want 8-bit HEVC in hardware
- Master at 100%
device.routes.default-sink-volumesaved at1.0- With the screen connected,
wpctl get-volume @DEFAULT_AUDIO_SINK@printsVolume: 1.00
Play one known-good H.264 file (720p, AAC is the safe shape) and confirm the Jellyfin dashboard playback session says Direct Play, not Transcode. If FFmpeg logs an exit code, read the failure table in the playback section before changing drivers.
HDMI can be unplugged while you SSH. You will not see the picture until the car screen is connected, and the player process will be absent until then because the autostart script is waiting for a connected output.
Some of these small Intel PCs time out on the SSH banner from a Mac. Add -o IPQoS=none, and use your own account and the address your router shows. A single timeout does not mean the PC is off. Ping, then try again.
What This Box Will Not Do
- It will not turn 10-bit 4K, HDR, or AV1 into a smooth picture. Video transcoding stays off. Re-encode those files on another computer to the baseline: H.264, 1080p, AAC.
- On the minimum 6th-gen PC it will not hardware-decode 10-bit HEVC. Newer Intel graphics do that only when
vainfolists HEVC Main 10. - It will not survive years of ignition power cuts on the SSD just because the packages are current. A clean shutdown and a spare drive matter more.
- It will not pick something to watch by itself. The boot path only gets you to the library.
Questions People Ask About a Car Jellyfin Box
Will this Jellyfin box work with a Honda Odyssey rear screen?
It works when the Odyssey rear entertainment input is HDMI and the screen only receives picture and sound. The head unit does not click back at the PC, so Jellyfin Media Player has to already be fullscreen in TV mode when the screen comes up. This guide does not cover a specific Odyssey model year or a factory remote protocol.
Will this work with a Toyota Sienna rear entertainment system?
Same requirement as the Odyssey. If the Sienna rear screen, or a screen added in the vehicle, takes HDMI video and audio, the player, the libraries, and the boot path stay the same.
Can I use this with a generic car TV?
Yes. Any HDMI display in a car is the same job: video and audio out, no control channel back to the PC, power that disappears with the ignition, and no internet. Set the PC firmware to power on when AC returns, and let the autostart script wait until that HDMI output is connected.
Does the Jellyfin car box need internet?
No. Jellyfin serves the local player at http://127.0.0.1:8096. The guide masks the boot services that otherwise wait several minutes for a network that is not there. Metadata providers can use the internet later, when the box is home and online. Playback in the car does not.
Why is video transcoding turned off?
The minimum PC direct-plays H.264 at 720p or 1080p. It is not there to turn 10-bit 4K into a smooth 1080p stream. Remux stays on, audio transcoding stays on, and video transcoding stays off.
Why does a copied file fail to play in Jellyfin?
A Mac can save files over SMB as mode 700, and Jellyfin cannot open them. FFmpeg exits 243 with Permission denied. Windows uses the same share. The Samba settings unix extensions = no and force create mode = 0664 keep both Mac and Windows copies readable.
Why does Jellyfin Media Player freeze on the poster?
Current Ubuntu’s libmpv expects a playlist index on loadfile. The published player build may still call it the old way. The log line is Command loadfile: argument index has incompatible type. The preload shim inserts that index. FFmpeg never starts in this failure.
Which video formats play well on the minimum car PC?
H.264 High, 8-bit, yuv420p, 720p or 1080p, with AAC stereo. That is the format every PC in the minimum direct-plays, including a 6th-gen Intel NUC. 8-bit HEVC at 1080p also works when vainfo lists HEVC Main. 10-bit HEVC, 4K, HDR, and AV1 do not play well on the minimum. Re-encode those on another computer. Use text subtitles or none. Picture subtitles ask for a video transcode.
Rebuild Prompt
Paste this into a new session when the machine is on the network and you want the same box built again. It is the short form of everything above.
Build this Ubuntu Desktop machine into a Jellyfin autostart streaming box.
Goal: on boot, auto-login the desktop user, wait until Jellyfin is healthy, then open Jellyfin Media Player fullscreen in TV mode. The machine is weak. Prefer direct play. Do not transcode video. Hardware-decode what the GPU can decode. Leave remux on. Leave audio transcoding on.
Do this:
1. Confirm Ubuntu Desktop (graphical.target, GDM). Do not wipe the disk. Do not install a minimal kiosk. Do not use Docker for Jellyfin.
2. In the BIOS, set "After Power Loss" to Power On. That cannot be set from Linux. Do not flash BIOS or UEFI remotely.
3. Install Jellyfin server, jellyfin-web, and jellyfin-ffmpeg from the official Jellyfin apt repo for this Ubuntu codename. Enable the service.
4. Create group `media`. Add the desktop user and the `jellyfin` user. Add `jellyfin` to `video` and `render`.
5. Create `/srv/media/movies` and `/srv/media/tv`, owner desktop-user:media, mode 2775, setgid. Default ACL so `jellyfin` and group `media` can read new files.
6. Finish the Jellyfin startup wizard with a real admin user. Do not mark the wizard complete with no user. Libraries: Movies -> /srv/media/movies, TV Shows -> /srv/media/tv. Turn on internet metadata and the realtime monitor.
7. For that user, turn OFF video playback transcoding and sync transcoding. Leave playback remuxing ON. Leave audio playback transcoding ON. Set the remote bitrate limit to 0.
8. Set encoding.xml to VA-API on /dev/dri/renderD128, hardware encoding on, tonemapping off, encoding threads 2. HEVC encode and AV1 encode stay off.
9. Run `vainfo`. Skylake and older need `i965-va-driver` and `LIBVA_DRIVER_NAME=i965` in a systemd drop-in for jellyfin. Newer Intel GPUs use the iHD driver instead. Do not install the wrong one.
10. Install Samba. Share name `media`, path `/srv/media`, valid user is the desktop user, force user that user, force group `media`, force create mode 0664, force directory mode 2775, vfs fruit, `fruit:nfs_aces = no`. Under [global] set `unix extensions = no`. Without that, a Mac copies files as mode 700 and Jellyfin cannot open them. Both direct play and transcode then fail with FFmpeg exit 243, "Permission denied".
11. Install Jellyfin Media Player. On Ubuntu 26.04 the Ubuntu 24.04 .deb fails because it depends on libqt6*t64 names that 26.04 dropped. Unpack the deb, rewrite those Depends to the non-t64 libqt6* names, repack, and install. Do not apt autoremove afterward. Ubuntu 26.04's libmpv 0.41 rejects this player's loadfile command (`argument index has incompatible type`), which freezes the app on the poster. Launch it through a wrapper that preloads a shim inserting the playlist index `-1`, and use hwdec auto-copy. Do not start `/usr/bin/jellyfinmediaplayer` directly.
12. GDM auto-login for the desktop user. Session name must be one that exists. On Ubuntu 26.04 the only session is `ubuntu` (Wayland). Write that to AccountsService. On 24.04, `ubuntu-xorg` was valid.
13. Mask sleep, suspend, hibernate, and hybrid-sleep. logind: HandleLidSwitch=ignore, IdleAction=ignore.
14. Mask systemd-networkd-wait-online and disable NetworkManager-wait-online. Mark ethernet optional. The car has no internet, and those waits hold the desktop for several minutes. Autostart script: disable the screensaver and idle sleep, wait for http://127.0.0.1:8096/health, wait until an HDMI or DisplayPort output is connected, then exec the wrapped player `--fullscreen --tv`. A fullscreen window with no monitor crashes GNOME Shell on this NUC.
15. Player config: hardwareDecoding enabled. Every force_transcode flag false, including force_transcode_dovi. allow_transcode_to_hevc and prefer_transcode_to_h265 false. layout tv, fullscreen true, server http://127.0.0.1:8096. Audio device hdmi, passthrough off. The web client's saved volume key is 1 (100%).
16. Volume at 100% every boot. WirePlumber's factory default for a new HDMI sink is 0.064, which shows up near half on the GNOME slider. Save `device.routes.default-sink-volume` as 1.0. Set ALSA Master and PCM to 100% and `alsactl store`. After HDMI connects, the autostart script runs `wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.0` and unmutes.
17. apt-mark manual: jellyfin, jellyfin-server, jellyfin-web, jellyfin-ffmpeg8, jellyfin-media-player, the VA-API driver, intel-microcode, libmpv2. Enable unattended-upgrades. Do not apt autoremove a huge leftover library list.
18. Verify: health is Healthy, the jellyfin user can read a sample file (run that find from /tmp), smbd is listening on 445, the player process is `jellyfinmediaplayer --fullscreen --tv` and has `libjmp-mpv-loadfile.so` loaded, and `wpctl get-volume @DEFAULT_AUDIO_SINK@` is 1.00 once the screen is connected. Play a known-good H.264 file and confirm the dashboard says Direct Play. FFmpeg exit 243 is a permission problem. `Command loadfile: argument index has incompatible type` is the player/libmpv mismatch, and it happens before FFmpeg runs.
Playback rules: keep files at H.264 High, 8-bit, yuv420p, 720p or 1080p, AAC stereo. That direct-plays on a 6th-gen Intel NUC and anything newer in this guide. 8-bit HEVC is optional. 10-bit HEVC, 4K, HDR, and AV1 do not play well on the minimum. Re-encode those on another computer. Do not transcode them on this PC.
SSH from a Mac to some of these small Intel PCs times out on banner exchange unless you use `ssh -o IPQoS=none`.
More of What I Build
This sits next to the other project write-ups, including the local battery dashboard.
View projects → · Battery dashboard guide →