fix:: Plugin Erste Tests
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s

This commit is contained in:
2026-03-01 16:01:26 +01:00
commit b3304e61bf
18 changed files with 2060 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
name: Build and Publish Plugin
on:
release:
types: [published]
permissions:
contents: write
jobs:
build:
name: Build Plugin + Update Manifest
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GT_TOKEN }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Set version from tag
id: vars
run: |
TAG="${{ gitea.event.release.tag_name }}"
VERSION="${TAG#v}" # Remove 'v' prefix
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "TAG=$TAG" >> "$GITHUB_ENV"
- name: Restore dependencies
run: dotnet restore
- name: Build plugin
run: |
dotnet publish Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj \
--configuration Release \
--output ./publish \
-p:Version=${VERSION}.0
- name: Create plugin ZIP
run: |
cd publish
zip -r ../smartnotify_${VERSION}.zip .
cd ..
# Calculate MD5 checksum
CHECKSUM=$(md5sum smartnotify_${VERSION}.zip | cut -d' ' -f1)
echo "CHECKSUM=$CHECKSUM" >> "$GITHUB_ENV"
echo "Checksum: $CHECKSUM"
- name: Upload ZIP to Release
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
run: |
# Get release ID
RELEASE_ID=$(curl -s "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/tags/${TAG}" \
-H "Authorization: token $GIT_TOKEN" | jq -r '.id')
# Upload asset
curl -X POST "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/${RELEASE_ID}/assets?name=smartnotify_${VERSION}.zip" \
-H "Authorization: token $GIT_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @smartnotify_${VERSION}.zip
- name: Update manifest.json
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
run: |
# Get changelog for this version
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md | tail -n +2)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Update manifest.json with new version
python3 << EOF
import json
with open('manifest.json', 'r') as f:
manifest = json.load(f)
new_version = {
"version": "${VERSION}.0",
"changelog": """${CHANGELOG}""".strip(),
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/${TAG}/smartnotify_${VERSION}.zip",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
# Insert at beginning of versions array
manifest[0]["versions"].insert(0, new_version)
# Keep only last 10 versions
manifest[0]["versions"] = manifest[0]["versions"][:10]
with open('manifest.json', 'w') as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print("Manifest updated successfully")
EOF
# Commit and push manifest
git config user.name "Gitea Actions"
git config user.email "actions@git.tdpi.dev"
git add manifest.json
git commit -m "chore: update manifest for ${TAG}"
git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git HEAD:main
- name: Summary
run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** ${VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Checksum:** ${CHECKSUM}" >> $GITHUB_STEP_SUMMARY
echo "- **Target ABI:** 10.11.0.0" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Manifest URL" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,209 @@
name: Create Release PR
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-pr:
name: Create Release PR
if: "!contains(gitea.event.head_commit.message, 'chore(main): release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GT_TOKEN }}
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install gitpython packaging
- name: Create Release PR
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
REPO: "TDPI/jellyfin-plugin-smartnotify"
run: |
python3 << 'EOF'
import re
import subprocess
import json
from packaging import version as pkg_version
def get_commits_since_last_tag():
try:
last_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], text=True).strip()
commits = subprocess.check_output(['git', 'log', f'{last_tag}..HEAD', '--pretty=format:%s'], text=True).strip().split('\n')
except:
commits = subprocess.check_output(['git', 'log', '--pretty=format:%s'], text=True).strip().split('\n')
return [c for c in commits if c]
def analyze_commits(commits):
has_feat = any(c.startswith('feat') for c in commits)
has_fix = any(c.startswith('fix') for c in commits)
has_breaking = any('!' in c.split(':')[0] or 'BREAKING CHANGE' in c for c in commits)
has_chore = any(c.startswith('chore') for c in commits)
return has_breaking, has_feat, has_fix, has_chore
def get_current_version():
try:
with open('.release-please-manifest.json', 'r') as f:
manifest = json.load(f)
return manifest.get('.', '1.0.0')
except:
return '1.0.0'
def bump_version(current, has_breaking, has_feat, has_fix, has_chore):
v = pkg_version.parse(current)
major, minor, patch = v.major, v.minor, v.micro
if has_breaking:
return f'{major + 1}.0.0'
elif has_feat:
return f'{major}.{minor + 1}.0'
elif has_fix or has_chore:
return f'{major}.{minor}.{patch + 1}'
return None
commits = get_commits_since_last_tag()
if not commits or commits == ['']:
print('No commits to release')
exit(0)
has_breaking, has_feat, has_fix, has_chore = analyze_commits(commits)
if not (has_breaking or has_feat or has_fix or has_chore):
print('No release-worthy commits')
exit(0)
current_version = get_current_version()
new_version = bump_version(current_version, has_breaking, has_feat, has_fix, has_chore)
if not new_version:
print('No version bump needed')
exit(0)
print(f'Bumping version from {current_version} to {new_version}')
# Update version in manifest
with open('.release-please-manifest.json', 'r') as f:
manifest = json.load(f)
manifest['.'] = new_version
with open('.release-please-manifest.json', 'w') as f:
json.dump(manifest, f, indent=2)
# Update version in .csproj
csproj_path = 'Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj'
with open(csproj_path, 'r') as f:
csproj = f.read()
csproj = re.sub(r'<AssemblyVersion>.*?</AssemblyVersion>', f'<AssemblyVersion>{new_version}.0</AssemblyVersion>', csproj)
csproj = re.sub(r'<FileVersion>.*?</FileVersion>', f'<FileVersion>{new_version}.0</FileVersion>', csproj)
with open(csproj_path, 'w') as f:
f.write(csproj)
# Generate CHANGELOG
changelog_entry = f'## {new_version} ({subprocess.check_output(["date", "+%Y-%m-%d"], text=True).strip()})\n\n'
if has_breaking:
changelog_entry += '### BREAKING CHANGES\n\n'
for c in commits:
if '!' in c.split(':')[0] or 'BREAKING CHANGE' in c:
changelog_entry += f'* {c}\n'
changelog_entry += '\n'
if has_feat:
changelog_entry += '### Features\n\n'
for c in commits:
if c.startswith('feat'):
changelog_entry += f'* {c}\n'
changelog_entry += '\n'
if has_fix:
changelog_entry += '### Bug Fixes\n\n'
for c in commits:
if c.startswith('fix'):
changelog_entry += f'* {c}\n'
changelog_entry += '\n'
if has_chore:
changelog_entry += '### Chores\n\n'
for c in commits:
if c.startswith('chore'):
changelog_entry += f'* {c}\n'
changelog_entry += '\n'
try:
with open('CHANGELOG.md', 'r') as f:
old_changelog = f.read()
except:
old_changelog = '# Changelog\n\n'
with open('CHANGELOG.md', 'w') as f:
f.write('# Changelog\n\n' + changelog_entry + '\n' + old_changelog.replace('# Changelog\n', '').lstrip())
subprocess.run(['git', 'config', 'user.name', 'Gitea Actions'])
subprocess.run(['git', 'config', 'user.email', 'actions@git.tdpi.dev'])
subprocess.run(['git', 'add', '.release-please-manifest.json', 'CHANGELOG.md', csproj_path])
subprocess.run(['git', 'commit', '-m', f'chore(main): release {new_version}'])
with open('/tmp/new_version', 'w') as f:
f.write(new_version)
EOF
NEW_VERSION=$(cat /tmp/new_version 2>/dev/null || echo "")
if [ -z "$NEW_VERSION" ]; then
echo "No version bump needed"
exit 0
fi
BRANCH_NAME="release-please--branches--main"
# Push changes
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
git push -f origin HEAD:"$BRANCH_NAME"
PR_EXISTS=true
else
git push origin HEAD:"$BRANCH_NAME"
PR_EXISTS=false
fi
# Get changelog content for PR body
CHANGELOG_CONTENT=$(awk '/^## '"$NEW_VERSION"'/{flag=1} /^## / && flag && !/^## '"$NEW_VERSION"'/{exit} flag' CHANGELOG.md | jq -Rs .)
if [ "$PR_EXISTS" = true ]; then
PR_NUMBER=$(curl -s "https://git.tdpi.dev/api/v1/repos/$REPO/pulls?state=open&head=$BRANCH_NAME" \
-H "Authorization: token $GIT_TOKEN" | jq -r '.[0].number')
if [ "$PR_NUMBER" != "null" ]; then
curl -X PATCH "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
-H "Authorization: token $GIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore(main): release $NEW_VERSION\",
\"body\": $CHANGELOG_CONTENT
}"
fi
else
curl -X POST "https://git.tdpi.dev/api/v1/repos/$REPO/pulls" \
-H "Authorization: token $GIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore(main): release $NEW_VERSION\",
\"head\": \"$BRANCH_NAME\",
\"base\": \"main\",
\"body\": $CHANGELOG_CONTENT
}"
fi

