Compare commits

...

27 Commits

Author SHA1 Message Date
Gitea Actions 86ec128ec8 Update manifest.json for v0.1.22-beta 2026-09-19 22:27:28 +02:00
karol 526ee3d165 feat: add live subtitle offset configuration and real-time player hotkeys
Build and Release Plugin / release (push) Successful in 45s
2026-09-19 22:25:12 +02:00
karol d1691655a4 docs: highlight plugin repository URL with GitHub alert box 2026-09-18 00:44:03 +02:00
karol d0a6669179 feat: add configurable multi-threaded parallel scanning for library tasks 2026-09-18 00:17:24 +02:00
Gitea Actions 359aa21394 Update manifest.json for v0.1.21-beta 2026-09-18 00:16:10 +02:00
Gitea Actions 0d5c55c843 Update manifest.json for v0.1.20-beta 2026-09-17 23:46:33 +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
12 changed files with 943 additions and 265 deletions
+190 -63
View File
@@ -46,48 +46,167 @@ jobs:
echo "md5_9=$MD5_SUM_9" >> $GITHUB_OUTPUT echo "md5_9=$MD5_SUM_9" >> $GITHUB_OUTPUT
echo "md5_10=$MD5_SUM_10" >> $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: | run: |
API_URL="${{ github.api_url }}" python3 -c '
REPO="${{ github.repository }}" import os, json, urllib.request, base64, datetime
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 Assets
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@BlackBarRemover_${TAG}_jf10.11.zip"
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@BlackBarRemover_${TAG}_jf12.0.zip"
- name: Update manifest.json api_url = os.environ["API_URL"]
run: | repo = os.environ["REPO"]
git config user.name "Gitea Actions" token = os.environ["TOKEN"]
git config user.email "actions@gitea.local" tag = os.environ["TAG"]
md5_9 = os.environ["MD5_9"]
git checkout -B manifest md5_10 = os.environ["MD5_10"]
VERSION="${{ github.ref_name }}" headers = {
VERSION="${VERSION#v}" "Authorization": f"token {token}",
CLEAN_VERSION="${VERSION%%-*}" "Content-Type": "application/json"
}
cat > manifest.json <<EOF
[ # 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", "guid": "12345678-1234-1234-1234-1234567890ab",
"name": "Black Bar Remover", "name": "Black Bar Remover",
@@ -96,28 +215,36 @@ jobs:
"owner": "karol", "owner": "karol",
"category": "Playback", "category": "Playback",
"imageUrl": "", "imageUrl": "",
"versions": [ "versions": all_versions
{
"version": "$CLEAN_VERSION",
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf12.0.zip",
"checksum": "${{ steps.checksum.outputs.md5_10 }}",
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
},
{
"version": "$CLEAN_VERSION",
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf10.11.zip",
"checksum": "${{ steps.checksum.outputs.md5_9 }}",
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
}
]
} }
] ]
EOF
b64_manifest = base64.b64encode(json.dumps(manifest_data, indent=4).encode("utf-8")).decode("utf-8")
git add manifest.json
git commit -m "Update manifest for ${{ github.ref_name }}" # Update on master and manifest branches
git push -f origin manifest 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}")
'
+270 -16
View File
@@ -25,6 +25,7 @@ namespace JellyfinPlugin.Api
public ContentResult GetCropByPath([FromQuery] string path) public ContentResult GetCropByPath([FromQuery] string path)
{ {
if (string.IsNullOrEmpty(path)) return Content(string.Empty, "text/plain"); if (string.IsNullOrEmpty(path)) return Content(string.Empty, "text/plain");
path = path.Trim('"').Trim('\'');
var mode = Plugin.Instance!.Configuration.PlaybackMode; var mode = Plugin.Instance!.Configuration.PlaybackMode;
if (mode == "Client") return Content(string.Empty, "text/plain"); if (mode == "Client") return Content(string.Empty, "text/plain");
@@ -60,19 +61,218 @@ namespace JellyfinPlugin.Api
return Content(string.Empty, "text/plain"); return Content(string.Empty, "text/plain");
} }
[HttpGet("Config")]
[Produces("application/json")]
public IActionResult GetConfig()
{
var config = Plugin.Instance!.Configuration;
return Ok(new
{
cropDetectLimit = config.CropDetectLimit,
playbackMode = config.PlaybackMode,
subtitleOffsetVh = config.SubtitleOffsetVh,
maxParallelScans = config.MaxParallelScans
});
}
[HttpGet("ClientScript.js")] [HttpGet("ClientScript.js")]
[Produces("application/javascript")] [Produces("application/javascript")]
public ContentResult GetClientScript() public ContentResult GetClientScript()
{ {
double defaultSubOffset = Plugin.Instance!.Configuration.SubtitleOffsetVh;
string defaultSubOffsetStr = defaultSubOffset.ToString(System.Globalization.CultureInfo.InvariantCulture);
string js = @" string js = @"
(function() { (function() {
console.log('Black Bar Remover: Script loaded'); console.log('Black Bar Remover: Script loaded');
let currentItemId = null; let currentItemId = null;
let interceptedItemId = null; let interceptedItemId = null;
let currentCropData = null;
window._currentPlayMethod = null;
window._isVideoDirect = null;
window._configuredSubtitleOffsetVh = {SUB_OFFSET};
window._customSubtitleOffsetVh = null;
function isTranscoding() {
if (window._isVideoDirect === false) {
return true;
}
if (window._isVideoDirect === true) {
return false;
}
return false;
}
function fetchServerConfig() {
if (!window._originalFetch) return;
window._originalFetch('/BlackBarRemover/Config')
.then(r => r.json())
.then(data => {
if (data && typeof data.subtitleOffsetVh === 'number') {
const newOffset = data.subtitleOffsetVh;
if (newOffset !== window._configuredSubtitleOffsetVh) {
window._configuredSubtitleOffsetVh = newOffset;
console.log('Black Bar Remover: Synced configured subtitle offset:', newOffset + 'vh');
if (window._customSubtitleOffsetVh === null && lastBottomPercent > 0) {
updateSubtitleStyles(lastBottomPercent);
}
}
}
})
.catch(e => {});
}
function showSubtitleToast(msg) {
let toastEl = document.getElementById('black-bar-remover-sub-toast');
if (!toastEl) {
toastEl = document.createElement('div');
toastEl.id = 'black-bar-remover-sub-toast';
toastEl.style.position = 'fixed';
toastEl.style.bottom = '15%';
toastEl.style.left = '50%';
toastEl.style.transform = 'translateX(-50%)';
toastEl.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
toastEl.style.color = '#fff';
toastEl.style.padding = '8px 16px';
toastEl.style.borderRadius = '20px';
toastEl.style.fontSize = '0.95rem';
toastEl.style.fontWeight = 'bold';
toastEl.style.zIndex = '999999';
toastEl.style.pointerEvents = 'none';
toastEl.style.transition = 'opacity 0.3s ease';
document.body.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.style.opacity = '1';
if (window._toastTimeout) clearTimeout(window._toastTimeout);
window._toastTimeout = setTimeout(() => {
toastEl.style.opacity = '0';
}, 2000);
}
function getActiveOffsetVh() {
if (window._customSubtitleOffsetVh !== null && window._customSubtitleOffsetVh !== undefined) {
return window._customSubtitleOffsetVh;
}
if (window._configuredSubtitleOffsetVh !== null && window._configuredSubtitleOffsetVh !== undefined) {
return window._configuredSubtitleOffsetVh;
}
return 5.0;
}
if (!window._subHotkeyBound) {
window._subHotkeyBound = true;
window.addEventListener('keydown', function(e) {
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
const video = document.querySelector('video');
if (!video) return;
e.preventDefault();
let current = getActiveOffsetVh();
if (e.key === 'ArrowUp') {
current = Math.min(25.0, current + 0.5);
} else if (e.key === 'ArrowDown') {
current = Math.max(0.0, current - 0.5);
}
window._customSubtitleOffsetVh = Math.round(current * 10) / 10;
showSubtitleToast('Subtitle Offset: ' + window._customSubtitleOffsetVh.toFixed(1) + ' vh (Alt+Up/Down)');
if (lastBottomPercent > 0) {
updateSubtitleStyles(lastBottomPercent);
}
}
});
}
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 activeOffset = getActiveOffsetVh();
const offsetVh = activeOffset.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 activeOffset = getActiveOffsetVh();
const targetLine = Math.max(10, 95 - Math.round(activeOffset * 1.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) { function applyCrop(cropData) {
const video = document.querySelector('video'); const video = document.querySelector('video');
if (!video) return; 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(':'); const parts = cropData.split(':');
if (parts.length === 4) { if (parts.length === 4) {
const w = parseInt(parts[0]); const w = parseInt(parts[0]);
@@ -80,21 +280,28 @@ namespace JellyfinPlugin.Api
const x = parseInt(parts[2]); const x = parseInt(parts[2]);
const y = parseInt(parts[3]); const y = parseInt(parts[3]);
const topPercent = (y / 1080) * 100; if (h > 0 && h < 1080) {
const bottomPercent = ((1080 - (y + h)) / 1080) * 100; const heightPercent = (1080 / h) * 100;
const leftPercent = (x / 1920) * 100; const topPercent = -(y / h) * 100;
const rightPercent = ((1920 - (x + w)) / 1920) * 100; const bottomPercent = ((1080 - (y + h)) / 1080) * 100;
video.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`; video.style.position = 'absolute';
video.style.objectFit = 'fill';
const scaleY = 1 / (1 - (topPercent + bottomPercent)/100); video.style.width = '100%';
const scaleX = 1 / (1 - (leftPercent + rightPercent)/100); video.style.height = heightPercent.toFixed(2) + '%';
const scale = Math.max(scaleY, scaleX); video.style.top = topPercent.toFixed(2) + '%';
video.style.left = '0%';
if (scale > 1.01) { video.style.transform = 'none';
video.style.transform = `scale(${scale})`; 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);
} }
} }
@@ -105,6 +312,7 @@ namespace JellyfinPlugin.Api
.then(crop => { .then(crop => {
console.log('Black Bar Remover: Received crop data:', crop); console.log('Black Bar Remover: Received crop data:', crop);
if (crop) { if (crop) {
currentCropData = crop;
setTimeout(() => applyCrop(crop), 500); setTimeout(() => applyCrop(crop), 500);
setTimeout(() => applyCrop(crop), 2000); setTimeout(() => applyCrop(crop), 2000);
} }
@@ -116,17 +324,39 @@ namespace JellyfinPlugin.Api
window._originalFetch = window.fetch; window._originalFetch = window.fetch;
window.fetch = async function(...args) { window.fetch = async function(...args) {
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url); const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url);
if (url) { if (url && typeof url === 'string') {
const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i); const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i);
if (match) { if (match) {
const id = match[1]; const id = match[1];
if (id && id !== interceptedItemId) { if (id && id !== interceptedItemId) {
interceptedItemId = id; interceptedItemId = id;
window._currentPlayMethod = null;
window._isVideoDirect = null;
console.log('Black Bar Remover: Intercepted itemId from fetch:', id); console.log('Black Bar Remover: Intercepted itemId from fetch:', id);
} }
} }
} }
return window._originalFetch.apply(this, args);
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;
}; };
} }
@@ -142,9 +372,27 @@ namespace JellyfinPlugin.Api
const id = match[1]; const id = match[1];
if (id && id !== interceptedItemId) { if (id && id !== interceptedItemId) {
interceptedItemId = id; interceptedItemId = id;
window._currentPlayMethod = null;
window._isVideoDirect = null;
console.log('Black Bar Remover: Intercepted itemId from XHR:', id); 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 originalOpen.apply(this, [method, url, ...rest]);
}; };
@@ -219,19 +467,25 @@ namespace JellyfinPlugin.Api
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
setInterval(() => { setInterval(() => {
fetchServerConfig();
const video = document.querySelector('video'); const video = document.querySelector('video');
if (video && interceptedItemId && interceptedItemId !== currentItemId) { if (video && interceptedItemId && interceptedItemId !== currentItemId) {
currentItemId = interceptedItemId; currentItemId = interceptedItemId;
console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId); console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId);
fetchCrop(currentItemId); fetchCrop(currentItemId);
checkServerCrop(currentItemId); checkServerCrop(currentItemId);
} else if (video && lastBottomPercent > 0) {
updateSubtitleStyles(lastBottomPercent);
adjustVttCues(lastBottomPercent);
} else if (!video) { } else if (!video) {
currentItemId = null; currentItemId = null;
lastBottomPercent = 0;
window._isServerCropping = false; window._isServerCropping = false;
} }
}, 2000); }, 2000);
})(); })();
"; ";
js = js.Replace("{SUB_OFFSET}", defaultSubOffsetStr);
return Content(js, "application/javascript"); return Content(js, "application/javascript");
} }
} }
@@ -10,7 +10,13 @@ namespace JellyfinPlugin.Configuration
// Czy wtyczka jest aktywna // Czy wtyczka jest aktywna
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
// Tryb odtwarzania: "Server" (FFmpeg wrapper), "Client" (CSS injection w Web UI), "Both" (Obydwa naraz) // 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; } = "Both"; 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;
// Przesunięcie pionowe napisów w vh w trybie klienckim CSS (0.0 - 25.0, domyślnie 5.0)
public double SubtitleOffsetVh { get; set; } = 5.0;
} }
} }
+21 -3
View File
@@ -26,11 +26,23 @@
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="selectPlaybackMode">Black Bar Removal Method:</label> <label class="selectLabel" for="selectPlaybackMode">Black Bar Removal Method:</label>
<select is="emby-select" id="selectPlaybackMode" class="emby-select-withcolor"> <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="Server">Server Only (FFmpeg Transcoding)</option>
<option value="Client">Client Only (CSS Clip in Jellyfin Web)</option> <option value="Client">Client Only (CSS Clip in Jellyfin Web)</option>
<option value="Both">Both (Transcoding or CSS, depending on client)</option>
</select> </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 class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtSubtitleOffsetVh">Client Subtitle Offset (0.0 - 25.0 vh):</label>
<input type="number" step="0.5" min="0.0" max="25.0" id="txtSubtitleOffsetVh" is="emby-input" required />
<div class="fieldDescription">Vertical offset shift for subtitles in Jellyfin Web player (in viewport height % / vh). Default is 5.0. Increase to move subtitles higher up into the video, or decrease if they appear too high.</div>
</div> </div>
<div> <div>
@@ -53,7 +65,11 @@
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled !== false); $('#chkEnablePlugin', page).prop('checked', config.IsEnabled !== false);
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.094); $('#txtCropLimit', page).val(config.CropDetectLimit || 0.094);
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both"); var mode = config.PlaybackMode || "Auto";
if (mode === "Both") mode = "Auto";
$('#selectPlaybackMode', page).val(mode);
$('#txtMaxParallelScans', page).val(config.MaxParallelScans || 4);
$('#txtSubtitleOffsetVh', page).val(config.SubtitleOffsetVh !== undefined ? config.SubtitleOffsetVh : 5.0);
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -67,6 +83,8 @@
config.IsEnabled = $('#chkEnablePlugin', form).prop('checked'); config.IsEnabled = $('#chkEnablePlugin', form).prop('checked');
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val()); config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
config.PlaybackMode = $('#selectPlaybackMode', form).val(); config.PlaybackMode = $('#selectPlaybackMode', form).val();
config.MaxParallelScans = parseInt($('#txtMaxParallelScans', form).val(), 10) || 4;
config.SubtitleOffsetVh = parseFloat($('#txtSubtitleOffsetVh', form).val());
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
+8 -1
View File
@@ -9,6 +9,13 @@ namespace JellyfinPlugin
{ {
public static class CropHelper 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) 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 // FFmpeg w nowych wersjach przyjmuje limit w formie ułamka, np. 24/255 to ok 0.094
@@ -17,7 +24,7 @@ namespace JellyfinPlugin
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = "ffmpeg", FileName = GetFFmpegPath(),
// KLUCZOWE: reset=1, żeby oceniał każdą klatkę niezależnie! // 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 -", Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=1\" -f null -",
RedirectStandardError = true, RedirectStandardError = true,
+17 -1
View File
@@ -23,6 +23,8 @@ namespace JellyfinPlugin
private readonly ILogger<LibraryMonitor> _logger; private readonly ILogger<LibraryMonitor> _logger;
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2); 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) public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> logger)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@@ -48,6 +50,16 @@ namespace JellyfinPlugin
{ {
if (!Plugin.Instance!.Configuration.IsEnabled) return; 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 // Fire and forget, don't block library scan
_ = Task.Run(() => ProcessVideoAsync(video)); _ = Task.Run(() => ProcessVideoAsync(video));
} }
@@ -57,7 +69,11 @@ namespace JellyfinPlugin
{ {
try 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; return;
} }
@@ -60,11 +60,19 @@ namespace JellyfinPlugin
return; 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 --- // --- HLS STREAM QUERY REWRITE ---
// If the client requests the m3u8 playlist, we simply tell Jellyfin NOT to allow video stream copy. // 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! // This elegantly forces transcoding for the BEST supported codec without breaking FFmpeg tags/filters!
if (path.EndsWith("master.m3u8", StringComparison.OrdinalIgnoreCase) || if (path.Contains("/Videos/", StringComparison.OrdinalIgnoreCase) &&
path.EndsWith("main.m3u8", StringComparison.OrdinalIgnoreCase)) (path.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) || path.Contains("/stream", StringComparison.OrdinalIgnoreCase)))
{ {
var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase); var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase);
if (matchId.Success) if (matchId.Success)
@@ -112,113 +120,78 @@ namespace JellyfinPlugin
return; return;
} }
_logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo for {itemId}. Found crop in DB: {cropData}. Forcing transcode."); _logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo request for {itemId}. Found crop in DB: {cropData}. Disabling DirectPlayProfiles in incoming request.");
// Kestrel middleware do nadpisania odpowiedzi // Modify incoming request body to clear DirectPlayProfiles
context.Request.Headers.Remove("Accept-Encoding"); context.Request.EnableBuffering();
var originalBody = context.Response.Body; string reqJson = string.Empty;
using var buffer = new MemoryStream(); using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true))
context.Response.Body = buffer; {
reqJson = await reader.ReadToEndAsync().ConfigureAwait(false);
}
JsonNode rootNode;
try try
{ {
await nextMw().ConfigureAwait(false); rootNode = string.IsNullOrWhiteSpace(reqJson) ? new JsonObject() : (JsonNode.Parse(reqJson) ?? new JsonObject());
} }
catch catch
{ {
context.Response.Body = originalBody; rootNode = new JsonObject();
throw;
} }
context.Response.Body = originalBody; if (rootNode["DeviceProfile"] is not JsonObject devProfile)
buffer.Seek(0, SeekOrigin.Begin);
var isJson = context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) ?? false;
if (!isJson || context.Response.StatusCode != 200)
{ {
await buffer.CopyToAsync(originalBody).ConfigureAwait(false); devProfile = new JsonObject();
return; rootNode["DeviceProfile"] = devProfile;
} }
string json; devProfile["DirectPlayProfiles"] = new JsonArray();
using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true))
{
json = await reader.ReadToEndAsync().ConfigureAwait(false);
}
try 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 root = JsonNode.Parse(json); var authStr = authHeader.ToString();
var mediaSources = root?["MediaSources"]?.AsArray(); if (authStr.Contains("Client=\"Jellyfin Web\"", StringComparison.OrdinalIgnoreCase) ||
if (mediaSources != null) authStr.Contains("Client=\"Jellyfin%20Web\"", StringComparison.OrdinalIgnoreCase) ||
authStr.Contains("Client=Jellyfin Web", StringComparison.OrdinalIgnoreCase))
{ {
bool modified = false; return true;
foreach (var source in mediaSources)
{
if (source == null) continue;
var obj = source.AsObject();
// Pobieramy flagi niezależnie od wielkości liter (Jellyfin 10.9+ może używać camelCase)
var dpNode = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectPlay", StringComparison.OrdinalIgnoreCase)).Value;
var dsNode = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectStream", StringComparison.OrdinalIgnoreCase)).Value;
var dp = dpNode?.GetValue<bool>() ?? false;
var ds = dsNode?.GetValue<bool>() ?? false;
if (dp || ds)
{
// Szukamy oryginalnych kluczy, żeby ich nie zdublować w innym casing-u
var dpKey = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectPlay", StringComparison.OrdinalIgnoreCase)).Key ?? "SupportsDirectPlay";
var dsKey = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectStream", StringComparison.OrdinalIgnoreCase)).Key ?? "SupportsDirectStream";
var trKey = obj.FirstOrDefault(x => x.Key.Equals("TranscodingReasons", StringComparison.OrdinalIgnoreCase)).Key ?? "TranscodingReasons";
obj[dpKey] = false;
obj[dsKey] = false;
var transcodingReasons = obj[trKey]?.GetValue<string>();
string customReason = "VideoCodecNotSupported";
if (transcodingReasons == null)
{
obj[trKey] = customReason;
}
else if (!transcodingReasons.Contains(customReason))
{
obj[trKey] = transcodingReasons + "," + customReason;
}
// Dodatkowo obcinamy wsparcie dla Direct Stream w pod-strumieniach (Jellyfin czasami wymusza z poziomu klienta)
var reqKey = obj.FirstOrDefault(x => x.Key.Equals("RequiresOpening", StringComparison.OrdinalIgnoreCase)).Key ?? "RequiresOpening";
obj[reqKey] = true;
var reqCloseKey = obj.FirstOrDefault(x => x.Key.Equals("RequiresClosing", StringComparison.OrdinalIgnoreCase)).Key ?? "RequiresClosing";
obj[reqCloseKey] = true;
modified = true;
_logger.LogInformation($"[Black Bar Remover DEBUG] Modified PlaybackInfo JSON: forced {dpKey}/{dsKey} to false for MediaSource ID {obj.FirstOrDefault(x => x.Key.Equals("Id", StringComparison.OrdinalIgnoreCase)).Value}");
}
}
if (modified)
{
json = root!.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
_logger.LogInformation($"[Black Bar Remover DEBUG] Successfully rewrote PlaybackInfo JSON to enforce transcoding.");
}
} }
} }
catch (Exception ex)
if (context.Request.Headers.TryGetValue("User-Agent", out var uaHeader))
{ {
_logger.LogWarning($"[Black Bar Remover DEBUG] Error modifying PlaybackInfo JSON: {ex.Message}"); 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;
}
} }
var bytes = Encoding.UTF8.GetBytes(json); return false;
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.ContentLength = bytes.Length;
context.Response.Headers.Remove("ETag");
context.Response.Headers.Remove("Last-Modified");
await originalBody.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
} }
} }
} }
+21 -17
View File
@@ -42,15 +42,21 @@ namespace JellyfinPlugin.Tasks
.ToList(); .ToList();
int total = items.Count; int total = items.Count;
if (total == 0) return;
int processed = 0; 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
// Wymuszamy skanowanie, więc nie sprawdzamy czy ma już BlackBarCrop };
_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; var path = item.Path;
if (!string.IsNullOrEmpty(path) && File.Exists(path)) if (!string.IsNullOrEmpty(path) && File.Exists(path))
{ {
@@ -58,18 +64,16 @@ namespace JellyfinPlugin.Tasks
{ {
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit); string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
// Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku
// (puste oznacza że nie ma czarnych pasów do ucięcia)
item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
if (!string.IsNullOrEmpty(cropData)) if (!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); _logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -77,9 +81,9 @@ namespace JellyfinPlugin.Tasks
} }
} }
processed++; int current = Interlocked.Increment(ref processed);
progress.Report((processed / (double)total) * 100); progress.Report((current / (double)total) * 100);
} });
} }
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
@@ -42,17 +42,28 @@ namespace JellyfinPlugin.Tasks
.ToList(); .ToList();
int total = items.Count; int total = items.Count;
if (total == 0) return;
int processed = 0; 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++; int currentSkipped = Interlocked.Increment(ref processed);
progress.Report((processed / (double)total) * 100); progress.Report((currentSkipped / (double)total) * 100);
continue; return;
} }
var path = item.Path; var path = item.Path;
@@ -62,18 +73,16 @@ namespace JellyfinPlugin.Tasks
{ {
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit); string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
if (item.ProviderIds == null)
{
item.ProviderIds = new Dictionary<string, string>();
}
// Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku
// (puste oznacza że nie ma czarnych pasów do ucięcia)
item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty;
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
if (!string.IsNullOrEmpty(cropData)) if (!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); _logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -81,9 +90,9 @@ namespace JellyfinPlugin.Tasks
} }
} }
processed++; int currentDone = Interlocked.Increment(ref processed);
progress.Report((processed / (double)total) * 100); progress.Report((currentDone / (double)total) * 100);
} });
} }
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+51 -40
View File
@@ -44,37 +44,34 @@ namespace JellyfinPlugin
return; return;
} }
// Pobieramy obecną konfigurację kodowania // Przygotowujemy zamianę pliku ffmpeg bezpośrednio w /usr/lib/jellyfin-ffmpeg/ffmpeg
var encodingConfig = _configManager.GetConfiguration("encoding"); string defaultFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
if (encodingConfig == null) return; 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) if (!File.Exists(origFfmpeg))
dynamic dynConfig = encodingConfig; {
string currentFfmpegPath = dynConfig.EncoderAppPath ?? "/usr/lib/jellyfin-ffmpeg/ffmpeg"; 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"); string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover");
Directory.CreateDirectory(wrapperDir); Directory.CreateDirectory(wrapperDir);
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh"); string wrapperPath = Path.Combine(wrapperDir, "ffmpeg");
// 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 script = $@"#!/bin/bash string script = $@"#!/bin/bash
REAL_FFMPEG=""{realFfmpegPath}"" REAL_FFMPEG=""{realFfmpegPath}""
@@ -92,9 +89,7 @@ for (( i=1; i<=$#; i++ )); do
done done
if [ -n ""$INPUT_FILE"" ]; then if [ -n ""$INPUT_FILE"" ]; then
# Jellyfin przekazuje ścieżkę z prefiksem file: (np. file:""/home/...""). CLEAN_PATH=$(echo ""$INPUT_FILE"" | tr -d '""' | sed 's/^file://')
# Shell usuwa cudzysłowy automatycznie – zostaje nam tylko file: do usunięcia.
CLEAN_PATH=""${{INPUT_FILE#file:}}""
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$CLEAN_PATH"" http://127.0.0.1:8096/BlackBarRemover/CropByPath) CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$CLEAN_PATH"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
@@ -153,21 +148,37 @@ fi
exec ""$REAL_FFMPEG"" ""$@"" exec ""$REAL_FFMPEG"" ""$@""
"; ";
File.WriteAllText(wrapperPath, script); File.WriteAllText(wrapperPath, script);
if (File.Exists(origFfmpeg) || defaultFfmpeg != origFfmpeg)
if (Environment.OSVersion.Platform == PlatformID.Unix)
{ {
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); if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || OperatingSystem.IsMacOS())
// Teraz automagicznie podmieniamy ścieżkę w Jellyfin!
if (dynConfig.EncoderAppPath != wrapperPath)
{ {
dynConfig.EncoderAppPath = wrapperPath; string[] pathsToChmod = new[] { wrapperPath, defaultFfmpeg };
_configManager.SaveConfiguration("encoding", encodingConfig); foreach (var path in pathsToChmod)
_logger.LogInformation("Automatycznie zmieniono ścieżkę FFmpeg w konfiguracji Jellyfin na wrapper wtyczki."); {
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) catch (Exception ex)
{ {
+38 -14
View File
@@ -13,19 +13,31 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
## Requirements ## 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. - 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 ## Installation
### Method 1: Jellyfin Repository (Recommended & Easiest) ### Method 1: Jellyfin Repository (Recommended & Easiest)
1. Open your Jellyfin Dashboard and navigate to **Plugins** -> **Repositories**. > [!TIP]
2. Click the `+` icon to add a new repository. > **Plugin Repository URL**:
3. Enter `Black Bar Remover` as the name. > ```text
4. Enter `https://gitea.labus.uk/karol/blackBarRemover/raw/branch/manifest/manifest.json` as the Repository URL and save. > https://gitea.labus.uk/karol/blackBarRemover/raw/branch/master/manifest.json
5. Go back to the **Catalog** tab, find "Black Bar Remover" under the Playback category, and click **Install**. > ```
6. Restart the Jellyfin server.
1. Open your Jellyfin Dashboard and navigate to **Dashboard** -> **Plugins** -> **Repositories** (`Repositories` tab).
2. Click the **`+`** icon to add a new repository.
3. Enter **`Black Bar Remover`** as the Repository Name.
4. Copy and paste the **Repository URL** above into the **Repository URL** field, then click **Save**.
5. Switch to the **Catalog** tab, find **Black Bar Remover** (under the *Playback* category), and click **Install**.
6. Restart your Jellyfin server.
### Method 2: Manual Installation from Releases ### Method 2: Manual Installation from Releases
@@ -37,22 +49,34 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
### Method 3: Building from Source ### Method 3: Building from Source
1. Clone this repository. 1. Clone this repository.
2. Build the plugin using the .NET 9 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 ```bash
dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
``` ```
3. Copy the compiled DLLs from `JellyfinPlugin/bin/Release/net9.0/` into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory. Or build for a specific target framework:
4. Restart the Jellyfin server. ```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 ### Configuration & Initial Scan
1. Go to **Dashboard** -> **Plugins** -> **Black Bar Remover**. 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. 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. Select your preferred **Playback Mode**: 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. Set **Client Subtitle Offset** (Default is `5.0 vh`, Range `0.0 - 25.0 vh`). Adjusts the vertical offset of subtitles when CSS crop is active in the Web player.
5. 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. - `Client`: Uses CSS injection in the web player.
- `Server`: Requires setting up the FFmpeg wrapper. - `Server`: Requires setting up the FFmpeg wrapper.
- `Both`: Attempts both methods depending on the client. 6. **Live Subtitle Adjustment during Playback**: While watching a video in Jellyfin Web with CSS crop enabled, you can press **`Alt + ArrowUp`** or **`Alt + ArrowDown`** to adjust the subtitle vertical position on the fly in real-time with on-screen feedback!
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. 7. **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) ### Activating the Server-Side Wrapper (Automatic)
+229
View File
@@ -0,0 +1,229 @@
[
{
"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.22",
"changelog": "Release v0.1.22-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.22-beta/BlackBarRemover_v0.1.22-beta_jf12.0.zip",
"checksum": "55d64618d81ea1091200c5566cd771e5",
"timestamp": "2026-09-19T20:27:28Z"
},
{
"version": "0.1.22",
"changelog": "Release v0.1.22-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.22-beta/BlackBarRemover_v0.1.22-beta_jf10.11.zip",
"checksum": "e18d3599973ebedba08de6494cfed3cb",
"timestamp": "2026-09-19T20:27:28Z"
},
{
"version": "0.1.21",
"changelog": "Release v0.1.21-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.21-beta/BlackBarRemover_v0.1.21-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-18T00:14:20+02:00"
},
{
"version": "0.1.21",
"changelog": "Release v0.1.21-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.21-beta/BlackBarRemover_v0.1.21-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-18T00:14:20+02:00"
},
{
"version": "0.1.20",
"changelog": "Release v0.1.20-beta (Jellyfin 12.0)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.20-beta/BlackBarRemover_v0.1.20-beta_jf12.0.zip",
"checksum": "",
"timestamp": "2026-09-17T23:45:19+02:00"
},
{
"version": "0.1.20",
"changelog": "Release v0.1.20-beta (Jellyfin 10.11)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/v0.1.20-beta/BlackBarRemover_v0.1.20-beta_jf10.11.zip",
"checksum": "",
"timestamp": "2026-09-17T23:45:19+02:00"
},
{
"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": "",
"timestamp": "2026-09-17T21:56:15+02:00"
},
{
"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": "",
"timestamp": "2026-09-17T21:56:15+02:00"
},
{
"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"
}
]
}
]