Compare commits
31 Commits
v0.1.2
...
v0.1.16-beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8b9a6d43 | |||
| 0cfaa50805 | |||
| fea812d3a3 | |||
| fe6e20a68a | |||
| b7693b606e | |||
| 13f9fd642c | |||
| 322cfd9acd | |||
| 648930aa21 | |||
| cb16bf0074 | |||
| f6ed790801 | |||
| d5e11a52ed | |||
| d018a8e5ac | |||
| 25c7a56901 | |||
| dfe59ed84a | |||
| 795f42048f | |||
| 5f8674fae4 | |||
| 0a437a5588 | |||
| 5b14fe9657 | |||
| f8b7c95e01 | |||
| e2c286e42c | |||
| e44aafae70 | |||
| 44325b9f9c | |||
| 9159d8f8d7 | |||
| a048a91f23 | |||
| aa8fb54e8f | |||
| 24a85cf0ec | |||
| e826801482 | |||
| 6cc61baeda | |||
| 5aff92d296 | |||
| 18b4a71a2f | |||
| b46e5e1786 |
+150
-53
@@ -17,59 +17,128 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
dotnet-version: |
|
||||
9.0.x
|
||||
10.0.x
|
||||
|
||||
- name: Build Plugin
|
||||
run: dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
|
||||
|
||||
- name: Create ZIP Archive
|
||||
- name: Create ZIP Archives
|
||||
run: |
|
||||
cd JellyfinPlugin/bin/Release/net10.0/
|
||||
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}.zip *
|
||||
cd JellyfinPlugin/bin/Release/
|
||||
|
||||
# Zip net9.0 (Jellyfin 10.11)
|
||||
cd net9.0
|
||||
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}_jf10.11.zip *
|
||||
cd ..
|
||||
|
||||
# Zip net10.0 (Jellyfin 12.0)
|
||||
cd net10.0
|
||||
zip -j ../../../../BlackBarRemover_${{ github.ref_name }}_jf12.0.zip *
|
||||
cd ..
|
||||
|
||||
- name: Generate Checksum
|
||||
- name: Generate Checksums
|
||||
id: checksum
|
||||
run: |
|
||||
MD5_SUM=$(md5sum BlackBarRemover_${{ github.ref_name }}.zip | awk '{print $1}')
|
||||
echo "md5=$MD5_SUM" >> $GITHUB_OUTPUT
|
||||
MD5_SUM_9=$(md5sum BlackBarRemover_${{ github.ref_name }}_jf10.11.zip | awk '{print $1}')
|
||||
MD5_SUM_10=$(md5sum BlackBarRemover_${{ github.ref_name }}_jf12.0.zip | awk '{print $1}')
|
||||
echo "md5_9=$MD5_SUM_9" >> $GITHUB_OUTPUT
|
||||
echo "md5_10=$MD5_SUM_10" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Gitea Release
|
||||
- name: Publish Release and Update Manifest
|
||||
env:
|
||||
API_URL: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
MD5_9: ${{ steps.checksum.outputs.md5_9 }}
|
||||
MD5_10: ${{ steps.checksum.outputs.md5_10 }}
|
||||
run: |
|
||||
API_URL="${{ github.api_url }}"
|
||||
REPO="${{ github.repository }}"
|
||||
TOKEN="${{ github.token }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
|
||||
IS_PRERELEASE="false"
|
||||
if [[ "$TAG" == *"beta"* ]] || [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == v0.* ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
fi
|
||||
|
||||
# Create Release
|
||||
RELEASE_RES=$(curl -s -X POST "$API_URL/repos/$REPO/releases" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\", \"name\":\"Release $TAG\", \"body\":\"Automated release\", \"draft\":false, \"prerelease\":$IS_PRERELEASE}")
|
||||
|
||||
REL_ID=$(echo "$RELEASE_RES" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
|
||||
# Upload Asset
|
||||
curl -s -X POST "$API_URL/repos/$REPO/releases/$REL_ID/assets" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@BlackBarRemover_$TAG.zip"
|
||||
python3 -c '
|
||||
import os, json, urllib.request, base64, datetime
|
||||
|
||||
- name: Update manifest.json
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
|
||||
git checkout -B manifest
|
||||
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}" # remove 'v' prefix
|
||||
|
||||
cat > manifest.json <<EOF
|
||||
[
|
||||
api_url = os.environ["API_URL"]
|
||||
repo = os.environ["REPO"]
|
||||
token = os.environ["TOKEN"]
|
||||
tag = os.environ["TAG"]
|
||||
md5_9 = os.environ["MD5_9"]
|
||||
md5_10 = os.environ["MD5_10"]
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 1. Get or Create Release
|
||||
rel_url = f"{api_url}/repos/{repo}/releases/tags/{tag}"
|
||||
req = urllib.request.Request(rel_url, headers=headers)
|
||||
rel_data = None
|
||||
try:
|
||||
res = urllib.request.urlopen(req)
|
||||
rel_data = json.loads(res.read().decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not rel_data or "id" not in rel_data:
|
||||
is_pre = "beta" in tag or "alpha" in tag or tag.startswith("v0.")
|
||||
create_url = f"{api_url}/repos/{repo}/releases"
|
||||
body = json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": f"Release {tag}",
|
||||
"body": "Automated release",
|
||||
"draft": False,
|
||||
"prerelease": is_pre
|
||||
}).encode("utf-8")
|
||||
create_req = urllib.request.Request(create_url, data=body, headers=headers)
|
||||
res = urllib.request.urlopen(create_req)
|
||||
rel_data = json.loads(res.read().decode("utf-8"))
|
||||
|
||||
rel_id = rel_data["id"]
|
||||
|
||||
# 2. Upload Assets
|
||||
for filename in [f"BlackBarRemover_{tag}_jf10.11.zip", f"BlackBarRemover_{tag}_jf12.0.zip"]:
|
||||
if not os.path.exists(filename):
|
||||
print(f"File {filename} not found, skipping.")
|
||||
continue
|
||||
|
||||
# Delete existing asset if any
|
||||
for existing_asset in rel_data.get("assets", []) or []:
|
||||
if existing_asset.get("name") == filename:
|
||||
del_url = f"{api_url}/repos/{repo}/releases/{rel_id}/assets/{existing_asset['id']}"
|
||||
del_req = urllib.request.Request(del_url, headers=headers, method="DELETE")
|
||||
try:
|
||||
urllib.request.urlopen(del_req)
|
||||
print(f"Deleted old asset {existing_asset['id']}")
|
||||
except Exception as e:
|
||||
print(f"Delete asset notice: {e}")
|
||||
|
||||
# Upload asset
|
||||
with open(filename, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
|
||||
body_data = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{filename}\"\r\n"
|
||||
f"Content-Type: application/zip\r\n\r\n"
|
||||
).encode("utf-8") + file_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
|
||||
upload_headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}"
|
||||
}
|
||||
upload_url = f"{api_url}/repos/{repo}/releases/{rel_id}/assets"
|
||||
upload_req = urllib.request.Request(upload_url, data=body_data, headers=upload_headers)
|
||||
try:
|
||||
urllib.request.urlopen(upload_req)
|
||||
print(f"Uploaded {filename} successfully.")
|
||||
except Exception as e:
|
||||
print(f"Asset upload error: {e}")
|
||||
|
||||
# 3. Update manifest.json on master branch
|
||||
clean_ver = tag.lstrip("v").split("-")[0]
|
||||
manifest_data = [
|
||||
{
|
||||
"guid": "12345678-1234-1234-1234-1234567890ab",
|
||||
"name": "Black Bar Remover",
|
||||
@@ -80,19 +149,47 @@ jobs:
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"changelog": "Automated Release ${{ github.ref_name }}",
|
||||
"version": clean_ver,
|
||||
"changelog": f"Automated Release {tag} (Jellyfin 12.0)",
|
||||
"targetAbi": "12.0.0.0",
|
||||
"sourceUrl": "https://gitea.labus.uk/karol/blackBarRemover",
|
||||
"checksum": "${{ steps.checksum.outputs.md5 }}",
|
||||
"timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
|
||||
"repositoryUrl": "https://gitea.labus.uk/karol/blackBarRemover/releases/download/${{ github.ref_name }}/BlackBarRemover_${{ github.ref_name }}.zip"
|
||||
"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')
|
||||
},
|
||||
{
|
||||
"version": clean_ver,
|
||||
"changelog": f"Automated 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')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
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 branch
|
||||
get_mf_url = f"{api_url}/repos/{repo}/contents/manifest.json?ref=master"
|
||||
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": "master"
|
||||
}
|
||||
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")
|
||||
urllib.request.urlopen(put_req)
|
||||
print("manifest.json updated successfully via API.")
|
||||
'
|
||||
|
||||
@@ -21,41 +21,44 @@ namespace JellyfinPlugin.Api
|
||||
}
|
||||
|
||||
[HttpGet("CropByPath")]
|
||||
public ActionResult<string> GetCropByPath([FromQuery] string path)
|
||||
[Produces("text/plain")]
|
||||
public ContentResult GetCropByPath([FromQuery] string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return Ok(string.Empty);
|
||||
if (string.IsNullOrEmpty(path)) return Content(string.Empty, "text/plain");
|
||||
path = path.Trim('"').Trim('\'');
|
||||
|
||||
var mode = Plugin.Instance!.Configuration.PlaybackMode;
|
||||
if (mode == "Client") return Ok(string.Empty);
|
||||
if (mode == "Client") return Content(string.Empty, "text/plain");
|
||||
|
||||
var item = _libraryManager.RootFolder.GetRecursiveChildren().FirstOrDefault(x => x.Path == path);
|
||||
|
||||
if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
|
||||
{
|
||||
return Ok(cropData ?? string.Empty);
|
||||
return Content(cropData ?? string.Empty, "text/plain");
|
||||
}
|
||||
|
||||
return Ok(string.Empty);
|
||||
return Content(string.Empty, "text/plain");
|
||||
}
|
||||
|
||||
[HttpGet("CropById")]
|
||||
public ActionResult<string> GetCropById([FromQuery] string itemId)
|
||||
[Produces("text/plain")]
|
||||
public ContentResult GetCropById([FromQuery] string itemId, [FromQuery] bool ignoreMode = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return Ok(string.Empty);
|
||||
if (string.IsNullOrEmpty(itemId)) return Content(string.Empty, "text/plain");
|
||||
|
||||
var mode = Plugin.Instance!.Configuration.PlaybackMode;
|
||||
if (mode == "Server") return Ok(string.Empty);
|
||||
if (mode == "Server" && !ignoreMode) return Content(string.Empty, "text/plain");
|
||||
|
||||
if (Guid.TryParse(itemId, out Guid id))
|
||||
{
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
|
||||
{
|
||||
return Ok(cropData ?? string.Empty);
|
||||
return Content(cropData ?? string.Empty, "text/plain");
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(string.Empty);
|
||||
return Content(string.Empty, "text/plain");
|
||||
}
|
||||
|
||||
[HttpGet("ClientScript.js")]
|
||||
@@ -65,10 +68,12 @@ namespace JellyfinPlugin.Api
|
||||
string js = @"
|
||||
(function() {
|
||||
console.log('Black Bar Remover: Script loaded');
|
||||
let currentVideoElement = null;
|
||||
let currentItemId = null;
|
||||
let interceptedItemId = null;
|
||||
|
||||
function applyCrop(cropData) {
|
||||
if (!currentVideoElement) return;
|
||||
const video = document.querySelector('video');
|
||||
if (!video) return;
|
||||
const parts = cropData.split(':');
|
||||
if (parts.length === 4) {
|
||||
const w = parseInt(parts[0]);
|
||||
@@ -81,39 +86,151 @@ namespace JellyfinPlugin.Api
|
||||
const leftPercent = (x / 1920) * 100;
|
||||
const rightPercent = ((1920 - (x + w)) / 1920) * 100;
|
||||
|
||||
currentVideoElement.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`;
|
||||
video.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`;
|
||||
|
||||
const scaleY = 1 / (1 - (topPercent + bottomPercent)/100);
|
||||
const scaleX = 1 / (1 - (leftPercent + rightPercent)/100);
|
||||
const scale = Math.max(scaleY, scaleX);
|
||||
|
||||
if (scale > 1.01) {
|
||||
currentVideoElement.style.transform = `scale(${scale})`;
|
||||
video.style.transform = `scale(${scale})`;
|
||||
}
|
||||
console.log('Black Bar Remover: Applied crop', cropData, 'scale', scale);
|
||||
}
|
||||
}
|
||||
|
||||
function checkPlayback() {
|
||||
const video = document.querySelector('video');
|
||||
if (video && video !== currentVideoElement) {
|
||||
currentVideoElement = video;
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1]);
|
||||
const itemId = params.get('id') || params.get('itemId');
|
||||
|
||||
if (itemId) {
|
||||
fetch('/BlackBarRemover/CropById?itemId=' + itemId)
|
||||
.then(r => r.text())
|
||||
.then(crop => {
|
||||
if (crop) applyCrop(crop);
|
||||
});
|
||||
}
|
||||
} else if (!video && currentVideoElement) {
|
||||
currentVideoElement = null;
|
||||
}
|
||||
function fetchCrop(itemId) {
|
||||
console.log('Black Bar Remover: Fetching crop data for item', itemId);
|
||||
window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId)
|
||||
.then(r => r.text())
|
||||
.then(crop => {
|
||||
console.log('Black Bar Remover: Received crop data:', crop);
|
||||
if (crop) {
|
||||
setTimeout(() => applyCrop(crop), 500);
|
||||
setTimeout(() => applyCrop(crop), 2000);
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Black Bar Remover: API fetch error:', e));
|
||||
}
|
||||
|
||||
setInterval(checkPlayback, 2000);
|
||||
if (!window._originalFetch) {
|
||||
window._originalFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url);
|
||||
if (url) {
|
||||
const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (id && id !== interceptedItemId) {
|
||||
interceptedItemId = id;
|
||||
console.log('Black Bar Remover: Intercepted itemId from fetch:', id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return window._originalFetch.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window._originalXHR) {
|
||||
window._originalXHR = window.XMLHttpRequest;
|
||||
window.XMLHttpRequest = function() {
|
||||
const xhr = new window._originalXHR();
|
||||
const originalOpen = xhr.open;
|
||||
xhr.open = function(method, url, ...rest) {
|
||||
if (url && typeof url === 'string') {
|
||||
const match = url.match(/\/Items\/([a-f0-9]{32}|[a-f0-9\-]{36})/i);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (id && id !== interceptedItemId) {
|
||||
interceptedItemId = id;
|
||||
console.log('Black Bar Remover: Intercepted itemId from XHR:', id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalOpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
return xhr;
|
||||
};
|
||||
}
|
||||
|
||||
function checkServerCrop(itemId) {
|
||||
window._originalFetch('/BlackBarRemover/CropById?itemId=' + itemId + '&ignoreMode=true')
|
||||
.then(r => r.text())
|
||||
.then(crop => {
|
||||
if (crop) {
|
||||
window._isServerCropping = true;
|
||||
} else {
|
||||
window._isServerCropping = false;
|
||||
}
|
||||
})
|
||||
.catch(e => { window._isServerCropping = false; });
|
||||
}
|
||||
|
||||
const playbackInfoObserver = new MutationObserver((mutations) => {
|
||||
if (!window._isServerCropping) return;
|
||||
|
||||
for (let m of mutations) {
|
||||
if (m.type === 'characterData') {
|
||||
if (m.target.nodeValue) {
|
||||
const text = m.target.nodeValue.trim();
|
||||
if (text === ""The video's resolution is not supported"" ||
|
||||
text === ""VideoResolutionNotSupported"" ||
|
||||
text.includes(""rozdzielczość nie jest obsługiwana"") ||
|
||||
text === ""Rozdzielczość wideo nie jest obsługiwana"") {
|
||||
m.target.nodeValue = ""Black Bar Removal (Server Mode)"";
|
||||
}
|
||||
}
|
||||
} else if (m.type === 'childList') {
|
||||
m.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const textContent = node.textContent || """";
|
||||
if (textContent.includes(""The video's resolution is not supported"") ||
|
||||
textContent.includes(""VideoResolutionNotSupported"") ||
|
||||
textContent.includes(""rozdzielczość"")) {
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
|
||||
let textNode;
|
||||
while (textNode = walker.nextNode()) {
|
||||
if (textNode.nodeValue) {
|
||||
const t = textNode.nodeValue.trim();
|
||||
if (t === ""The video's resolution is not supported"" ||
|
||||
t === ""VideoResolutionNotSupported"" ||
|
||||
t.includes(""rozdzielczość nie jest obsługiwana"") ||
|
||||
t === ""Rozdzielczość wideo nie jest obsługiwana"") {
|
||||
textNode.nodeValue = ""Black Bar Removal (Server Mode)"";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node.nodeValue) {
|
||||
const t = node.nodeValue.trim();
|
||||
if (t === ""The video's resolution is not supported"" ||
|
||||
t === ""VideoResolutionNotSupported"" ||
|
||||
t.includes(""rozdzielczość nie jest obsługiwana"") ||
|
||||
t === ""Rozdzielczość wideo nie jest obsługiwana"") {
|
||||
node.nodeValue = ""Black Bar Removal (Server Mode)"";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
playbackInfoObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
setInterval(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (video && interceptedItemId && interceptedItemId !== currentItemId) {
|
||||
currentItemId = interceptedItemId;
|
||||
console.log('Black Bar Remover: Video element found for intercepted item:', currentItemId);
|
||||
fetchCrop(currentItemId);
|
||||
checkServerCrop(currentItemId);
|
||||
} else if (!video) {
|
||||
currentItemId = null;
|
||||
window._isServerCropping = false;
|
||||
}
|
||||
}, 2000);
|
||||
})();
|
||||
";
|
||||
return Content(js, "application/javascript");
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace JellyfinPlugin.Configuration
|
||||
{
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
// Tolerancja detekcji czarnych pasów (domyślnie 0.15 czyli 15%)
|
||||
public double CropDetectLimit { get; set; } = 0.15;
|
||||
// Tolerancja detekcji czarnych pasów (0.094 = domyślne 24/255)
|
||||
public double CropDetectLimit { get; set; } = 0.094;
|
||||
|
||||
// Czy wtyczka jest aktywna
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Black Bar Detection Tolerance (0.01 - 1.0):</label>
|
||||
<input type="number" step="0.01" min="0.01" max="1.0" id="txtCropLimit" is="emby-input" required />
|
||||
<div class="fieldDescription">Default is 0.15. Increase this value if black bars are not being detected due to video noise (e.g., change to 0.20 for WEB-DL).</div>
|
||||
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Black Bar Detection Tolerance (0.001 - 1.0):</label>
|
||||
<input type="number" step="0.001" min="0.001" max="1.0" id="txtCropLimit" is="emby-input" required />
|
||||
<div class="fieldDescription">Default is 0.094. Increase this value (e.g., to 0.15) if the plugin fails to detect black bars on heavily compressed WEB-DLs with noise. Decrease it if the plugin aggressively crops dark scenes.</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
@@ -47,31 +47,29 @@
|
||||
pluginUniqueId: "12345678-1234-1234-1234-1234567890ab"
|
||||
};
|
||||
|
||||
$('.blackBarRemoverConfigForm').on('pageinit', function (event) {
|
||||
$('.pluginConfigurationPage').on('pageshow', function (event) {
|
||||
var page = event.target;
|
||||
|
||||
page.addEventListener('viewshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
|
||||
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled || false);
|
||||
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.15);
|
||||
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both");
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
|
||||
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled !== false);
|
||||
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.094);
|
||||
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both");
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
$('.blackBarRemoverConfigForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
$('.blackBarRemoverConfigForm').on('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
|
||||
config.IsEnabled = $('#chkEnablePlugin', form).prop('checked');
|
||||
config.CropDetectLimit = parseFloat($('#txtCropLimit', form).val());
|
||||
config.PlaybackMode = $('#selectPlaybackMode', form).val();
|
||||
|
||||
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
|
||||
config.IsEnabled = $('#chkEnablePlugin', page).prop('checked');
|
||||
config.CropDetectLimit = parseFloat($('#txtCropLimit', page).val());
|
||||
config.PlaybackMode = $('#selectPlaybackMode', page).val();
|
||||
|
||||
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,14 +9,24 @@ namespace JellyfinPlugin
|
||||
{
|
||||
public static class CropHelper
|
||||
{
|
||||
private static string GetFFmpegPath()
|
||||
{
|
||||
if (File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")) return "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
if (File.Exists("/usr/bin/ffmpeg")) return "/usr/bin/ffmpeg";
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
public static async Task<string> DetectCropAsync(string filePath, double limit)
|
||||
{
|
||||
// FFmpeg w nowych wersjach przyjmuje limit w formie ułamka, np. 24/255 to ok 0.094
|
||||
// Używamy domyślnego limitu ffmpeg (ok. 0.094), ewentualnie tego z configu jeśli użytkownik zmienił
|
||||
string limitStr = limit.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=0\" -f null -",
|
||||
FileName = GetFFmpegPath(),
|
||||
// KLUCZOWE: reset=1, żeby oceniał każdą klatkę niezależnie!
|
||||
Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=1\" -f null -",
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
@@ -44,12 +54,17 @@ namespace JellyfinPlugin
|
||||
|
||||
if (totalFrames == 0) return string.Empty;
|
||||
|
||||
// Bierzemy pod uwagę tylko wymiary, które występują przez co najmniej 5% czasu filmu
|
||||
// Dzięki temu ignorujemy jednosekundowe loga czy zaszumione klatki
|
||||
var validCrops = crops.Where(kvp => (double)kvp.Value / totalFrames > 0.05).ToList();
|
||||
if (!validCrops.Any())
|
||||
{
|
||||
validCrops = crops.OrderByDescending(kvp => kvp.Value).Take(1).ToList();
|
||||
}
|
||||
|
||||
// Tworzymy maksymalną obwiednię (Bounding Box) ze wszystkich WAŻNYCH kadrów
|
||||
// Dzięki temu jeśli film to np. Interstellar (30% czasu w 16:9), kadr 16:9 zostanie zachowany
|
||||
// i nie utniemy ważnych scen!
|
||||
int minX = int.MaxValue, minY = int.MaxValue, maxXW = 0, maxYH = 0;
|
||||
|
||||
foreach (var kvp in validCrops)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Zależności Jellyfin -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.2" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.2" ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0" ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace JellyfinPlugin
|
||||
private readonly ILogger<LibraryMonitor> _logger;
|
||||
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, byte> _processedItems = new ConcurrentDictionary<Guid, byte>();
|
||||
|
||||
public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
@@ -48,6 +50,16 @@ namespace JellyfinPlugin
|
||||
{
|
||||
if (!Plugin.Instance!.Configuration.IsEnabled) return;
|
||||
|
||||
if (_processedItems.ContainsKey(video.Id)) return;
|
||||
|
||||
if (video.ProviderIds != null &&
|
||||
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
_processedItems.TryAdd(video.Id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire and forget, don't block library scan
|
||||
_ = Task.Run(() => ProcessVideoAsync(video));
|
||||
}
|
||||
@@ -57,7 +69,11 @@ namespace JellyfinPlugin
|
||||
{
|
||||
try
|
||||
{
|
||||
if (video.ProviderIds != null && video.ProviderIds.ContainsKey("BlackBarCrop"))
|
||||
if (!_processedItems.TryAdd(video.Id, 0)) return;
|
||||
|
||||
if (video.ProviderIds != null &&
|
||||
video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +94,7 @@ namespace JellyfinPlugin
|
||||
}
|
||||
video.ProviderIds["BlackBarCrop"] = cropData;
|
||||
await _libraryManager.UpdateItemAsync(video, video, ItemUpdateType.MetadataEdit, CancellationToken.None);
|
||||
_logger.LogInformation("Zapisano proporcje crop {CropData} dla pliku {Path}", cropData, path);
|
||||
_logger.LogInformation("Saved crop proportions {CropData} for file {Path}", cropData, path);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -88,7 +104,7 @@ namespace JellyfinPlugin
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Błąd podczas procesowania wideo: {Path}", video.Path);
|
||||
_logger.LogError(ex, "Error processing video: {Path}", video.Path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class PlaybackInfoInterceptorStartupFilter : IStartupFilter
|
||||
{
|
||||
private readonly ILogger<PlaybackInfoInterceptorStartupFilter> _logger;
|
||||
private readonly MediaBrowser.Controller.Library.ILibraryManager _libraryManager;
|
||||
|
||||
public PlaybackInfoInterceptorStartupFilter(ILogger<PlaybackInfoInterceptorStartupFilter> logger, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
{
|
||||
return app =>
|
||||
{
|
||||
app.Use(InvokeAsync);
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
|
||||
private string? GetCropFromId(string itemId)
|
||||
{
|
||||
if (Guid.TryParse(itemId, out Guid id))
|
||||
{
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
if (item != null && item.ProviderIds != null)
|
||||
{
|
||||
if (item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData))
|
||||
{
|
||||
return cropData;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task InvokeAsync(HttpContext context, Func<Task> nextMw)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.IsEnabled || config.PlaybackMode == "Client")
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- HLS STREAM QUERY REWRITE ---
|
||||
// If the client requests the m3u8 playlist, we simply tell Jellyfin NOT to allow video stream copy.
|
||||
// This elegantly forces transcoding for the BEST supported codec without breaking FFmpeg tags/filters!
|
||||
if (path.Contains("/Videos/", StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) || path.Contains("/stream", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase);
|
||||
if (matchId.Success)
|
||||
{
|
||||
var idStr = matchId.Groups[1].Value;
|
||||
var crop = GetCropFromId(idStr);
|
||||
if (crop != null)
|
||||
{
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(context.Request.QueryString.Value);
|
||||
var items = query.SelectMany(x => x.Value, (col, val) => new System.Collections.Generic.KeyValuePair<string, string>(col.Key, val)).ToList();
|
||||
|
||||
var newQueryString = "?";
|
||||
foreach (var kvp in items)
|
||||
{
|
||||
if (!kvp.Key.Equals("allowVideoStreamCopy", StringComparison.OrdinalIgnoreCase) &&
|
||||
!kvp.Key.Equals("transcodeReasons", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
newQueryString += $"{kvp.Key}={System.Net.WebUtility.UrlEncode(kvp.Value)}&";
|
||||
}
|
||||
}
|
||||
// Force disable stream copy AND provide a valid reason for the dashboard
|
||||
newQueryString += "allowVideoStreamCopy=false&transcodeReasons=VideoResolutionNotSupported";
|
||||
|
||||
context.Request.QueryString = new QueryString(newQueryString);
|
||||
_logger.LogInformation($"[Black Bar Remover DEBUG] Intercepted HLS request for {idStr}. Appended allowVideoStreamCopy=false and transcodeReasons=VideoResolutionNotSupported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var match = Regex.Match(path, @"/Items/([^/]+)/PlaybackInfo", RegexOptions.IgnoreCase);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var itemId = match.Groups[1].Value;
|
||||
var cropData = GetCropFromId(itemId);
|
||||
|
||||
if (cropData == null)
|
||||
{
|
||||
_logger.LogInformation($"[Black Bar Remover DEBUG] PlaybackInfo request for {itemId}, but no crop found in DB. Skipping interception.");
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo request for {itemId}. Found crop in DB: {cropData}. Disabling DirectPlayProfiles in incoming request.");
|
||||
|
||||
// Modify incoming request body to clear DirectPlayProfiles
|
||||
context.Request.EnableBuffering();
|
||||
string reqJson = string.Empty;
|
||||
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
reqJson = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
JsonNode rootNode;
|
||||
try
|
||||
{
|
||||
rootNode = string.IsNullOrWhiteSpace(reqJson) ? new JsonObject() : (JsonNode.Parse(reqJson) ?? new JsonObject());
|
||||
}
|
||||
catch
|
||||
{
|
||||
rootNode = new JsonObject();
|
||||
}
|
||||
|
||||
if (rootNode["DeviceProfile"] is not JsonObject devProfile)
|
||||
{
|
||||
devProfile = new JsonObject();
|
||||
rootNode["DeviceProfile"] = devProfile;
|
||||
}
|
||||
|
||||
devProfile["DirectPlayProfiles"] = new JsonArray();
|
||||
|
||||
var newReqBytes = Encoding.UTF8.GetBytes(rootNode.ToJsonString());
|
||||
var reqMs = new MemoryStream(newReqBytes);
|
||||
context.Request.Body = reqMs;
|
||||
context.Request.ContentLength = newReqBytes.Length;
|
||||
|
||||
await nextMw().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using JellyfinPlugin.Tasks;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Zarejestrowanie filtru wstrzykującego nasz skrypt kliencki
|
||||
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, ScriptInjectionStartupFilter>();
|
||||
|
||||
// Zarejestrowanie filtru wymuszającego transkodowanie dla Server Mode
|
||||
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, PlaybackInfoInterceptorStartupFilter>();
|
||||
serviceCollection.AddHostedService<WrapperGenerator>();
|
||||
serviceCollection.AddHostedService<LibraryMonitor>();
|
||||
|
||||
// W Jellyfin 10.9+ zadania harmonogramu czasami trzeba jawnie rejestrować jeśli refleksja ich nie wychwyci
|
||||
serviceCollection.AddScoped<MediaBrowser.Model.Tasks.IScheduledTask, LibraryRetroactiveScanTask>();
|
||||
serviceCollection.AddScoped<MediaBrowser.Model.Tasks.IScheduledTask, LibraryForceRescanTask>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JellyfinPlugin
|
||||
{
|
||||
public class ScriptInjectionStartupFilter : IStartupFilter
|
||||
{
|
||||
private readonly ILogger<ScriptInjectionStartupFilter> _logger;
|
||||
private int _loggedOnce;
|
||||
|
||||
public ScriptInjectionStartupFilter(ILogger<ScriptInjectionStartupFilter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
{
|
||||
return app =>
|
||||
{
|
||||
app.Use(InvokeAsync);
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
|
||||
private async Task InvokeAsync(HttpContext context, Func<Task> nextMw)
|
||||
{
|
||||
if (!IsIndexRequest(context.Request.Path.Value))
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.IsEnabled)
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Request.Headers.Remove("Accept-Encoding");
|
||||
context.Request.Headers.Remove("Range");
|
||||
context.Request.Headers.Remove("If-Range");
|
||||
|
||||
var originalBody = context.Response.Body;
|
||||
using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
try
|
||||
{
|
||||
await nextMw().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
throw;
|
||||
}
|
||||
|
||||
context.Response.Body = originalBody;
|
||||
buffer.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var isHtml = context.Response.StatusCode == 200
|
||||
&& (context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
if (!isHtml)
|
||||
{
|
||||
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string html;
|
||||
using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true))
|
||||
{
|
||||
html = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var alreadyInjected = html.IndexOf("BlackBarRemover/ClientScript.js", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
var bodyClose = html.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!alreadyInjected && bodyClose >= 0)
|
||||
{
|
||||
var tag = "<script src=\"/BlackBarRemover/ClientScript.js\"></script>";
|
||||
html = html.Substring(0, bodyClose) + tag + "\n" + html.Substring(bodyClose);
|
||||
|
||||
if (System.Threading.Interlocked.Exchange(ref _loggedOnce, 1) == 0)
|
||||
{
|
||||
_logger.LogInformation("Black Bar Remover: injected the client script via request-time middleware (IStartupFilter).");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning($"Script injection middleware error (serving original HTML): {ex.Message}");
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(html);
|
||||
context.Response.ContentType = "text/html;charset=utf-8";
|
||||
context.Response.ContentLength = bytes.Length;
|
||||
context.Response.Headers.Remove("ETag");
|
||||
context.Response.Headers.Remove("Last-Modified");
|
||||
context.Response.Headers.Remove("Accept-Ranges");
|
||||
await originalBody.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsIndexRequest(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
return path.EndsWith("/web/index.html", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith("/web/", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Equals("/web", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JellyfinPlugin.Tasks
|
||||
{
|
||||
public class LibraryForceRescanTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<LibraryForceRescanTask> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
public LibraryForceRescanTask(ILogger<LibraryForceRescanTask> logger, ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
public string Name => "Force Rescan All Black Bars (Black Bar Remover)";
|
||||
public string Key => "BlackBarForceRescanTask";
|
||||
public string Description => "Forces a rescan of all files, overwriting any previously saved crop data. Use this if you changed the detection limit.";
|
||||
public string Category => "Library";
|
||||
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
if (!config.IsEnabled)
|
||||
{
|
||||
_logger.LogInformation("Plugin is disabled. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var items = _libraryManager.RootFolder.GetRecursiveChildren()
|
||||
.Where(x => x is Movie || x is Episode)
|
||||
.ToList();
|
||||
|
||||
int total = items.Count;
|
||||
int processed = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Wymuszamy skanowanie, więc nie sprawdzamy czy ma już BlackBarCrop
|
||||
|
||||
|
||||
var path = item.Path;
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
|
||||
|
||||
if (!string.IsNullOrEmpty(cropData))
|
||||
{
|
||||
if (item.ProviderIds == null)
|
||||
{
|
||||
item.ProviderIds = new Dictionary<string, string>();
|
||||
}
|
||||
item.ProviderIds["BlackBarCrop"] = cropData;
|
||||
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return Array.Empty<TaskTriggerInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ namespace JellyfinPlugin.Tasks
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
if (!config.IsEnabled)
|
||||
{
|
||||
_logger.LogInformation("Wtyczka wyłączona. Pomijanie.");
|
||||
_logger.LogInformation("Plugin is disabled. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ namespace JellyfinPlugin.Tasks
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (item.ProviderIds != null && item.ProviderIds.ContainsKey("BlackBarCrop"))
|
||||
if (item.ProviderIds != null &&
|
||||
item.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) &&
|
||||
!string.IsNullOrEmpty(existingCrop))
|
||||
{
|
||||
processed++;
|
||||
progress.Report((processed / (double)total) * 100);
|
||||
@@ -62,22 +64,20 @@ namespace JellyfinPlugin.Tasks
|
||||
{
|
||||
string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit);
|
||||
|
||||
if (item.ProviderIds == null)
|
||||
{
|
||||
item.ProviderIds = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
// Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku
|
||||
// (puste oznacza że nie ma czarnych pasów do ucięcia)
|
||||
item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty;
|
||||
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(cropData))
|
||||
_logger.LogInformation("Znaleziono pasy {Crop} w {Path}", cropData, path);
|
||||
{
|
||||
if (item.ProviderIds == null)
|
||||
{
|
||||
item.ProviderIds = new Dictionary<string, string>();
|
||||
}
|
||||
item.ProviderIds["BlackBarCrop"] = cropData;
|
||||
await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken);
|
||||
_logger.LogInformation("Found black bars {Crop} in {Path}", cropData, path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Błąd skanowania pliku {Path}", path);
|
||||
_logger.LogError(ex, "Error scanning file {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,80 +44,141 @@ namespace JellyfinPlugin
|
||||
return;
|
||||
}
|
||||
|
||||
// Pobieramy obecną konfigurację kodowania
|
||||
var encodingConfig = _configManager.GetConfiguration("encoding");
|
||||
if (encodingConfig == null) return;
|
||||
// Przygotowujemy zamianę pliku ffmpeg bezpośrednio w /usr/lib/jellyfin-ffmpeg/ffmpeg
|
||||
string defaultFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
string origFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg.original";
|
||||
string realFfmpegPath = origFfmpeg;
|
||||
|
||||
// Używamy dynamic, aby ominąć potrzebę rzutowania na konkretny, wewnętrzny typ Jellyfin (EncodingOptions)
|
||||
dynamic dynConfig = encodingConfig;
|
||||
string currentFfmpegPath = dynConfig.EncoderAppPath ?? "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
if (!File.Exists(origFfmpeg))
|
||||
{
|
||||
if (File.Exists(defaultFfmpeg))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(defaultFfmpeg, origFfmpeg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Nie udało się przenieść {DefaultFfmpeg} do {OrigFfmpeg}", defaultFfmpeg, origFfmpeg);
|
||||
realFfmpegPath = defaultFfmpeg;
|
||||
}
|
||||
}
|
||||
else if (File.Exists("/usr/bin/ffmpeg"))
|
||||
{
|
||||
realFfmpegPath = "/usr/bin/ffmpeg";
|
||||
}
|
||||
}
|
||||
|
||||
string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover");
|
||||
Directory.CreateDirectory(wrapperDir);
|
||||
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh");
|
||||
|
||||
// Jeśli obecna ścieżka to już nasz wrapper, nie robimy pętli w nieskończoność.
|
||||
// Znajdujemy poprzednio zapisaną prawdziwą ścieżkę z pliku.
|
||||
string realFfmpegPath = currentFfmpegPath;
|
||||
if (currentFfmpegPath == wrapperPath)
|
||||
{
|
||||
if (File.Exists(wrapperPath))
|
||||
{
|
||||
var lines = File.ReadAllLines(wrapperPath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("REAL_FFMPEG="))
|
||||
{
|
||||
realFfmpegPath = line.Split('=')[1].Trim('"');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (realFfmpegPath == wrapperPath) realFfmpegPath = "/usr/lib/jellyfin-ffmpeg/ffmpeg";
|
||||
}
|
||||
string wrapperPath = Path.Combine(wrapperDir, "ffmpeg");
|
||||
|
||||
string script = $@"#!/bin/bash
|
||||
# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina
|
||||
REAL_FFMPEG=""{realFfmpegPath}""
|
||||
|
||||
# Znajdujemy ścieżkę pliku wejściowego (argument po -i)
|
||||
INPUT_FILE=""""
|
||||
for i in ""${{!@}}""; do
|
||||
if [[ ""${{@:$i:1}}"" == ""-i"" ]]; then
|
||||
INPUT_FILE=""${{@:$i+1:1}}""
|
||||
for (( i=1; i<=$#; i++ )); do
|
||||
arg=""${{!i}}""
|
||||
if [[ ""$arg"" == ""-i"" ]]; then
|
||||
let next_idx=i+1
|
||||
if [ $next_idx -le $# ]; then
|
||||
INPUT_FILE=""${{!next_idx}}""
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Pobieramy crop z API naszej wtyczki
|
||||
if [ -n ""$INPUT_FILE"" ]; then
|
||||
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$INPUT_FILE"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
|
||||
CLEAN_PATH=$(echo ""$INPUT_FILE"" | tr -d '""' | sed 's/^file://')
|
||||
|
||||
CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$CLEAN_PATH"" http://127.0.0.1:8096/BlackBarRemover/CropByPath)
|
||||
|
||||
if [ -n ""$CROP_DATA"" ] && [ ""$CROP_DATA"" != """" ]; then
|
||||
exec ""$REAL_FFMPEG"" ""$@"" -vf ""crop=$CROP_DATA""
|
||||
# Modyfikujemy argumenty w locie
|
||||
declare -a NEW_ARGS
|
||||
HAS_VF=0
|
||||
IS_COPY=0
|
||||
|
||||
for (( i=1; i<=$#; i++ )); do
|
||||
arg=""${{!i}}""
|
||||
|
||||
if [[ ""$arg"" == ""copy"" ]]; then
|
||||
let prev_idx=i-1
|
||||
prev_arg=""""
|
||||
if [ $prev_idx -gt 0 ]; then
|
||||
prev_arg=""${{!prev_idx}}""
|
||||
fi
|
||||
if [[ ""$prev_arg"" == *codec:v* ]] || [[ ""$prev_arg"" == *-c:v* ]] || [[ ""$prev_arg"" == *-vcodec* ]]; then
|
||||
IS_COPY=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ""$arg"" == ""-vf"" ]]; then
|
||||
HAS_VF=1
|
||||
NEW_ARGS+=(""-vf"")
|
||||
next_arg=""""
|
||||
let next_idx=i+1
|
||||
if [ $next_idx -le $# ]; then
|
||||
next_arg=""${{!next_idx}}""
|
||||
fi
|
||||
NEW_ARGS+=(""$next_arg,crop=$CROP_DATA"")
|
||||
let i++
|
||||
continue
|
||||
fi
|
||||
|
||||
NEW_ARGS+=(""$arg"")
|
||||
done
|
||||
|
||||
# Jeżeli Jellyfin nadal upiera się przy copy, nie możemy wstrzyknąć filtra crop (bo zcrashuje FFmpega)
|
||||
if [ $IS_COPY -eq 1 ]; then
|
||||
exec ""$REAL_FFMPEG"" ""$@""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $HAS_VF -eq 0 ]; then
|
||||
NEW_ARGS+=(""-vf"")
|
||||
NEW_ARGS+=(""crop=$CROP_DATA"")
|
||||
fi
|
||||
|
||||
exec ""$REAL_FFMPEG"" ""${{NEW_ARGS[@]}}""
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Zwykłe uruchomienie bez crop
|
||||
exec ""$REAL_FFMPEG"" ""$@""
|
||||
";
|
||||
File.WriteAllText(wrapperPath, script);
|
||||
|
||||
if (Environment.OSVersion.Platform == PlatformID.Unix)
|
||||
if (File.Exists(origFfmpeg) || defaultFfmpeg != origFfmpeg)
|
||||
{
|
||||
System.Diagnostics.Process.Start("chmod", $"+x \"{wrapperPath}\"");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(defaultFfmpeg, script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Nie udało się zapisać skryptu wrappera do {DefaultFfmpeg}", defaultFfmpeg);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path}", wrapperPath);
|
||||
|
||||
// Teraz automagicznie podmieniamy ścieżkę w Jellyfin!
|
||||
if (dynConfig.EncoderAppPath != wrapperPath)
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
dynConfig.EncoderAppPath = wrapperPath;
|
||||
_configManager.SaveConfiguration("encoding", encodingConfig);
|
||||
_logger.LogInformation("Automatycznie zmieniono ścieżkę FFmpeg w konfiguracji Jellyfin na wrapper wtyczki.");
|
||||
string[] pathsToChmod = new[] { wrapperPath, defaultFfmpeg };
|
||||
foreach (var path in pathsToChmod)
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var proc = System.Diagnostics.Process.Start("chmod", $"+x \"{path}\"");
|
||||
proc?.WaitForExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path} oraz {DefaultFfmpeg}", wrapperPath, defaultFfmpeg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -37,11 +37,11 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin
|
||||
### Method 3: Building from Source
|
||||
|
||||
1. Clone this repository.
|
||||
2. Build the plugin using the .NET 10 SDK:
|
||||
2. Build the plugin using the .NET 9 SDK:
|
||||
```bash
|
||||
dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release
|
||||
```
|
||||
3. Copy the compiled DLLs from `JellyfinPlugin/bin/Release/net10.0/` into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory (e.g., `/var/lib/jellyfin/plugins/BlackBarRemover/`).
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user