View File

@@ -0,0 +1,60 @@
name: Create Release
on:
pull_request:
types: [closed]
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
publish-release:
name: Publish Release
if: gitea.event_name == 'pull_request' && gitea.event.pull_request.merged == true && startsWith(gitea.event.pull_request.head.ref, 'release-please')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GT_TOKEN }}
- name: Get version
id: version
run: |
VERSION=$(jq -r '."."' .release-please-manifest.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Git Tag
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
run: |
git config user.name "Gitea Actions"
git config user.email "actions@git.tdpi.dev"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}"
git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git "v${{ steps.version.outputs.version }}"
- name: Create Release
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md)
PAYLOAD=$(jq -n \
--arg tag "v$VERSION" \
--arg name "v$VERSION" \
--arg body "$CHANGELOG" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
curl -X POST "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases" \
-H "Authorization: token $GIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
- name: Cleanup Release Branch
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
run: |
git push --delete origin release-please--branches--main || true

View File

@@ -0,0 +1,3 @@
{
".": "0.0.0"
}

2
CHANGELOG.md Normal file
View File

@@ -0,0 +1,2 @@
# Changelog

View File

@@ -0,0 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.SmartNotify", "Jellyfin.Plugin.SmartNotify\Jellyfin.Plugin.SmartNotify.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,69 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.SmartNotify.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
DiscordWebhookUrl = string.Empty;
NotificationDelayMinutes = 5;
GroupingWindowMinutes = 30;
EnableMovieNotifications = true;
EnableEpisodeNotifications = true;
SuppressUpgrades = true;
BotUsername = "Jellyfin SmartNotify";
EmbedColor = 3447003; // Discord blue
}
/// <summary>
/// Gets or sets the Discord webhook URL.
/// </summary>
public string DiscordWebhookUrl { get; set; }
/// <summary>
/// Gets or sets the delay before processing notifications (allows metadata to settle).
/// </summary>
public int NotificationDelayMinutes { get; set; }
/// <summary>
/// Gets or sets the time window in which episodes are grouped together.
/// </summary>
public int GroupingWindowMinutes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify for movies.
/// </summary>
public bool EnableMovieNotifications { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify for episodes.
/// </summary>
public bool EnableEpisodeNotifications { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to suppress notifications for quality upgrades.
/// </summary>
public bool SuppressUpgrades { get; set; }
/// <summary>
/// Gets or sets the Discord bot username.
/// </summary>
public string BotUsername { get; set; }
/// <summary>
/// Gets or sets the Discord embed color.
/// </summary>
public int EmbedColor { get; set; }
/// <summary>
/// Gets or sets the Jellyfin server URL for image links.
/// </summary>
public string ServerUrl { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>SmartNotify</title>
</head>
<body>
<div id="SmartNotifyConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox,emby-select">
<div data-role="content">
<div class="content-primary">
<form id="SmartNotifyConfigForm">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">SmartNotify Konfiguration</h2>
</div>
<div class="verticalSection">
<p class="fieldDescription">
SmartNotify erkennt automatisch, ob ein Item wirklich <strong>neu</strong> ist oder nur ein
<strong>Qualitäts-Upgrade</strong> einer bereits existierenden Datei. Benachrichtigungen
werden intelligent gruppiert (z.B. "Episode 1-12 hinzugefügt").
</p>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Discord Einstellungen</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtDiscordWebhookUrl">Discord Webhook URL</label>
<input is="emby-input" type="url" id="txtDiscordWebhookUrl" required />
<div class="fieldDescription">Die Webhook-URL deines Discord-Kanals</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtBotUsername">Bot Name</label>
<input is="emby-input" type="text" id="txtBotUsername" />
<div class="fieldDescription">Name des Bots in Discord</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtEmbedColor">Embed Farbe (Dezimal)</label>
<input is="emby-input" type="number" id="txtEmbedColor" />
<div class="fieldDescription">Discord Embed-Farbe als Dezimalzahl (z.B. 3447003 für Blau)</div>
</div>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Server Einstellungen</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtServerUrl">Server URL</label>
<input is="emby-input" type="url" id="txtServerUrl" placeholder="https://jellyfin.example.com" />
<div class="fieldDescription">Öffentliche URL deines Jellyfin-Servers (für Bilder in Discord)</div>
</div>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Benachrichtigungstypen</h3>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEnableEpisodeNotifications" />
<span>Episoden-Benachrichtigungen</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Benachrichtigungen für neue Episoden senden</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEnableMovieNotifications" />
<span>Film-Benachrichtigungen</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Benachrichtigungen für neue Filme senden</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkSuppressUpgrades" />
<span>Qualitäts-Upgrades unterdrücken</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Keine Benachrichtigungen senden, wenn eine Datei durch eine bessere Qualität ersetzt wird
</div>
</div>
</div>
<div class="verticalSection">
<h3 class="sectionTitle">Timing</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtNotificationDelayMinutes">Verzögerung (Minuten)</label>
<input is="emby-input" type="number" id="txtNotificationDelayMinutes" min="1" max="60" />
<div class="fieldDescription">
Wartezeit bevor eine Benachrichtigung gesendet wird.
Erlaubt Metadaten-Aktualisierung und Erkennung von Ersetzungen.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtGroupingWindowMinutes">Gruppierungsfenster (Minuten)</label>
<input is="emby-input" type="number" id="txtGroupingWindowMinutes" min="5" max="120" />
<div class="fieldDescription">
Zeitfenster in dem Episoden derselben Serie zusammengefasst werden.
Z.B. bei 30 Minuten: Wenn Episode 1-12 innerhalb von 30 Minuten hinzugefügt werden,
wird nur eine Benachrichtigung gesendet.
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Speichern</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var SmartNotifyConfig = {
pluginUniqueId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
};
document.querySelector('#SmartNotifyConfigPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SmartNotifyConfig.pluginUniqueId).then(function (config) {
document.querySelector('#txtDiscordWebhookUrl').value = config.DiscordWebhookUrl || '';
document.querySelector('#txtBotUsername').value = config.BotUsername || 'Jellyfin SmartNotify';
document.querySelector('#txtEmbedColor').value = config.EmbedColor || 3447003;
document.querySelector('#txtServerUrl').value = config.ServerUrl || '';
document.querySelector('#chkEnableEpisodeNotifications').checked = config.EnableEpisodeNotifications;
document.querySelector('#chkEnableMovieNotifications').checked = config.EnableMovieNotifications;
document.querySelector('#chkSuppressUpgrades').checked = config.SuppressUpgrades;
document.querySelector('#txtNotificationDelayMinutes').value = config.NotificationDelayMinutes || 5;
document.querySelector('#txtGroupingWindowMinutes').value = config.GroupingWindowMinutes || 30;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#SmartNotifyConfigForm')
.addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SmartNotifyConfig.pluginUniqueId).then(function (config) {
config.DiscordWebhookUrl = document.querySelector('#txtDiscordWebhookUrl').value;
config.BotUsername = document.querySelector('#txtBotUsername').value;
config.EmbedColor = parseInt(document.querySelector('#txtEmbedColor').value) || 3447003;
config.ServerUrl = document.querySelector('#txtServerUrl').value;
config.EnableEpisodeNotifications = document.querySelector('#chkEnableEpisodeNotifications').checked;
config.EnableMovieNotifications = document.querySelector('#chkEnableMovieNotifications').checked;
config.SuppressUpgrades = document.querySelector('#chkSuppressUpgrades').checked;
config.NotificationDelayMinutes = parseInt(document.querySelector('#txtNotificationDelayMinutes').value) || 5;
config.GroupingWindowMinutes = parseInt(document.querySelector('#txtGroupingWindowMinutes').value) || 30;
ApiClient.updatePluginConfiguration(SmartNotifyConfig.pluginUniqueId, config).then(function () {
Dashboard.processPluginConfigurationUpdateResult();
});
});
return false;
});
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>Default</AnalysisMode>
</PropertyGroup>
<!-- Jellyfin 10.11 references -->
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.*" />
<PackageReference Include="Jellyfin.Model" Version="10.11.*" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="LiteDB" Version="5.0.21" />
</ItemGroup>
<!-- Embedded Resources -->
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,308 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using Jellyfin.Plugin.SmartNotify.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace Jellyfin.Plugin.SmartNotify.Notifiers;
/// <summary>
/// Background service that monitors library changes and sends smart notifications.
/// </summary>
public class SmartNotifyBackgroundService : IHostedService, IDisposable
{
private readonly ILogger<SmartNotifyBackgroundService> _logger;
private readonly ILibraryManager _libraryManager;
private readonly ItemHistoryService _historyService;
private readonly DiscordNotificationService _discordService;
private Timer? _processTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SmartNotifyBackgroundService"/> class.
/// </summary>
public SmartNotifyBackgroundService(
ILogger<SmartNotifyBackgroundService> logger,
ILibraryManager libraryManager,
ItemHistoryService historyService,
DiscordNotificationService discordService)
{
_logger = logger;
_libraryManager = libraryManager;
_historyService = historyService;
_discordService = discordService;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("SmartNotify background service starting");
// Subscribe to library events
_libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemRemoved += OnItemRemoved;
// Start the notification processing timer (runs every minute)
_processTimer = new Timer(60_000);
_processTimer.Elapsed += ProcessPendingNotifications;
_processTimer.AutoReset = true;
_processTimer.Start();
_logger.LogInformation("SmartNotify is now monitoring library changes");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("SmartNotify background service stopping");
_libraryManager.ItemAdded -= OnItemAdded;
_libraryManager.ItemRemoved -= OnItemRemoved;
_processTimer?.Stop();
return Task.CompletedTask;
}
/// <summary>
/// Called when an item is added to the library.
/// </summary>
private void OnItemAdded(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
// Only process Episodes and Movies
if (item is not Episode && item is not Movie)
{
return;
}
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return;
}
// Check if this type of notification is enabled
if (item is Episode && !config.EnableEpisodeNotifications)
{
return;
}
if (item is Movie && !config.EnableMovieNotifications)
{
return;
}
try
{
ProcessNewItem(item);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing new item {Name}", item.Name);
}
}
/// <summary>
/// Processes a newly added item.
/// </summary>
private void ProcessNewItem(BaseItem item)
{
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return;
}
// Check 1: Is this a quality upgrade? (Same content, different file)
var isUpgrade = _historyService.IsQualityUpgrade(item);
// Check 2: Is there duplicate content currently in the library?
// This catches the case where the new file arrives BEFORE the old one is deleted
var hasDuplicate = _historyService.HasDuplicateContent(item, _libraryManager);
if (isUpgrade || hasDuplicate)
{
_logger.LogInformation(
"Suppressing notification for {Name} - IsUpgrade: {IsUpgrade}, HasDuplicate: {HasDuplicate}",
item.Name,
isUpgrade,
hasDuplicate);
if (config.SuppressUpgrades)
{
// Just record the item and don't notify
_historyService.RecordItem(item);
return;
}
// If not suppressing, we could send an "upgrade" notification instead
// For now, just fall through and send as normal
}
// Record this item in our database
_historyService.RecordItem(item);
// Create pending notification
var notification = CreateNotification(item);
if (notification == null)
{
return;
}
_historyService.QueueNotification(notification);
_logger.LogInformation("Queued notification for {Name}", item.Name);
}
/// <summary>
/// Creates a pending notification from a base item.
/// </summary>
private PendingNotification? CreateNotification(BaseItem item)
{
var config = Plugin.Instance?.Configuration;
var serverUrl = config?.ServerUrl?.TrimEnd('/') ?? string.Empty;
var notification = new PendingNotification
{
JellyfinItemId = item.Id.ToString(),
ItemType = item.GetType().Name,
Name = item.Name,
QueuedAt = DateTime.UtcNow,
Type = NotificationType.NewContent,
ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>()),
Overview = item.Overview
};
// Set image URL
if (!string.IsNullOrEmpty(serverUrl))
{
notification.ImageUrl = $"{serverUrl}/Items/{item.Id}/Images/Primary";
}
if (item is Episode episode)
{
notification.SeriesName = episode.SeriesName;
notification.SeriesId = episode.SeriesId.ToString();
notification.SeasonNumber = episode.ParentIndexNumber;
notification.EpisodeNumber = episode.IndexNumber;
notification.Year = episode.ProductionYear;
// Use series image if episode doesn't have one
if (!string.IsNullOrEmpty(serverUrl) && episode.SeriesId != Guid.Empty)
{
notification.ImageUrl = $"{serverUrl}/Items/{episode.SeriesId}/Images/Primary";
}
}
else if (item is Movie movie)
{
notification.Year = movie.ProductionYear;
}
return notification;
}
/// <summary>
/// Called when an item is removed from the library.
/// </summary>
private void OnItemRemoved(object? sender, ItemChangeEventArgs e)
{
// We keep the history record! This is important for detecting upgrades.
// When old file is deleted after new one is added, we don't want to
// remove our knowledge of this content.
_logger.LogDebug("Item removed: {Name} (ID: {Id})", e.Item.Name, e.Item.Id);
}
/// <summary>
/// Processes pending notifications (called by timer).
/// </summary>
private async void ProcessPendingNotifications(object? sender, ElapsedEventArgs e)
{
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
return;
}
var delayMinutes = config.NotificationDelayMinutes;
var groupingWindowMinutes = config.GroupingWindowMinutes;
var cutoff = DateTime.UtcNow.AddMinutes(-delayMinutes);
var pendingNotifications = _historyService.GetPendingNotifications(cutoff).ToList();
if (pendingNotifications.Count == 0)
{
return;
}
_logger.LogDebug("Processing {Count} pending notifications", pendingNotifications.Count);
// Group episodes by series
var episodesBySeries = pendingNotifications
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId))
.GroupBy(n => n.SeriesId!)
.ToList();
// Process each series group
foreach (var seriesGroup in episodesBySeries)
{
var oldestInGroup = seriesGroup.Min(n => n.QueuedAt);
var groupingCutoff = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes);
// Only process if the oldest notification is outside the grouping window
if (oldestInGroup > groupingCutoff)
{
_logger.LogDebug(
"Waiting for grouping window for series {SeriesId}, oldest: {Oldest}",
seriesGroup.Key,
oldestInGroup);
continue;
}
await _discordService.SendGroupedEpisodeNotificationAsync(
seriesGroup,
CancellationToken.None);
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(idsToRemove);
}
// Process movies
var movies = pendingNotifications
.Where(n => n.ItemType == "Movie")
.ToList();
foreach (var movie in movies)
{
await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
_historyService.RemoveNotifications(new[] { movie.Id });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending notifications");
}
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_processTimer?.Dispose();
_disposed = true;
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.SmartNotify.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.SmartNotify;
/// <summary>
/// SmartNotify Plugin - Intelligent Discord notifications that detect upgrades vs new content.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "SmartNotify";
/// <inheritdoc />
public override Guid Id => Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
/// <inheritdoc />
public override string Description => "Intelligent Discord notifications - detects quality upgrades vs truly new content. Groups episodes intelligently (e.g., 'Episode 1-12 added').";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html"
}
};
}
}

