Skip to content

Repository files navigation

Music Upload — a Jellyfin plugin

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).


Before it can work: one line in docker-compose.yml

Music libraries are very often mounted read-only into Jellyfin:

      - /path/to/music:/media/music:ro

A 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/music

and recreate the container (docker compose up -d jellyfin). Also make sure the UID the container runs as can write to the library directory.


Installing it from the dashboard

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 sidecar

deploy.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.

Hosting the repository somewhere else

sourceUrl in the manifest is generated, so point it wherever you like:

BASE_URL=https://plugins.example.com/jellyfin ./build.sh

Any 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.

Manual install, if you'd rather

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.


Using it

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.

Templates

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 as Time, not Time (), 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%.

Other settings

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.


What was verified

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, ARTISTalbum_artistVarious 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 with max 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.)


Known limitations

  • 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 and Jellyfin.Api internals change, and deprecated auth is disabled by default. This plugin targets 10.11.0.0 and 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: retarget net10.0, -p:JellyfinVersion=12.0.0-rc5, targetAbi 12.0.0.0. The upload path already uses the modern Authorization: MediaBrowser Token="…" scheme, so that part should survive.
  • EnableDoubleScan is 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_size caps it (often at 1 MB). For large uploads set proxy_request_buffering off; and a generous client_max_body_size on that host, otherwise you get a 413 or a redundant disk round-trip on every track.

Building

Needs only podman or docker — the .NET 9 SDK runs in a container, nothing is installed on the host.

./build.sh

build.yaml is the single source of truth for version, GUID and target ABI; bump version there and nothing else. Output lands in repo/.

Layout

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

Implementation notes

  • Compile-only Jellyfin references. ExcludeAssets="runtime" on Jellyfin.Controller, Jellyfin.Model and TagLibSharp, plus FrameworkReference 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.RequiresElevation comes from MediaBrowser.Common.Api, not Jellyfin.Api.Constants. Jellyfin.Api is not published on NuGet, so BaseJellyfinApiController is unavailable to plugins; this uses ControllerBase.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages