fix:: Plugin Erste Tests
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
This commit is contained in:
123
.gitea/workflows/build-publish.yaml
Normal file
123
.gitea/workflows/build-publish.yaml
Normal 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
|
||||||
209
.gitea/workflows/create-release-pr.yaml
Normal file
209
.gitea/workflows/create-release-pr.yaml
Normal 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
|
||||||
60
.gitea/workflows/create-release.yaml
Normal file
60
.gitea/workflows/create-release.yaml
Normal 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
|
||||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
".": "0.0.0"
|
||||||
|
}
|
||||||
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
19
Jellyfin.Plugin.SmartNotify.sln
Normal file
19
Jellyfin.Plugin.SmartNotify.sln
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
163
Jellyfin.Plugin.SmartNotify/Configuration/configPage.html
Normal file
163
Jellyfin.Plugin.SmartNotify/Configuration/configPage.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Jellyfin.Plugin.SmartNotify/Plugin.cs
Normal file
53
Jellyfin.Plugin.SmartNotify/Plugin.cs
Normal 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"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs
Normal file
27
Jellyfin.Plugin.SmartNotify/PluginServiceRegistrator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
329
Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs
Normal file
329
Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
Jellyfin.Plugin.SmartNotify/Services/Models.cs
Normal file
171
Jellyfin.Plugin.SmartNotify/Services/Models.cs
Normal 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
154
README.md
Normal 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
26
build.yaml
Normal 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
12
manifest.json
Normal 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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user