Audit + refactor: guard image decode/surface, clamp scrim & font size, Lazy typeface, dedupe backdrop lookup, drop unwired special-views scaffold
This commit is contained in:
+2
-4
@@ -13,11 +13,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
- Backdrop source rules: highest-rated / random / newest title in the library.
|
- Backdrop source rules: highest-rated / random / newest title in the library.
|
||||||
- Per-library pinned sources (`Library Name=Item Title`) to override the rule.
|
- Per-library pinned sources (`Library Name=Item Title`) to override the rule.
|
||||||
- Configuration page (enable, source rule, scrim opacity, label size, pins).
|
- Configuration page (enable, source rule, scrim opacity, label size, pins).
|
||||||
- SkiaSharp-based renderer, incl. a procedural SMPTE color-bars generator for
|
- SkiaSharp-based renderer that clamps out-of-range settings.
|
||||||
views that have no media backdrop.
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- Sets images through the internal provider API, so the home-screen view cache
|
- Sets images through the internal provider API, so the home-screen view cache
|
||||||
updates without a server restart.
|
updates without a server restart.
|
||||||
- Special views (Live TV / Playlists) are scaffolded (`IncludeSpecialViews`,
|
- Special views (Live TV / Playlists) are not covered yet. Planned for 1.1.
|
||||||
color-bars renderer) but not yet wired into the task. Planned for 1.1.
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
|
||||||
@@ -6,62 +7,45 @@ namespace Jellyfin.Plugin.Placard;
|
|||||||
/// <summary>Composites a library name onto a backdrop, matching Jellyfin's default cover style.</summary>
|
/// <summary>Composites a library name onto a backdrop, matching Jellyfin's default cover style.</summary>
|
||||||
public static class CardRenderer
|
public static class CardRenderer
|
||||||
{
|
{
|
||||||
private static SKTypeface? _typeface;
|
private const float FitWidthFraction = 0.90f; // label spans at most this much of the width
|
||||||
|
private const float ShadowBlurDivisor = 20f; // blur radius = fontSize / this
|
||||||
|
private const float ShadowOffsetDivisor = 28f; // shadow offset = fontSize / this
|
||||||
|
private const int MinFontSize = 12;
|
||||||
|
private const int JpegQuality = 92;
|
||||||
|
|
||||||
private static SKTypeface Typeface
|
private static readonly Lazy<SKTypeface> LazyTypeface = new(LoadTypeface);
|
||||||
|
|
||||||
|
private static SKTypeface Typeface => LazyTypeface.Value;
|
||||||
|
|
||||||
|
private static SKTypeface LoadTypeface()
|
||||||
{
|
{
|
||||||
get
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
using var stream = asm.GetManifestResourceStream("Jellyfin.Plugin.Placard.Fonts.NotoSans-Bold.ttf");
|
||||||
|
if (stream is null)
|
||||||
{
|
{
|
||||||
if (_typeface != null)
|
return SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy into SKData so the typeface does not depend on the (disposed) resource stream.
|
||||||
|
using var data = SKData.Create(stream);
|
||||||
|
return SKTypeface.FromData(data) ?? SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Render <paramref name="label"/> centered on the image at <paramref name="backdropPath"/>.</summary>
|
/// <summary>Render <paramref name="label"/> centered on the image at <paramref name="backdropPath"/>.</summary>
|
||||||
|
/// <param name="scrim">Darkening overlay opacity, 0-255.</param>
|
||||||
|
/// <param name="fontHeightPct">Label height as a percent of image height.</param>
|
||||||
public static byte[] Render(string backdropPath, string label, int scrim, int fontHeightPct)
|
public static byte[] Render(string backdropPath, string label, int scrim, int fontHeightPct)
|
||||||
{
|
{
|
||||||
using var input = SKBitmap.Decode(backdropPath);
|
using var input = SKBitmap.Decode(backdropPath)
|
||||||
return RenderBitmap(input, label, scrim, fontHeightPct);
|
?? throw new InvalidOperationException($"Placard could not decode backdrop image: {backdropPath}");
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 w = input.Width;
|
||||||
int h = input.Height;
|
int h = input.Height;
|
||||||
using var surface = SKSurface.Create(new SKImageInfo(w, h));
|
scrim = Math.Clamp(scrim, 0, 255);
|
||||||
|
fontHeightPct = Math.Clamp(fontHeightPct, 5, 40);
|
||||||
|
|
||||||
|
using var surface = SKSurface.Create(new SKImageInfo(w, h))
|
||||||
|
?? throw new InvalidOperationException($"Placard could not create a {w}x{h} drawing surface");
|
||||||
var canvas = surface.Canvas;
|
var canvas = surface.Canvas;
|
||||||
canvas.DrawBitmap(input, 0, 0);
|
canvas.DrawBitmap(input, 0, 0);
|
||||||
|
|
||||||
@@ -70,20 +54,22 @@ public static class CardRenderer
|
|||||||
canvas.DrawRect(0, 0, w, h, scrimPaint);
|
canvas.DrawRect(0, 0, w, h, scrimPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using var text = new SKPaint { Typeface = Typeface, IsAntialias = true, Color = SKColors.White };
|
||||||
|
|
||||||
|
// Shrink until the label fits within FitWidthFraction of the width.
|
||||||
float size = h * fontHeightPct / 100f;
|
float size = h * fontHeightPct / 100f;
|
||||||
using var measure = new SKPaint { Typeface = Typeface, IsAntialias = true };
|
for (; size > MinFontSize; size -= 4)
|
||||||
for (; size > 12; size -= 4)
|
|
||||||
{
|
{
|
||||||
measure.TextSize = size;
|
text.TextSize = size;
|
||||||
if (measure.MeasureText(label) <= w * 0.90f)
|
if (text.MeasureText(label) <= w * FitWidthFraction)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
measure.TextSize = size;
|
text.TextSize = size;
|
||||||
SKRect bounds = default;
|
SKRect bounds = default;
|
||||||
measure.MeasureText(label, ref bounds);
|
text.MeasureText(label, ref bounds);
|
||||||
float x = ((w - bounds.Width) / 2f) - bounds.Left;
|
float x = ((w - bounds.Width) / 2f) - bounds.Left;
|
||||||
float y = ((h - bounds.Height) / 2f) - bounds.Top;
|
float y = ((h - bounds.Height) / 2f) - bounds.Top;
|
||||||
|
|
||||||
@@ -93,25 +79,16 @@ public static class CardRenderer
|
|||||||
TextSize = size,
|
TextSize = size,
|
||||||
IsAntialias = true,
|
IsAntialias = true,
|
||||||
Color = new SKColor(0, 0, 0, 205),
|
Color = new SKColor(0, 0, 0, 205),
|
||||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, size / 20f)
|
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, size / ShadowBlurDivisor)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
canvas.DrawText(label, x, y + (size / 28f), shadow);
|
canvas.DrawText(label, x, y + (size / ShadowOffsetDivisor), shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var text = new SKPaint
|
canvas.DrawText(label, x, y, text);
|
||||||
{
|
|
||||||
Typeface = Typeface,
|
|
||||||
TextSize = size,
|
|
||||||
IsAntialias = true,
|
|
||||||
Color = SKColors.White
|
|
||||||
})
|
|
||||||
{
|
|
||||||
canvas.DrawText(label, x, y, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var image = surface.Snapshot();
|
using var image = surface.Snapshot();
|
||||||
using var data = image.Encode(SKEncodedImageFormat.Jpeg, 92);
|
using var data = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality);
|
||||||
return data.ToArray();
|
return data.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>Label height as a percent of image height.</summary>
|
/// <summary>Label height as a percent of image height.</summary>
|
||||||
public int FontHeightPercent { get; set; } = 20;
|
public int FontHeightPercent { get; set; } = 20;
|
||||||
|
|
||||||
/// <summary>Also label Live TV (SMPTE color bars) and Playlists.</summary>
|
|
||||||
public bool IncludeSpecialViews { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-library pinned source titles, one per line as "Library Name=Item Title".
|
/// 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.
|
/// A pinned library uses that item's backdrop instead of the <see cref="Source"/> rule.
|
||||||
|
|||||||
@@ -45,16 +45,9 @@
|
|||||||
<input is="emby-input" type="number" id="FontHeightPercent" min="8" max="40" />
|
<input is="emby-input" type="number" id="FontHeightPercent" min="8" max="40" />
|
||||||
</div>
|
</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">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="PinnedSources">Pinned sources</label>
|
<label class="inputLabel inputLabelUnfocused" for="PinnedSources">Pinned sources</label>
|
||||||
<textarea is="emby-textarea" id="PinnedSources" rows="6"
|
<textarea id="PinnedSources" rows="6" class="emby-input"
|
||||||
placeholder="Movies=The Dark Knight Anime=Death Note"></textarea>
|
placeholder="Movies=The Dark Knight Anime=Death Note"></textarea>
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
One per line as <code>Library Name=Item Title</code>. Pinned libraries use that title's
|
One per line as <code>Library Name=Item Title</code>. Pinned libraries use that title's
|
||||||
@@ -80,7 +73,6 @@
|
|||||||
document.querySelector('#Source').value = config.Source;
|
document.querySelector('#Source').value = config.Source;
|
||||||
document.querySelector('#ScrimOpacity').value = config.ScrimOpacity;
|
document.querySelector('#ScrimOpacity').value = config.ScrimOpacity;
|
||||||
document.querySelector('#FontHeightPercent').value = config.FontHeightPercent;
|
document.querySelector('#FontHeightPercent').value = config.FontHeightPercent;
|
||||||
document.querySelector('#IncludeSpecialViews').checked = config.IncludeSpecialViews;
|
|
||||||
document.querySelector('#PinnedSources').value = config.PinnedSources || '';
|
document.querySelector('#PinnedSources').value = config.PinnedSources || '';
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
@@ -94,7 +86,6 @@
|
|||||||
config.Source = parseInt(document.querySelector('#Source').value, 10);
|
config.Source = parseInt(document.querySelector('#Source').value, 10);
|
||||||
config.ScrimOpacity = parseInt(document.querySelector('#ScrimOpacity').value, 10);
|
config.ScrimOpacity = parseInt(document.querySelector('#ScrimOpacity').value, 10);
|
||||||
config.FontHeightPercent = parseInt(document.querySelector('#FontHeightPercent').value, 10);
|
config.FontHeightPercent = parseInt(document.querySelector('#FontHeightPercent').value, 10);
|
||||||
config.IncludeSpecialViews = document.querySelector('#IncludeSpecialViews').checked;
|
|
||||||
config.PinnedSources = document.querySelector('#PinnedSources').value;
|
config.PinnedSources = document.querySelector('#PinnedSources').value;
|
||||||
ApiClient.updatePluginConfiguration(PlacardPluginId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(PlacardPluginId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ namespace Jellyfin.Plugin.Placard;
|
|||||||
/// <summary>Scheduled task that (re)generates a labeled Primary image for each library.</summary>
|
/// <summary>Scheduled task that (re)generates a labeled Primary image for each library.</summary>
|
||||||
public class PlacardTask : IScheduledTask
|
public class PlacardTask : IScheduledTask
|
||||||
{
|
{
|
||||||
|
private static readonly BaseItemKind[] SourceTypes =
|
||||||
|
{
|
||||||
|
BaseItemKind.Movie, BaseItemKind.Series,
|
||||||
|
BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum
|
||||||
|
};
|
||||||
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly IProviderManager _providerManager;
|
private readonly IProviderManager _providerManager;
|
||||||
private readonly ILogger<PlacardTask> _logger;
|
private readonly ILogger<PlacardTask> _logger;
|
||||||
@@ -62,6 +68,7 @@ public class PlacardTask : IScheduledTask
|
|||||||
|
|
||||||
var pins = ParsePins(config.PinnedSources);
|
var pins = ParsePins(config.PinnedSources);
|
||||||
_logger.LogInformation("Placard: processing {Count} libraries ({Pins} pinned)", folders.Count, pins.Count);
|
_logger.LogInformation("Placard: processing {Count} libraries ({Pins} pinned)", folders.Count, pins.Count);
|
||||||
|
|
||||||
int done = 0;
|
int done = 0;
|
||||||
foreach (var folder in folders)
|
foreach (var folder in folders)
|
||||||
{
|
{
|
||||||
@@ -79,11 +86,8 @@ public class PlacardTask : IScheduledTask
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly BaseItemKind[] SourceTypes =
|
private static ItemImageInfo? Backdrop(BaseItem item)
|
||||||
{
|
=> item.GetImages(ImageType.Backdrop).FirstOrDefault();
|
||||||
BaseItemKind.Movie, BaseItemKind.Series,
|
|
||||||
BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Dictionary<string, string> ParsePins(string raw)
|
private static Dictionary<string, string> ParsePins(string raw)
|
||||||
{
|
{
|
||||||
@@ -106,7 +110,7 @@ public class PlacardTask : IScheduledTask
|
|||||||
return pins;
|
return pins;
|
||||||
}
|
}
|
||||||
|
|
||||||
private (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch
|
private static (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch
|
||||||
{
|
{
|
||||||
SourceRule.Newest => new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
|
SourceRule.Newest => new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
|
||||||
SourceRule.Random => new[] { (ItemSortBy.Random, SortOrder.Ascending) },
|
SourceRule.Random => new[] { (ItemSortBy.Random, SortOrder.Ascending) },
|
||||||
@@ -115,20 +119,20 @@ public class PlacardTask : IScheduledTask
|
|||||||
|
|
||||||
private BaseItem? PickSource(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins)
|
private BaseItem? PickSource(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins)
|
||||||
{
|
{
|
||||||
// Pinned title wins.
|
// A pinned title wins, when it exists and has a backdrop.
|
||||||
if (pins.TryGetValue(folder.Name, out var pinned) && !string.IsNullOrWhiteSpace(pinned))
|
if (pins.TryGetValue(folder.Name, out var pinned) && !string.IsNullOrWhiteSpace(pinned))
|
||||||
{
|
{
|
||||||
var byName = _libraryManager.GetItemList(new InternalItemsQuery
|
var candidates = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
{
|
{
|
||||||
Parent = folder,
|
Parent = folder,
|
||||||
Recursive = true,
|
Recursive = true,
|
||||||
SearchTerm = pinned,
|
SearchTerm = pinned,
|
||||||
IncludeItemTypes = SourceTypes,
|
IncludeItemTypes = SourceTypes,
|
||||||
Limit = 15
|
Limit = 15
|
||||||
});
|
}).Where(i => Backdrop(i) is not null).ToList();
|
||||||
var match = byName.FirstOrDefault(i =>
|
|
||||||
string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase) && i.GetImages(ImageType.Backdrop).Any())
|
var match = candidates.FirstOrDefault(i => string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase))
|
||||||
?? byName.FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
?? candidates.FirstOrDefault();
|
||||||
if (match is not null)
|
if (match is not null)
|
||||||
{
|
{
|
||||||
return match;
|
return match;
|
||||||
@@ -144,23 +148,16 @@ public class PlacardTask : IScheduledTask
|
|||||||
IncludeItemTypes = SourceTypes,
|
IncludeItemTypes = SourceTypes,
|
||||||
OrderBy = OrderFor(config.Source),
|
OrderBy = OrderFor(config.Source),
|
||||||
Limit = 60
|
Limit = 60
|
||||||
}).FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
|
}).FirstOrDefault(i => Backdrop(i) is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessFolderAsync(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins, CancellationToken cancellationToken)
|
private async Task ProcessFolderAsync(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary<string, string> pins, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var source = PickSource(folder, config, pins);
|
var source = PickSource(folder, config, pins);
|
||||||
|
var backdropPath = source is null ? null : Backdrop(source)?.Path;
|
||||||
if (source is null)
|
if (source is null || string.IsNullOrEmpty(backdropPath) || !File.Exists(backdropPath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Placard: no backdrop candidate found for {Name}", folder.Name);
|
_logger.LogWarning("Placard: no usable backdrop 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import sys, json, time, urllib.request
|
||||||
|
BASE="http://192.168.50.1:30013"; USER="flan"
|
||||||
|
MYURL="https://git.onetick.ninja/flan/jellyfin-placard/raw/branch/main/manifest.json"
|
||||||
|
H='MediaBrowser Client="poster-pin", Device="code", DeviceId="poster-pin-code-8e1f", Version="1.0"'
|
||||||
|
def call(p,m="GET",d=None,h=None):
|
||||||
|
r=urllib.request.Request(BASE+p,data=d,method=m,headers=h or {})
|
||||||
|
with urllib.request.urlopen(r,timeout=30) as x:
|
||||||
|
b=x.read(); return json.loads(b) if b else None
|
||||||
|
pw=sys.stdin.readline().rstrip("\n")
|
||||||
|
res=call("/Users/AuthenticateByName","POST",json.dumps({"Username":USER,"Pw":pw}).encode(),
|
||||||
|
{"Authorization":H,"Content-Type":"application/json"})
|
||||||
|
TOK={"Authorization":f'MediaBrowser Token="{res["AccessToken"]}"'}
|
||||||
|
repos=call("/Repositories",h=TOK) or []
|
||||||
|
print("existing repos:", [r.get("Name") for r in repos])
|
||||||
|
if not any(r.get("Url")==MYURL for r in repos):
|
||||||
|
repos.append({"Name":"Placard (flan)","Url":MYURL,"Enabled":True})
|
||||||
|
call("/Repositories","POST",json.dumps(repos).encode(),{**TOK,"Content-Type":"application/json"})
|
||||||
|
print("-> repo added")
|
||||||
|
else:
|
||||||
|
print("-> repo already present")
|
||||||
|
time.sleep(5)
|
||||||
|
pkgs=call("/Packages",h=TOK) or []
|
||||||
|
placard=[p for p in pkgs if "placard" in (p.get("name","")).lower()]
|
||||||
|
if placard:
|
||||||
|
p=placard[0]
|
||||||
|
print("CATALOG OK:", p.get("name"), "| guid:", p.get("guid"),
|
||||||
|
"| versions:", [v.get("version") for v in p.get("versions",[])])
|
||||||
|
else:
|
||||||
|
print("CATALOG: Placard NOT found (container may not reach the manifest URL, or cache lag)")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import sys, os, json, time, hashlib, urllib.request, urllib.error
|
||||||
|
gh_tok=sys.stdin.readline().strip()
|
||||||
|
GH="https://api.github.com"; UP="https://uploads.github.com"
|
||||||
|
ZIP="/home/dev/jellyfin-placard/Jellyfin.Plugin.Placard/bin/Release/net9.0/placard_1.0.0.0.zip"
|
||||||
|
if not os.path.exists(ZIP):
|
||||||
|
print("ZIP MISSING:", ZIP); sys.exit(1)
|
||||||
|
zbytes=open(ZIP,"rb").read()
|
||||||
|
print("zip:", len(zbytes), "bytes md5", hashlib.md5(zbytes).hexdigest())
|
||||||
|
def gh(base,path,method="GET",body=None,data=None,ctype="application/json"):
|
||||||
|
if body is not None: data=json.dumps(body).encode()
|
||||||
|
h={"Authorization":f"Bearer {gh_tok}","Accept":"application/vnd.github+json"}
|
||||||
|
if data is not None: h["Content-Type"]=ctype
|
||||||
|
r=urllib.request.Request(base+path,data=data,method=method,headers=h)
|
||||||
|
with urllib.request.urlopen(r,timeout=90) as x:
|
||||||
|
b=x.read(); return json.loads(b) if b else None
|
||||||
|
# wait for mirrored tag
|
||||||
|
ok=False
|
||||||
|
for _ in range(40):
|
||||||
|
try: gh(GH,"/repos/sudolulo/jellyfin-placard/git/refs/tags/v1.0.0"); ok=True; break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code==404: time.sleep(3)
|
||||||
|
else: raise
|
||||||
|
print("tag v1.0.0 present on github:", ok)
|
||||||
|
# create (or fetch) release
|
||||||
|
try:
|
||||||
|
rel=gh(GH,"/repos/sudolulo/jellyfin-placard/releases","POST",
|
||||||
|
{"tag_name":"v1.0.0","name":"Placard 1.0.0","body":"Initial release.","draft":False,"prerelease":False})
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code==422:
|
||||||
|
rel=gh(GH,"/repos/sudolulo/jellyfin-placard/releases/tags/v1.0.0")
|
||||||
|
else:
|
||||||
|
print("release err:", e.code, e.read()[:200].decode(errors='replace')); raise
|
||||||
|
rid=rel["id"]; print("release id:", rid)
|
||||||
|
# upload asset (delete existing same-name first if present)
|
||||||
|
for a in rel.get("assets",[]):
|
||||||
|
if a["name"]=="placard_1.0.0.0.zip":
|
||||||
|
gh(GH,f"/repos/sudolulo/jellyfin-placard/releases/assets/{a['id']}","DELETE")
|
||||||
|
try:
|
||||||
|
asset=gh(UP,f"/repos/sudolulo/jellyfin-placard/releases/{rid}/assets?name=placard_1.0.0.0.zip",
|
||||||
|
"POST",data=zbytes,ctype="application/zip")
|
||||||
|
print("ASSET_URL:", asset.get("browser_download_url"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("asset upload:", e.code, e.read()[:200].decode(errors='replace'))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import sys, json, time, urllib.request, urllib.error
|
||||||
|
from urllib.parse import quote
|
||||||
|
BASE="http://192.168.50.1:30013"; USER="flan"
|
||||||
|
GUID="b6f8e2a4-1c3d-4e5f-9a7b-2d4c6e8f0a1b"
|
||||||
|
MYURL="https://git.onetick.ninja/flan/jellyfin-placard/raw/branch/main/manifest.json"
|
||||||
|
H='MediaBrowser Client="poster-pin", Device="code", DeviceId="poster-pin-code-8e1f", Version="1.0"'
|
||||||
|
def call(p,m="GET",d=None,h=None,t=30):
|
||||||
|
r=urllib.request.Request(BASE+p,data=d,method=m,headers=h or {})
|
||||||
|
with urllib.request.urlopen(r,timeout=t) as x:
|
||||||
|
b=x.read(); return json.loads(b) if b else None
|
||||||
|
def auth(pw):
|
||||||
|
r=call("/Users/AuthenticateByName","POST",json.dumps({"Username":USER,"Pw":pw}).encode(),
|
||||||
|
{"Authorization":H,"Content-Type":"application/json"})
|
||||||
|
return {"Authorization":f'MediaBrowser Token="{r["AccessToken"]}"'}
|
||||||
|
pw=sys.stdin.readline().rstrip("\n"); TOK=auth(pw)
|
||||||
|
# 1. uninstall the manually-dropped plugin
|
||||||
|
try:
|
||||||
|
call(f"/Plugins/{GUID}/1.0.0.0","DELETE",h=TOK); print("uninstalled manual copy")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("uninstall ->", e.code)
|
||||||
|
time.sleep(3)
|
||||||
|
# 2. install from the catalog (downloads + checksum-verifies the release zip)
|
||||||
|
q=f"version=1.0.0.0&assemblyGuid={GUID}&repositoryUrl={quote(MYURL, safe='')}"
|
||||||
|
try:
|
||||||
|
call(f"/Packages/Installed/Placard?{q}","POST",h=TOK); print("catalog install queued")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("install ->", e.code, e.read()[:200])
|
||||||
|
time.sleep(10)
|
||||||
|
# 3. restart to activate
|
||||||
|
print("restart ...")
|
||||||
|
try: call("/System/Restart","POST",h=TOK,t=10)
|
||||||
|
except Exception: pass
|
||||||
|
time.sleep(12)
|
||||||
|
for _ in range(75):
|
||||||
|
try: call("/System/Info/Public"); break
|
||||||
|
except Exception: time.sleep(2)
|
||||||
|
time.sleep(4); TOK=auth(pw)
|
||||||
|
# 4. verify loaded + config reachable
|
||||||
|
plugins=call("/Plugins",h=TOK) or []
|
||||||
|
pl=[p for p in plugins if "placard" in p.get("Name","").lower()]
|
||||||
|
if pl:
|
||||||
|
print("INSTALLED:", pl[0].get("Name"), pl[0].get("Version"), "status:", pl[0].get("Status"),
|
||||||
|
"| canUninstall:", pl[0].get("CanUninstall"))
|
||||||
|
else:
|
||||||
|
print("Placard NOT loaded after install")
|
||||||
|
try:
|
||||||
|
cfg=call(f"/Plugins/{GUID}/Configuration",h=TOK)
|
||||||
|
print("config OK; pins lines:", len((cfg or {}).get("PinnedSources","").splitlines()))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("config ->", e.code)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import sys, json, urllib.request
|
||||||
|
GITEA="https://git.onetick.ninja/api/v1"
|
||||||
|
token=sys.stdin.readline().strip()
|
||||||
|
def g(path):
|
||||||
|
r=urllib.request.Request(GITEA+path, headers={"Authorization":f"token {token}"})
|
||||||
|
with urllib.request.urlopen(r,timeout=20) as x: return json.loads(x.read())
|
||||||
|
repos=[r["name"] for r in g("/users/flan/repos?limit=50") if not r["private"]]
|
||||||
|
for name in repos:
|
||||||
|
try: pm=g(f"/repos/flan/{name}/push_mirrors")
|
||||||
|
except Exception: pm=[]
|
||||||
|
try: topics=g(f"/repos/flan/{name}/topics").get("topics",[])
|
||||||
|
except Exception: topics=[]
|
||||||
|
if pm:
|
||||||
|
for m in pm:
|
||||||
|
print(f"[MIRROR] {name:26s} -> {m.get('remote_address')} sync_on_commit={m.get('sync_on_commit')} topics={topics}")
|
||||||
|
else:
|
||||||
|
print(f" {name:26s} topics={topics}")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import sys, json, urllib.request, urllib.error
|
||||||
|
gitea_tok = sys.stdin.readline().strip()
|
||||||
|
gh_tok = sys.stdin.readline().strip()
|
||||||
|
GITEA="https://git.onetick.ninja/api/v1"; GH="https://api.github.com"
|
||||||
|
def api(base, path, tokhdr, method="GET", body=None, extra=None):
|
||||||
|
d=json.dumps(body).encode() if body is not None else None
|
||||||
|
h={"Authorization":tokhdr,"Content-Type":"application/json"}
|
||||||
|
if extra: h.update(extra)
|
||||||
|
r=urllib.request.Request(base+path, data=d, method=method, headers=h)
|
||||||
|
with urllib.request.urlopen(r,timeout=30) as x:
|
||||||
|
b=x.read(); return json.loads(b) if b else None
|
||||||
|
def gitea(p,m="GET",b=None): return api(GITEA,p,f"token {gitea_tok}",m,b)
|
||||||
|
def gh(p,m="GET",b=None): return api(GH,p,f"Bearer {gh_tok}",m,b,{"Accept":"application/vnd.github+json"})
|
||||||
|
TOPICS=["jellyfin","jellyfin-plugin","skiasharp","dotnet","media-server"]
|
||||||
|
|
||||||
|
# 1. create GitHub repo
|
||||||
|
try:
|
||||||
|
repo=gh("/user/repos","POST",{"name":"jellyfin-placard",
|
||||||
|
"description":"Jellyfin plugin: bake library names onto backdrops (Placard)",
|
||||||
|
"private":False,"has_issues":True,"has_wiki":False,
|
||||||
|
"homepage":"https://git.onetick.ninja/flan/jellyfin-placard"})
|
||||||
|
print("github repo:", repo.get("full_name"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("gh repo:", e.code, e.read()[:150].decode(errors='replace'))
|
||||||
|
# github topics
|
||||||
|
try:
|
||||||
|
gh("/repos/sudolulo/jellyfin-placard/topics","PUT",{"names":TOPICS}); print("gh topics set")
|
||||||
|
except urllib.error.HTTPError as e: print("gh topics:", e.code)
|
||||||
|
|
||||||
|
# 2. gitea push-mirror -> github
|
||||||
|
try:
|
||||||
|
pm=gitea("/repos/flan/jellyfin-placard/push_mirrors","POST",{
|
||||||
|
"remote_address":"https://github.com/sudolulo/jellyfin-placard.git",
|
||||||
|
"remote_username":"sudolulo","remote_password":gh_tok,
|
||||||
|
"sync_on_commit":True,"interval":"8h0m0s"})
|
||||||
|
print("push-mirror:", pm.get("remote_address"), "sync_on_commit", pm.get("sync_on_commit"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("push_mirror:", e.code, e.read()[:200].decode(errors='replace'))
|
||||||
|
# gitea topics
|
||||||
|
try:
|
||||||
|
gitea("/repos/flan/jellyfin-placard/topics","PUT",{"topics":TOPICS}); print("gitea topics set")
|
||||||
|
except urllib.error.HTTPError as e: print("gitea topics:", e.code)
|
||||||
|
|
||||||
|
# 3. trigger sync
|
||||||
|
try:
|
||||||
|
gitea("/repos/flan/jellyfin-placard/push_mirrors-sync","POST"); print("mirror sync triggered")
|
||||||
|
except urllib.error.HTTPError as e: print("sync:", e.code, e.read()[:150].decode(errors='replace'))
|
||||||
Reference in New Issue
Block a user