View File

@@ -0,0 +1,27 @@
using Jellyfin.Plugin.SmartNotify.Notifiers;
using Jellyfin.Plugin.SmartNotify.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.SmartNotify;
/// <summary>
/// Registers plugin services with the DI container.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register singleton services
serviceCollection.AddSingleton<ItemHistoryService>();
serviceCollection.AddSingleton<DiscordNotificationService>();
// Register the background service
serviceCollection.AddHostedService<SmartNotifyBackgroundService>();
// Ensure HttpClient is available
serviceCollection.AddHttpClient();
}
}

View File

@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SmartNotify.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SmartNotify.Services;
/// <summary>
/// Service for sending Discord notifications with intelligent grouping.
/// </summary>
public class DiscordNotificationService
{
private readonly ILogger<DiscordNotificationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="DiscordNotificationService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public DiscordNotificationService(
ILogger<DiscordNotificationService> logger,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Sends a grouped notification for multiple episodes of the same series.
/// </summary>
/// <param name="notifications">The notifications to group and send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
public async Task SendGroupedEpisodeNotificationAsync(
IEnumerable<PendingNotification> notifications,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
}
var notificationList = notifications.ToList();
if (notificationList.Count == 0)
{
return;
}
var first = notificationList.First();
var seriesName = first.SeriesName ?? "Unknown Series";
// Group by season
var bySeason = notificationList
.Where(n => n.SeasonNumber.HasValue && n.EpisodeNumber.HasValue)
.GroupBy(n => n.SeasonNumber!.Value)
.OrderBy(g => g.Key)
.ToList();
var episodeDescription = BuildEpisodeDescription(bySeason);
var title = $"📺 {seriesName}";
var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}";
// Get image from first notification
var imageUrl = first.ImageUrl;
await SendDiscordWebhookAsync(
config,
title,
description,
imageUrl,
BuildExternalLinks(first.ProviderIdsJson),
cancellationToken);
}
/// <summary>
/// Builds an intelligent episode description like "Episode 1-12" or "Episode 1, 3, 5-7".
/// </summary>
private string BuildEpisodeDescription(List<IGrouping<int, PendingNotification>> bySeason)
{
var parts = new List<string>();
foreach (var seasonGroup in bySeason)
{
var season = seasonGroup.Key;
var episodes = seasonGroup
.Select(n => n.EpisodeNumber!.Value)
.Distinct()
.OrderBy(e => e)
.ToList();
var episodeRanges = BuildRangeString(episodes);
parts.Add($"**Staffel {season}:** Episode {episodeRanges}");
}
return string.Join("\n", parts);
}
/// <summary>
/// Builds a range string like "1-12" or "1, 3, 5-7, 10".
/// </summary>
private string BuildRangeString(List<int> numbers)
{
if (numbers.Count == 0)
{
return string.Empty;
}
if (numbers.Count == 1)
{
return numbers[0].ToString();
}
var ranges = new List<string>();
int rangeStart = numbers[0];
int rangeEnd = numbers[0];
for (int i = 1; i < numbers.Count; i++)
{
if (numbers[i] == rangeEnd + 1)
{
// Continue the range
rangeEnd = numbers[i];
}
else
{
// End current range, start new one
ranges.Add(FormatRange(rangeStart, rangeEnd));
rangeStart = numbers[i];
rangeEnd = numbers[i];
}
}
// Add the last range
ranges.Add(FormatRange(rangeStart, rangeEnd));
return string.Join(", ", ranges);
}
private string FormatRange(int start, int end)
{
return start == end ? start.ToString() : $"{start}-{end}";
}
/// <summary>
/// Sends a notification for a single movie.
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task SendMovieNotificationAsync(
PendingNotification notification,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
}
var title = $"🎬 {notification.Name}";
if (notification.Year.HasValue)
{
title += $" ({notification.Year})";
}
var description = notification.Overview ?? "Neuer Film hinzugefügt!";
if (description.Length > 300)
{
description = description.Substring(0, 297) + "...";
}
await SendDiscordWebhookAsync(
config,
title,
description,
notification.ImageUrl,
BuildExternalLinks(notification.ProviderIdsJson),
cancellationToken);
}
/// <summary>
/// Builds external links from provider IDs.
/// </summary>
private string BuildExternalLinks(string providerIdsJson)
{
try
{
var providerIds = JsonSerializer.Deserialize<Dictionary<string, string>>(providerIdsJson);
if (providerIds == null || providerIds.Count == 0)
{
return string.Empty;
}
var links = new List<string>();
if (providerIds.TryGetValue("Imdb", out var imdb) && !string.IsNullOrEmpty(imdb))
{
links.Add($"[IMDb](https://www.imdb.com/title/{imdb}/)");
}
if (providerIds.TryGetValue("Tmdb", out var tmdb) && !string.IsNullOrEmpty(tmdb))
{
links.Add($"[TMDb](https://www.themoviedb.org/movie/{tmdb})");
}
if (providerIds.TryGetValue("AniDB", out var anidb) && !string.IsNullOrEmpty(anidb))
{
links.Add($"[AniDB](https://anidb.net/anime/{anidb})");
}
if (providerIds.TryGetValue("AniList", out var anilist) && !string.IsNullOrEmpty(anilist))
{
links.Add($"[AniList](https://anilist.co/anime/{anilist})");
}
if (providerIds.TryGetValue("Tvdb", out var tvdb) && !string.IsNullOrEmpty(tvdb))
{
links.Add($"[TVDB](https://thetvdb.com/?id={tvdb}&tab=series)");
}
return links.Count > 0 ? string.Join(" | ", links) : string.Empty;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Sends the actual Discord webhook request.
/// </summary>
private async Task SendDiscordWebhookAsync(
PluginConfiguration config,
string title,
string description,
string? imageUrl,
string externalLinks,
CancellationToken cancellationToken)
{
var embed = new Dictionary<string, object>
{
["title"] = title,
["description"] = description,
["color"] = config.EmbedColor
};
if (!string.IsNullOrEmpty(imageUrl))
{
embed["thumbnail"] = new Dictionary<string, string> { ["url"] = imageUrl };
}
if (!string.IsNullOrEmpty(externalLinks))
{
embed["footer"] = new Dictionary<string, string> { ["text"] = externalLinks };
}
embed["timestamp"] = DateTime.UtcNow.ToString("o");
var payload = new Dictionary<string, object>
{
["username"] = config.BotUsername,
["embeds"] = new[] { embed }
};
var json = JsonSerializer.Serialize(payload);
_logger.LogDebug("Sending Discord webhook: {Json}", json);
try
{
using var client = _httpClientFactory.CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(config.DiscordWebhookUrl, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError(
"Discord webhook failed with status {Status}: {Body}",
response.StatusCode,
responseBody);
}
else
{
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Discord webhook");
}
}
}

View File

@@ -0,0 +1,329 @@
using System;
using System.IO;
using System.Text.Json;
using LiteDB;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SmartNotify.Services;
/// <summary>
/// Service for managing the local database of known media items.
/// </summary>
public class ItemHistoryService : IDisposable
{
private readonly ILogger<ItemHistoryService> _logger;
private readonly LiteDatabase _database;
private readonly ILiteCollection<KnownMediaItem> _knownItems;
private readonly ILiteCollection<PendingNotification> _pendingNotifications;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="ItemHistoryService"/> class.
/// </summary>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="logger">The logger.</param>
public ItemHistoryService(
IApplicationPaths applicationPaths,
ILogger<ItemHistoryService> logger)
{
_logger = logger;
var pluginDataPath = Path.Combine(applicationPaths.PluginConfigurationsPath, "SmartNotify");
Directory.CreateDirectory(pluginDataPath);
var dbPath = Path.Combine(pluginDataPath, "smartnotify.db");
_logger.LogInformation("SmartNotify database path: {Path}", dbPath);
_database = new LiteDatabase(dbPath);
_knownItems = _database.GetCollection<KnownMediaItem>("known_items");
_pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications");
// Create indexes for efficient queries
_knownItems.EnsureIndex(x => x.ContentKey);
_knownItems.EnsureIndex(x => x.JellyfinItemId);
_pendingNotifications.EnsureIndex(x => x.SeriesId);
_pendingNotifications.EnsureIndex(x => x.QueuedAt);
}
/// <summary>
/// Generates a content key for uniquely identifying media content (regardless of file).
/// </summary>
/// <param name="item">The base item.</param>
/// <returns>A unique content key, or null if insufficient metadata.</returns>
public string? GenerateContentKey(BaseItem item)
{
if (item is Episode episode)
{
// For episodes: use series provider IDs + season + episode number
var series = episode.Series;
if (series == null)
{
_logger.LogDebug("Episode {Name} has no series, cannot generate content key", episode.Name);
return null;
}
var seriesKey = GetProviderKey(series.ProviderIds);
if (string.IsNullOrEmpty(seriesKey))
{
// Fallback to series name if no provider IDs
seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown";
}
var seasonNum = episode.ParentIndexNumber ?? 0;
var episodeNum = episode.IndexNumber ?? 0;
if (episodeNum == 0)
{
_logger.LogDebug("Episode {Name} has no episode number", episode.Name);
return null;
}
return $"episode|{seriesKey}|S{seasonNum:D2}E{episodeNum:D3}";
}
if (item is Movie movie)
{
// For movies: use provider IDs
var movieKey = GetProviderKey(movie.ProviderIds);
if (string.IsNullOrEmpty(movieKey))
{
// Fallback to name + year
var year = movie.ProductionYear ?? 0;
movieKey = $"{movie.Name?.ToLowerInvariant().Trim()}|{year}";
}
return $"movie|{movieKey}";
}
_logger.LogDebug("Item {Name} is not an Episode or Movie, type: {Type}", item.Name, item.GetType().Name);
return null;
}
/// <summary>
/// Gets a consistent provider key from provider IDs.
/// </summary>
private string? GetProviderKey(Dictionary<string, string>? providerIds)
{
if (providerIds == null || providerIds.Count == 0)
{
return null;
}
// Priority order for provider IDs
string[] priorityOrder = { "AniDB", "AniList", "Tmdb", "Tvdb", "Imdb" };
foreach (var provider in priorityOrder)
{
if (providerIds.TryGetValue(provider, out var id) && !string.IsNullOrEmpty(id))
{
return $"{provider.ToLowerInvariant()}:{id}";
}
}
// Fallback: use first available
foreach (var kvp in providerIds)
{
if (!string.IsNullOrEmpty(kvp.Value))
{
return $"{kvp.Key.ToLowerInvariant()}:{kvp.Value}";
}
}
return null;
}
/// <summary>
/// Checks if an item is a quality upgrade (content already known).
/// </summary>
/// <param name="item">The item to check.</param>
/// <returns>True if this is an upgrade of existing content.</returns>
public bool IsQualityUpgrade(BaseItem item)
{
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
_logger.LogDebug("Could not generate content key for {Name}, treating as new", item.Name);
return false;
}
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey);
if (existing != null)
{
_logger.LogInformation(
"Item {Name} is a quality upgrade (content key: {Key}, first seen: {FirstSeen})",
item.Name,
contentKey,
existing.FirstSeen);
return true;
}
return false;
}
/// <summary>
/// Checks if there's another item with the same content currently in the library.
/// </summary>
/// <param name="item">The item to check.</param>
/// <param name="libraryManager">The library manager to query current items.</param>
/// <returns>True if duplicate content exists.</returns>
public bool HasDuplicateContent(BaseItem item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
{
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return false;
}
// Check if we have another item with the same content key but different Jellyfin ID
var existing = _knownItems.FindOne(x =>
x.ContentKey == contentKey &&
x.JellyfinItemId != item.Id.ToString());
if (existing != null)
{
// Verify the other item still exists in the library
var otherId = Guid.Parse(existing.JellyfinItemId);
var otherItem = libraryManager.GetItemById(otherId);
if (otherItem != null)
{
_logger.LogInformation(
"Item {Name} has duplicate content already in library: {OtherName}",
item.Name,
otherItem.Name);
return true;
}
}
return false;
}
/// <summary>
/// Records a known item in the database.
/// </summary>
/// <param name="item">The item to record.</param>
public void RecordItem(BaseItem item)
{
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return;
}
var jellyfinId = item.Id.ToString();
// Check if we already have this exact Jellyfin item
var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId);
if (existing != null)
{
// Update the record
existing.ContentKey = contentKey;
existing.FilePath = item.Path;
_knownItems.Update(existing);
return;
}
// Check if we have this content key already (upgrade scenario)
var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey);
if (byContentKey != null)
{
// Content exists, this is an upgrade - update the record
byContentKey.JellyfinItemId = jellyfinId;
byContentKey.FilePath = item.Path;
_knownItems.Update(byContentKey);
_logger.LogDebug("Updated existing content record for {Key} with new file", contentKey);
return;
}
// New content - create record
var record = new KnownMediaItem
{
JellyfinItemId = jellyfinId,
ContentKey = contentKey,
ItemType = item.GetType().Name,
Name = item.Name,
FirstSeen = DateTime.UtcNow,
FilePath = item.Path,
ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>())
};
if (item is Episode ep)
{
record.SeriesName = ep.SeriesName;
record.SeasonNumber = ep.ParentIndexNumber;
record.EpisodeNumber = ep.IndexNumber;
record.Year = ep.ProductionYear;
}
else if (item is Movie movie)
{
record.Year = movie.ProductionYear;
}
_knownItems.Insert(record);
_logger.LogDebug("Recorded new content: {Key}", contentKey);
}
/// <summary>
/// Removes an item from the known items database.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
public void RemoveItem(Guid itemId)
{
var jellyfinId = itemId.ToString();
_knownItems.DeleteMany(x => x.JellyfinItemId == jellyfinId);
}
/// <summary>
/// Queues a notification for later processing.
/// </summary>
/// <param name="notification">The notification to queue.</param>
public void QueueNotification(PendingNotification notification)
{
_pendingNotifications.Insert(notification);
}
/// <summary>
/// Gets all pending notifications older than the specified delay.
/// </summary>
/// <param name="olderThan">The cutoff time.</param>
/// <returns>The pending notifications.</returns>
public IEnumerable<PendingNotification> GetPendingNotifications(DateTime olderThan)
{
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan);
}
/// <summary>
/// Removes processed notifications.
/// </summary>
/// <param name="ids">The notification IDs to remove.</param>
public void RemoveNotifications(IEnumerable<int> ids)
{
foreach (var id in ids)
{
_pendingNotifications.Delete(id);
}
}
/// <summary>
/// Gets pending notifications grouped by series.
/// </summary>
/// <param name="seriesId">The series ID.</param>
/// <returns>The notifications for the series.</returns>
public IEnumerable<PendingNotification> GetNotificationsForSeries(string seriesId)
{
return _pendingNotifications.Find(x => x.SeriesId == seriesId);
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_database.Dispose();
_disposed = true;
}
}
}

