Skip to content

Keep JellyTag's data out of <config>/plugins (fixes #21) - #34

Open
calif94577 wants to merge 1 commit into
Atilil:mainfrom
calif94577:fix/issue-21-plugin-configs-wiped
Open

Keep JellyTag's data out of <config>/plugins (fixes #21)#34
calif94577 wants to merge 1 commit into
Atilil:mainfrom
calif94577:fix/issue-21-plugin-configs-wiped

Conversation

@calif94577

Copy link
Copy Markdown

The problem

On some servers, installing JellyTag causes every installed plugin's settings to reset on restart (#21). The settings aren't "reset" — <config>/plugins/configurations/ is being recursively deleted by Jellyfin itself, and JellyTag is what triggers it.

Chain of events

1. JellyTag creates an un-versioned folder directly inside <config>/plugins/.

CacheFolderPath is built from BasePlugin.DataFolderPath, and ImageCacheService creates it eagerly at startup:

public string CacheFolderPath => Path.Combine(DataFolderPath, "cache");

Jellyfin sets DataFolderPath to <config>/plugins/Jellyfin.Plugin.JellyTag — with no version suffix, because BasePluginOfT.cs tests Version before SetAttributes() has assigned it:

var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath));
if (Version is not null && !Directory.Exists(dataFolderPath))  // Version is always null here
{
    dataFolderPath += "_" + Version;
}
SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version);

So we end up with an underscore-free folder sitting next to <config>/plugins/configurations/.

2. Jellyfin treats every folder under plugins/ as a plugin candidate, and for folders without a meta.json it derives the plugin name by cutting the entire path at its last underscore (PluginManager.LoadManifest):

int versionIndex = dir.LastIndexOf('_');          // searches the whole path, not the folder name
if (versionIndex != -1)
{
    metafile = Path.GetFileName(dir[..versionIndex]);
    version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out var parsed) ? parsed : _appVersion;
}

3. If the server's data path contains an underscore, both folders collapse to the same generated identity — same name, same MD5 id, same version — so DiscoverPlugins() treats one as a stale duplicate and runs Directory.Delete(path, recursive: true) on it.

Running Jellyfin's own derivation code against real NAS paths:

/share/CACHEDEV1_DATA/.qpkg/JellyfinServer/config/plugins       (QNAP, the reporter)
   configurations/           -> name="CACHEDEV1"  v10.11.6.0
   Jellyfin.Plugin.JellyTag/ -> name="CACHEDEV1"  v10.11.6.0    *** COLLISION ***

/volume1/media_server/jellyfin/config/plugins                   (Synology, underscore in share)
   configurations/           -> name="media"      v10.11.6.0
   Jellyfin.Plugin.JellyTag/ -> name="media"      v10.11.6.0    *** COLLISION ***

/config/plugins                                                 (Docker)
   configurations/           -> name="configurations"
   Jellyfin.Plugin.JellyTag/ -> name="Jellyfin.Plugin.JellyTag"    no collision

Why the report looks the way it does

  • Intermittent. Which of the two folders gets deleted depends on directory enumeration order and .NET's unstable List.Sort, so it's roughly a coin flip per boot. If Jellyfin.Plugin.JellyTag loses, only the badge cache is lost and nobody notices.
  • Uninstalling doesn't help. Jellyfin's uninstall removes plugins/JellyTag_<version>/ but leaves plugins/Jellyfin.Plugin.JellyTag/ behind, so the collision keeps recurring on every boot until chance removes the stray folder instead of configurations.
  • Reinstalling Jellyfin fixes it, because the stray folder goes with it.

This is a latent Jellyfin core bug (I'm filing it upstream separately), but any plugin that touches DataFolderPath can detonate it, and JellyTag does so unconditionally at startup. Fixing it plugin-side is both possible and, I'd argue, correct regardless.

The fix

  • Cache moves to <cache>/jellytag via IApplicationPaths.CachePath.
  • Custom badges move to <data>/jellytag via IApplicationPaths.DataPath, behind a new BadgeFolderPath property replacing the four DataFolderPath call sites in JellyTagController and the one in ImageOverlayService.
  • Nothing of JellyTag's lives under <config>/plugins anymore, so the name collision cannot form.
  • MoveOutOfPluginsFolder() runs once at startup: it copies any existing custom-badges to the new location, then deletes the stray plugins/Jellyfin.Plugin.JellyTag/ folder. This is what actually un-breaks servers that already hit this.

Safety of the migration:

  • Wrapped in try/catch so it can never prevent the plugin from loading.
  • Refuses to touch anything that isn't strictly inside PluginsPath, and refuses to touch the plugin's own install directory.
  • Copies rather than moves the badges, because cache/data and config often live on different mounts where Directory.Move throws.
  • If the copy fails, the legacy folder is left alone and the failure is logged, so no user data is lost.

Side benefit: the badge cache no longer grows inside the config volume, which on a NAS is frequently small.

Known limitation — please mention this in the release notes

Jellyfin runs DiscoverPlugins() in the PluginManager constructor, before any plugin assembly is loaded. No plugin code can run earlier. So on the single boot where this update is first applied, the collision still exists and there is a last coin flip. From the boot after that, the stray folder is gone and it can never happen again.

To skip that last coin flip, affected users should stop the server and remove the folder by hand before updating:

rm -rf "<config>/plugins/Jellyfin.Plugin.JellyTag"

Settings already lost cannot be recovered — they were deleted recursively — so they'll need re-entering once.

Testing

  • Builds clean against Jellyfin.Controller/Jellyfin.Model 10.11.0 on .NET 9 (0 warnings, 0 errors).
  • Jellyfin's LoadManifest name/version derivation was extracted and run under .NET 9 against the path layouts above to confirm exactly which configurations collide; DiscoverPlugins' deletion loop was simulated to confirm which folder gets deleted and that enumeration order decides it.
  • Not yet smoke-tested on a live server. I don't have a QNAP/Synology box to reproduce on, so a run on a real install before release would be worth it.

I've left the version in build.yaml/.csproj and manifest.json alone — that's yours to bump on release.


🤖 Generated with Claude Code

Jellyfin points BasePlugin.DataFolderPath at <config>/plugins/Jellyfin.Plugin.JellyTag,
a sibling of <config>/plugins/configurations, and ImageCacheService creates it at
startup. PluginManager.DiscoverPlugins() treats every folder under <config>/plugins as
a plugin candidate and derives the name of a folder without a meta.json by cutting the
full path at its last underscore. On a server whose data path contains an underscore
(QNAP's /share/CACHEDEV1_DATA/..., or a bind such as /volume1/media_server/...) both
folders collapse to the same generated identity and the duplicate is removed with
Directory.Delete(path, recursive: true) - sometimes taking plugins/configurations, and
with it every installed plugin's settings.

Move the image cache to <cache>/jellytag and custom badges to <data>/jellytag so
nothing of ours sits under <config>/plugins, and add a guarded one-time migration that
relocates existing custom badges and deletes the stray folder left by earlier versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant