Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0bfaffb1 | |||
| 86ec128ec8 | |||
| 526ee3d165 | |||
| d1691655a4 | |||
| d0a6669179 | |||
| 359aa21394 | |||
| 0d5c55c843 | |||
| d88f910b58 | |||
| 512dabc611 | |||
| 78e100c99e | |||
| b2885aa163 | |||
| 73f4bb488a | |||
| 9042643383 | |||
| 739c96c376 | |||
| 14831c6cd5 | |||
| 5cdde631e1 | |||
| 83f3c2975a | |||
| 2d0af2aed0 | |||
| 8b8b9a6d43 | |||
| 0cfaa50805 | |||
| fea812d3a3 | |||
| fe6e20a68a | |||
| b7693b606e | |||
| 13f9fd642c | |||
| 322cfd9acd | |||
| 648930aa21 | |||
| cb16bf0074 | |||
| f6ed790801 | |||
| d5e11a52ed | |||
| d018a8e5ac | |||
| 25c7a56901 | |||
| dfe59ed84a | |||
| 795f42048f | |||
| 5f8674fae4 | |||
| 0a437a5588 | |||
| 5b14fe9657 | |||
| f8b7c95e01 | |||
| e2c286e42c | |||
| e44aafae70 | |||
| 44325b9f9c |
+184
-56
@@ -17,7 +17,9 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: |
|
||||
9.0.x
|
||||
10.0.x
|
||||
|
||||
- name: Build Plugin
|
||||
run: dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
|
||||
@@ -44,47 +46,167 @@ jobs:
|
||||
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 }}"
|
||||
python3 -c '
|
||||
import os, json, urllib.request, base64, datetime
|
||||
|
||||
IS_PRERELEASE="false"
|
||||
if [[ "$TAG" == *"beta"* ]] || [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == v0.* ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
fi
|
||||
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"]
|
||||
|
||||
# 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}")
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
REL_ID=$(echo "$RELEASE_RES" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
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"))
|
||||
|
||||
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@BlackBarRemover_${TAG}_jf12.0.zip"
|
||||
rel_id = rel_data["id"]
|
||||
|
||||
- name: Update manifest.json
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
# 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
|
||||
|
||||
git checkout -B manifest
|
||||
# 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}")
|
||||
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
# Upload asset
|
||||
with open(filename, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
cat > manifest.json <<EOF
|
||||
[
|
||||
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",
|
||||
@@ -93,30 +215,36 @@ jobs:
|
||||
"owner": "karol",
|
||||
"category": "Playback",
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 12.0)",
|
||||
"targetAbi": "12.0.0.0",
|
||||
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
|
||||
"checksum": "${{ steps.checksum.outputs.md5_10 }}",
|
||||
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
|
||||
"repositoryUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf12.0.zip"
|
||||
},
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"changelog": "Automated Release ${{ github.ref_name }} (Jellyfin 10.11)",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
|
||||
"checksum": "${{ steps.checksum.outputs.md5_9 }}",
|
||||
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
|
||||
"repositoryUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}_jf10.11.zip"
|
||||
"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}")
|
||||
'
|
||||
|
||||
@@ -21,54 +21,258 @@ 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("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")]
|
||||
[Produces("application/javascript")]
|
||||
public ContentResult GetClientScript()
|
||||
{
|
||||
double defaultSubOffset = Plugin.Instance!.Configuration.SubtitleOffsetVh;
|
||||
string defaultSubOffsetStr = defaultSubOffset.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
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;
|
||||
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) {
|
||||
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,46 +280,212 @@ namespace JellyfinPlugin.Api
|
||||
const x = parseInt(parts[2]);
|
||||
const y = parseInt(parts[3]);
|
||||
|
||||
const topPercent = (y / 1080) * 100;
|
||||
if (h > 0 && h < 1080) {
|
||||
const heightPercent = (1080 / h) * 100;
|
||||
const topPercent = -(y / h) * 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}%)`;
|
||||
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';
|
||||
|
||||
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 (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() {
|
||||
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)
|
||||
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 => {
|
||||
if (crop) applyCrop(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);
|
||||
}
|
||||
} else if (!video && currentVideoElement) {
|
||||
currentVideoElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(checkPlayback, 2000);
|
||||
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(() => {
|
||||
fetchServerConfig();
|
||||
const video = document.querySelector('video');
|
||||
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
|
||||
currentItemId = interceptedItemId;
|
||||
console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId);
|
||||
fetchCrop(currentItemId);
|
||||
checkServerCrop(currentItemId);
|
||||
} else if (video && lastBottomPercent > 0) {
|
||||
updateSubtitleStyles(lastBottomPercent);
|
||||
adjustVttCues(lastBottomPercent);
|
||||
} else if (!video) {
|
||||
currentItemId = null;
|
||||
lastBottomPercent = 0;
|
||||
window._isServerCropping = false;
|
||||
}
|
||||
}, 2000);
|
||||
})();
|
||||
";
|
||||
js = js.Replace("{SUB_OFFSET}", defaultSubOffsetStr);
|
||||
return Content(js, "application/javascript");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ namespace JellyfinPlugin.Configuration
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,23 @@
|
||||
<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 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>
|
||||
@@ -47,34 +59,38 @@
|
||||
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");
|
||||
$('#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);
|
||||
$('#txtSubtitleOffsetVh', page).val(config.SubtitleOffsetVh !== undefined ? config.SubtitleOffsetVh : 5.0);
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
$('.blackBarRemoverConfigForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
$('.blackBarRemoverConfigForm').on('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
|
||||
config.IsEnabled = $('#chkEnablePlugin', page).prop('checked');
|
||||
config.CropDetectLimit = parseFloat($('#txtCropLimit', page).val());
|
||||
config.PlaybackMode = $('#selectPlaybackMode', page).val();
|
||||
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;
|
||||
config.SubtitleOffsetVh = parseFloat($('#txtSubtitleOffsetVh', form).val());
|
||||
|
||||
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -9,6 +9,13 @@ namespace JellyfinPlugin
|
||||
{
|
||||
public static class CropHelper
|
||||
{
|
||||
private static string GetFFmpegPath()
|
||||
{
|
||||
if (File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")) return "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
if (File.Exists("/usr/bin/ffmpeg")) return "/usr/bin/ffmpeg";
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
public static async Task<string> DetectCropAsync(string filePath, double limit)
|
||||
{
|
||||
// FFmpeg w nowych wersjach przyjmuje limit w formie ułamka, np. 24/255 to ok 0.094
|
||||
@@ -17,7 +24,7 @@ namespace JellyfinPlugin
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
FileName = GetFFmpegPath(),
|
||||
// KLUCZOWE: reset=1, żeby oceniał każdą klatkę niezależnie!
|
||||
Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=1\" -f null -",
|
||||
RedirectStandardError = true,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ namespace JellyfinPlugin
|
||||
private readonly ILogger<LibraryMonitor> _logger;
|
||||
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, byte> _processedItems = new ConcurrentDictionary<Guid, byte>();
|
||||
|
||||
public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
@@ -48,6 +50,16 @@ namespace JellyfinPlugin
|
||||
{
|
||||
if (!Plugin.Instance!.Configuration.IsEnabled) return;
|
||||
|
||||
if (_processedItems.ContainsKey(video.Id)) return;
|
||||
|
||||
if (video.ProviderIds != null &&
|
||||
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
_processedItems.TryAdd(video.Id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire and forget, don't block library scan
|
||||
_ = Task.Run(() => ProcessVideoAsync(video));
|
||||
}
|
||||
@@ -57,7 +69,11 @@ namespace JellyfinPlugin
|
||||
{
|
||||
try
|
||||
{
|
||||
if (video.ProviderIds != null && video.ProviderIds.ContainsKey("BlackBarCrop"))
|
||||
if (!_processedItems.TryAdd(video.Id, 0)) return;
|
||||
|
||||
if (video.ProviderIds != null &&
|
||||
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ namespace JellyfinPlugin
|
||||
{
|
||||
public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Rejestracja serwisów tła
|
||||
serviceCollection.AddHostedService<HtmlPatcher>();
|
||||
// Zarejestrowanie filtru wstrzykującego nasz skrypt kliencki
|
||||
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, ScriptInjectionStartupFilter>();
|
||||
|
||||
// Zarejestrowanie filtru wymuszającego transkodowanie dla Server Mode
|
||||
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, PlaybackInfoInterceptorStartupFilter>();
|
||||
serviceCollection.AddHostedService<WrapperGenerator>();
|
||||
serviceCollection.AddHostedService<LibraryMonitor>();
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class ScriptInjectionStartupFilter : IStartupFilter
|
||||
{
|
||||
private readonly ILogger<ScriptInjectionStartupFilter> _logger;
|
||||
private int _loggedOnce;
|
||||
|
||||
public ScriptInjectionStartupFilter(ILogger<ScriptInjectionStartupFilter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
{
|
||||
return app =>
|
||||
{
|
||||
app.Use(InvokeAsync);
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
|
||||
private async Task InvokeAsync(HttpContext context, Func<Task> nextMw)
|
||||
{
|
||||
if (!IsIndexRequest(context.Request.Path.Value))
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.IsEnabled)
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Request.Headers.Remove("Accept-Encoding");
|
||||
context.Request.Headers.Remove("Range");
|
||||
context.Request.Headers.Remove("If-Range");
|
||||
|
||||
var originalBody = context.Response.Body;
|
||||
using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
try
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
throw;
|
||||
}
|
||||
|
||||
context.Response.Body = originalBody;
|
||||
buffer.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var isHtml = context.Response.StatusCode == 200
|
||||
&& (context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
if (!isHtml)
|
||||
{
|
||||
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string html;
|
||||
using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true))
|
||||
{
|
||||
html = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var alreadyInjected = html.IndexOf("BlackBarRemover/ClientScript.js", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
var bodyClose = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!alreadyInjected && bodyClose >= 0)
|
||||
{
|
||||
var tag = "<script src=\"/BlackBarRemover/ClientScript.js\"></script>";
|
||||
html = html.Substring(0, bodyClose) + tag + "\n" + html.Substring(bodyClose);
|
||||
|
||||
if (System.Threading.Interlocked.Exchange(ref _loggedOnce, 1) == 0)
|
||||
{
|
||||
_logger.LogInformation("Black Bar Remover: injected the client script via request-time middleware (IStartupFilter).");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning($"Script injection middleware error (serving original HTML): {ex.Message}");
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(html);
|
||||
context.Response.ContentType = "text/html;charset=utf-8";
|
||||
context.Response.ContentLength = bytes.Length;
|
||||
context.Response.Headers.Remove("ETag");
|
||||
context.Response.Headers.Remove("Last-Modified");
|
||||
context.Response.Headers.Remove("Accept-Ranges");
|
||||
await originalBody.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsIndexRequest(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
return path.EndsWith("/web/index.html", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith("/web/", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Equals("/web", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,15 +42,21 @@ 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();
|
||||
|
||||
// Wymuszamy skanowanie, więc nie sprawdzamy czy ma już BlackBarCrop
|
||||
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))
|
||||
{
|
||||
@@ -58,28 +64,26 @@ namespace JellyfinPlugin.Tasks
|
||||
{
|
||||
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
|
||||
|
||||
if (!string.IsNullOrEmpty(cropData))
|
||||
{
|
||||
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))
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
progress.Report((processed / (double)total) * 100);
|
||||
}
|
||||
int current = Interlocked.Increment(ref processed);
|
||||
progress.Report((current / (double)total) * 100);
|
||||
});
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
processed++;
|
||||
progress.Report((processed / (double)total) * 100);
|
||||
continue;
|
||||
if (item.ProviderIds != null &&
|
||||
item.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
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 (!string.IsNullOrEmpty(cropData))
|
||||
{
|
||||
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))
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
progress.Report((processed / (double)total) * 100);
|
||||
}
|
||||
int currentDone = Interlocked.Increment(ref processed);
|
||||
progress.Report((currentDone / (double)total) * 100);
|
||||
});
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Jellyfin Black Bar Remover Plugin
|
||||
# Jellyfin Black Bar Remover
|
||||
|
||||
A Jellyfin plugin that automatically detects and removes black bars (letterboxing/pillarboxing) from video files.
|
||||
|
||||
[Features](#features) · [Requirements](#requirements) · [Getting started](#getting-started) · [Configuration](#configuration) · [Architecture](#architecture) · [Documentation](#documentation) · [Credits](#credits) · [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated Scanning**: Hooks into the Jellyfin library scan (`ItemAdded` / `ItemUpdated`) to analyze videos in the background using FFmpeg keyframe skipping.
|
||||
@@ -9,65 +11,100 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
|
||||
- **Native Storage**: Saves detected crop parameters natively inside the Jellyfin SQLite database via `ProviderIds`.
|
||||
- **Dual Playback Modes**:
|
||||
- **Client-Side (CSS Clip)**: Automatically injects a JavaScript module into the Jellyfin Web UI. The script reads the crop data and uses CSS `clip-path` and `transform` to crop the video in the browser without any server-side transcoding overhead (Direct Play).
|
||||
- **Server-Side (FFmpeg Wrapper)**: Generates an `ffmpeg-wrapper.sh` script that can be used to inject the FFmpeg `-vf crop` filter during active transcoding.
|
||||
- **Server-Side (FFmpeg Wrapper)**: Generates an FFmpeg wrapper script that can be used to inject the FFmpeg `-vf crop` filter during active transcoding.
|
||||
|
||||
## 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.
|
||||
|
||||
## Installation
|
||||
### Previously documented compatibility
|
||||
|
||||
### Method 1: Jellyfin Repository (Recommended & Easiest)
|
||||
The previous README records testing on the following versions; this documentation update does not revalidate runtime compatibility:
|
||||
|
||||
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.
|
||||
5. Go back to the **Catalog** tab, find "Black Bar Remover" under the Playback category, and click **Install**.
|
||||
6. Restart the Jellyfin server.
|
||||
- **Jellyfin 10.11.x** (.NET 9.0)
|
||||
- **Jellyfin 12.0 / 13.0 Unstable** (.NET 10.0)
|
||||
|
||||
### Method 2: Manual Installation from Releases
|
||||
## Getting started
|
||||
|
||||
### Install from the Jellyfin repository
|
||||
|
||||
> [!TIP]
|
||||
> **Plugin Repository URL**:
|
||||
> ```text
|
||||
> https://gitea.labus.uk/karol/blackBarRemover/raw/branch/master/manifest.json
|
||||
> ```
|
||||
|
||||
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.
|
||||
|
||||
### Install manually from releases
|
||||
|
||||
1. Go to the **Releases** tab on the Gitea repository page.
|
||||
2. Download the latest `BlackBarRemover_vX.X.zip` file.
|
||||
3. Extract the contents of the ZIP file into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory (e.g., `/var/lib/jellyfin/plugins/BlackBarRemover/`).
|
||||
4. Restart the Jellyfin server.
|
||||
|
||||
### Method 3: Building from Source
|
||||
### Build from source
|
||||
|
||||
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
|
||||
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.
|
||||
4. Restart the Jellyfin server.
|
||||
|
||||
### Configuration & Initial Scan
|
||||
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
|
||||
|
||||
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. 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.
|
||||
- `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.
|
||||
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!
|
||||
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)
|
||||
### Server-side wrapper
|
||||
|
||||
If you select "Server" or "Both" mode, the plugin automatically generates a wrapper script and dynamically injects it into Jellyfin's `EncoderAppPath` configuration during startup. It takes over the transcoding process effortlessly. If it ever points to the wrong real FFmpeg, you can adjust it in the wrapper script.
|
||||
In `Auto` or `Server` mode, startup generates a wrapper at `BlackBarRemover/ffmpeg` inside the Jellyfin plugins directory and installs it at `/usr/lib/jellyfin-ffmpeg/ffmpeg`. The original executable is retained as `ffmpeg.original`. The Jellyfin process needs write permissions for those paths. If playback fails, check the wrapper's `REAL_FFMPEG` path and the Jellyfin logs.
|
||||
|
||||
### Activating the Client-Side CSS (Automatic)
|
||||
### Client-side script
|
||||
|
||||
The plugin attempts to automatically inject its JavaScript into the Jellyfin Web `index.html` on startup. If it fails due to file permissions, you can manually activate it by adding the following line to **Dashboard** -> **General** -> **Custom CSS**:
|
||||
```html
|
||||
<script src="/BlackBarRemover/ClientScript.js"></script>
|
||||
```
|
||||
The plugin injects `/BlackBarRemover/ClientScript.js` into Jellyfin Web HTML responses at request time. Reload the web player after enabling the plugin. If the script does not load, check the Jellyfin logs and the browser network panel for the script request.
|
||||
|
||||
## How it works
|
||||
## Architecture
|
||||
|
||||
When a video is scanned, a background process runs `ffmpeg -skip_frame nokey -i <file> -vf cropdetect`. It collects all crops and ignores anomalies (appearing in < 5% of frames). The resulting bounding box is saved to the Jellyfin database under the `BlackBarCrop` provider ID.
|
||||
|
||||
---
|
||||
*Note: This plugin, including its logic, FFmpeg integrations, and documentation, was generated entirely with the assistance of an advanced AI agent (Google Antigravity / Gemini) during a pair-programming session.*
|
||||
## Documentation
|
||||
|
||||
[README style guide](docs/README-STYLE.md).
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin, including its logic, FFmpeg integrations, and documentation, was generated entirely with the assistance of an advanced AI agent (Google Antigravity / Gemini) during a pair-programming session.
|
||||
|
||||
## License
|
||||
|
||||
No project-level license file is currently included in this repository. Third-party components retain their own licenses.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Repository README standard
|
||||
|
||||
Version: 1.0. Documentation language: English.
|
||||
|
||||
A README should explain the project, help a new reader start it, and point to detailed references. Keep project-specific instructions and existing attribution when applying this standard.
|
||||
|
||||
## Original projects
|
||||
|
||||
Start with one `# Project name` heading and a short description of what the project does and who it serves. An optional local screenshot or banner may follow. Then add a navigation line linking to the level-two sections present in the document.
|
||||
|
||||
Use these level-two headings in this order. Omit sections that do not apply; do not add empty sections or filler just to match the template.
|
||||
|
||||
| Section | Content |
|
||||
| --- | --- |
|
||||
| Features | Concrete capabilities and supported use cases. |
|
||||
| Requirements | Runtime, hardware, services, and build prerequisites; distinguish build from runtime needs. |
|
||||
| Getting started | Shortest complete path from checkout or download to a working result, including initial setup. |
|
||||
| Configuration | Settings, defaults, required values, and example files. Explain prerequisites before commands that need them. |
|
||||
| Usage | Common workflows, commands, and expected output or artifact locations. |
|
||||
| Architecture | Project layout, interfaces, data model, or hardware overview. |
|
||||
| Limitations | Known constraints and validation boundaries; keep critical setup constraints near the affected step too. |
|
||||
| Development | Build, test, validation, and release instructions supported by repository files. |
|
||||
| Documentation | Links to detailed guides, diagrams, screenshots, and this style guide. |
|
||||
| Credits | Contributors, reused work, upstream sources, and existing AI-assistance disclosures. |
|
||||
| License | Link to the actual project license; state explicitly when no project-level license file exists. |
|
||||
|
||||
Keep specialized content under these headings using `###` and `####`. For example, put power variants under Configuration, pinouts under Architecture, and schematic downloads under Documentation or Architecture. Small component READMEs can use only the relevant sections and link back to the root README.
|
||||
|
||||
## Forks
|
||||
|
||||
Use the same formatting rules, but introduce the fork before describing the original project. Start with one title, a short fork description, and clearly labeled links to upstream and the fork repository.
|
||||
|
||||
Use these headings in order, omitting optional sections when unnecessary:
|
||||
|
||||
1. Fork purpose
|
||||
2. Changes from upstream
|
||||
3. Requirements
|
||||
4. Getting started
|
||||
5. Configuration
|
||||
6. Usage
|
||||
7. Architecture
|
||||
8. Limitations
|
||||
9. Development
|
||||
10. Upstream maintenance
|
||||
11. Documentation
|
||||
12. Credits
|
||||
13. License
|
||||
|
||||
Explain why the fork exists, list its differences, and identify where to obtain its builds. Clearly label links to upstream builds, contribution channels, and screenshots. Include upstream synchronization instructions based on the actual workflow, and checks needed after merges.
|
||||
|
||||
Keep inherited documentation in `README.upstream.md` at the repository root. Keeping it beside the original README preserves relative links to assets. Retain upstream attribution, donations, and license notices there. Label any local corrections; this file is inherited documentation, not a claim to be the latest upstream version. After an upstream merge, refresh the inherited file and review the fork README separately. Do not overwrite the fork's explanation with the upstream README.
|
||||
|
||||
Borrowing a component or taking inspiration from another project does not by itself make the entire repository a fork. Explain that relationship under Credits and preserve component licensing.
|
||||
|
||||
## Markdown and accuracy
|
||||
|
||||
- Write documentation in English. Preserve proper names and UI labels as shown in the product.
|
||||
- Use one H1, sentence-case headings, and consecutive heading levels. Do not number headings.
|
||||
- Separate paragraphs, headings, lists, tables, and fenced blocks with blank lines.
|
||||
- Use `-` for unordered lists. Indent nested content consistently; use four spaces for nested unordered lists.
|
||||
- Label fenced blocks: `bash`, `sh`, `powershell`, `python`, `json`, `yaml`, `dotenv`, `text`, or `mermaid` as appropriate.
|
||||
- Run commands from the repository root unless a different working directory is explicitly stated.
|
||||
- Use placeholders or portable example paths instead of private workstation paths. Never include real tokens, passwords, private keys, or local data.
|
||||
- Link to versioned files using relative paths. Use full URLs for other repositories, services, and upstream sources. Avoid sibling-checkout links.
|
||||
- Prefer Markdown. Use HTML only when it improves a gallery or layout; provide image alt text.
|
||||
- Keep navigation anchors synchronized with headings. Avoid duplicate headings that create ambiguous anchors.
|
||||
- Avoid decorative badges, repeated separators, promotional claims, and unsupported claims of production readiness or completed testing.
|
||||
- Verify requirements and commands against manifests, scripts, configuration, and workflows. Distinguish previously documented results from checks performed during an edit.
|
||||
- Preserve existing technical instructions, screenshots, credits, and license notices when reorganizing. Move long references into `docs/` when useful and update links relative to their new location.
|
||||
- Never infer a license from a badge, framework, dependency, or a different repository. License choices require an explicit project decision.
|
||||
|
||||
## Maintenance
|
||||
|
||||
Update the README alongside changes to installation, configuration, commands, supported hardware, or release behavior. Check local links, navigation, and Markdown structure before committing. Run a command only when its effects are appropriate; a documentation check must not flash hardware, publish a release, send messages, or start an import.
|
||||
+229
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user