Compare commits

...

21 Commits

Author SHA1 Message Date
karol 0cfaa50805 fix: update manifest on master branch via Gitea API
Build and Release Plugin / release (push) Failing after 33s
2026-09-17 21:17:44 +02:00
karol fea812d3a3 fix: update manifest via Gitea API in release workflow
Build and Release Plugin / release (push) Failing after 32s
2026-09-17 21:15:28 +02:00
karol fe6e20a68a fix: use authenticated git push URL in release workflow
Build and Release Plugin / release (push) Failing after 33s
2026-09-17 21:12:40 +02:00
karol b7693b606e fix: replace release assets cleanly in Gitea Action
Build and Release Plugin / release (push) Failing after 34s
2026-09-17 21:09:12 +02:00
karol 13f9fd642c fix: make Gitea release step idempotent in workflow
Build and Release Plugin / release (push) Successful in 34s
2026-09-17 21:05:29 +02:00
karol 322cfd9acd fix: fix server mode crop application, request rewrite, and prevent infinite library scan loop
Build and Release Plugin / release (push) Failing after 1m7s
2026-09-17 20:51:54 +02:00
karol 648930aa21 fix: remove RequiresOpening/RequiresClosing from PlaybackInfo rewrite to prevent LiveStream errors in Jellyfin 12
Build and Release Plugin / release (push) Successful in 35s
2026-09-17 20:07:27 +02:00
karol cb16bf0074 fix(server-mode): handle quote stripping in wrapper and empty crop checks
Build and Release Plugin / release (push) Successful in 31s
2026-09-17 19:54:01 +02:00
karol f6ed790801 fix: resolve ffmpeg path dynamically, handle empty crop check, and strip quotes in wrapper
Build and Release Plugin / release (push) Successful in 37s
2026-09-17 19:26:25 +02:00
karol d5e11a52ed fix(ci): point sourceUrl to zip archive download link in manifest.json
Build and Release Plugin / release (push) Successful in 31s
2026-09-17 18:29:04 +02:00
karol d018a8e5ac fix(ci): strip pre-release suffixes from version in manifest.json
Build and Release Plugin / release (push) Successful in 37s
2026-09-17 18:23:27 +02:00
karol 25c7a56901 ci: add .NET 10 to runner
Build and Release Plugin / release (push) Successful in 33s
2026-09-17 18:11:37 +02:00
karol dfe59ed84a feat(server): display custom reason in Playback Info UI
Build and Release Plugin / release (push) Failing after 17s
2026-09-17 18:01:26 +02:00
karol 795f42048f feat(server): elegantly force transcoding for black bar removal
- Intercept HLS playlist queries to append `allowVideoStreamCopy=false`
- Append `transcodeReasons=VideoResolutionNotSupported` for accurate dashboard reporting
- Modify ffmpeg-wrapper.sh to cleanly inject `-vf crop=...` during active transcodes
- Strip `file:` prefix from inputs in the wrapper to ensure correct API lookups
- Remove hacky URL manipulations (e.g., dummy codecs) that caused FFmpeg crashes
2026-09-17 17:41:59 +02:00
karol 5f8674fae4 Fix Client Mode execution and network interception logic
Build and Release Plugin / release (push) Failing after 20s
2026-09-17 16:38:23 +02:00
karol 0a437a5588 Add extensive diagnostic logging to ClientScript 2026-09-17 16:25:01 +02:00
karol 5b14fe9657 Make ClientScript URL parsing robust against Jellyfin router changes 2026-09-17 16:21:21 +02:00
karol f8b7c95e01 Fix API JSON serialization to return raw strings for client JS 2026-09-17 16:14:02 +02:00
karol e2c286e42c Replace on-disk index.html patching with IStartupFilter request-time injection 2026-09-17 16:08:24 +02:00
karol e44aafae70 Fix UI save/load via jQuery on/pageshow in configPage 2026-09-17 16:00:51 +02:00
karol 44325b9f9c Fix UI save/load via native JS in configPage 2026-09-17 15:58:58 +02:00
12 changed files with 650 additions and 240 deletions
+50 -24
View File
@@ -17,7 +17,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x
- name: Build Plugin
run: dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
@@ -52,18 +54,30 @@ jobs:
TAG="${{ github.ref_name }}"
IS_PRERELEASE="false"
if [[ "$TAG" == *"beta"* ]] || [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == v0.* ]]; then
if [[ "$TAG" =~ beta|alpha|^v0\. ]]; then
IS_PRERELEASE="true"
fi
# Create Release
RELEASE_RES=$(curl -s -X POST "$API_URL/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\", \"name\":\"Release $TAG\", \"body\":\"Automated release\", \"draft\":false, \"prerelease\":$IS_PRERELEASE}")
# Check if release exists
EXISTING_REL=$(curl -s -H "Authorization: token $TOKEN" "$API_URL/repos/$REPO/releases/tags/$TAG")
REL_ID=$(echo "$EXISTING_REL" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
REL_ID=$(echo "$RELEASE_RES" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ -z "$REL_ID" ] || [ "$REL_ID" == "null" ]; then
RELEASE_RES=$(curl -s -X POST "$API_URL/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\", \"name\":\"Release $TAG\", \"body\":\"Automated release\", \"draft\":false, \"prerelease\":$IS_PRERELEASE}")
REL_ID=$(echo "$RELEASE_RES" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
fi
# Delete existing assets for this release if present
ASSETS=$(curl -s -H "Authorization: token $TOKEN" "$API_URL/repos/$REPO/releases/$REL_ID/assets")
echo "$ASSETS" | grep -o '"id":[0-9]*' | cut -d':' -f2 | while read -r asset_id; do
if [ -n "$asset_id" ]; then
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API_URL/repos/$REPO/releases/$REL_ID/assets/$asset_id"
fi
done
# Upload Assets
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
-H "Authorization: token $TOKEN" \
@@ -73,15 +87,15 @@ jobs:
-H "Authorization: token $TOKEN" \
-F "attachment=@BlackBarRemover_${TAG}_jf12.0.zip"
- name: Update manifest.json
- name: Update manifest.json via API
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
git checkout -B manifest
API_URL="${{ github.api_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
CLEAN_VERSION="${VERSION%%-*}"
cat > manifest.json <<EOF
[
@@ -95,28 +109,40 @@ jobs:
"imageUrl": "",
"versions": [
{
"version": "$VERSION",
"version": "$CLEAN_VERSION",
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf12.0.zip",
"checksum": "${{ steps.checksum.outputs.md5_10 }}",
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
"repositoryUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf12.0.zip"
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
},
{
"version": "$VERSION",
"version": "$CLEAN_VERSION",
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf10.11.zip",
"checksum": "${{ steps.checksum.outputs.md5_9 }}",
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
"repositoryUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf10.11.zip"
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
}
]
}
]
EOF
git add manifest.json
git commit -m "Update manifest for ${{ github.ref_name }}"
git push -f origin manifest
B64_CONTENT=$(base64 -w0 manifest.json)
# Update on master branch
EXISTING_MANIFEST=$(curl -s -H "Authorization: token $TOKEN" "$API_URL/repos/$REPO/contents/manifest.json?ref=master")
FILE_SHA=$(echo "$EXISTING_MANIFEST" | grep -o '"sha":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$FILE_SHA" ]; then
curl -s -X PUT "$API_URL/repos/$REPO/contents/manifest.json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\":\"$B64_CONTENT\", \"message\":\"Update manifest.json for ${{ github.ref_name }}\", \"sha\":\"$FILE_SHA\", \"branch\":\"master\"}"
else
curl -s -X PUT "$API_URL/repos/$REPO/contents/manifest.json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\":\"$B64_CONTENT\", \"message\":\"Create manifest.json for ${{ github.ref_name }}\", \"branch\":\"master\"}"
fi
+149 -32
View File
@@ -21,41 +21,44 @@ namespace JellyfinPlugin.Api
}
[HttpGet("CropByPath")]
public ActionResult<string> GetCropByPath([FromQuery] string path)
[Produces("text/plain")]
public ContentResult GetCropByPath([FromQuery] string path)
{
if (string.IsNullOrEmpty(path)) return Ok(string.Empty);
if (string.IsNullOrEmpty(path)) return Content(string.Empty, "text/plain");
path = path.Trim('"').Trim('\'');
var mode = Plugin.Instance!.Configuration.PlaybackMode;
if (mode == "Client") return Ok(string.Empty);
if (mode == "Client") return Content(string.Empty, "text/plain");
var item = _libraryManager.RootFolder.GetRecursiveChildren().FirstOrDefault(x => x.Path == path);
if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
{
return Ok(cropData ?? string.Empty);
return Content(cropData ?? string.Empty, "text/plain");
}
return Ok(string.Empty);
return Content(string.Empty, "text/plain");
}
[HttpGet("CropById")]
public ActionResult<string> GetCropById([FromQuery] string itemId)
[Produces("text/plain")]
public ContentResult GetCropById([FromQuery] string itemId, [FromQuery] bool ignoreMode = false)
{
if (string.IsNullOrEmpty(itemId)) return Ok(string.Empty);
if (string.IsNullOrEmpty(itemId)) return Content(string.Empty, "text/plain");
var mode = Plugin.Instance!.Configuration.PlaybackMode;
if (mode == "Server") return Ok(string.Empty);
if (mode == "Server" && !ignoreMode) return Content(string.Empty, "text/plain");
if (Guid.TryParse(itemId, out Guid id))
{
var item = _libraryManager.GetItemById(id);
if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
{
return Ok(cropData ?? string.Empty);
return Content(cropData ?? string.Empty, "text/plain");
}
}
return Ok(string.Empty);
return Content(string.Empty, "text/plain");
}
[HttpGet("ClientScript.js")]
@@ -65,10 +68,12 @@ namespace JellyfinPlugin.Api
string js = @"
(function() {
console.log('Black Bar Remover: Script loaded');
let currentVideoElement = null;
let currentItemId = null;
let interceptedItemId = null;
function applyCrop(cropData) {
if (!currentVideoElement) return;
const video = document.querySelector('video');
if (!video) return;
const parts = cropData.split(':');
if (parts.length === 4) {
const w = parseInt(parts[0]);
@@ -81,39 +86,151 @@ namespace JellyfinPlugin.Api
const leftPercent = (x / 1920) * 100;
const rightPercent = ((1920 - (x + w)) / 1920) * 100;
currentVideoElement.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`;
video.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`;
const scaleY = 1 / (1 - (topPercent + bottomPercent)/100);
const scaleX = 1 / (1 - (leftPercent + rightPercent)/100);
const scale = Math.max(scaleY, scaleX);
if (scale > 1.01) {
currentVideoElement.style.transform = `scale(${scale})`;
video.style.transform = `scale(${scale})`;
}
console.log('Black Bar Remover: Applied crop', cropData, 'scale', scale);
}
}
function checkPlayback() {
const video = document.querySelector('video');
if (video && video !== currentVideoElement) {
currentVideoElement = video;
const params = new URLSearchParams(window.location.hash.split('?')[1]);
const itemId = params.get('id') || params.get('itemId');
if (itemId) {
fetch('/BlackBarRemover/CropById?itemId=' + itemId)
.then(r => r.text())
.then(crop => {
if (crop) applyCrop(crop);
});
}
} else if (!video && currentVideoElement) {
currentVideoElement = null;
}
function fetchCrop(itemId) {
console.log('Black Bar Remover: Fetching crop data for item', itemId);
window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId)
.then(r => r.text())
.then(crop => {
console.log('Black Bar Remover: Received crop data:', crop);
if (crop) {
setTimeout(() => applyCrop(crop), 500);
setTimeout(() => applyCrop(crop), 2000);
}
})
.catch(e => console.error('Black Bar Remover: API fetch error:', e));
}
setInterval(checkPlayback, 2000);
if (!window._originalFetch) {
window._originalFetch = window.fetch;
window.fetch = async function(...args) {
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url);
if (url) {
const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i);
if (match) {
const id = match[1];
if (id && id !== interceptedItemId) {
interceptedItemId = id;
console.log('Black Bar Remover: Intercepted itemId from fetch:', id);
}
}
}
return window._originalFetch.apply(this, args);
};
}
if (!window._originalXHR) {
window._originalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new window._originalXHR();
const originalOpen = xhr.open;
xhr.open = function(method, url, ...rest) {
if (url && typeof url === 'string') {
const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i);
if (match) {
const id = match[1];
if (id && id !== interceptedItemId) {
interceptedItemId = id;
console.log('Black Bar Remover: Intercepted itemId from XHR:', id);
}
}
}
return originalOpen.apply(this, [method, url, ...rest]);
};
return xhr;
};
}
function checkServerCrop(itemId) {
window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId + '&ignoreMode=true')
.then(r => r.text())
.then(crop => {
if (crop) {
window._isServerCropping = true;
} else {
window._isServerCropping = false;
}
})
.catch(e => { window._isServerCropping = false; });
}
const playbackInfoObserver = new MutationObserver((mutations) => {
if (!window._isServerCropping) return;
for (let m of mutations) {
if (m.type === 'characterData') {
if (m.target.nodeValue) {
const text = m.target.nodeValue.trim();
if (text === ""The video's resolution is not supported"" ||
text === ""VideoResolutionNotSupported"" ||
text.includes(""rozdzielczość nie jest obsługiwana"") ||
text === ""Rozdzielczość wideo nie jest obsługiwana"") {
m.target.nodeValue = ""Black Bar Removal (Server Mode)"";
}
}
} else if (m.type === 'childList') {
m.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const textContent = node.textContent || """";
if (textContent.includes(""The video's resolution is not supported"") ||
textContent.includes(""VideoResolutionNotSupported"") ||
textContent.includes(""rozdzielczość"")) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
let textNode;
while (textNode = walker.nextNode()) {
if (textNode.nodeValue) {
const t = textNode.nodeValue.trim();
if (t === ""The video's resolution is not supported"" ||
t === ""VideoResolutionNotSupported"" ||
t.includes(""rozdzielczość nie jest obsługiwana"") ||
t === ""Rozdzielczość wideo nie jest obsługiwana"") {
textNode.nodeValue = ""Black Bar Removal (Server Mode)"";
}
}
}
}
} else if (node.nodeType === Node.TEXT_NODE) {
if (node.nodeValue) {
const t = node.nodeValue.trim();
if (t === ""The video's resolution is not supported"" ||
t === ""VideoResolutionNotSupported"" ||
t.includes(""rozdzielczość nie jest obsługiwana"") ||
t === ""Rozdzielczość wideo nie jest obsługiwana"") {
node.nodeValue = ""Black Bar Removal (Server Mode)"";
}
}
}
});
}
}
});
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
setInterval(() => {
const video = document.querySelector('video');
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
currentItemId = interceptedItemId;
console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId);
fetchCrop(currentItemId);
checkServerCrop(currentItemId);
} else if (!video) {
currentItemId = null;
window._isServerCropping = false;
}
}, 2000);
})();
";
return Content(js, "application/javascript");
+19 -21
View File
@@ -47,31 +47,29 @@
pluginUniqueId: "12345678-1234-1234-1234-1234567890ab"
};
$('.blackBarRemoverConfigForm').on('pageinit', function (event) {
$('.pluginConfigurationPage').on('pageshow', function (event) {
var page = event.target;
page.addEventListener('viewshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled || false);
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.15);
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both");
Dashboard.hideLoadingMsg();
});
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled !== false);
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.094);
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both");
Dashboard.hideLoadingMsg();
});
});
$('.blackBarRemoverConfigForm').on('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
$('.blackBarRemoverConfigForm').on('submit', function (event) {
event.preventDefault();
var form = event.target;
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
config.IsEnabled = $('#chkEnablePlugin', form).prop('checked');
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
config.PlaybackMode = $('#selectPlaybackMode', form).val();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
config.IsEnabled = $('#chkEnablePlugin', page).prop('checked');
config.CropDetectLimit = parseFloat($('#txtCropLimit', page).val());
config.PlaybackMode = $('#selectPlaybackMode', page).val();
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
});
+8 -1
View File
@@ -9,6 +9,13 @@ namespace JellyfinPlugin
{
public static class CropHelper
{
private static string GetFFmpegPath()
{
if (File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")) return "/usr/lib/jellyfin-ffmpeg/ffmpeg";
if (File.Exists("/usr/bin/ffmpeg")) return "/usr/bin/ffmpeg";
return "ffmpeg";
}
public static async Task<string> DetectCropAsync(string filePath, double limit)
{
// FFmpeg w nowych wersjach przyjmuje limit w formie ułamka, np. 24/255 to ok 0.094
@@ -17,7 +24,7 @@ namespace JellyfinPlugin
var startInfo = new ProcessStartInfo
{
FileName = "ffmpeg",
FileName = GetFFmpegPath(),
// KLUCZOWE: reset=1, żeby oceniał każdą klatkę niezależnie!
Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=1\" -f null -",
RedirectStandardError = true,
-92
View File
@@ -1,92 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
namespace JellyfinPlugin
{
public class HtmlPatcher : IHostedService
{
private readonly IApplicationPaths _appPaths;
private readonly ILogger<HtmlPatcher> _logger;
public HtmlPatcher(IApplicationPaths appPaths, ILogger<HtmlPatcher> logger)
{
_appPaths = appPaths;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
PatchHtml();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private void PatchHtml()
{
try
{
// Znajdujemy ścieżkę do web UI Jellyfina.
// W większości instalacji linuksowych (np. Debian/Ubuntu) to:
string[] possiblePaths = {
Path.Combine(_appPaths.ProgramDataPath, "web"),
"/usr/share/jellyfin/web",
"/jellyfin/jellyfin-web", // Docker
};
string webPath = string.Empty;
foreach (var path in possiblePaths)
{
if (Directory.Exists(path) && File.Exists(Path.Combine(path, "index.html")))
{
webPath = path;
break;
}
}
if (string.IsNullOrEmpty(webPath))
{
_logger.LogWarning("Failed to find Jellyfin Web directory. Manual installation of the client script is required.");
return;
}
string indexPath = Path.Combine(webPath, "index.html");
string indexContent = File.ReadAllText(indexPath);
string scriptTag = "<script src=\"/BlackBarRemover/ClientScript.js\"></script>";
if (!indexContent.Contains("BlackBarRemover/ClientScript.js"))
{
indexContent = indexContent.Replace("</body>", $" {scriptTag}\n</body>");
File.WriteAllText(indexPath, indexContent);
_logger.LogInformation("Successfully injected BlackBarRemover script into index.html at {Path}", indexPath);
}
else
{
_logger.LogInformation("BlackBarRemover client script is already injected into index.html");
}
}
catch (UnauthorizedAccessException)
{
_logger.LogWarning("Permission denied to modify index.html. Change Jellyfin Web file permissions or add the script manually in Custom CSS.");
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while injecting the script into index.html.");
}
}
public void Dispose()
{
}
}
}
+17 -1
View File
@@ -23,6 +23,8 @@ namespace JellyfinPlugin
private readonly ILogger<LibraryMonitor> _logger;
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2);
private readonly ConcurrentDictionary<Guid, byte> _processedItems = new ConcurrentDictionary<Guid, byte>();
public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> logger)
{
_libraryManager = libraryManager;
@@ -48,6 +50,16 @@ namespace JellyfinPlugin
{
if (!Plugin.Instance!.Configuration.IsEnabled) return;
if (_processedItems.ContainsKey(video.Id)) return;
if (video.ProviderIds != null &&
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
!string.IsNullOrEmpty(existingCrop))
{
_processedItems.TryAdd(video.Id, 0);
return;
}
// Fire and forget, don't block library scan
_ = Task.Run(() => ProcessVideoAsync(video));
}
@@ -57,7 +69,11 @@ namespace JellyfinPlugin
{
try
{
if (video.ProviderIds != null && video.ProviderIds.ContainsKey("BlackBarCrop"))
if (!_processedItems.TryAdd(video.Id, 0)) return;
if (video.ProviderIds != null &&
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
!string.IsNullOrEmpty(existingCrop))
{
return;
}
@@ -0,0 +1,151 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace JellyfinPlugin
{
public class PlaybackInfoInterceptorStartupFilter : IStartupFilter
{
private readonly ILogger<PlaybackInfoInterceptorStartupFilter> _logger;
private readonly MediaBrowser.Controller.Library.ILibraryManager _libraryManager;
public PlaybackInfoInterceptorStartupFilter(ILogger<PlaybackInfoInterceptorStartupFilter> logger, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
}
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(InvokeAsync);
next(app);
};
}
private string? GetCropFromId(string itemId)
{
if (Guid.TryParse(itemId, out Guid id))
{
var item = _libraryManager.GetItemById(id);
if (item != null && item.ProviderIds != null)
{
if (item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
{
return cropData;
}
}
}
return null;
}
private async Task InvokeAsync(HttpContext context, Func<Task> nextMw)
{
var path = context.Request.Path.Value ?? string.Empty;
var config = Plugin.Instance?.Configuration;
if (config == null || !config.IsEnabled || config.PlaybackMode == "Client")
{
await nextMw().ConfigureAwait(false);
return;
}
// --- HLS STREAM QUERY REWRITE ---
// If the client requests the m3u8 playlist, we simply tell Jellyfin NOT to allow video stream copy.
// This elegantly forces transcoding for the BEST supported codec without breaking FFmpeg tags/filters!
if (path.Contains("/Videos/", StringComparison.OrdinalIgnoreCase) &&
(path.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) || path.Contains("/stream", StringComparison.OrdinalIgnoreCase)))
{
var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase);
if (matchId.Success)
{
var idStr = matchId.Groups[1].Value;
var crop = GetCropFromId(idStr);
if (crop != null)
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(context.Request.QueryString.Value);
var items = query.SelectMany(x => x.Value, (col, val) => new System.Collections.Generic.KeyValuePair<string, string>(col.Key, val)).ToList();
var newQueryString = "?";
foreach (var kvp in items)
{
if (!kvp.Key.Equals("allowVideoStreamCopy", StringComparison.OrdinalIgnoreCase) &&
!kvp.Key.Equals("transcodeReasons", StringComparison.OrdinalIgnoreCase))
{
newQueryString += $"{kvp.Key}={System.Net.WebUtility.UrlEncode(kvp.Value)}&";
}
}
// Force disable stream copy AND provide a valid reason for the dashboard
newQueryString += "allowVideoStreamCopy=false&transcodeReasons=VideoResolutionNotSupported";
context.Request.QueryString = new QueryString(newQueryString);
_logger.LogInformation($"[Black Bar Remover DEBUG] Intercepted HLS request for {idStr}. Appended allowVideoStreamCopy=false and transcodeReasons=VideoResolutionNotSupported.");
}
}
}
var match = Regex.Match(path, @"/Items/([^/]+)/PlaybackInfo", RegexOptions.IgnoreCase);
if (!match.Success)
{
await nextMw().ConfigureAwait(false);
return;
}
var itemId = match.Groups[1].Value;
var cropData = GetCropFromId(itemId);
if (cropData == null)
{
_logger.LogInformation($"[Black Bar Remover DEBUG] PlaybackInfo request for {itemId}, but no crop found in DB. Skipping interception.");
await nextMw().ConfigureAwait(false);
return;
}
_logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo request for {itemId}. Found crop in DB: {cropData}. Disabling DirectPlayProfiles in incoming request.");
// Modify incoming request body to clear DirectPlayProfiles
context.Request.EnableBuffering();
string reqJson = string.Empty;
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true))
{
reqJson = await reader.ReadToEndAsync().ConfigureAwait(false);
}
JsonNode rootNode;
try
{
rootNode = string.IsNullOrWhiteSpace(reqJson) ? new JsonObject() : (JsonNode.Parse(reqJson) ?? new JsonObject());
}
catch
{
rootNode = new JsonObject();
}
if (rootNode["DeviceProfile"] is not JsonObject devProfile)
{
devProfile = new JsonObject();
rootNode["DeviceProfile"] = devProfile;
}
devProfile["DirectPlayProfiles"] = new JsonArray();
var newReqBytes = Encoding.UTF8.GetBytes(rootNode.ToJsonString());
var reqMs = new MemoryStream(newReqBytes);
context.Request.Body = reqMs;
context.Request.ContentLength = newReqBytes.Length;
await nextMw().ConfigureAwait(false);
}
}
}
+5 -2
View File
@@ -8,8 +8,11 @@ namespace JellyfinPlugin
{
public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost)
{
// Rejestracja serwisów tła
serviceCollection.AddHostedService<HtmlPatcher>();
// Zarejestrowanie filtru wstrzykującego nasz skrypt kliencki
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, ScriptInjectionStartupFilter>();
// Zarejestrowanie filtru wymuszającego transkodowanie dla Server Mode
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, PlaybackInfoInterceptorStartupFilter>();
serviceCollection.AddHostedService<WrapperGenerator>();
serviceCollection.AddHostedService<LibraryMonitor>();
@@ -0,0 +1,125 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace JellyfinPlugin
{
public class ScriptInjectionStartupFilter : IStartupFilter
{
private readonly ILogger<ScriptInjectionStartupFilter> _logger;
private int _loggedOnce;
public ScriptInjectionStartupFilter(ILogger<ScriptInjectionStartupFilter> logger)
{
_logger = logger;
}
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(InvokeAsync);
next(app);
};
}
private async Task InvokeAsync(HttpContext context, Func<Task> nextMw)
{
if (!IsIndexRequest(context.Request.Path.Value))
{
await nextMw().ConfigureAwait(false);
return;
}
if (!HttpMethods.IsGet(context.Request.Method))
{
await nextMw().ConfigureAwait(false);
return;
}
var config = Plugin.Instance?.Configuration;
if (config == null || !config.IsEnabled)
{
await nextMw().ConfigureAwait(false);
return;
}
context.Request.Headers.Remove("Accept-Encoding");
context.Request.Headers.Remove("Range");
context.Request.Headers.Remove("If-Range");
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await nextMw().ConfigureAwait(false);
}
catch
{
context.Response.Body = originalBody;
throw;
}
context.Response.Body = originalBody;
buffer.Seek(0, SeekOrigin.Begin);
var isHtml = context.Response.StatusCode == 200
&& (context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false);
if (!isHtml)
{
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
return;
}
string html;
using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true))
{
html = await reader.ReadToEndAsync().ConfigureAwait(false);
}
try
{
var alreadyInjected = html.IndexOf("BlackBarRemover/ClientScript.js", StringComparison.OrdinalIgnoreCase) >= 0;
var bodyClose = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
if (!alreadyInjected && bodyClose >= 0)
{
var tag = "<script src=\"/BlackBarRemover/ClientScript.js\"></script>";
html = html.Substring(0, bodyClose) + tag + "\n" + html.Substring(bodyClose);
if (System.Threading.Interlocked.Exchange(ref _loggedOnce, 1) == 0)
{
_logger.LogInformation("Black Bar Remover: injected the client script via request-time middleware (IStartupFilter).");
}
}
}
catch (Exception ex)
{
_logger.LogWarning($"Script injection middleware error (serving original HTML): {ex.Message}");
}
var bytes = Encoding.UTF8.GetBytes(html);
context.Response.ContentType = "text/html;charset=utf-8";
context.Response.ContentLength = bytes.Length;
context.Response.Headers.Remove("ETag");
context.Response.Headers.Remove("Last-Modified");
context.Response.Headers.Remove("Accept-Ranges");
await originalBody.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
}
private static bool IsIndexRequest(string? path)
{
if (string.IsNullOrEmpty(path)) return false;
return path.EndsWith("/web/index.html", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/web/", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/web", StringComparison.OrdinalIgnoreCase);
}
}
}
+8 -10
View File
@@ -58,18 +58,16 @@ namespace JellyfinPlugin.Tasks
{
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
// Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku
// (puste oznacza że nie ma czarnych pasów do ucięcia)
item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
if (!string.IsNullOrEmpty(cropData))
{
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
item.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
catch (Exception ex)
{
@@ -48,7 +48,9 @@ namespace JellyfinPlugin.Tasks
{
cancellationToken.ThrowIfCancellationRequested();
if (item.ProviderIds != null && item.ProviderIds.ContainsKey("BlackBarCrop"))
if (item.ProviderIds != null &&
item.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
!string.IsNullOrEmpty(existingCrop))
{
processed++;
progress.Report((processed / (double)total) * 100);
@@ -62,18 +64,16 @@ namespace JellyfinPlugin.Tasks
{
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
// Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku
// (puste oznacza że nie ma czarnych pasów do ucięcia)
item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
if (!string.IsNullOrEmpty(cropData))
{
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
item.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
catch (Exception ex)
{
+107 -46
View File
@@ -44,80 +44,141 @@ namespace JellyfinPlugin
return;
}
// Pobieramy obecną konfigurację kodowania
var encodingConfig = _configManager.GetConfiguration("encoding");
if (encodingConfig == null) return;
// Przygotowujemy zamianę pliku ffmpeg bezpośrednio w /usr/lib/jellyfin-ffmpeg/ffmpeg
string defaultFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
string origFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg.original";
string realFfmpegPath = origFfmpeg;
// Używamy dynamic, aby ominąć potrzebę rzutowania na konkretny, wewnętrzny typ Jellyfin (EncodingOptions)
dynamic dynConfig = encodingConfig;
string currentFfmpegPath = dynConfig.EncoderAppPath ?? "/usr/lib/jellyfin-ffmpeg/ffmpeg";
if (!File.Exists(origFfmpeg))
{
if (File.Exists(defaultFfmpeg))
{
try
{
File.Move(defaultFfmpeg, origFfmpeg);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nie udało się przenieść {DefaultFfmpeg} do {OrigFfmpeg}", defaultFfmpeg, origFfmpeg);
realFfmpegPath = defaultFfmpeg;
}
}
else if (File.Exists("/usr/bin/ffmpeg"))
{
realFfmpegPath = "/usr/bin/ffmpeg";
}
}
string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover");
Directory.CreateDirectory(wrapperDir);
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh");
// Jeśli obecna ścieżka to już nasz wrapper, nie robimy pętli w nieskończoność.
// Znajdujemy poprzednio zapisaną prawdziwą ścieżkę z pliku.
string realFfmpegPath = currentFfmpegPath;
if (currentFfmpegPath == wrapperPath)
{
if (File.Exists(wrapperPath))
{
var lines = File.ReadAllLines(wrapperPath);
foreach (var line in lines)
{
if (line.StartsWith("REAL_FFMPEG="))
{
realFfmpegPath = line.Split('=')[1].Trim('"');
break;
}
}
}
if (realFfmpegPath == wrapperPath) realFfmpegPath = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
}
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg");
string script = $@"#!/bin/bash
# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina
REAL_FFMPEG=""{realFfmpegPath}""
# Znajdujemy ścieżkę pliku wejściowego (argument po -i)
INPUT_FILE=""""
for i in ""${{!@}}""; do
if [[ ""${{@:$i:1}}"" == ""-i"" ]]; then
INPUT_FILE=""${{@:$i+1:1}}""
for (( i=1; i<=$#; i++ )); do
arg=""${{!i}}""
if [[ ""$arg"" == ""-i"" ]]; then
let next_idx=i+1
if [ $next_idx -le $# ]; then
INPUT_FILE=""${{!next_idx}}""
fi
break
fi
done
# Pobieramy crop z API naszej wtyczki
if [ -n ""$INPUT_FILE"" ]; then
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$INPUT_FILE"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
CLEAN_PATH=$(echo ""$INPUT_FILE"" | tr -d '""' | sed 's/^file://')
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$CLEAN_PATH"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
if [ -n ""$CROP_DATA"" ] && [ ""$CROP_DATA"" != """" ]; then
exec ""$REAL_FFMPEG"" ""$@"" -vf ""crop=$CROP_DATA""
# Modyfikujemy argumenty w locie
declare -a NEW_ARGS
HAS_VF=0
IS_COPY=0
for (( i=1; i<=$#; i++ )); do
arg=""${{!i}}""
if [[ ""$arg"" == ""copy"" ]]; then
let prev_idx=i-1
prev_arg=""""
if [ $prev_idx -gt 0 ]; then
prev_arg=""${{!prev_idx}}""
fi
if [[ ""$prev_arg"" == *codec:v* ]] || [[ ""$prev_arg"" == *-c:v* ]] || [[ ""$prev_arg"" == *-vcodec* ]]; then
IS_COPY=1
fi
fi
if [[ ""$arg"" == ""-vf"" ]]; then
HAS_VF=1
NEW_ARGS+=(""-vf"")
next_arg=""""
let next_idx=i+1
if [ $next_idx -le $# ]; then
next_arg=""${{!next_idx}}""
fi
NEW_ARGS+=(""$next_arg,crop=$CROP_DATA"")
let i++
continue
fi
NEW_ARGS+=(""$arg"")
done
# Jeżeli Jellyfin nadal upiera się przy copy, nie możemy wstrzyknąć filtra crop (bo zcrashuje FFmpega)
if [ $IS_COPY -eq 1 ]; then
exec ""$REAL_FFMPEG"" ""$@""
exit 0
fi
if [ $HAS_VF -eq 0 ]; then
NEW_ARGS+=(""-vf"")
NEW_ARGS+=(""crop=$CROP_DATA"")
fi
exec ""$REAL_FFMPEG"" ""${{NEW_ARGS[@]}}""
exit 0
fi
fi
# Zwykłe uruchomienie bez crop
exec ""$REAL_FFMPEG"" ""$@""
";
File.WriteAllText(wrapperPath, script);
if (Environment.OSVersion.Platform == PlatformID.Unix)
if (File.Exists(origFfmpeg) || defaultFfmpeg != origFfmpeg)
{
System.Diagnostics.Process.Start("chmod", $"+x \"{wrapperPath}\"");
try
{
File.WriteAllText(defaultFfmpeg, script);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nie udało się zapisać skryptu wrappera do {DefaultFfmpeg}", defaultFfmpeg);
}
}
_logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path}", wrapperPath);
// Teraz automagicznie podmieniamy ścieżkę w Jellyfin!
if (dynConfig.EncoderAppPath != wrapperPath)
if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || OperatingSystem.IsMacOS())
{
dynConfig.EncoderAppPath = wrapperPath;
_configManager.SaveConfiguration("encoding", encodingConfig);
_logger.LogInformation("Automatycznie zmieniono ścieżkę FFmpeg w konfiguracji Jellyfin na wrapper wtyczki.");
string[] pathsToChmod = new[] { wrapperPath, defaultFfmpeg };
foreach (var path in pathsToChmod)
{
if (!File.Exists(path)) continue;
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
catch
{
var proc = System.Diagnostics.Process.Start("chmod", $"+x \"{path}\"");
proc?.WaitForExit();
}
}
}
_logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path} oraz {DefaultFfmpeg}", wrapperPath, defaultFfmpeg);
}
catch (Exception ex)
{