Adds an upload page to the Jellyfin dashboard. Drop in single tracks, whole album folders or zip archives; the plugin reads the embedded tags, files each track into the library using a template you control, and tells Jellyfin to pick it up.
Built and tested against Jellyfin 10.11.11 (net9.0, targetAbi 10.11.0.0).
Music libraries are very often mounted read-only into Jellyfin:
- /path/to/music:/media/music:roA plugin runs inside the Jellyfin container, so with :ro every upload fails
with Read-only file system. There is no way around this from inside the plugin.
Drop the :ro:
- /path/to/music:/media/musicand recreate the container (docker compose up -d jellyfin). Also make sure the
UID the container runs as can write to the library directory.
Jellyfin has no "upload a plugin zip" button — it never has. The normal way plugins are installed is from a repository, which is what the tooling here produces.
./build.sh # compile + package + regenerate repo/manifest.json
./deploy.sh # sync repo/ to the server and start the repo sidecardeploy.sh does not touch docker-compose.yml. It copies the two files to a
directory on the server and runs a standalone nginx:alpine container on the same
Docker network as Jellyfin. Because Jellyfin fetches repository manifests
server-side, this needs no published port, no reverse-proxy host, no certificate
and no DNS record — the container is reachable by name and nothing else.
Then, once, in the web UI:
Dashboard → Plugins → Repositories → + Name: anything URL:
http://jellyfin-plugin-repo/manifest.json
and Dashboard → Plugins → Catalog → Music Upload → Install, then restart Jellyfin.
From then on it behaves like any other plugin: new versions appear in the catalog
after ./build.sh && ./deploy.sh, and older versions stay listed so you can roll
back.
sourceUrl in the manifest is generated, so point it wherever you like:
BASE_URL=https://plugins.example.com/jellyfin ./build.shAny URL the Jellyfin server can reach over HTTP works — GitHub Releases, an
OpenCloud public link, a path served by NPM. The zip and manifest.json just have
to sit next to each other.
Unzip it into a folder named <Plugin Name>_<Version> inside Jellyfin's plugin
directory and restart the server:
DEST="$JELLYFIN_CONFIG/plugins/Music Upload_1.0.1.0"
mkdir -p "$DEST" && unzip -o Jellyfin.Plugin.MusicUpload_1.0.1.0.zip -d "$DEST"
# then restart Jellyfin$JELLYFIN_CONFIG is /config in the official Docker image, or
/var/lib/jellyfin on a package install.
No meta.json is needed — Jellyfin writes one itself. You lose catalog updates
this way.
Dashboard → Plugins → Music Upload.
Drop files or a folder on the drop zone (or use Choose files / Choose folder), pick the target library, hit Upload. Files go up one per request with a progress bar, and each row reports where it landed — or why it didn't.
The optional Overrides only apply to untagged rips; normally leave them empty and let the tags decide.
| Setting | Default |
|---|---|
| Folder template | %AlbumArtist%/%Album% (%Year%) |
| File name template | %Disc%%Track% %Title% |
Tokens: %AlbumArtist% %Artist% %Album% %Title% %Year% %Genre%
%Track% %Disc%. Use / in the folder template to nest.
Two details worth knowing:
%AlbumArtist%, not%Artist%. On soundtracks and compilations the per-track artist varies while the album artist is constant. Keying folders off%Artist%shatters one album into a folder per performer, which on a soundtrack-heavy library is most of it.%Artist%is available if you want it; the default avoids it.- Empty tokens collapse.
%Album% (%Year%)renders asTime, notTime (), when a file has no year.
%Disc% only renders (2-03 …) when the release genuinely has more than one
disc, so single-disc albums stay 03 ….
To match your existing Artist - Album/NN Title.flac folders instead, set the
folder template to %AlbumArtist% - %Album%.
| Setting | Default | Note |
|---|---|---|
| Allowed extensions | flac, mp3, m4a, aac, ogg, opus, wav, wma, aiff, alac | anything else → 415 |
| Max file size | 1024 MB | 0 disables the check |
| Unpack zips | on | non-audio entries ignored |
| Overwrite existing | off | duplicates report as Skipped |
| Notify Jellyfin | on | |
| Second notification | off | see Known limitations |
| Staging folder | .musicupload-staging |
must stay inside the library root |
The staging folder is where an upload is written before it is moved into place. Keeping it inside the library root makes that final move a same-filesystem rename rather than a copy, and the leading dot keeps the scanner out of it.
Run against a real Jellyfin 10.11.11 container, installed through the catalog:
- installs from the repository (checksum verified by Jellyfin), loads Active, config page serves 200; updating 1.0.0.0 → 1.0.1.0 through the catalog works
- ordinary album track →
Glass Animals/Dreamland (2020)/02 Tangerine.flac - compilation,
ARTIST≠album_artist→Various Artists/Life is Strange (2015)/07 Haven.flac(does not use the per-track artist) - multi-disc →
Gustavo Santaolalla/The Last of Us Part II (2020)/2-03 Longing.flac - no year →
Mr.Kitty/Time/05 After Dark.flac(no empty parens) - untagged →
Unknown Artist/Unknown Album/untagged.flac - zip of two tracks → both filed into one album folder
- duplicate re-upload → Skipped, original untouched
.txt→ 415; unauthenticated → 401; non-admin → 403; admin → 200- 92 MB FLAC uploads fine; with
[DisableRequestSizeLimit]removed the same file fails withmax request body size is 30000000 bytes, confirming the attribute is load-bearing - uploads are byte-identical to the source (md5) and tags survive
- staging directory drains to empty
- a new track appeared in the library automatically after ~63 s, no manual scan
Adversarial tags — album_artist of ../../../../etc/evil, album
AC/DC: Back<>In|Black?, title Sneaky/../Track — produced exactly two
directory levels inside the library root and nothing outside it. (An early build
did let slashes inside tag values invent extra directories; tokens are now
sanitised before substitution, and PathTemplate.IsInside is a second guard.)
- New files take up to ~60 s to appear. That is Jellyfin's own library-monitor
debounce, not the plugin. The plugin reports the change immediately via
ILibraryMonitor.ReportFileSystemChanged; Jellyfin decides when to act. - The library must have been scanned at least once for the notification to attach new folders to a known tree. Yours has been.
- Jellyfin 12 will break this. 12.0 is in RC (RC5 at time of writing) and drops
the
10.prefix; the plugin ABI andJellyfin.Apiinternals change, and deprecated auth is disabled by default. This plugin targets10.11.0.0and will simply stop loading. If your Jellyfin tracks a floating tag with an auto-updater, that will happen without warning — pin the tag if you care. The port is: retargetnet10.0,-p:JellyfinVersion=12.0.0-rc5,targetAbi 12.0.0.0. The upload path already uses the modernAuthorization: MediaBrowser Token="…"scheme, so that part should survive. EnableDoubleScanis off by default. It exists for jellyfin#15304 (music albums importing half-finished), but that issue is triaged cannot reproduce and was not observed in testing. Turn it on only if you actually see missing cover art or track names.- If Jellyfin sits behind nginx, check two directives. By default nginx spools
the entire request body to disk before forwarding it, and
client_max_body_sizecaps it (often at 1 MB). For large uploads setproxy_request_buffering off;and a generousclient_max_body_sizeon that host, otherwise you get a 413 or a redundant disk round-trip on every track.
Needs only podman or docker — the .NET 9 SDK runs in a container, nothing is
installed on the host.
./build.shbuild.yaml is the single source of truth for version, GUID and target ABI; bump
version there and nothing else. Output lands in repo/.
build.yaml version / guid / targetAbi — the only file to bump
Directory.Packages.props central NuGet versions, $(JellyfinVersion) override
build.sh containerised build + zip + manifest
deploy.sh sync repo/ to the server, run the repo sidecar
repo/ manifest.json + versioned zip (the repository)
Jellyfin.Plugin.MusicUpload/
Plugin.cs BasePlugin<PluginConfiguration>, IHasWebPages
PluginServiceRegistrator.cs DI registration
Configuration/
PluginConfiguration.cs
configPage.html upload UI + settings (embedded resource)
Controllers/
MusicUploadController.cs GET Libraries, POST File
Services/
PathTemplate.cs token substitution, sanitising, traversal guard
MusicPlacementService.cs tag reading, placement, zip, scan notification
PlacementModels.cs
IMusicPlacementService.cs
- Compile-only Jellyfin references.
ExcludeAssets="runtime"onJellyfin.Controller,Jellyfin.ModelandTagLibSharp, plusFrameworkReference Microsoft.AspNetCore.App. The host already ships all of them — including TagLibSharp 2.3.0 — so the plugin is a single 66 KB DLL with no duplicate assemblies loaded next to Jellyfin's own. - Raw body, not multipart. The upload endpoint takes the file as the request
body with metadata in the query string. No form parser, no 128 MiB
MultipartBodyLengthLimit, and no temp copy on the container's overlay filesystem — it streams socket → staging file in one pass. Policies.RequiresElevationcomes fromMediaBrowser.Common.Api, notJellyfin.Api.Constants.Jellyfin.Apiis not published on NuGet, soBaseJellyfinApiControlleris unavailable to plugins; this usesControllerBase.