Placard 1.0.0 — bake library names onto backdrops
Jellyfin 10.11 plugin: a scheduled task that labels each library card with the library name over a representative backdrop (Noto Sans, centered, soft shadow), matching Jellyfin's default cover style. Supports source rules (top-rated / random / newest) and per-library pinned sources. Sets images via the internal provider API so the home-screen view cache updates without a server restart.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.nupkg
|
||||||
|
artifacts/
|
||||||
|
# local dev/deploy helpers, not part of the plugin
|
||||||
|
deploy_plugin.py
|
||||||
|
redeploy_plugin.py
|
||||||
|
run_plugin.py
|
||||||
|
diag_plugin.py
|
||||||
|
verify_output.py
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Placard are documented here. The format is based on
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||||
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Scheduled task ("Generate Placard library cards") that bakes each library's
|
||||||
|
name onto a representative backdrop, matching Jellyfin's default cover style
|
||||||
|
(Noto Sans, centered, soft drop shadow over a darkening scrim).
|
||||||
|
- Backdrop source rules: highest-rated / random / newest title in the library.
|
||||||
|
- Per-library pinned sources (`Library Name=Item Title`) to override the rule.
|
||||||
|
- Configuration page (enable, source rule, scrim opacity, label size, pins).
|
||||||
|
- SkiaSharp-based renderer, incl. a procedural SMPTE color-bars generator for
|
||||||
|
views that have no media backdrop.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Sets images through the internal provider API, so the home-screen view cache
|
||||||
|
updates without a server restart.
|
||||||
|
- Special views (Live TV / Playlists) are scaffolded (`IncludeSpecialViews`,
|
||||||
|
color-bars renderer) but not yet wired into the task. Planned for 1.1.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Placard;
|
||||||
|
|
||||||
|
/// <summary>Composites a library name onto a backdrop, matching Jellyfin's default cover style.</summary>
|
||||||
|
public static class CardRenderer
|
||||||
|
{
|
||||||
|
private static SKTypeface? _typeface;
|
||||||
|
|
||||||
|
private static SKTypeface Typeface
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_typeface != null)
|
||||||
|
{
|
||||||
|
return _typeface;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
using var s = asm.GetManifestResourceStream("Jellyfin.Plugin.Placard.Fonts.NotoSans-Bold.ttf");
|
||||||
|
_typeface = s != null
|
||||||
|
? SKTypeface.FromStream(s)
|
||||||
|
: SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||||
|
return _typeface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render <paramref name="label"/> centered on the image at <paramref name="backdropPath"/>.</summary>
|
||||||
|
public static byte[] Render(string backdropPath, string label, int scrim, int fontHeightPct)
|
||||||
|
{
|
||||||
|
using var input = SKBitmap.Decode(backdropPath);
|
||||||
|
return RenderBitmap(input, label, scrim, fontHeightPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Generate SMPTE color bars and label them (for Live TV, which has no media backdrop).</summary>
|
||||||
|
public static byte[] RenderColorBars(string label, int scrim)
|
||||||
|
{
|
||||||
|
const int w = 1920;
|
||||||
|
const int h = 1080;
|
||||||
|
using var bmp = new SKBitmap(w, h);
|
||||||
|
using (var canvas = new SKCanvas(bmp))
|
||||||
|
{
|
||||||
|
var cols = new[]
|
||||||
|
{
|
||||||
|
new SKColor(192, 192, 192), new SKColor(192, 192, 0), new SKColor(0, 192, 192),
|
||||||
|
new SKColor(0, 192, 0), new SKColor(192, 0, 192), new SKColor(192, 0, 0), new SKColor(0, 0, 192)
|
||||||
|
};
|
||||||
|
float bw = (float)w / cols.Length;
|
||||||
|
for (int i = 0; i < cols.Length; i++)
|
||||||
|
{
|
||||||
|
using var p = new SKPaint { Color = cols[i] };
|
||||||
|
canvas.DrawRect(i * bw, 0, bw + 1, h, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RenderBitmap(bmp, label, scrim, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RenderBitmap(SKBitmap input, string label, int scrim, int fontHeightPct)
|
||||||
|
{
|
||||||
|
int w = input.Width;
|
||||||
|
int h = input.Height;
|
||||||
|
using var surface = SKSurface.Create(new SKImageInfo(w, h));
|
||||||
|
var canvas = surface.Canvas;
|
||||||
|
canvas.DrawBitmap(input, 0, 0);
|
||||||
|
|
||||||
|
using (var scrimPaint = new SKPaint { Color = new SKColor(0, 0, 0, (byte)scrim) })
|
||||||
|
{
|
||||||
|
canvas.DrawRect(0, 0, w, h, scrimPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
float size = h * fontHeightPct / 100f;
|
||||||
|
using var measure = new SKPaint { Typeface = Typeface, IsAntialias = true };
|
||||||
|
for (; size > 12; size -= 4)
|
||||||
|
{
|
||||||
|
measure.TextSize = size;
|
||||||
|
if (measure.MeasureText(label) <= w * 0.90f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
measure.TextSize = size;
|
||||||
|
SKRect bounds = default;
|
||||||
|
measure.MeasureText(label, ref bounds);
|
||||||
|
float x = ((w - bounds.Width) / 2f) - bounds.Left;
|
||||||
|
float y = ((h - bounds.Height) / 2f) - bounds.Top;
|
||||||
|
|
||||||
|
using (var shadow = new SKPaint
|
||||||
|
{
|
||||||
|
Typeface = Typeface,
|
||||||
|
TextSize = size,
|
||||||
|
IsAntialias = true,
|
||||||
|
Color = new SKColor(0, 0, 0, 205),
|
||||||
|
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, size / 20f)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
canvas.DrawText(label, x, y + (size / 28f), shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var text = new SKPaint
|
||||||
|
{
|
||||||
|
Typeface = Typeface,
|
||||||
|
TextSize = size,
|
||||||
|
IsAntialias = true,
|
||||||
|
Color = SKColors.White
|
||||||
|
})
|
||||||
|
{
|
||||||
|
canvas.DrawText(label, x, y, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var image = surface.Snapshot();
|
||||||
|
using var data = image.Encode(SKEncodedImageFormat.Jpeg, 92);
|
||||||
|
return data.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Placard.Configuration;
|
||||||
|
|
||||||
|
/// <summary>Rule for picking which child item's backdrop represents a library.</summary>
|
||||||
|
public enum SourceRule
|
||||||
|
{
|
||||||
|
TopRated = 0,
|
||||||
|
Random = 1,
|
||||||
|
Newest = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>Master on/off.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>How to choose the source backdrop for each library.</summary>
|
||||||
|
public SourceRule Source { get; set; } = SourceRule.TopRated;
|
||||||
|
|
||||||
|
/// <summary>Darkening overlay opacity, 0-255 (default ~41%).</summary>
|
||||||
|
public int ScrimOpacity { get; set; } = 105;
|
||||||
|
|
||||||
|
/// <summary>Label height as a percent of image height.</summary>
|
||||||
|
public int FontHeightPercent { get; set; } = 20;
|
||||||
|
|
||||||
|
/// <summary>Also label Live TV (SMPTE color bars) and Playlists.</summary>
|
||||||
|
public bool IncludeSpecialViews { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-library pinned source titles, one per line as "Library Name=Item Title".
|
||||||
|
/// A pinned library uses that item's backdrop instead of the <see cref="Source"/> rule.
|
||||||
|
/// </summary>
|
||||||
|
public string PinnedSources { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Placard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="PlacardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||||
|
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="PlacardConfigForm">
|
||||||
|
<div class="verticalSection">
|
||||||
|
<div class="sectionTitleContainer flex align-items-center">
|
||||||
|
<h2 class="sectionTitle">Placard</h2>
|
||||||
|
</div>
|
||||||
|
<p class="fieldDescription">
|
||||||
|
Bakes each library's name onto a representative backdrop, matching Jellyfin's default card style.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label>
|
||||||
|
<input is="emby-checkbox" type="checkbox" id="Enabled" />
|
||||||
|
<span>Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selectContainer">
|
||||||
|
<label class="selectLabel" for="Source">Backdrop source</label>
|
||||||
|
<select is="emby-select" id="Source" name="Source">
|
||||||
|
<option value="0">Highest rated title</option>
|
||||||
|
<option value="1">Random title</option>
|
||||||
|
<option value="2">Newest title</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="ScrimOpacity">Darkening (0-255)</label>
|
||||||
|
<input is="emby-input" type="number" id="ScrimOpacity" min="0" max="255" />
|
||||||
|
<div class="fieldDescription">How much to darken the backdrop so the label reads. ~105 matches the default.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="FontHeightPercent">Label size (% of height)</label>
|
||||||
|
<input is="emby-input" type="number" id="FontHeightPercent" min="8" max="40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label>
|
||||||
|
<input is="emby-checkbox" type="checkbox" id="IncludeSpecialViews" />
|
||||||
|
<span>Include Live TV (color bars) and Playlists</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="PinnedSources">Pinned sources</label>
|
||||||
|
<textarea is="emby-textarea" id="PinnedSources" rows="6"
|
||||||
|
placeholder="Movies=The Dark Knight Anime=Death Note"></textarea>
|
||||||
|
<div class="fieldDescription">
|
||||||
|
One per line as <code>Library Name=Item Title</code>. Pinned libraries use that title's
|
||||||
|
backdrop instead of the rule above. Leave blank to use the rule everywhere.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var PlacardPluginId = "b6f8e2a4-1c3d-4e5f-9a7b-2d4c6e8f0a1b";
|
||||||
|
|
||||||
|
document.querySelector('#PlacardConfigPage').addEventListener('pageshow', function () {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(PlacardPluginId).then(function (config) {
|
||||||
|
document.querySelector('#Enabled').checked = config.Enabled;
|
||||||
|
document.querySelector('#Source').value = config.Source;
|
||||||
|
document.querySelector('#ScrimOpacity').value = config.ScrimOpacity;
|
||||||
|
document.querySelector('#FontHeightPercent').value = config.FontHeightPercent;
|
||||||
|
document.querySelector('#IncludeSpecialViews').checked = config.IncludeSpecialViews;
|
||||||
|
document.querySelector('#PinnedSources').value = config.PinnedSources || '';
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#PlacardConfigForm').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(PlacardPluginId).then(function (config) {
|
||||||
|
config.Enabled = document.querySelector('#Enabled').checked;
|
||||||
|
config.Source = parseInt(document.querySelector('#Source').value, 10);
|
||||||
|
config.ScrimOpacity = parseInt(document.querySelector('#ScrimOpacity').value, 10);
|
||||||
|
config.FontHeightPercent = parseInt(document.querySelector('#FontHeightPercent').value, 10);
|
||||||
|
config.IncludeSpecialViews = document.querySelector('#IncludeSpecialViews').checked;
|
||||||
|
config.PinnedSources = document.querySelector('#PinnedSources').value;
|
||||||
|
ApiClient.updatePluginConfiguration(PlacardPluginId, config).then(function (result) {
|
||||||
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<RootNamespace>Jellyfin.Plugin.Placard</RootNamespace>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Fonts\NotoSans-Bold.ttf" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Plugin.Placard.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Placard;
|
||||||
|
|
||||||
|
/// <summary>Scheduled task that (re)generates a labeled Primary image for each library.</summary>
|
||||||
|
public class PlacardTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly ILogger<PlacardTask> _logger;
|
||||||
|
|
||||||
|
public PlacardTask(ILibraryManager libraryManager, IProviderManager providerManager, ILogger<PlacardTask> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Generate Placard library cards";
|
||||||
|
|
||||||
|
public string Key => "PlacardGenerate";
|
||||||
|
|
||||||
|
public string Description => "Bake each library's name onto a representative backdrop.";
|
||||||
|
|
||||||
|
public string Category => "Placard";
|
||||||
|
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
yield return new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfoType.DailyTrigger,
|
||||||
|
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance!.Configuration;
|
||||||
|
if (!config.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Placard is disabled; skipping run.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folders = _libraryManager.GetUserRootFolder().Children
|
||||||
|
.OfType<CollectionFolder>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var pins = ParsePins(config.PinnedSources);
|
||||||
|
_logger.LogInformation("Placard: processing {Count} libraries ({Pins} pinned)", folders.Count, pins.Count);
|
||||||
|
int done = 0;
|
||||||
|
foreach (var folder in folders)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessFolderAsync(folder, config, pins, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Placard: failed for library {Name}", folder.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100.0 * ++done / Math.Max(folders.Count, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly BaseItemKind[] SourceTypes =
|
||||||
|
{
|
||||||
|
BaseItemKind.Movie, BaseItemKind.Series,
|
||||||
|
BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParsePins(string raw)
|
||||||
|
{
|
||||||
|
var pins = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in raw.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
var eq = trimmed.IndexOf('=', StringComparison.Ordinal);
|
||||||
|
if (eq > 0)
|
||||||
|
{
|
||||||
|
pins[trimmed[..eq].Trim()] = trimmed[(eq + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch
|
||||||
|
{
|
||||||
|
SourceRule.Newest => new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
|
||||||
|
SourceRule.Random => new[] { (ItemSortBy.Random, SortOrder.Ascending) },
|
||||||
|
_ => new[] { (ItemSortBy.CommunityRating, SortOrder.Descending) }
|
||||||
|
};
|
||||||
|
|
||||||
|
private BaseItem? PickSource(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins)
|
||||||
|
{
|
||||||
|
// Pinned title wins.
|
||||||
|
if (pins.TryGetValue(folder.Name, out var pinned) && !string.IsNullOrWhiteSpace(pinned))
|
||||||
|
{
|
||||||
|
var byName = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
Parent = folder,
|
||||||
|
Recursive = true,
|
||||||
|
SearchTerm = pinned,
|
||||||
|
IncludeItemTypes = SourceTypes,
|
||||||
|
Limit = 15
|
||||||
|
});
|
||||||
|
var match = byName.FirstOrDefault(i =>
|
||||||
|
string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase) && i.GetImages(ImageType.Backdrop).Any())
|
||||||
|
?? byName.FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Placard: pinned title '{Pin}' not found in {Name}; using rule", pinned, folder.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
Parent = folder,
|
||||||
|
Recursive = true,
|
||||||
|
IncludeItemTypes = SourceTypes,
|
||||||
|
OrderBy = OrderFor(config.Source),
|
||||||
|
Limit = 60
|
||||||
|
}).FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessFolderAsync(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var source = PickSource(folder, config, pins);
|
||||||
|
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Placard: no backdrop candidate found for {Name}", folder.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backdropPath = source.GetImages(ImageType.Backdrop).First().Path;
|
||||||
|
if (string.IsNullOrEmpty(backdropPath) || !File.Exists(backdropPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Placard: backdrop path missing for {Name}", folder.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Placard: {Library} <- backdrop of {Item}", folder.Name, source.Name);
|
||||||
|
var bytes = CardRenderer.Render(backdropPath, folder.Name, config.ScrimOpacity, config.FontHeightPercent);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(bytes);
|
||||||
|
await _providerManager
|
||||||
|
.SaveImage(folder, stream, "image/jpeg", ImageType.Primary, null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await folder.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.Placard.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Placard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placard: bakes each library's name onto a representative backdrop,
|
||||||
|
/// matching Jellyfin's default library-card styling.
|
||||||
|
/// </summary>
|
||||||
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
|
{
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
|
public override string Name => "Placard";
|
||||||
|
|
||||||
|
public override Guid Id => Guid.Parse("b6f8e2a4-1c3d-4e5f-9a7b-2d4c6e8f0a1b");
|
||||||
|
|
||||||
|
public override string Description =>
|
||||||
|
"Bakes each library's name onto a representative backdrop, matching Jellyfin's default card style.";
|
||||||
|
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
yield return new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
EmbeddedResourcePath = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.Configuration.configPage.html",
|
||||||
|
GetType().Namespace)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Placard — a Jellyfin plugin.
|
||||||
|
|
||||||
|
Portions of this project were developed with AI assistance (Claude, by Anthropic).
|
||||||
|
|
||||||
|
Bundled fonts:
|
||||||
|
- Noto Sans (SIL Open Font License 1.1), (c) The Noto Project Authors.
|
||||||
|
https://github.com/notofonts/notofonts.github.io
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Placard
|
||||||
|
|
||||||
|
A Jellyfin plugin that bakes each **library's name onto a representative backdrop**,
|
||||||
|
matching Jellyfin's default library-card styling — so your home-screen library cards
|
||||||
|
look intentional and stop rotating.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Jellyfin auto-generates a collage for libraries with no image and re-rolls it over
|
||||||
|
time. Placard replaces that with a static, labeled card per library:
|
||||||
|
|
||||||
|
- Picks a backdrop from a title in the library (highest-rated, random, or newest),
|
||||||
|
or a **pinned** title you choose.
|
||||||
|
- Composites the library name centered, in **Noto Sans**, with a soft drop shadow
|
||||||
|
over a light darkening scrim — the same look Jellyfin uses for its default covers.
|
||||||
|
- Sets the image through the internal provider API, so the change shows **without a
|
||||||
|
server restart**.
|
||||||
|
- Runs as a scheduled task (daily by default) and applies to new libraries too.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Dashboard → Plugins → **Placard**:
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---|---|
|
||||||
|
| Enabled | Master on/off. |
|
||||||
|
| Backdrop source | Highest rated / Random / Newest title in each library. |
|
||||||
|
| Darkening | Scrim opacity (0–255); ~105 matches the default. |
|
||||||
|
| Label size | Label height as a percent of image height. |
|
||||||
|
| Pinned sources | One per line, `Library Name=Item Title`. Overrides the rule. |
|
||||||
|
|
||||||
|
Example pins:
|
||||||
|
|
||||||
|
```
|
||||||
|
Movies=The Dark Knight
|
||||||
|
Anime=Death Note
|
||||||
|
Collections=The Avengers Collection
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run **Scheduled Tasks → Generate Placard library cards** (or wait for the daily run).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires the .NET 9 SDK (Jellyfin 10.11 targets net9.0).
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `bin/Release/net9.0/Jellyfin.Plugin.Placard.dll` (plus `meta.json`) into
|
||||||
|
`<jellyfin-config>/plugins/Placard_<version>/` and restart Jellyfin.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Jellyfin **10.11.x** (targetAbi `10.11.0.0`, net9.0).
|
||||||
|
- Uses the SkiaSharp bundled with the Jellyfin server.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: "Placard"
|
||||||
|
guid: "b6f8e2a4-1c3d-4e5f-9a7b-2d4c6e8f0a1b"
|
||||||
|
version: "1.0.0.0"
|
||||||
|
targetAbi: "10.11.0.0"
|
||||||
|
framework: "net9.0"
|
||||||
|
overview: "Bake each library's name onto a representative backdrop."
|
||||||
|
description: >
|
||||||
|
Bakes each library's name onto a representative backdrop, matching Jellyfin's
|
||||||
|
default library-card style (Noto Sans, centered, soft shadow). Supports a
|
||||||
|
source rule and per-library pinned sources.
|
||||||
|
category: "General"
|
||||||
|
owner: "flan"
|
||||||
|
artifacts:
|
||||||
|
- "Jellyfin.Plugin.Placard.dll"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"guid": "b6f8e2a4-1c3d-4e5f-9a7b-2d4c6e8f0a1b",
|
||||||
|
"name": "Placard",
|
||||||
|
"version": "1.0.0.0",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"framework": "net9.0",
|
||||||
|
"overview": "Labels library cards with the library name baked onto a backdrop.",
|
||||||
|
"description": "Bakes each library's name onto a representative backdrop, matching Jellyfin's default card style.",
|
||||||
|
"category": "General",
|
||||||
|
"owner": "flan",
|
||||||
|
"assemblyFileName": "Jellyfin.Plugin.Placard.dll",
|
||||||
|
"timestamp": "2026-07-19T00:00:00.0000000Z",
|
||||||
|
"imagePath": "",
|
||||||
|
"status": "Active",
|
||||||
|
"autoUpdate": false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user