From b3304e61bfbf30195591c2657cf41d76e93eb817 Mon Sep 17 00:00:00 2001 From: TDPI Date: Sun, 1 Mar 2026 16:01:26 +0100 Subject: [PATCH] fix:: Plugin Erste Tests --- .gitea/workflows/build-publish.yaml | 123 +++++++ .gitea/workflows/create-release-pr.yaml | 209 +++++++++++ .gitea/workflows/create-release.yaml | 60 ++++ .release-please-manifest.json | 3 + CHANGELOG.md | 2 + Jellyfin.Plugin.SmartNotify.sln | 19 + .../Configuration/PluginConfiguration.cs | 69 ++++ .../Configuration/configPage.html | 163 +++++++++ .../Jellyfin.Plugin.SmartNotify.csproj | 30 ++ .../Notifiers/SmartNotifyBackgroundService.cs | 308 ++++++++++++++++ Jellyfin.Plugin.SmartNotify/Plugin.cs | 53 +++ .../PluginServiceRegistrator.cs | 27 ++ .../Services/DiscordNotificationService.cs | 302 ++++++++++++++++ .../Services/ItemHistoryService.cs | 329 ++++++++++++++++++ .../Services/Models.cs | 171 +++++++++ README.md | 154 ++++++++ build.yaml | 26 ++ manifest.json | 12 + 18 files changed, 2060 insertions(+) create mode 100644 .gitea/workflows/build-publish.yaml create mode 100644 .gitea/workflows/create-release-pr.yaml create mode 100644 .gitea/workflows/create-release.yaml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 Jellyfin.Plugin.SmartNotify.sln create mode 100644 Jellyfin.Plugin.SmartNotify/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.SmartNotify/Configuration/configPage.html create mode 100644 Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj create mode 100644 Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs create mode 100644 Jellyfin.Plugin.SmartNotify/Plugin.cs create mode 100644 Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs create mode 100644 Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs create mode 100644 Jellyfin.Plugin.SmartNotify/Services/Models.cs create mode 100644 README.md create mode 100644 build.yaml create mode 100644 manifest.json diff --git a/.gitea/workflows/build-publish.yaml b/.gitea/workflows/build-publish.yaml new file mode 100644 index 0000000..525e9ca --- /dev/null +++ b/.gitea/workflows/build-publish.yaml @@ -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 diff --git a/.gitea/workflows/create-release-pr.yaml b/.gitea/workflows/create-release-pr.yaml new file mode 100644 index 0000000..60489af --- /dev/null +++ b/.gitea/workflows/create-release-pr.yaml @@ -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'.*?', f'{new_version}.0', csproj) + csproj = re.sub(r'.*?', f'{new_version}.0', 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 diff --git a/.gitea/workflows/create-release.yaml b/.gitea/workflows/create-release.yaml new file mode 100644 index 0000000..215c248 --- /dev/null +++ b/.gitea/workflows/create-release.yaml @@ -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 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..46b1b67 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4dc68c6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/Jellyfin.Plugin.SmartNotify.sln b/Jellyfin.Plugin.SmartNotify.sln new file mode 100644 index 0000000..a8d5c0e --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify.sln @@ -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 diff --git a/Jellyfin.Plugin.SmartNotify/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SmartNotify/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..09b2b4b --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Configuration/PluginConfiguration.cs @@ -0,0 +1,69 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.SmartNotify.Configuration; + +/// +/// Plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + DiscordWebhookUrl = string.Empty; + NotificationDelayMinutes = 5; + GroupingWindowMinutes = 30; + EnableMovieNotifications = true; + EnableEpisodeNotifications = true; + SuppressUpgrades = true; + BotUsername = "Jellyfin SmartNotify"; + EmbedColor = 3447003; // Discord blue + } + + /// + /// Gets or sets the Discord webhook URL. + /// + public string DiscordWebhookUrl { get; set; } + + /// + /// Gets or sets the delay before processing notifications (allows metadata to settle). + /// + public int NotificationDelayMinutes { get; set; } + + /// + /// Gets or sets the time window in which episodes are grouped together. + /// + public int GroupingWindowMinutes { get; set; } + + /// + /// Gets or sets a value indicating whether to notify for movies. + /// + public bool EnableMovieNotifications { get; set; } + + /// + /// Gets or sets a value indicating whether to notify for episodes. + /// + public bool EnableEpisodeNotifications { get; set; } + + /// + /// Gets or sets a value indicating whether to suppress notifications for quality upgrades. + /// + public bool SuppressUpgrades { get; set; } + + /// + /// Gets or sets the Discord bot username. + /// + public string BotUsername { get; set; } + + /// + /// Gets or sets the Discord embed color. + /// + public int EmbedColor { get; set; } + + /// + /// Gets or sets the Jellyfin server URL for image links. + /// + public string ServerUrl { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.SmartNotify/Configuration/configPage.html b/Jellyfin.Plugin.SmartNotify/Configuration/configPage.html new file mode 100644 index 0000000..b4c3364 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Configuration/configPage.html @@ -0,0 +1,163 @@ + + + + + SmartNotify + + +
+
+
+
+
+

SmartNotify Konfiguration

+
+ +
+

+ SmartNotify erkennt automatisch, ob ein Item wirklich neu ist oder nur ein + Qualitäts-Upgrade einer bereits existierenden Datei. Benachrichtigungen + werden intelligent gruppiert (z.B. "Episode 1-12 hinzugefügt"). +

+
+ +
+

Discord Einstellungen

+ +
+ + +
Die Webhook-URL deines Discord-Kanals
+
+ +
+ + +
Name des Bots in Discord
+
+ +
+ + +
Discord Embed-Farbe als Dezimalzahl (z.B. 3447003 für Blau)
+
+
+ +
+

Server Einstellungen

+ +
+ + +
Öffentliche URL deines Jellyfin-Servers (für Bilder in Discord)
+
+
+ +
+

Benachrichtigungstypen

+ +
+ +
Benachrichtigungen für neue Episoden senden
+
+ +
+ +
Benachrichtigungen für neue Filme senden
+
+ +
+ +
+ Keine Benachrichtigungen senden, wenn eine Datei durch eine bessere Qualität ersetzt wird +
+
+
+ +
+

Timing

+ +
+ + +
+ Wartezeit bevor eine Benachrichtigung gesendet wird. + Erlaubt Metadaten-Aktualisierung und Erkennung von Ersetzungen. +
+
+ +
+ + +
+ 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. +
+
+
+ +
+ +
+
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj b/Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj new file mode 100644 index 0000000..ef24c59 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + Jellyfin.Plugin.SmartNotify + 1.0.0.0 + 1.0.0.0 + enable + enable + true + false + true + Default + + + + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs b/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs new file mode 100644 index 0000000..5fb74d3 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs @@ -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; + +/// +/// Background service that monitors library changes and sends smart notifications. +/// +public class SmartNotifyBackgroundService : IHostedService, IDisposable +{ + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly ItemHistoryService _historyService; + private readonly DiscordNotificationService _discordService; + private Timer? _processTimer; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + public SmartNotifyBackgroundService( + ILogger logger, + ILibraryManager libraryManager, + ItemHistoryService historyService, + DiscordNotificationService discordService) + { + _logger = logger; + _libraryManager = libraryManager; + _historyService = historyService; + _discordService = discordService; + } + + /// + 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; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SmartNotify background service stopping"); + + _libraryManager.ItemAdded -= OnItemAdded; + _libraryManager.ItemRemoved -= OnItemRemoved; + + _processTimer?.Stop(); + + return Task.CompletedTask; + } + + /// + /// Called when an item is added to the library. + /// + 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); + } + } + + /// + /// Processes a newly added item. + /// + 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); + } + + /// + /// Creates a pending notification from a base item. + /// + 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()), + 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; + } + + /// + /// Called when an item is removed from the library. + /// + 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); + } + + /// + /// Processes pending notifications (called by timer). + /// + 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"); + } + } + + /// + public void Dispose() + { + if (!_disposed) + { + _processTimer?.Dispose(); + _disposed = true; + } + } +} diff --git a/Jellyfin.Plugin.SmartNotify/Plugin.cs b/Jellyfin.Plugin.SmartNotify/Plugin.cs new file mode 100644 index 0000000..12aa8f7 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Plugin.cs @@ -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; + +/// +/// SmartNotify Plugin - Intelligent Discord notifications that detect upgrades vs new content. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "SmartNotify"; + + /// + public override Guid Id => Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + /// + public override string Description => "Intelligent Discord notifications - detects quality upgrades vs truly new content. Groups episodes intelligently (e.g., 'Episode 1-12 added')."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" + } + }; + } +} diff --git a/Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs b/Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs new file mode 100644 index 0000000..2af9e84 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs @@ -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; + +/// +/// Registers plugin services with the DI container. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + // Register singleton services + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + // Register the background service + serviceCollection.AddHostedService(); + + // Ensure HttpClient is available + serviceCollection.AddHttpClient(); + } +} diff --git a/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs b/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs new file mode 100644 index 0000000..76c771c --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs @@ -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; + +/// +/// Service for sending Discord notifications with intelligent grouping. +/// +public class DiscordNotificationService +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The HTTP client factory. + public DiscordNotificationService( + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + /// Sends a grouped notification for multiple episodes of the same series. + /// + /// The notifications to group and send. + /// The cancellation token. + /// A task representing the async operation. + public async Task SendGroupedEpisodeNotificationAsync( + IEnumerable 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); + } + + /// + /// Builds an intelligent episode description like "Episode 1-12" or "Episode 1, 3, 5-7". + /// + private string BuildEpisodeDescription(List> bySeason) + { + var parts = new List(); + + 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); + } + + /// + /// Builds a range string like "1-12" or "1, 3, 5-7, 10". + /// + private string BuildRangeString(List numbers) + { + if (numbers.Count == 0) + { + return string.Empty; + } + + if (numbers.Count == 1) + { + return numbers[0].ToString(); + } + + var ranges = new List(); + 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}"; + } + + /// + /// Sends a notification for a single movie. + /// + /// The notification. + /// The cancellation token. + 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); + } + + /// + /// Builds external links from provider IDs. + /// + private string BuildExternalLinks(string providerIdsJson) + { + try + { + var providerIds = JsonSerializer.Deserialize>(providerIdsJson); + if (providerIds == null || providerIds.Count == 0) + { + return string.Empty; + } + + var links = new List(); + + 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; + } + } + + /// + /// Sends the actual Discord webhook request. + /// + private async Task SendDiscordWebhookAsync( + PluginConfiguration config, + string title, + string description, + string? imageUrl, + string externalLinks, + CancellationToken cancellationToken) + { + var embed = new Dictionary + { + ["title"] = title, + ["description"] = description, + ["color"] = config.EmbedColor + }; + + if (!string.IsNullOrEmpty(imageUrl)) + { + embed["thumbnail"] = new Dictionary { ["url"] = imageUrl }; + } + + if (!string.IsNullOrEmpty(externalLinks)) + { + embed["footer"] = new Dictionary { ["text"] = externalLinks }; + } + + embed["timestamp"] = DateTime.UtcNow.ToString("o"); + + var payload = new Dictionary + { + ["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"); + } + } +} diff --git a/Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs b/Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs new file mode 100644 index 0000000..1586a13 --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs @@ -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; + +/// +/// Service for managing the local database of known media items. +/// +public class ItemHistoryService : IDisposable +{ + private readonly ILogger _logger; + private readonly LiteDatabase _database; + private readonly ILiteCollection _knownItems; + private readonly ILiteCollection _pendingNotifications; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The application paths. + /// The logger. + public ItemHistoryService( + IApplicationPaths applicationPaths, + ILogger 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("known_items"); + _pendingNotifications = _database.GetCollection("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); + } + + /// + /// Generates a content key for uniquely identifying media content (regardless of file). + /// + /// The base item. + /// A unique content key, or null if insufficient metadata. + 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; + } + + /// + /// Gets a consistent provider key from provider IDs. + /// + private string? GetProviderKey(Dictionary? 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; + } + + /// + /// Checks if an item is a quality upgrade (content already known). + /// + /// The item to check. + /// True if this is an upgrade of existing content. + 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; + } + + /// + /// Checks if there's another item with the same content currently in the library. + /// + /// The item to check. + /// The library manager to query current items. + /// True if duplicate content exists. + 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; + } + + /// + /// Records a known item in the database. + /// + /// The item to record. + 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()) + }; + + 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); + } + + /// + /// Removes an item from the known items database. + /// + /// The Jellyfin item ID. + public void RemoveItem(Guid itemId) + { + var jellyfinId = itemId.ToString(); + _knownItems.DeleteMany(x => x.JellyfinItemId == jellyfinId); + } + + /// + /// Queues a notification for later processing. + /// + /// The notification to queue. + public void QueueNotification(PendingNotification notification) + { + _pendingNotifications.Insert(notification); + } + + /// + /// Gets all pending notifications older than the specified delay. + /// + /// The cutoff time. + /// The pending notifications. + public IEnumerable GetPendingNotifications(DateTime olderThan) + { + return _pendingNotifications.Find(x => x.QueuedAt <= olderThan); + } + + /// + /// Removes processed notifications. + /// + /// The notification IDs to remove. + public void RemoveNotifications(IEnumerable ids) + { + foreach (var id in ids) + { + _pendingNotifications.Delete(id); + } + } + + /// + /// Gets pending notifications grouped by series. + /// + /// The series ID. + /// The notifications for the series. + public IEnumerable GetNotificationsForSeries(string seriesId) + { + return _pendingNotifications.Find(x => x.SeriesId == seriesId); + } + + /// + public void Dispose() + { + if (!_disposed) + { + _database.Dispose(); + _disposed = true; + } + } +} diff --git a/Jellyfin.Plugin.SmartNotify/Services/Models.cs b/Jellyfin.Plugin.SmartNotify/Services/Models.cs new file mode 100644 index 0000000..2c1cd2e --- /dev/null +++ b/Jellyfin.Plugin.SmartNotify/Services/Models.cs @@ -0,0 +1,171 @@ +using LiteDB; + +namespace Jellyfin.Plugin.SmartNotify.Services; + +/// +/// Represents a known media item in the database. +/// Used to detect if an "added" item is actually a quality upgrade. +/// +public class KnownMediaItem +{ + /// + /// Gets or sets the database ID. + /// + [BsonId] + public int Id { get; set; } + + /// + /// Gets or sets the Jellyfin Item ID (GUID as string). + /// + public string JellyfinItemId { get; set; } = string.Empty; + + /// + /// Gets or sets the unique content identifier. + /// For episodes: "{SeriesProviderIds}|S{Season}E{Episode}" + /// For movies: "{ProviderIds}" + /// + public string ContentKey { get; set; } = string.Empty; + + /// + /// Gets or sets the item type (Episode, Movie, etc.). + /// + public string ItemType { get; set; } = string.Empty; + + /// + /// Gets or sets the series name (for episodes). + /// + public string? SeriesName { get; set; } + + /// + /// Gets or sets the season number (for episodes). + /// + public int? SeasonNumber { get; set; } + + /// + /// Gets or sets the episode number (for episodes). + /// + public int? EpisodeNumber { get; set; } + + /// + /// Gets or sets the item name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the year. + /// + public int? Year { get; set; } + + /// + /// Gets or sets when this item was first seen. + /// + public DateTime FirstSeen { get; set; } + + /// + /// Gets or sets the file path (for detecting file changes). + /// + public string? FilePath { get; set; } + + /// + /// Gets or sets the file size in bytes. + /// + public long? FileSize { get; set; } + + /// + /// Gets or sets the provider IDs as JSON string. + /// + public string ProviderIdsJson { get; set; } = "{}"; +} + +/// +/// Represents a pending notification in the queue. +/// +public class PendingNotification +{ + /// + /// Gets or sets the database ID. + /// + [BsonId] + public int Id { get; set; } + + /// + /// Gets or sets the Jellyfin Item ID. + /// + public string JellyfinItemId { get; set; } = string.Empty; + + /// + /// Gets or sets the item type. + /// + public string ItemType { get; set; } = string.Empty; + + /// + /// Gets or sets the series name (for grouping episodes). + /// + public string? SeriesName { get; set; } + + /// + /// Gets or sets the series ID (for grouping). + /// + public string? SeriesId { get; set; } + + /// + /// Gets or sets the season number. + /// + public int? SeasonNumber { get; set; } + + /// + /// Gets or sets the episode number. + /// + public int? EpisodeNumber { get; set; } + + /// + /// Gets or sets the item name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the year. + /// + public int? Year { get; set; } + + /// + /// Gets or sets when the notification was queued. + /// + public DateTime QueuedAt { get; set; } + + /// + /// Gets or sets the notification type. + /// + public NotificationType Type { get; set; } + + /// + /// Gets or sets the image URL. + /// + public string? ImageUrl { get; set; } + + /// + /// Gets or sets the provider IDs JSON. + /// + public string ProviderIdsJson { get; set; } = "{}"; + + /// + /// Gets or sets the overview/description. + /// + public string? Overview { get; set; } +} + +/// +/// Type of notification. +/// +public enum NotificationType +{ + /// + /// Truly new content. + /// + NewContent, + + /// + /// Quality upgrade of existing content. + /// + QualityUpgrade +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c01e4c --- /dev/null +++ b/README.md @@ -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. diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..a0cb121 --- /dev/null +++ b/build.yaml @@ -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 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7753f7c --- /dev/null +++ b/manifest.json @@ -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": [] + } +]