Compare commits

...

43 Commits

Author SHA1 Message Date
karol efd0f14278 feat: add configurable multi-threaded parallel scanning for library tasks
Build and Release Plugin / release (push) Successful in 33s
2026-09-18 00:14:20 +02:00
karol d88f910b58 fix: update client-side subtitle positioning logic and vertical offset alignment
Build and Release Plugin / release (push) Successful in 33s
2026-09-17 23:45:19 +02:00
karol 512dabc611 fix: intercept PlaybackInfo API response in ClientScript.js to detect transcode state and prevent CSS over-zoom 2026-09-17 23:03:18 +02:00
karol 78e100c99e fix: place crop filter at start of FFmpeg vf chain to support downscaling/low bitrate 2026-09-17 22:57:53 +02:00
karol b2885aa163 docs: update default CropDetectLimit in README to 0.094 2026-09-17 22:41:59 +02:00
karol 73f4bb488a docs: clarify multi-targeting (.NET 9 and .NET 10) in README build instructions 2026-09-17 22:40:11 +02:00
karol 9042643383 docs: update README with tested versions (Jellyfin 10.11 and 12.0/13.0 unstable) 2026-09-17 22:18:48 +02:00
Gitea Actions 739c96c376 Update manifest.json for v0.1.19-beta 2026-09-17 21:57:09 +02:00
karol 14831c6cd5 fix: extract asset name variables to avoid f-string quote escaping error in inline python script
Build and Release Plugin / release (push) Successful in 34s
2026-09-17 21:56:15 +02:00
karol 5cdde631e1 fix: build complete manifest version history dynamically from Gitea releases API 2026-09-17 21:54:03 +02:00
Gitea Actions 83f3c2975a Update manifest.json for v0.1.17-beta 2026-09-17 21:33:27 +02:00
karol 2d0af2aed0 fix: quote strftime format string in release workflow python script
Build and Release Plugin / release (push) Successful in 33s
2026-09-17 21:32:32 +02:00
karol 8b8b9a6d43 fix: refine python release workflow script with dynamic timestamp
Build and Release Plugin / release (push) Failing after 51s
2026-09-17 21:29:50 +02:00
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
karol 9159d8f8d7 Add Force Rescan All Black Bars scheduled task
Build and Release Plugin / release (push) Failing after 17s
2026-09-17 15:45:57 +02:00
karol a048a91f23 Fix input step validation in configPage.html 2026-09-17 15:42:33 +02:00
karol aa8fb54e8f Translate logs in LibraryMonitor to English 2026-09-17 15:40:51 +02:00
karol 24a85cf0ec Fix crop bounding box and limit defaults, setting reset=1 2026-09-17 15:35:25 +02:00
karol e826801482 Fix crop bounding box logic to use most frequent crop (mode) instead of bounding box 2026-09-17 15:27:29 +02:00
karol 6cc61baeda Fix assembly conflicts by removing CopyLocalLockFileAssemblies and setting ExcludeAssets 2026-09-17 15:22:46 +02:00
karol 5aff92d296 Add PluginServiceRegistrator for DI and translate logs to English 2026-09-17 15:18:45 +02:00
karol 18b4a71a2f Support multiple Jellyfin versions (10.11 and 12.0) via multi-targeting 2026-09-17 15:11:54 +02:00
karol b46e5e1786 Downgrade plugin to .NET 9 to support Jellyfin 10.11 2026-09-17 15:10:01 +02:00
16 changed files with 1401 additions and 312 deletions
+210 -58
View File
@@ -17,59 +17,196 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '10.0.x'
dotnet-version: |
9.0.x
10.0.x
- name: Build Plugin
run: dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
- name: Create ZIP Archive
- name: Create ZIP Archives
run: |
cd JellyfinPlugin/bin/Release/net10.0/
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}.zip *
cd JellyfinPlugin/bin/Release/
# Zip net9.0 (Jellyfin 10.11)
cd net9.0
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}_jf10.11.zip *
cd ..
# Zip net10.0 (Jellyfin 12.0)
cd net10.0
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}_jf12.0.zip *
cd ..
- name: Generate Checksum
- name: Generate Checksums
id: checksum
run: |
MD5_SUM=$(md5sum BlackBarRemover_${{ github.ref_name }}.zip | awk '{print $1}')
echo "md5=$MD5_SUM" >> $GITHUB_OUTPUT
MD5_SUM_9=$(md5sum BlackBarRemover_${{ github.ref_name }}_jf10.11.zip | awk '{print $1}')
MD5_SUM_10=$(md5sum BlackBarRemover_${{ github.ref_name }}_jf12.0.zip | awk '{print $1}')
echo "md5_9=$MD5_SUM_9" >> $GITHUB_OUTPUT
echo "md5_10=$MD5_SUM_10" >> $GITHUB_OUTPUT
- name: Create Gitea Release
- name: Publish Release and Update Manifest
env:
API_URL: ${{ github.api_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
MD5_9: ${{ steps.checksum.outputs.md5_9 }}
MD5_10: ${{ steps.checksum.outputs.md5_10 }}
run: |
API_URL="${{ github.api_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
TAG="${{ github.ref_name }}"
IS_PRERELEASE="false"
if [[ "$TAG" == *"beta"* ]] || [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == 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}")
REL_ID=$(echo "$RELEASE_RES" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
# Upload Asset
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@BlackBarRemover_$TAG.zip"
python3 -c '
import os, json, urllib.request, base64, datetime
- name: Update manifest.json
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
git checkout -B manifest
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}" # remove 'v' prefix
cat > manifest.json <<EOF
[
api_url = os.environ["API_URL"]
repo = os.environ["REPO"]
token = os.environ["TOKEN"]
tag = os.environ["TAG"]
md5_9 = os.environ["MD5_9"]
md5_10 = os.environ["MD5_10"]
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json"
}
# 1. Get or Create Release
rel_url = f"{api_url}/repos/{repo}/releases/tags/{tag}"
req = urllib.request.Request(rel_url, headers=headers)
rel_data = None
try:
res = urllib.request.urlopen(req)
rel_data = json.loads(res.read().decode("utf-8"))
except Exception:
pass
if not rel_data or "id" not in rel_data:
is_pre = "beta" in tag or "alpha" in tag or tag.startswith("v0.")
create_url = f"{api_url}/repos/{repo}/releases"
body = json.dumps({
"tag_name": tag,
"name": f"Release {tag}",
"body": "Automated release",
"draft": False,
"prerelease": is_pre
}).encode("utf-8")
create_req = urllib.request.Request(create_url, data=body, headers=headers)
res = urllib.request.urlopen(create_req)
rel_data = json.loads(res.read().decode("utf-8"))
rel_id = rel_data["id"]
# 2. Upload Assets
for filename in [f"BlackBarRemover_{tag}_jf10.11.zip", f"BlackBarRemover_{tag}_jf12.0.zip"]:
if not os.path.exists(filename):
print(f"File {filename} not found, skipping.")
continue
# Delete existing asset if any
for existing_asset in rel_data.get("assets", []) or []:
if existing_asset.get("name") == filename:
del_url = f"{api_url}/repos/{repo}/releases/{rel_id}/assets/{existing_asset['id']}"
del_req = urllib.request.Request(del_url, headers=headers, method="DELETE")
try:
urllib.request.urlopen(del_req)
print(f"Deleted old asset {existing_asset['id']}")
except Exception as e:
print(f"Delete asset notice: {e}")
# Upload asset
with open(filename, "rb") as f:
file_bytes = f.read()
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
body_data = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{filename}\"\r\n"
f"Content-Type: application/zip\r\n\r\n"
).encode("utf-8") + file_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
upload_headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}"
}
upload_url = f"{api_url}/repos/{repo}/releases/{rel_id}/assets"
upload_req = urllib.request.Request(upload_url, data=body_data, headers=upload_headers)
try:
urllib.request.urlopen(upload_req)
print(f"Uploaded {filename} successfully.")
except Exception as e:
print(f"Asset upload error: {e}")
# 3. Build manifest.json dynamically from Gitea Releases API
rel_list_req = urllib.request.Request(f"{api_url}/repos/{repo}/releases?per_page=100", headers=headers)
all_releases = []
try:
res_list = urllib.request.urlopen(rel_list_req)
all_releases = json.loads(res_list.read().decode("utf-8"))
except Exception as e:
print(f"Notice fetching releases list: {e}")
all_versions = []
for r in all_releases:
rel_tag = r.get("tag_name")
if not rel_tag:
continue
clean_v = rel_tag.lstrip("v").split("-")[0]
ts = r.get("published_at") or r.get("created_at") or "2026-09-17T20:00:00Z"
assets = r.get("assets", [])
if rel_tag == tag:
all_versions.append({
"version": clean_v,
"changelog": f"Release {tag} (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": f"https://gitea.labus.uk/karol/blackBarRemover/releases/download/{tag}/BlackBarRemover_{tag}_jf12.0.zip",
"checksum": md5_10,
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
})
all_versions.append({
"version": clean_v,
"changelog": f"Release {tag} (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": f"https://gitea.labus.uk/karol/blackBarRemover/releases/download/{tag}/BlackBarRemover_{tag}_jf10.11.zip",
"checksum": md5_9,
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
})
else:
a12 = next((a for a in assets if "jf12.0" in a.get("name", "")), None)
if a12:
a12_name = a12.get("name")
all_versions.append({
"version": clean_v,
"changelog": f"Release {rel_tag} (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": f"https://gitea.labus.uk/karol/blackBarRemover/releases/download/{rel_tag}/{a12_name}",
"checksum": a12.get("md5") or "",
"timestamp": ts
})
a10 = next((a for a in assets if "jf10.11" in a.get("name", "")), None)
if a10:
a10_name = a10.get("name")
all_versions.append({
"version": clean_v,
"changelog": f"Release {rel_tag} (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": f"https://gitea.labus.uk/karol/blackBarRemover/releases/download/{rel_tag}/{a10_name}",
"checksum": a10.get("md5") or "",
"timestamp": ts
})
if not a12 and not a10 and assets:
alegacy = assets[0]
leg_name = alegacy.get("name")
all_versions.append({
"version": clean_v,
"changelog": f"Release {rel_tag}",
"targetAbi": "10.9.0.0",
"sourceUrl": f"https://gitea.labus.uk/karol/blackBarRemover/releases/download/{rel_tag}/{leg_name}",
"checksum": alegacy.get("md5") or "",
"timestamp": ts
})
manifest_data = [
{
"guid": "12345678-1234-1234-1234-1234567890ab",
"name": "Black Bar Remover",
@@ -78,21 +215,36 @@ jobs:
"owner": "karol",
"category": "Playback",
"imageUrl": "",
"versions": [
{
"version": "$VERSION",
"changelog": "Automated Release ${{ github.ref_name }}",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
"checksum": "${{ steps.checksum.outputs.md5 }}",
"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 }}.zip"
}
]
"versions": all_versions
}
]
EOF
git add manifest.json
git commit -m "Update manifest for ${{ github.ref_name }}"
git push -f origin manifest
b64_manifest = base64.b64encode(json.dumps(manifest_data, indent=4).encode("utf-8")).decode("utf-8")
# Update on master and manifest branches
for target_branch in ["master", "manifest"]:
get_mf_url = f"{api_url}/repos/{repo}/contents/manifest.json?ref={target_branch}"
req_mf = urllib.request.Request(get_mf_url, headers=headers)
file_sha = None
try:
res_mf = urllib.request.urlopen(req_mf)
file_sha = json.loads(res_mf.read().decode("utf-8")).get("sha")
except Exception:
pass
put_payload = {
"content": b64_manifest,
"message": f"Update manifest.json for {tag}",
"branch": target_branch
}
if file_sha:
put_payload["sha"] = file_sha
put_url = f"{api_url}/repos/{repo}/contents/manifest.json"
put_req = urllib.request.Request(put_url, data=json.dumps(put_payload).encode("utf-8"), headers=headers, method="PUT")
try:
urllib.request.urlopen(put_req)
print(f"manifest.json updated successfully on branch {target_branch}.")
except Exception as e:
print(f"Notice updating branch {target_branch}: {e}")
'
+321 -44
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,120 @@ namespace JellyfinPlugin.Api
string js = @"
(function() {
console.log('Black Bar Remover: Script loaded');
let currentVideoElement = null;
let currentItemId = null;
let interceptedItemId = null;
let currentCropData = null;
window._currentPlayMethod = null;
window._isVideoDirect = null;
function isTranscoding() {
if (window._isVideoDirect === false) {
return true;
}
if (window._isVideoDirect === true) {
return false;
}
return false;
}
function ensureBoundingBoxHook(video) {
if (!video._origGetBoundingClientRect) {
video._origGetBoundingClientRect = video.getBoundingClientRect;
video.getBoundingClientRect = function() {
if (video._isCroppedByPlugin && video.parentElement) {
const rect = video.parentElement.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
x: rect.x !== undefined ? rect.x : rect.left,
y: rect.y !== undefined ? rect.y : rect.top,
toJSON: function() { return this; }
};
}
return video._origGetBoundingClientRect.apply(this, arguments);
};
}
}
let lastBottomPercent = 0;
function updateSubtitleStyles(bottomPercent) {
lastBottomPercent = bottomPercent;
let styleEl = document.getElementById('black-bar-remover-sub-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'black-bar-remover-sub-style';
document.head.appendChild(styleEl);
}
if (bottomPercent > 0) {
const offsetVh = (bottomPercent * 0.4 + 2.5).toFixed(2);
styleEl.textContent = `
.videoSubtitles {
display: flex !important;
flex-direction: column !important;
justify-content: flex-end !important;
transform: translateY(-${offsetVh}vh) !important;
}
.track-text-output,
.custom-subtitle-overlay,
.subtitleSyncContainer,
.videoSubtitlesContainer,
.libassjs-canvas-parent,
canvas.libassjs-canvas,
.subtitles-octopus-canvas,
.htmlvideoplayer::-webkit-media-text-track-container,
.htmlvideoplayer::-webkit-media-text-track-display {
transform: translateY(-${offsetVh}vh) !important;
}
`;
console.log('Black Bar Remover: Subtitle CSS style updated translateY(-' + offsetVh + 'vh)');
} else {
styleEl.textContent = '';
}
}
function adjustVttCues(bottomPercent) {
const video = document.querySelector('video');
if (!video || !video.textTracks) return;
const targetLine = Math.max(10, 92 - Math.round(bottomPercent * 0.4 + 2.5));
for (let i = 0; i < video.textTracks.length; i++) {
const track = video.textTracks[i];
if (track && track.cues) {
for (let j = 0; j < track.cues.length; j++) {
const cue = track.cues[j];
if (cue && !cue._pluginAdjusted) {
cue.snapToLines = false;
cue.line = targetLine;
cue._pluginAdjusted = true;
}
}
}
}
}
function applyCrop(cropData) {
if (!currentVideoElement) return;
const video = document.querySelector('video');
if (!video) return;
if (isTranscoding()) {
console.log('Black Bar Remover: Video is transcoded by server (IsVideoDirect: false), skipping CSS crop.');
video.style.position = '';
video.style.objectFit = '';
video.style.width = '';
video.style.height = '';
video.style.top = '';
video.style.left = '';
video.style.clipPath = '';
video.style.transform = '';
updateSubtitleStyles(0);
return;
}
const parts = cropData.split(':');
if (parts.length === 4) {
const w = parseInt(parts[0]);
@@ -76,44 +189,208 @@ namespace JellyfinPlugin.Api
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})`;
if (h > 0 && h < 1080) {
const heightPercent = (1080 / h) * 100;
const topPercent = -(y / h) * 100;
const bottomPercent = ((1080 - (y + h)) / 1080) * 100;
video.style.position = 'absolute';
video.style.objectFit = 'fill';
video.style.width = '100%';
video.style.height = heightPercent.toFixed(2) + '%';
video.style.top = topPercent.toFixed(2) + '%';
video.style.left = '0%';
video.style.transform = 'none';
video.style.clipPath = 'none';
if (video.parentElement) {
video.parentElement.style.overflow = 'hidden';
}
updateSubtitleStyles(bottomPercent);
adjustVttCues(bottomPercent);
console.log('Black Bar Remover: Applied non-scaling crop height:', heightPercent.toFixed(2) + '% top:', topPercent.toFixed(2) + '% subOffset:', (bottomPercent * 0.4 + 2.5).toFixed(2) + 'vh');
}
console.log('Black Bar Remover: Applied crop', cropData, 'scale', scale);
}
}
function checkPlayback() {
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) {
currentCropData = crop;
setTimeout(() => applyCrop(crop), 500);
setTimeout(() => applyCrop(crop), 2000);
}
})
.catch(e => console.error('Black Bar Remover: API fetch error:', e));
}
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 && 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;
window._currentPlayMethod = null;
window._isVideoDirect = null;
console.log('Black Bar Remover: Intercepted itemId from fetch:', id);
}
}
}
const res = await window._originalFetch.apply(this, args);
if (url && typeof url === 'string' && url.includes('/PlaybackInfo')) {
try {
const clone = res.clone();
clone.json().then(data => {
if (data && data.MediaSources && data.MediaSources[0]) {
const ms = data.MediaSources[0];
window._currentPlayMethod = ms.PlayMethod;
window._isVideoDirect = ms.IsVideoDirect !== false;
console.log('Black Bar Remover: PlaybackInfo detected -> PlayMethod:', window._currentPlayMethod, 'IsVideoDirect:', window._isVideoDirect);
if (currentCropData) {
applyCrop(currentCropData);
}
}
}).catch(e => {});
} catch(e) {}
}
return res;
};
}
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;
window._currentPlayMethod = null;
window._isVideoDirect = null;
console.log('Black Bar Remover: Intercepted itemId from XHR:', id);
}
}
if (url.includes('/PlaybackInfo')) {
xhr.addEventListener('load', function() {
try {
const data = JSON.parse(xhr.responseText);
if (data && data.MediaSources && data.MediaSources[0]) {
const ms = data.MediaSources[0];
window._currentPlayMethod = ms.PlayMethod;
window._isVideoDirect = ms.IsVideoDirect !== false;
console.log('Black Bar Remover: XHR PlaybackInfo detected -> PlayMethod:', window._currentPlayMethod, 'IsVideoDirect:', window._isVideoDirect);
if (currentCropData) {
applyCrop(currentCropData);
}
}
} catch(e) {}
});
}
}
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 && 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;
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 && lastBottomPercent > 0) {
updateSubtitleStyles(lastBottomPercent);
adjustVttCues(lastBottomPercent);
} else if (!video) {
currentItemId = null;
lastBottomPercent = 0;
window._isServerCropping = false;
}
}
setInterval(checkPlayback, 2000);
}, 2000);
})();
";
return Content(js, "application/javascript");
@@ -4,13 +4,16 @@ 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;
// Tolerancja detekcji czarnych pasów (0.094 = domyślne 24/255)
public double CropDetectLimit { get; set; } = 0.094;
// 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";
// Tryb odtwarzania: "Server" (FFmpeg wrapper), "Client" (CSS injection w Web UI), "Auto" (Automatyczny: CSS dla Web, FFmpeg dla aplikacji natywnych)
public string PlaybackMode { get; set; } = "Auto";
// Maksymalna liczba równoległych wątków skanowania czarnych pasów (1 - 16, domyślnie 4)
public int MaxParallelScans { get; set; } = 4;
}
}
+34 -26
View File
@@ -18,19 +18,25 @@
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Black Bar Detection Tolerance (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">Default is 0.15. Increase this value if black bars are not being detected due to video noise (e.g., change to 0.20 for WEB-DL).</div>
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Black Bar Detection Tolerance (0.001 - 1.0):</label>
<input type="number" step="0.001" min="0.001" max="1.0" id="txtCropLimit" is="emby-input" required />
<div class="fieldDescription">Default is 0.094. Increase this value (e.g., to 0.15) if the plugin fails to detect black bars on heavily compressed WEB-DLs with noise. Decrease it if the plugin aggressively crops dark scenes.</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="selectPlaybackMode">Black Bar Removal Method:</label>
<select is="emby-select" id="selectPlaybackMode" class="emby-select-withcolor">
<option value="Auto">Auto (CSS clip in Web, Transcode on Native Apps / Transcoded streams)</option>
<option value="Server">Server Only (FFmpeg Transcoding)</option>
<option value="Client">Client Only (CSS Clip in Jellyfin Web)</option>
<option value="Both">Both (Transcoding or CSS, depending on client)</option>
</select>
<div class="fieldDescription">Choose how the plugin should remove black bars. Server option forces transcoding (CPU/GPU load), Client option is lightweight but only works in web browsers.</div>
<div class="fieldDescription">Choose how the plugin should remove black bars. Auto mode uses lightweight CSS clipping for Web browser DirectPlay, and falls back to FFmpeg transcoding for native apps or unsupported video formats.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtMaxParallelScans">Max Parallel Scans (1 - 16):</label>
<input type="number" step="1" min="1" max="16" id="txtMaxParallelScans" is="emby-input" required />
<div class="fieldDescription">Number of files to analyze concurrently during library scheduled tasks. Default is 4. Higher values speed up library scans on multi-core CPUs.</div>
</div>
<div>
@@ -47,31 +53,33 @@
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);
var mode = config.PlaybackMode || "Auto";
if (mode === "Both") mode = "Auto";
$('#selectPlaybackMode', page).val(mode);
$('#txtMaxParallelScans', page).val(config.MaxParallelScans || 4);
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();
config.MaxParallelScans = parseInt($('#txtMaxParallelScans', form).val(), 10) || 4;
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);
});
});
});
+17 -2
View File
@@ -9,14 +9,24 @@ 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
// Używamy domyślnego limitu ffmpeg (ok. 0.094), ewentualnie tego z configu jeśli użytkownik zmienił
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 -",
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,
RedirectStandardOutput = true,
UseShellExecute = false,
@@ -44,12 +54,17 @@ namespace JellyfinPlugin
if (totalFrames == 0) return string.Empty;
// Bierzemy pod uwagę tylko wymiary, które występują przez co najmniej 5% czasu filmu
// Dzięki temu ignorujemy jednosekundowe loga czy zaszumione klatki
var validCrops = crops.Where(kvp => (double)kvp.Value / totalFrames > 0.05).ToList();
if (!validCrops.Any())
{
validCrops = crops.OrderByDescending(kvp => kvp.Value).Take(1).ToList();
}
// Tworzymy maksymalną obwiednię (Bounding Box) ze wszystkich WAŻNYCH kadrów
// Dzięki temu jeśli film to np. Interstellar (30% czasu w 16:9), kadr 16:9 zostanie zachowany
// i nie utniemy ważnych scen!
int minX = int.MaxValue, minY = int.MaxValue, maxXW = 0, maxYH = 0;
foreach (var kvp in validCrops)
-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()
{
}
}
}
+9 -6
View File
@@ -1,17 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<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 Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.2" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Model" Version="10.11.2" ExcludeAssets="runtime" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" ExcludeAssets="runtime" />
</ItemGroup>
<ItemGroup>
+19 -3
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;
}
@@ -78,7 +94,7 @@ namespace JellyfinPlugin
}
video.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(video, video, ItemUpdateType.MetadataEdit, CancellationToken.None);
_logger.LogInformation("Zapisano proporcje crop {CropData} dla pliku {Path}", cropData, path);
_logger.LogInformation("Saved crop proportions {CropData} for file {Path}", cropData, path);
}
}
finally
@@ -88,7 +104,7 @@ namespace JellyfinPlugin
}
catch (Exception ex)
{
_logger.LogError(ex, "Błąd podczas procesowania wideo: {Path}", video.Path);
_logger.LogError(ex, "Error processing video: {Path}", video.Path);
}
}
@@ -0,0 +1,197 @@
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;
}
var isAutoMode = config.PlaybackMode == "Auto" || config.PlaybackMode == "Both";
if (isAutoMode && IsJellyfinWebClient(context))
{
// W trybie Auto dla przeglądarki Web pozwalamy na Direct Play (przycięcie wykona skrypt CSS w przeglądarce).
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);
}
private static bool IsJellyfinWebClient(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader) ||
context.Request.Headers.TryGetValue("Authorization", out authHeader))
{
var authStr = authHeader.ToString();
if (authStr.Contains("Client=\"Jellyfin Web\"", StringComparison.OrdinalIgnoreCase) ||
authStr.Contains("Client=\"Jellyfin%20Web\"", StringComparison.OrdinalIgnoreCase) ||
authStr.Contains("Client=Jellyfin Web", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
if (context.Request.Headers.TryGetValue("User-Agent", out var uaHeader))
{
var ua = uaHeader.ToString();
if (ua.Contains("JellyfinMediaPlayer", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("Jellyfin-Android", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("ExoPlayer", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("okhttp", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("Kodi", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (ua.Contains("Mozilla", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("Safari", StringComparison.OrdinalIgnoreCase) ||
ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using JellyfinPlugin.Tasks;
namespace JellyfinPlugin
{
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost)
{
// 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>();
// W Jellyfin 10.9+ zadania harmonogramu czasami trzeba jawnie rejestrować jeśli refleksja ich nie wychwyci
serviceCollection.AddScoped<MediaBrowser.Model.Tasks.IScheduledTask, LibraryRetroactiveScanTask>();
serviceCollection.AddScoped<MediaBrowser.Model.Tasks.IScheduledTask, LibraryForceRescanTask>();
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace JellyfinPlugin.Tasks
{
public class LibraryForceRescanTask : IScheduledTask
{
private readonly ILogger<LibraryForceRescanTask> _logger;
private readonly ILibraryManager _libraryManager;
public LibraryForceRescanTask(ILogger<LibraryForceRescanTask> logger, ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
}
public string Name => "Force Rescan All Black Bars (Black Bar Remover)";
public string Key => "BlackBarForceRescanTask";
public string Description => "Forces a rescan of all files, overwriting any previously saved crop data. Use this if you changed the detection limit.";
public string Category => "Library";
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var config = Plugin.Instance!.Configuration;
if (!config.IsEnabled)
{
_logger.LogInformation("Plugin is disabled. Skipping.");
return;
}
var items = _libraryManager.RootFolder.GetRecursiveChildren()
.Where(x => x is Movie || x is Episode)
.ToList();
int total = items.Count;
if (total == 0) return;
int processed = 0;
int maxParallel = Math.Clamp(config.MaxParallelScans, 1, 16);
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = maxParallel,
CancellationToken = cancellationToken
};
_logger.LogInformation("Starting Force Rescan of {Total} items with {MaxParallel} parallel workers.", total, maxParallel);
await Parallel.ForEachAsync(items, parallelOptions, async (item, ct) =>
{
var path = item.Path;
if (!string.IsNullOrEmpty(path) && File.Exists(path))
{
try
{
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
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, ct);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning file {Path}", path);
}
}
int current = Interlocked.Increment(ref processed);
progress.Report((current / (double)total) * 100);
});
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}
}
@@ -33,7 +33,7 @@ namespace JellyfinPlugin.Tasks
var config = Plugin.Instance!.Configuration;
if (!config.IsEnabled)
{
_logger.LogInformation("Wtyczka wyłączona. Pomijanie.");
_logger.LogInformation("Plugin is disabled. Skipping.");
return;
}
@@ -42,17 +42,28 @@ namespace JellyfinPlugin.Tasks
.ToList();
int total = items.Count;
if (total == 0) return;
int processed = 0;
int maxParallel = Math.Clamp(config.MaxParallelScans, 1, 16);
foreach (var item in items)
var parallelOptions = new ParallelOptions
{
cancellationToken.ThrowIfCancellationRequested();
MaxDegreeOfParallelism = maxParallel,
CancellationToken = cancellationToken
};
if (item.ProviderIds != null && item.ProviderIds.ContainsKey("BlackBarCrop"))
_logger.LogInformation("Starting Retroactive Scan of {Total} items with {MaxParallel} parallel workers.", total, maxParallel);
await Parallel.ForEachAsync(items, parallelOptions, async (item, ct) =>
{
if (item.ProviderIds != null &&
item.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
!string.IsNullOrEmpty(existingCrop))
{
processed++;
progress.Report((processed / (double)total) * 100);
continue;
int currentSkipped = Interlocked.Increment(ref processed);
progress.Report((currentSkipped / (double)total) * 100);
return;
}
var path = item.Path;
@@ -62,28 +73,26 @@ 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))
_logger.LogInformation("Znaleziono pasy {Crop} w {Path}", cropData, path);
{
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
item.ProviderIds["BlackBarCrop"] = cropData;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, ct);
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Błąd skanowania pliku {Path}", path);
_logger.LogError(ex, "Error scanning file {Path}", path);
}
}
processed++;
progress.Report((processed / (double)total) * 100);
}
int currentDone = Interlocked.Increment(ref processed);
progress.Report((currentDone / (double)total) * 100);
});
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+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+=(""crop=$CROP_DATA,$next_arg"")
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)
{
+25 -9
View File
@@ -13,9 +13,15 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
## Requirements
- Jellyfin Server **12.0** or newer (.NET 10).
- Jellyfin Server **10.11** or **12.0+** (.NET 9 / .NET 10).
- FFmpeg installed on the server.
## Tested Versions
This plugin has been verified and tested on:
- **Jellyfin 10.11.x** (.NET 9.0)
- **Jellyfin 12.0 / 13.0 Unstable** (.NET 10.0)
## Installation
### Method 1: Jellyfin Repository (Recommended & Easiest)
@@ -23,7 +29,7 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
1. Open your Jellyfin Dashboard and navigate to **Plugins** -> **Repositories**.
2. Click the `+` icon to add a new repository.
3. Enter `Black Bar Remover` as the name.
4. Enter `https://gitea.labus.uk/karol/blackBarRemover/raw/branch/manifest/manifest.json` as the Repository URL and save.
4. Enter `https://gitea.labus.uk/karol/blackBarRemover/raw/branch/master/manifest.json` as the Repository URL and save.
5. Go back to the **Catalog** tab, find "Black Bar Remover" under the Playback category, and click **Install**.
6. Restart the Jellyfin server.
@@ -37,22 +43,32 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
### Method 3: Building from Source
1. Clone this repository.
2. Build the plugin using the .NET 10 SDK:
2. Ensure you have the **.NET 9.0 SDK** and/or **.NET 10.0 SDK** installed (the project multi-targets both `net9.0` and `net10.0`).
3. Build the plugin for all targets:
```bash
dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
```
3. Copy the compiled DLLs from `JellyfinPlugin/bin/Release/net10.0/` into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory (e.g., `/var/lib/jellyfin/plugins/BlackBarRemover/`).
4. Restart the Jellyfin server.
Or build for a specific target framework:
```bash
# For Jellyfin 10.11 (.NET 9.0)
dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release -f net9.0
# For Jellyfin 12.0 / 13.0 Unstable (.NET 10.0)
dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release -f net10.0
```
4. Copy the compiled DLLs from `JellyfinPlugin/bin/Release/net9.0/` (for Jellyfin 10.11) or `JellyfinPlugin/bin/Release/net10.0/` (for Jellyfin 12.0+) into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory (e.g., `/var/lib/jellyfin/plugins/BlackBarRemover/`).
5. Restart the Jellyfin server.
### Configuration & Initial Scan
1. Go to **Dashboard** -> **Plugins** -> **Black Bar Remover**.
2. Set your **Crop Detect Limit** (Default is 0.15). If your web-dl rips have noisy black bars that are not being cropped, increase this value to 0.20 or 0.25.
3. Select your preferred **Playback Mode**:
2. Set your **Crop Detect Limit** (Default is `0.094`). If your web-dl rips have noisy black bars that are not being cropped, increase this value to `0.15` or `0.20`.
3. Set **Max Parallel Scans** (Default is `4`, Range `1 - 16`). Controls how many video files are scanned concurrently during library tasks to maximize multi-core CPU utilization.
4. Select your preferred **Playback Mode**:
- `Auto`: Lightweight CSS clipping for Web browser DirectPlay; FFmpeg transcoding for native apps.
- `Client`: Uses CSS injection in the web player.
- `Server`: Requires setting up the FFmpeg wrapper.
- `Both`: Attempts both methods depending on the client.
4. **Important for existing libraries**: The plugin automatically scans *new* files when they are added to Jellyfin. For your existing library, go to **Dashboard -> Scheduled Tasks** and run the **"Scan Missing Black Bars (Black Bar Remover)"** task manually. It will scan all existing movies and episodes in the background.
5. **Important for existing libraries**: The plugin automatically scans *new* files when they are added to Jellyfin. For your existing library, go to **Dashboard -> Scheduled Tasks** and run the **"Scan Missing Black Bars (Black Bar Remover)"** task manually. It will scan all existing movies and episodes in the background.
### Activating the Server-Side Wrapper (Automatic)
+181
View File
@@ -0,0 +1,181 @@
[
{
"guid": "12345678-1234-1234-1234-1234567890ab",
"name": "Black Bar Remover",
"description": "Automatically detects and removes black bars from video files.",
"overview": "Automatically detects and removes black bars from video files via CSS or FFmpeg.",
"owner": "karol",
"category": "Playback",
"imageUrl": "",
"versions": [
{
"version": "0.1.19",
"changelog": "Release v0.1.19-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.19-beta/BlackBarRemover_v0.1.19-beta_jf12.0.zip",
"checksum": "baed36aef30d4cece36d1b36cd67775f",
"timestamp": "2026-09-17T19:57:09Z"
},
{
"version": "0.1.19",
"changelog": "Release v0.1.19-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.19-beta/BlackBarRemover_v0.1.19-beta_jf10.11.zip",
"checksum": "9bb29cf4ba5e351059a92a9a7ac5b85f",
"timestamp": "2026-09-17T19:57:09Z"
},
{
"version": "0.1.18",
"changelog": "Release v0.1.18-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.18-beta/BlackBarRemover_v0.1.18-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T21:51:42+02:00"
},
{
"version": "0.1.18",
"changelog": "Release v0.1.18-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.18-beta/BlackBarRemover_v0.1.18-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T21:51:42+02:00"
},
{
"version": "0.1.17",
"changelog": "Release v0.1.17-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.17-beta/BlackBarRemover_v0.1.17-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T21:32:32+02:00"
},
{
"version": "0.1.17",
"changelog": "Release v0.1.17-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.17-beta/BlackBarRemover_v0.1.17-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T21:32:32+02:00"
},
{
"version": "0.1.11",
"changelog": "Release v0.1.11-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.11-beta/BlackBarRemover_v0.1.11-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T21:05:29+02:00"
},
{
"version": "0.1.11",
"changelog": "Release v0.1.11-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.11-beta/BlackBarRemover_v0.1.11-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T21:05:29+02:00"
},
{
"version": "0.1.10",
"changelog": "Release v0.1.10-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.10-beta/BlackBarRemover_v0.1.10-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T19:54:01+02:00"
},
{
"version": "0.1.10",
"changelog": "Release v0.1.10-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.10-beta/BlackBarRemover_v0.1.10-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T19:54:01+02:00"
},
{
"version": "0.1.9",
"changelog": "Release v0.1.9-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.9-beta/BlackBarRemover_v0.1.9-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T19:39:22+02:00"
},
{
"version": "0.1.9",
"changelog": "Release v0.1.9-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.9-beta/BlackBarRemover_v0.1.9-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T19:39:22+02:00"
},
{
"version": "0.1.8",
"changelog": "Release v0.1.8-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.8-beta/BlackBarRemover_v0.1.8-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T18:29:04+02:00"
},
{
"version": "0.1.8",
"changelog": "Release v0.1.8-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.8-beta/BlackBarRemover_v0.1.8-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T18:29:04+02:00"
},
{
"version": "0.1.7",
"changelog": "Release v0.1.7-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.7-beta/BlackBarRemover_v0.1.7-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T18:23:27+02:00"
},
{
"version": "0.1.7",
"changelog": "Release v0.1.7-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.7-beta/BlackBarRemover_v0.1.7-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T18:23:27+02:00"
},
{
"version": "0.1.6",
"changelog": "Release v0.1.6-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.6-beta/BlackBarRemover_v0.1.6-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T18:11:37+02:00"
},
{
"version": "0.1.6",
"changelog": "Release v0.1.6-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.6-beta/BlackBarRemover_v0.1.6-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T18:11:37+02:00"
},
{
"version": "0.1.2",
"changelog": "Release v0.1.2",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.2/BlackBarRemover_v0.1.2.zip",
"checksum": "",
"timestamp": "2026-09-17T14:52:19+02:00"
},
{
"version": "0.1.1",
"changelog": "Release v0.1.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.1/BlackBarRemover_v0.1.1.zip",
"checksum": "",
"timestamp": "2026-09-17T14:45:08+02:00"
},
{
"version": "0.1",
"changelog": "Release v0.1-beta",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1-beta/BlackBarRemover_v0.1-beta.zip",
"checksum": "",
"timestamp": "2026-09-17T14:37:16+02:00"
}
]
}
]