Initial commit: Jellyfin Black Bar Remover MVP
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JellyfinPlugin.Api
|
||||
{
|
||||
[ApiController]
|
||||
[Route("BlackBarRemover")]
|
||||
public class BlackBarController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<BlackBarController> _logger;
|
||||
|
||||
public BlackBarController(ILibraryManager libraryManager, ILogger<BlackBarController> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("CropByPath")]
|
||||
public ActionResult<string> GetCropByPath([FromQuery] string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return Ok(string.Empty);
|
||||
|
||||
var mode = Plugin.Instance!.Configuration.PlaybackMode;
|
||||
if (mode == "Client") return Ok(string.Empty);
|
||||
|
||||
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 Ok(string.Empty);
|
||||
}
|
||||
|
||||
[HttpGet("CropById")]
|
||||
public ActionResult<string> GetCropById([FromQuery] string itemId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return Ok(string.Empty);
|
||||
|
||||
var mode = Plugin.Instance!.Configuration.PlaybackMode;
|
||||
if (mode == "Server") return Ok(string.Empty);
|
||||
|
||||
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 Ok(string.Empty);
|
||||
}
|
||||
|
||||
[HttpGet("ClientScript.js")]
|
||||
[Produces("application/javascript")]
|
||||
public ContentResult GetClientScript()
|
||||
{
|
||||
string js = @"
|
||||
(function() {
|
||||
console.log('Black Bar Remover: Script loaded');
|
||||
let currentVideoElement = null;
|
||||
|
||||
function applyCrop(cropData) {
|
||||
if (!currentVideoElement) return;
|
||||
const parts = cropData.split(':');
|
||||
if (parts.length === 4) {
|
||||
const w = parseInt(parts[0]);
|
||||
const h = parseInt(parts[1]);
|
||||
const x = parseInt(parts[2]);
|
||||
const y = parseInt(parts[3]);
|
||||
|
||||
const topPercent = (y / 1080) * 100;
|
||||
const bottomPercent = ((1080 - (y + h)) / 1080) * 100;
|
||||
const leftPercent = (x / 1920) * 100;
|
||||
const rightPercent = ((1920 - (x + w)) / 1920) * 100;
|
||||
|
||||
currentVideoElement.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})`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(checkPlayback, 2000);
|
||||
})();
|
||||
";
|
||||
return Content(js, "application/javascript");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace JellyfinPlugin.Configuration
|
||||
{
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
// Tolerancja detekcji czarnych pasów (domyślnie 0.15 czyli 15%)
|
||||
public double CropDetectLimit { get; set; } = 0.15;
|
||||
|
||||
// Czy wtyczka jest aktywna
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
// Tryb odtwarzania: "Server" (FFmpeg wrapper), "Client" (CSS injection w Web UI), "Both" (Obydwa naraz)
|
||||
public string PlaybackMode { get; set; } = "Both";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Black Bar Remover</title>
|
||||
</head>
|
||||
<body>
|
||||
<div 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 class="blackBarRemoverConfigForm">
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkEnablePlugin" />
|
||||
<span>Włącz wykrywanie czarnych pasów</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Włącza automatyczne skanowanie nowych i zaktualizowanych materiałów wideo w bibliotece.</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Tolerancja detekcji czarnych pasów (0.01 - 1.0):</label>
|
||||
<input type="number" step="0.01" min="0.01" max="1.0" id="txtCropLimit" is="emby-input" required />
|
||||
<div class="fieldDescription">Domyślnie 0.15. Zwiększ, jeśli czarne pasy nie są wykrywane ze względu na szum wideo (np. dla plików WEBDL zmień na 0.2).</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="selectPlaybackMode">Sposób usuwania pasów w odtwarzaczu:</label>
|
||||
<select is="emby-select" id="selectPlaybackMode" class="emby-select-withcolor">
|
||||
<option value="Server">Tylko Serwer (Transkodowanie FFmpeg)</option>
|
||||
<option value="Client">Tylko Klient (CSS Clip w Jellyfin Web)</option>
|
||||
<option value="Both">Oba (Transkodowanie lub CSS, zależy od klienta)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">Wybierz, w jaki sposób wtyczka ma usuwać pasy. Opcja serwerowa wymusza transkodowanie (obciążenie CPU/GPU), opcja kliencka jest lżejsza, ale działa tylko w przeglądarce.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Zapisz</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var BlackBarRemoverConfig = {
|
||||
pluginUniqueId: "12345678-1234-1234-1234-1234567890ab"
|
||||
};
|
||||
|
||||
$('.blackBarRemoverConfigForm').on('pageinit', 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();
|
||||
});
|
||||
});
|
||||
|
||||
$('.blackBarRemoverConfigForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
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("Nie udało się znaleźć katalogu Jellyfin Web. Ręczna instalacja skryptu klienckiego będzie wymagana.");
|
||||
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"))
|
||||
{
|
||||
// Wstrzykujemy skrypt tuż przed końcem taga </body>
|
||||
indexContent = indexContent.Replace("</body>", $" {scriptTag}\n</body>");
|
||||
File.WriteAllText(indexPath, indexContent);
|
||||
_logger.LogInformation("Pomyślnie wstrzyknięto skrypt BlackBarRemover do index.html w {Path}", indexPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skrypt kliencki BlackBarRemover jest już wstrzyknięty w index.html");
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning("Brak uprawnień do modyfikacji index.html. Zmodyfikuj uprawnienia plików Jellyfin Web lub dodaj skrypt ręcznie w Custom CSS.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Wystąpił błąd podczas wstrzykiwania skryptu do index.html.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Zależności Jellyfin -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class LibraryMonitor : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryMonitor> _logger;
|
||||
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2);
|
||||
|
||||
public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded += OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemUpdated += OnItemAddedOrUpdated;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e)
|
||||
{
|
||||
if (e.Item is Video video && (video is Movie || video is Episode))
|
||||
{
|
||||
if (!Plugin.Instance!.Configuration.IsEnabled) return;
|
||||
|
||||
// Fire and forget, don't block library scan
|
||||
_ = Task.Run(() => ProcessVideoAsync(video));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessVideoAsync(Video video)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (video.ProviderIds != null && video.ProviderIds.ContainsKey("BlackBarCrop"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = video.Path;
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
|
||||
|
||||
await _ffmpegSemaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
string cropData = await DetectCropAsync(path);
|
||||
if (!string.IsNullOrEmpty(cropData))
|
||||
{
|
||||
if (video.ProviderIds == null)
|
||||
{
|
||||
video.ProviderIds = new System.Collections.Generic.Dictionary<string, string>();
|
||||
}
|
||||
video.ProviderIds["BlackBarCrop"] = cropData;
|
||||
// Jellyfin 12.0 signature requires passing item, item, type, cancellationToken
|
||||
await _libraryManager.UpdateItemAsync(video, video, ItemUpdateType.MetadataEdit, CancellationToken.None);
|
||||
_logger.LogInformation("Zapisano proporcje crop {CropData} dla pliku {Path}", cropData, path);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ffmpegSemaphore.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Błąd podczas procesowania wideo: {Path}", video.Path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> DetectCropAsync(string filePath)
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
double limit = config.CropDetectLimit;
|
||||
string limitStr = limit.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=0\" -f null -",
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
|
||||
string output = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
var regex = new Regex(@"crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)");
|
||||
var matches = regex.Matches(output);
|
||||
|
||||
var crops = new ConcurrentDictionary<string, int>();
|
||||
int totalFrames = 0;
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
string cropStr = match.Value.Replace("crop=", "");
|
||||
crops.AddOrUpdate(cropStr, 1, (_, count) => count + 1);
|
||||
totalFrames++;
|
||||
}
|
||||
|
||||
if (totalFrames == 0) return string.Empty;
|
||||
|
||||
var validCrops = crops.Where(kvp => (double)kvp.Value / totalFrames > 0.05).ToList();
|
||||
if (!validCrops.Any())
|
||||
{
|
||||
validCrops = crops.OrderByDescending(kvp => kvp.Value).Take(1).ToList();
|
||||
}
|
||||
|
||||
int minX = int.MaxValue, minY = int.MaxValue, maxXW = 0, maxYH = 0;
|
||||
|
||||
foreach (var kvp in validCrops)
|
||||
{
|
||||
var parts = kvp.Key.Split(':');
|
||||
if (parts.Length == 4 &&
|
||||
int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h) &&
|
||||
int.TryParse(parts[2], out int x) && int.TryParse(parts[3], out int y))
|
||||
{
|
||||
minX = Math.Min(minX, x);
|
||||
minY = Math.Min(minY, y);
|
||||
maxXW = Math.Max(maxXW, x + w);
|
||||
maxYH = Math.Max(maxYH, y + h);
|
||||
}
|
||||
}
|
||||
|
||||
int finalW = maxXW - minX;
|
||||
int finalH = maxYH - minY;
|
||||
|
||||
if (finalW <= 0 || finalH <= 0) return string.Empty;
|
||||
|
||||
// Sprawdzanie czy faktycznie coś wycięto w pionie lub poziomie.
|
||||
// Zazwyczaj filmy 1080p mają h=1080.
|
||||
// Możemy zwrócić pusty string, jeśli ucięcie jest minimalne (np. mniej niż 10 px z każdej strony).
|
||||
// To zapobiega wymuszaniu transkodowania z byle powodu.
|
||||
|
||||
return $"{finalW}:{finalH}:{minX}:{minY}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnItemAddedOrUpdated;
|
||||
_libraryManager.ItemUpdated -= OnItemAddedOrUpdated;
|
||||
_ffmpegSemaphore.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using JellyfinPlugin.Configuration;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
public override string Name => "Black Bar Remover";
|
||||
public override Guid Id => Guid.Parse("12345678-1234-1234-1234-1234567890ab");
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = this.Name,
|
||||
EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class WrapperGenerator : IHostedService
|
||||
{
|
||||
private readonly ILogger<WrapperGenerator> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
public WrapperGenerator(ILogger<WrapperGenerator> logger, IApplicationPaths appPaths)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
GenerateWrapper();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void GenerateWrapper()
|
||||
{
|
||||
try
|
||||
{
|
||||
string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover");
|
||||
Directory.CreateDirectory(wrapperDir);
|
||||
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh");
|
||||
|
||||
string script = @"#!/bin/bash
|
||||
# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina (zmień jeśli trzeba)
|
||||
REAL_FFMPEG=""/usr/lib/jellyfin-ffmpeg/ffmpeg""
|
||||
|
||||
# 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}""
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Pobieramy crop z API naszej wtyczki
|
||||
if [ -n ""$INPUT_FILE"" ]; then
|
||||
# Jellyfin lokalnie słucha na 8096, odpytujemy API o crop
|
||||
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$INPUT_FILE"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
|
||||
|
||||
if [ -n ""$CROP_DATA"" ] && [ ""$CROP_DATA"" != """" ]; then
|
||||
# Znaleziono crop! Wstrzykujemy filtr crop=W:H:X:Y do argumentów
|
||||
# Dodajemy -vf crop przed argumentami wyjścia
|
||||
NEW_ARGS=()
|
||||
for arg in ""$@""; do
|
||||
# Prosta iniekcja, w realnym środowisku szukamy gdzie wkleić -vf, zazwyczaj przed plikiem wyjściowym.
|
||||
# Jellyfin sprzętowy tworzy filtry z -filter_complex, w softwareowym z -vf.
|
||||
NEW_ARGS+=(""$arg"")
|
||||
done
|
||||
|
||||
# Uruchamiamy ffmpeg z crop
|
||||
# UWAGA: to jest uproszczenie. Prawdziwy wrapper IntroSkipper musi łączyć filtry.
|
||||
# W tym MVP dodajemy prosty filter:
|
||||
exec ""$REAL_FFMPEG"" ""$@"" -vf ""crop=$CROP_DATA""
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Zwykłe uruchomienie bez crop
|
||||
exec ""$REAL_FFMPEG"" ""$@""
|
||||
";
|
||||
File.WriteAllText(wrapperPath, script);
|
||||
|
||||
// Nadajemy uprawnienia do wykonywania w systemach Linux (używając shella)
|
||||
if (Environment.OSVersion.Platform == PlatformID.Unix)
|
||||
{
|
||||
System.Diagnostics.Process.Start("chmod", $"+x \"{wrapperPath}\"");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path}", wrapperPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Błąd podczas generowania skryptu FFmpeg wrapper.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user