View File

@@ -0,0 +1,171 @@
using LiteDB;
namespace Jellyfin.Plugin.SmartNotify.Services;
/// <summary>
/// Represents a known media item in the database.
/// Used to detect if an "added" item is actually a quality upgrade.
/// </summary>
public class KnownMediaItem
{
/// <summary>
/// Gets or sets the database ID.
/// </summary>
[BsonId]
public int Id { get; set; }
/// <summary>
/// Gets or sets the Jellyfin Item ID (GUID as string).
/// </summary>
public string JellyfinItemId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the unique content identifier.
/// For episodes: "{SeriesProviderIds}|S{Season}E{Episode}"
/// For movies: "{ProviderIds}"
/// </summary>
public string ContentKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item type (Episode, Movie, etc.).
/// </summary>
public string ItemType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the series name (for episodes).
/// </summary>
public string? SeriesName { get; set; }
/// <summary>
/// Gets or sets the season number (for episodes).
/// </summary>
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the episode number (for episodes).
/// </summary>
public int? EpisodeNumber { get; set; }
/// <summary>
/// Gets or sets the item name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the year.
/// </summary>
public int? Year { get; set; }
/// <summary>
/// Gets or sets when this item was first seen.
/// </summary>
public DateTime FirstSeen { get; set; }
/// <summary>
/// Gets or sets the file path (for detecting file changes).
/// </summary>
public string? FilePath { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
public long? FileSize { get; set; }
/// <summary>
/// Gets or sets the provider IDs as JSON string.
/// </summary>
public string ProviderIdsJson { get; set; } = "{}";
}
/// <summary>
/// Represents a pending notification in the queue.
/// </summary>
public class PendingNotification
{
/// <summary>
/// Gets or sets the database ID.
/// </summary>
[BsonId]
public int Id { get; set; }
/// <summary>
/// Gets or sets the Jellyfin Item ID.
/// </summary>
public string JellyfinItemId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item type.
/// </summary>
public string ItemType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the series name (for grouping episodes).
/// </summary>
public string? SeriesName { get; set; }
/// <summary>
/// Gets or sets the series ID (for grouping).
/// </summary>
public string? SeriesId { get; set; }
/// <summary>
/// Gets or sets the season number.
/// </summary>
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the episode number.
/// </summary>
public int? EpisodeNumber { get; set; }
/// <summary>
/// Gets or sets the item name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the year.
/// </summary>
public int? Year { get; set; }
/// <summary>
/// Gets or sets when the notification was queued.
/// </summary>
public DateTime QueuedAt { get; set; }
/// <summary>
/// Gets or sets the notification type.
/// </summary>
public NotificationType Type { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the provider IDs JSON.
/// </summary>
public string ProviderIdsJson { get; set; } = "{}";
/// <summary>
/// Gets or sets the overview/description.
/// </summary>
public string? Overview { get; set; }
}
/// <summary>
/// Type of notification.
/// </summary>
public enum NotificationType
{
/// <summary>
/// Truly new content.
/// </summary>
NewContent,
/// <summary>
/// Quality upgrade of existing content.
/// </summary>
QualityUpgrade
}

154
README.md Normal file
View File

@@ -0,0 +1,154 @@
# SmartNotify - Jellyfin Plugin
**Intelligente Discord-Benachrichtigungen für Jellyfin 10.11+**
## Das Problem
Wenn du eine Serie gegen eine bessere Qualität austauschst, schreit Jellyfin "NEUE EPISODE!" - obwohl es nur ein Upgrade ist. Das nervt.
## Die Lösung
SmartNotify erkennt automatisch:
- **Wirklich neue Inhalte** → Benachrichtigung wird gesendet
- **Qualitäts-Upgrades** → Keine Benachrichtigung (oder optional eigener Typ)
Zusätzlich gruppiert SmartNotify Episoden intelligent:
- Statt 12 einzelner Nachrichten: **"Staffel 1: Episode 1-12 hinzugefügt"**
- Bei Lücken: **"Episode 1-4, 6, 8-12"**
## Features
- ✅ Erkennt Qualitäts-Upgrades automatisch
- ✅ Intelligente Episoden-Gruppierung
- ✅ Unterstützt AniDB, AniList, TMDB, TVDB, IMDB
- ✅ Konfigurierbare Verzögerung (für Metadaten-Updates)
- ✅ Konfigurierbare Gruppierungsfenster
- ✅ Discord Webhook Support
- ✅ Deutsche Oberfläche
## Wie funktioniert die Erkennung?
SmartNotify führt eine lokale Datenbank mit allen bekannten Inhalten:
1. **Neues Item kommt rein**
2. SmartNotify generiert einen "Content Key" basierend auf:
- Bei Episoden: Serie (Provider-IDs) + Staffel + Episode
- Bei Filmen: Provider-IDs (AniDB, TMDB, etc.)
3. **Prüfung 1:** Existiert dieser Content Key schon in der DB? → Upgrade!
4. **Prüfung 2:** Gibt es gerade ein anderes Item mit gleichem Content Key? → Duplikat (neues File vor Löschung des alten)
5. Nur wenn beide Prüfungen negativ sind → Benachrichtigung
## Installation
## Manuelle Installation
1. Plugin von Releases herunterladen
2. ZIP entpacken in `plugins/SmartNotify/`
3. Jellyfin neustarten
## Konfiguration
Nach der Installation im Jellyfin Dashboard:
1. **Dashboard****Plugins****SmartNotify**
2. Discord Webhook URL eintragen
3. Server URL eintragen (für Bilder in Discord)
4. Optional: Verzögerung und Gruppierungsfenster anpassen
### Einstellungen
| Einstellung | Standard | Beschreibung |
|-------------|----------|--------------|
| Discord Webhook URL | - | Die Webhook-URL deines Discord-Kanals |
| Server URL | - | Öffentliche Jellyfin-URL (für Bilder) |
| Verzögerung | 5 min | Wartezeit für Metadaten-Updates |
| Gruppierungsfenster | 30 min | Zeitfenster für Episoden-Gruppierung |
| Upgrades unterdrücken | ✓ | Keine Benachrichtigung bei Ersetzungen |
## Beispiel-Benachrichtigungen
### Einzelne Episode
```
📺 Demon Slayer
Neue Episoden hinzugefügt:
Staffel 1: Episode 5
```
### Mehrere Episoden (gruppiert)
```
📺 Attack on Titan
Neue Episoden hinzugefügt:
Staffel 4: Episode 1-12
```
### Episoden mit Lücken
```
📺 One Piece
Neue Episoden hinzugefügt:
Staffel 1: Episode 1-4, 6, 8-12
```
### Film
```
🎬 Your Name (2016)
Kimi no Na wa - Ein Junge und ein Mädchen...
```
## Technische Details
- **Framework:** .NET 8.0
- **Jellyfin-Version:** 10.11+
- **Datenbank:** LiteDB (lokal, im Plugin-Ordner)
- **Events:** `ILibraryManager.ItemAdded`, `ILibraryManager.ItemRemoved`
## Bekannte Einschränkungen
- Provider-IDs müssen vorhanden sein (AniDB, TMDB, etc.)
- Bei Items ohne Provider-IDs wird auf Name+Jahr zurückgefallen
- Die Datenbank wächst mit der Zeit (kann bei Bedarf gelöscht werden)
## Installation via Repository
In Jellyfin:
1. **Dashboard****Plugins****Repositories****+**
2. URL einfügen:
```
https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json
```
3. **Katalog** → SmartNotify installieren
4. Jellyfin neustarten
## CI/CD
Das Repository nutzt drei Gitea Workflows:
1. **create-release-pr.yaml** - Bei Push auf `main`:
- Analysiert Commits (feat/fix/chore)
- Bumpt Version nach Semantic Versioning
- Erstellt/aktualisiert Release-PR
2. **create-release.yaml** - Bei PR-Merge:
- Erstellt Git-Tag
- Erstellt Gitea Release
3. **build-publish.yaml** - Bei Release:
- Baut das Plugin mit `dotnet publish`
- Erstellt ZIP mit Checksum
- Lädt ZIP zum Release hoch
- Aktualisiert `manifest.json` automatisch
### Commit Convention
| Prefix | Beschreibung | Version Bump |
|--------|--------------|--------------|
| `feat:` | Neues Feature | Minor (1.x.0) |
| `fix:` | Bugfix | Patch (1.0.x) |
| `chore:` | Maintenance | Patch (1.0.x) |
| `feat!:` | Breaking Change | Major (x.0.0) |
## Lizenz
MIT
## Contributing
Pull Requests willkommen! Bitte teste auf Windows Dev-Umgebung.

26
build.yaml Normal file
View File

@@ -0,0 +1,26 @@
name: "SmartNotify"
guid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
version: "1.0.0.0"
targetAbi: "10.11.0.0"
framework: "net8.0"
owner: "YourName"
overview: "Intelligente Discord-Benachrichtigungen - erkennt Qualitäts-Upgrades und gruppiert Episoden"
description: |
SmartNotify ist ein Jellyfin-Plugin das intelligente Discord-Benachrichtigungen sendet.
Features:
- Erkennt automatisch ob ein Item wirklich NEU ist oder nur ein Qualitäts-Upgrade
- Gruppiert Episoden intelligent (z.B. "Episode 1-12 hinzugefügt" statt 12 einzelne Nachrichten)
- Unterstützt AniDB, AniList, TMDB, TVDB, IMDB Provider-IDs
- Konfigurierbare Verzögerung und Gruppierungsfenster
- Deutsche Oberfläche
category: "Notifications"
artifacts:
- "Jellyfin.Plugin.SmartNotify.dll"
- "LiteDB.dll"
changelog: |
## v1.0.0.0
- Initial release
- Erkennung von Qualitäts-Upgrades
- Intelligente Episoden-Gruppierung
- Discord Webhook Support

12
manifest.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "SmartNotify",
"description": "Intelligente Discord-Benachrichtigungen für Jellyfin. Erkennt automatisch Qualitäts-Upgrades und gruppiert Episoden intelligent (z.B. 'Episode 1-12 hinzugefügt').\n",
"overview": "Intelligente Discord-Benachrichtigungen mit Upgrade-Erkennung",
"owner": "TDPI",
"category": "Notifications",
"imageUrl": "",
"versions": []
}
]