71 Commits

Author SHA1 Message Date
Gitea Actions
7aadb40c82 chore: update manifest for v0.1.4
All checks were successful
Create Release PR / Create Release PR (push) Successful in 4s
2026-04-07 06:40:42 +00:00
Gitea Actions
48e347f5c7 docs: update changelog for v0.1.4
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 44s
2026-04-07 06:39:55 +00:00
76161230e1 Merge pull request 'chore(main): release 0.1.4' (#27) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Reviewed-on: #27
2026-04-07 08:39:49 +02:00
Gitea Actions
205cbd91af chore(main): release 0.1.4
All checks were successful
Create Release / Publish Release (pull_request) Successful in 9s
2026-04-07 06:38:44 +00:00
a71ced086a Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 19s
2026-04-07 08:38:20 +02:00
85fd002f4e fix: slow start of jellyfin 2026-04-07 08:38:18 +02:00
Gitea Actions
f9aacf2436 chore: update manifest for v0.1.3
All checks were successful
Create Release PR / Create Release PR (push) Successful in 4s
2026-04-05 16:13:21 +00:00
Gitea Actions
caee267f8b docs: update changelog for v0.1.3
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 1m1s
2026-04-05 16:12:18 +00:00
5a60a6f5b4 Merge pull request 'chore(main): release 0.1.3' (#26) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Reviewed-on: #26
2026-04-05 18:12:06 +02:00
Gitea Actions
133c00fab0 chore(main): release 0.1.3
All checks were successful
Create Release / Publish Release (pull_request) Successful in 15s
2026-04-05 15:22:58 +00:00
0bafe691a0 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 16s
2026-04-05 17:22:37 +02:00
82b34e288c fix: stop fodler name as names 2026-04-05 17:22:35 +02:00
Gitea Actions
8c77f82d2c chore: update manifest for v0.1.2
All checks were successful
Create Release PR / Create Release PR (push) Successful in 4s
2026-04-04 09:39:49 +00:00
Gitea Actions
cb0dfe2c21 docs: update changelog for v0.1.2
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 44s
2026-04-04 09:39:03 +00:00
eaf6ea91e1 Merge pull request 'chore(main): release 0.1.2' (#25) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Reviewed-on: #25
2026-04-04 11:38:56 +02:00
Gitea Actions
180a998be1 chore(main): release 0.1.2
All checks were successful
Create Release / Publish Release (pull_request) Successful in 9s
2026-04-04 09:36:28 +00:00
6fd2638414 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 19s
2026-04-04 11:36:02 +02:00
0e10e3c089 fix: refresh series name before notification 2026-04-04 11:35:59 +02:00
Gitea Actions
e729c7b8d5 chore: update manifest for v0.1.1
All checks were successful
Create Release PR / Create Release PR (push) Successful in 4s
2026-04-03 17:32:41 +00:00
Gitea Actions
fb8161f976 docs: update changelog for v0.1.1
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 1m42s
2026-04-03 17:30:57 +00:00
0f5ce9726b Merge pull request 'chore(main): release 0.1.1' (#24) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Reviewed-on: #24
2026-04-03 19:30:45 +02:00
Gitea Actions
8d93c17bde chore(main): release 0.1.1
All checks were successful
Create Release / Publish Release (pull_request) Successful in 15s
2026-04-03 15:41:18 +00:00
b1444094ad fix: unknown series
All checks were successful
Create Release PR / Create Release PR (push) Successful in 59s
2026-04-03 17:40:17 +02:00
cc2d02983f Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 10s
2026-03-05 17:22:17 +01:00
aa26dbb40b chore: workflow fix 2026-03-05 17:22:16 +01:00
5954974add chore: removed old versions
All checks were successful
Create Release PR / Create Release PR (push) Successful in 11s
2026-03-05 17:20:36 +01:00
Gitea Actions
52b3588933 chore: update manifest for v0.1.0
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-05 16:15:12 +00:00
Gitea Actions
c032d2b651 docs: update changelog for v0.1.0
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 47s
2026-03-05 16:14:24 +00:00
be5f71caff Merge pull request 'chore(main): release 0.1.0' (#22) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
Reviewed-on: #22
2026-03-05 17:14:18 +01:00
Gitea Actions
a5b75159e2 chore(main): release 0.1.0
All checks were successful
Create Release / Publish Release (pull_request) Successful in 9s
2026-03-05 16:13:33 +00:00
2386648a63 feat: dummer Claude!
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
2026-03-05 17:13:16 +01:00
948c4f8768 fix: Claude ist ein dummer Hurensohn! 2026-03-05 17:12:18 +01:00
c3a7c504db feat: own changelog
All checks were successful
Create Release PR / Create Release PR (push) Successful in 11s
2026-03-05 16:58:26 +01:00
9863778d8b fix: double notifications
All checks were successful
Create Release PR / Create Release PR (push) Successful in 18s
2026-03-05 16:40:03 +01:00
562bfbec54 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 11s
2026-03-04 18:32:15 +01:00
24c4bf9ba5 fix: claude ist dumm 2026-03-04 18:32:13 +01:00
Gitea Actions
0c44037f93 chore: update manifest for v0.0.19
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-04 17:22:49 +00:00
7ff65d1546 Merge pull request 'chore(main): release 0.0.19' (#20) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 41s
Reviewed-on: #20
2026-03-04 18:21:58 +01:00
Gitea Actions
bae22c908f chore(main): release 0.0.19
All checks were successful
Create Release / Publish Release (pull_request) Successful in 9s
2026-03-04 17:21:39 +00:00
27da8c90f7 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 20s
2026-03-04 18:21:15 +01:00
96d67a8655 fix: debuging 2026-03-04 18:21:13 +01:00
Gitea Actions
62a7547688 chore: update manifest for v0.0.18
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-03 13:53:53 +00:00
047dd82f3f Merge pull request 'chore(main): release 0.0.18' (#19) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 43s
Reviewed-on: #19
2026-03-03 14:53:02 +01:00
Gitea Actions
76fb874b4d chore(main): release 0.0.18
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-03 13:51:42 +00:00
7fadc75c84 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
2026-03-03 14:51:20 +01:00
d595b16573 fix: build error 2026-03-03 14:51:18 +01:00
20f603b4ee Merge pull request 'chore(main): release 0.0.17' (#18) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Failing after 43s
Reviewed-on: #18
2026-03-03 12:42:15 +01:00
Gitea Actions
798cdcaf9c chore(main): release 0.0.17
All checks were successful
Create Release / Publish Release (pull_request) Successful in 6s
2026-03-03 11:41:58 +00:00
af6ddeac0d Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
2026-03-03 12:41:36 +01:00
87f1eff7df fix: removed notifications on reorganization 2026-03-03 12:41:34 +01:00
Gitea Actions
97a7bc5422 chore: update manifest for v0.0.16
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-02 18:58:41 +00:00
f56cd701cb Merge pull request 'chore(main): release 0.0.16' (#17) from release-please--branches--main into main
All checks were successful
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 50s
Create Release PR / Create Release PR (push) Has been skipped
Reviewed-on: #17
2026-03-02 19:57:42 +01:00
Gitea Actions
d6ebbda8ad chore(main): release 0.0.16
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-02 18:57:28 +00:00
ec2ddf3728 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 16s
2026-03-02 19:57:09 +01:00
3389eb254c fix: ist das wirklich ein Fix und kein defix? 2026-03-02 19:57:07 +01:00
Gitea Actions
b056e8a199 chore: update manifest for v0.0.15
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 17:49:47 +00:00
fa52312228 Merge pull request 'chore(main): release 0.0.15' (#16) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 44s
Reviewed-on: #16
2026-03-01 18:48:56 +01:00
Gitea Actions
07cd31097a chore(main): release 0.0.15
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 17:45:54 +00:00
fe78850872 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 11s
2026-03-01 18:45:45 +01:00
f2903719a3 fix: timestamps! 2026-03-01 18:45:43 +01:00
Gitea Actions
7fcc917ca5 chore: update manifest for v0.0.14
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 17:36:26 +00:00
06cbbec97b Merge pull request 'chore(main): release 0.0.14' (#15) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 46s
Reviewed-on: #15
2026-03-01 18:35:32 +01:00
Gitea Actions
877cf59e44 chore(main): release 0.0.14
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 17:35:21 +00:00
895aafb987 fix: claude hat bugs im Kopf
All checks were successful
Create Release PR / Create Release PR (push) Successful in 10s
2026-03-01 18:35:12 +01:00
Gitea Actions
3925e502ec chore: update manifest for v0.0.13
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 17:21:50 +00:00
732dbda337 Merge pull request 'chore(main): release 0.0.13' (#14) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 46s
Reviewed-on: #14
2026-03-01 18:20:57 +01:00
Gitea Actions
579331c79f chore(main): release 0.0.13
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 17:20:37 +00:00
063f594753 fix: removed drecks versionen
All checks were successful
Create Release PR / Create Release PR (push) Successful in 9s
2026-03-01 18:20:27 +01:00
b383b5cd81 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 10s
2026-03-01 18:19:39 +01:00
6c485aa0f7 fix: Bilder und dumme min TImings! 2026-03-01 18:19:37 +01:00
Gitea Actions
5110c999b6 chore: update manifest for v0.0.12
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 17:07:16 +00:00
14 changed files with 1022 additions and 233 deletions

View File

@@ -11,24 +11,36 @@ permissions:
jobs:
release-pr:
name: Create Release PR
if: "!contains(gitea.event.head_commit.message, 'chore(main): release') && !contains(gitea.event.head_commit.message, 'chore: update manifest')"
runs-on: ubuntu-latest
steps:
- name: Skip if release/manifest commit
run: |
COMMIT_MSG="${{ gitea.event.head_commit.message }}"
if echo "$COMMIT_MSG" | grep -qE 'chore\(main\): release|chore: update manifest'; then
echo "Skipping release/manifest commit"
exit 0
fi
echo "SHOULD_RUN=true" >> $GITHUB_ENV
- uses: actions/checkout@v4
if: env.SHOULD_RUN == 'true'
with:
fetch-depth: 0
token: ${{ secrets.GT_TOKEN }}
- name: Setup Python
if: env.SHOULD_RUN == 'true'
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
if: env.SHOULD_RUN == 'true'
run: |
pip install gitpython packaging
- name: Create Release PR
if: env.SHOULD_RUN == 'true'
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
REPO: "TDPI/jellyfin-plugin-smartnotify"
@@ -38,7 +50,7 @@ jobs:
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()
@@ -46,14 +58,14 @@ jobs:
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:
@@ -61,11 +73,11 @@ jobs:
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:
@@ -73,57 +85,57 @@ jobs:
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:
@@ -144,33 +156,42 @@ jobs:
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)
# Write changelog body (without header) for PR body
with open('/tmp/changelog_body', 'w') as f:
f.write(changelog_entry)
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"
# Build PR body with changelog as editable template
CHANGELOG_BODY=$(cat /tmp/changelog_body)
PR_BODY="$(printf '> **Bearbeite diesen Text!** Der Changelog unten wurde automatisch aus Commits generiert.\n> Passe ihn an bevor du den PR mergest - dieser Text wird als Changelog verwendet.\n\n%s' "$CHANGELOG_BODY")"
PR_BODY_JSON=$(echo "$PR_BODY" | jq -Rs .)
# Push changes
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
git push -f origin HEAD:"$BRANCH_NAME"
@@ -179,21 +200,18 @@ jobs:
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
\"body\": $PR_BODY_JSON
}"
fi
else
@@ -204,6 +222,6 @@ jobs:
\"title\": \"chore(main): release $NEW_VERSION\",
\"head\": \"$BRANCH_NAME\",
\"base\": \"main\",
\"body\": $CHANGELOG_CONTENT
\"body\": $PR_BODY_JSON
}"
fi

View File

@@ -26,6 +26,60 @@ jobs:
VERSION=$(jq -r '."."' .release-please-manifest.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Get changelog from PR body
id: changelog
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
REPO: "TDPI/jellyfin-plugin-smartnotify"
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
# Get PR body (the manually edited changelog)
PR_BODY=$(curl -s "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
-H "Authorization: token $GIT_TOKEN" | jq -r '.body')
# Strip the instruction blockquote lines (lines starting with >)
CHANGELOG=$(echo "$PR_BODY" | sed '/^[[:space:]]*>/d' | sed '/^$/N;/^\n$/d')
echo "$CHANGELOG" > /tmp/pr_changelog
echo "Changelog from PR body:"
echo "$CHANGELOG"
- name: Update CHANGELOG.md with PR body text
env:
VERSION: ${{ steps.version.outputs.version }}
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
run: |
CHANGELOG=$(cat /tmp/pr_changelog)
# Replace the auto-generated changelog entry with the manually edited one
python3 << PYEOF
import re
version = "${VERSION}"
changelog = open('/tmp/pr_changelog').read().strip()
with open('CHANGELOG.md', 'r') as f:
content = f.read()
# Find and replace the section for this version
# Pattern: ## VERSION (DATE) ... until next ## or end
pattern = r'(## ' + re.escape(version) + r' \([^)]+\)\n\n).*?(?=\n## |\Z)'
replacement = r'\1' + changelog
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
with open('CHANGELOG.md', 'w') as f:
f.write(new_content)
PYEOF
# Commit updated CHANGELOG
git config user.name "Gitea Actions"
git config user.email "actions@git.tdpi.dev"
git add CHANGELOG.md
git diff --cached --quiet || {
git commit -m "docs: update changelog for v${VERSION}"
git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git HEAD:main
}
- name: Create Git Tag
env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
@@ -40,7 +94,7 @@ jobs:
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md)
CHANGELOG=$(cat /tmp/pr_changelog)
PAYLOAD=$(jq -n \
--arg tag "v$VERSION" \

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.claude
buildedplugin
buildedplugin
MEMORY.md

View File

@@ -1,3 +1,3 @@
{
".": "0.0.12"
".": "0.1.4"
}

View File

@@ -1,5 +1,148 @@
# Changelog
## 0.1.4 (2026-04-07)
## 0.1.4 (2026-04-07)
### Bug Fixes
Langsamer Start von Jellyfin wegen Überprüfung bestehender Einträge.
Läuft nun asynchron.
## 0.1.3 (2026-04-05)
## 0.1.3 (2026-04-05)
### Bug Fixes
Gebe keine Notification wenn der Name der Serie tmdbid oder ähnliches enthält.
Dies verhindert das die Notification kommt wenn noch nicht die Metadaten gezogen wurden.
## 0.1.2 (2026-04-04)
## 0.1.2 (2026-04-04)
### Bug Fixes
* Problem behoben das der Serienname vor der Benachrichtigung nicht aktualisiert wurde.
## 0.1.1 (2026-04-03)
## 0.1.1 (2026-04-03)
### Bug Fixes
* fix: unknown series
### Chores
* chore: workflow fix
* chore: removed old versions
* chore: update manifest for v0.1.0
## 0.1.0 (2026-03-05)
## 0.1.0 (2026-03-05)
**Features**
Intelligente Discord-Benachrichtigungen
Automatische Benachrichtigungen bei neuen Filmen und Episoden via Discord Webhook
Schöne Discord-Embeds mit Thumbnail, Beschreibung und Links zu externen Datenbanken (IMDb, TMDb, AniDB, AniList, TVDB)
**Smarte Episoden-Gruppierung**
Episoden werden intelligent gebündelt statt einzeln gemeldet — z.B. "Staffel 1: Episode 1-12" statt 12 einzelne Nachrichten
Konfigurierbares Zeitfenster für die Gruppierung
**Qualitäts-Upgrade-Erkennung**
Erkennt automatisch ob eine Datei neu ist oder nur ein Qualitäts-Upgrade einer bestehenden Datei
Keine Spam-Benachrichtigungen mehr beim Ersetzen von Dateien durch bessere Versionen
Stabile Erkennung über Provider-IDs (AniDB, TMDb etc.) — funktioniert auch mit Shokofin/VFS
**Robuste Metadaten-Verarbeitung**
Verzögerte Verarbeitung damit Jellyfin Zeit hat Metadaten zu laden
Dreistufige Validierung: beim Hinzufügen, beim Einreihen und beim Senden
Automatische Unterdrückung von reorganisierten Items (Pfad-/Metadata-Änderungen)
**Konfigurierbar**
Benachrichtigungen für Filme und Episoden einzeln aktivierbar
Upgrade-Unterdrückung optional
Anpassbare Verzögerung, Gruppierungsfenster, Bot-Name und Embed-Farbe
## 0.0.19 (2026-03-04)
### Bug Fixes
* fix: debuging
### Chores
* chore: update manifest for v0.0.18
## 0.0.18 (2026-03-03)
### Bug Fixes
* fix: build error
## 0.0.17 (2026-03-03)
### Bug Fixes
* fix: removed notifications on reorganization
### Chores
* chore: update manifest for v0.0.16
## 0.0.16 (2026-03-02)
### Bug Fixes
* fix: ist das wirklich ein Fix und kein defix?
### Chores
* chore: update manifest for v0.0.15
## 0.0.15 (2026-03-01)
### Bug Fixes
* fix: timestamps!
### Chores
* chore: update manifest for v0.0.14
## 0.0.14 (2026-03-01)
### Bug Fixes
* fix: claude hat bugs im Kopf
### Chores
* chore: update manifest for v0.0.13
## 0.0.13 (2026-03-01)
### Bug Fixes
* fix: removed drecks versionen
* fix: Bilder und dumme min TImings!
### Chores
* chore: update manifest for v0.0.12
## 0.0.12 (2026-03-01)
### Bug Fixes

View File

@@ -14,7 +14,7 @@ public class PluginConfiguration : BasePluginConfiguration
{
DiscordWebhookUrl = string.Empty;
NotificationDelayMinutes = 5;
GroupingWindowMinutes = 30;
GroupingWindowMinutes = 5;
EnableMovieNotifications = true;
EnableEpisodeNotifications = true;
SuppressUpgrades = true;

View File

@@ -88,7 +88,7 @@
<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" />
<input is="emby-input" type="number" id="txtNotificationDelayMinutes" min="0" max="60" />
<div class="fieldDescription">
Wartezeit bevor eine Benachrichtigung gesendet wird.
Erlaubt Metadaten-Aktualisierung und Erkennung von Ersetzungen.
@@ -97,7 +97,7 @@
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtGroupingWindowMinutes">Gruppierungsfenster (Minuten)</label>
<input is="emby-input" type="number" id="txtGroupingWindowMinutes" min="5" max="120" />
<input is="emby-input" type="number" id="txtGroupingWindowMinutes" min="0" 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,
@@ -131,8 +131,8 @@
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;
document.querySelector('#txtNotificationDelayMinutes').value = config.NotificationDelayMinutes ?? 0;
document.querySelector('#txtGroupingWindowMinutes').value = config.GroupingWindowMinutes ?? 0;
Dashboard.hideLoadingMsg();
});
});
@@ -149,8 +149,8 @@
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;
config.NotificationDelayMinutes = parseInt(document.querySelector('#txtNotificationDelayMinutes').value) || 0;
config.GroupingWindowMinutes = parseInt(document.querySelector('#txtGroupingWindowMinutes').value) || 0;
ApiClient.updatePluginConfiguration(SmartNotifyConfig.pluginUniqueId, config).then(function () {
Dashboard.processPluginConfigurationUpdateResult();
});

View File

@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
<AssemblyVersion>0.0.12.0</AssemblyVersion>
<FileVersion>0.0.12.0</FileVersion>
<AssemblyVersion>0.1.4.0</AssemblyVersion>
<FileVersion>0.1.4.0</FileVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

View File

@@ -1,13 +1,17 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SmartNotify.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
@@ -46,7 +50,7 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
{
_logger.LogInformation("SmartNotify background service starting");
// Subscribe to library events
// Subscribe to library events (before seeding so we don't miss items added during seed).
_libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemRemoved += OnItemRemoved;
@@ -56,11 +60,59 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
_processTimer.AutoReset = true;
_processTimer.Start();
// Pre-populate DB with existing library items in the background
// so we don't block Jellyfin startup.
_ = Task.Run(() => SeedExistingLibraryItems(), cancellationToken);
_logger.LogInformation("SmartNotify is now monitoring library changes");
return Task.CompletedTask;
}
/// <summary>
/// Seeds the database with all existing Episodes and Movies from the library.
/// Runs once at startup — only records items not yet in the DB.
/// </summary>
private void SeedExistingLibraryItems()
{
try
{
var query = new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie },
IsVirtualItem = false,
Recursive = true
};
var existingItems = _libraryManager.GetItemList(query);
var seeded = 0;
var alreadyKnown = 0;
foreach (var item in existingItems)
{
if (!_historyService.IsKnownItem(item.Id, item))
{
_historyService.RecordItem(item);
seeded++;
}
else
{
alreadyKnown++;
}
}
_logger.LogInformation(
"[DEBUG Seed] Seeded {Seeded} new items, {AlreadyKnown} already known, {Total} total in library",
seeded,
alreadyKnown,
existingItems.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding existing library items");
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
@@ -87,6 +139,68 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return;
}
// Skip virtual items — these are created by "Add Missing Episodes/Seasons"
// metadata feature and have no actual file on disk
if (item.IsVirtualItem || string.IsNullOrEmpty(item.Path))
{
_logger.LogDebug("Skipping virtual item (no file): {Name} (ID: {Id})", item.Name, item.Id);
return;
}
// Debug: log all available metadata on the item at ItemAdded time
if (item is Episode debugEp)
{
_logger.LogInformation(
"[DEBUG ItemAdded] Episode: Name={Name}, Id={Id}, Path={Path}, " +
"SeriesName={SeriesName}, SeriesId={SeriesId}, " +
"Season={Season}, Episode={Episode}, " +
"ProviderIds={ProviderIds}, " +
"DateCreated={DateCreated}, PremiereDate={PremiereDate}",
debugEp.Name,
debugEp.Id,
debugEp.Path,
debugEp.SeriesName,
debugEp.SeriesId,
debugEp.ParentIndexNumber,
debugEp.IndexNumber,
debugEp.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugEp.ProviderIds) : "null",
debugEp.DateCreated,
debugEp.PremiereDate);
// Also try to access the Series object directly
try
{
var debugSeries = debugEp.Series;
if (debugSeries != null)
{
_logger.LogInformation(
"[DEBUG ItemAdded] Series object found: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
debugSeries.Name,
debugSeries.Id,
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
}
else
{
_logger.LogInformation("[DEBUG ItemAdded] Series object is NULL for episode {Name}", debugEp.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[DEBUG ItemAdded] Failed to access Series object for {Name}", debugEp.Name);
}
}
else if (item is Movie debugMovie)
{
_logger.LogInformation(
"[DEBUG ItemAdded] Movie: Name={Name}, Id={Id}, Path={Path}, " +
"ProviderIds={ProviderIds}, Year={Year}",
debugMovie.Name,
debugMovie.Id,
debugMovie.Path,
debugMovie.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugMovie.ProviderIds) : "null",
debugMovie.ProductionYear);
}
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
var config = Plugin.Instance?.Configuration;
@@ -127,6 +241,17 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return;
}
// Check 0: Is this item already known in our DB? (by Jellyfin ID or content key)
if (_historyService.IsKnownItem(item.Id, item))
{
_logger.LogDebug(
"Item {Name} (ID: {Id}) is already known in DB, skipping notification",
item.Name,
item.Id);
_historyService.RecordItem(item);
return;
}
// Check 1: Is this a quality upgrade? (Same content, different file)
var isUpgrade = _historyService.IsQualityUpgrade(item);
@@ -185,25 +310,54 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
Overview = item.Overview
};
// Set image URL
if (!string.IsNullOrEmpty(serverUrl))
// Set local image path for attachment-based sending
var imagePath = item.GetImagePath(ImageType.Primary, 0);
if (!string.IsNullOrEmpty(imagePath))
{
notification.ImageUrl = $"{serverUrl}/Items/{item.Id}/Images/Primary";
notification.ImagePath = imagePath;
}
if (item is Episode episode)
{
notification.SeriesName = episode.SeriesName;
notification.SeriesId = episode.SeriesId.ToString();
var seriesObj = episode.Series;
var rawSeriesName = episode.SeriesName ?? seriesObj?.Name;
// If the name still looks like a Sonarr folder name, leave it null
// so the incomplete-episode logic defers until metadata scrape is done.
notification.SeriesName = LooksLikeFolderName(rawSeriesName) ? null : rawSeriesName;
notification.SeriesId = (episode.SeriesId != Guid.Empty
? episode.SeriesId
: seriesObj?.Id ?? Guid.Empty).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)
// Use series provider IDs for external links — episode provider IDs
// (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths
var resolvedSeriesId = notification.SeriesId;
if (resolvedSeriesId != Guid.Empty.ToString() && Guid.TryParse(resolvedSeriesId, out var seriesGuid))
{
notification.ImageUrl = $"{serverUrl}/Items/{episode.SeriesId}/Images/Primary";
var series = seriesObj ?? _libraryManager.GetItemById(seriesGuid);
if (series?.ProviderIds != null && series.ProviderIds.Count > 0)
{
notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds);
}
var seriesImage = series?.GetImagePath(ImageType.Primary, 0);
if (!string.IsNullOrEmpty(seriesImage))
{
notification.ImagePath = seriesImage;
}
}
_logger.LogInformation(
"[DEBUG CreateNotification] Result: SeriesName={SeriesName}, SeriesId={SeriesId}, " +
"S{Season}E{Episode}, ProviderIdsJson={ProviderIds}, ImagePath={ImagePath}",
notification.SeriesName,
notification.SeriesId,
notification.SeasonNumber,
notification.EpisodeNumber,
notification.ProviderIdsJson,
notification.ImagePath);
}
else if (item is Movie movie)
{
@@ -224,6 +378,157 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
_logger.LogDebug("Item removed: {Name} (ID: {Id})", e.Item.Name, e.Item.Id);
}
/// <summary>
/// Refreshes notification metadata from the library (series info may not be available at queue time).
/// </summary>
private void RefreshNotificationMetadata(PendingNotification notification)
{
if (!Guid.TryParse(notification.JellyfinItemId, out var itemId) || itemId == Guid.Empty)
{
return;
}
var item = _libraryManager.GetItemById(itemId);
if (item is not Episode episode)
{
_logger.LogInformation(
"[DEBUG Refresh] Item {Id} is {Type} (not Episode), skipping",
itemId,
item?.GetType().Name ?? "NULL");
return;
}
// Debug: log what Jellyfin returns for this episode at refresh time
_logger.LogInformation(
"[DEBUG Refresh] Episode from library: Name={Name}, SeriesName={SeriesName}, SeriesId={SeriesId}, " +
"Season={Season}, Episode={Episode}, ProviderIds={ProviderIds}",
episode.Name,
episode.SeriesName,
episode.SeriesId,
episode.ParentIndexNumber,
episode.IndexNumber,
episode.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(episode.ProviderIds) : "null");
try
{
var debugSeries = episode.Series;
if (debugSeries != null)
{
_logger.LogInformation(
"[DEBUG Refresh] Series object: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
debugSeries.Name,
debugSeries.Id,
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
}
else
{
_logger.LogInformation("[DEBUG Refresh] Series object is STILL NULL for {Name}", episode.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[DEBUG Refresh] Failed to access Series for {Name}", episode.Name);
}
var changed = false;
// Refresh SeriesName from the library. At queue time the name is often
// the Sonarr folder name (e.g. "Chained Soldier (2024) [tmdbid-139060]").
// After Jellyfin's metadata scrape completes, the name changes to the
// correct provider name (e.g. "Demon Slave - The Chained Soldier").
// We detect the unscraped folder name by checking for bracket patterns
// like [tmdbid-...] or (2024) and clear the SeriesName so the
// incomplete-episode logic defers sending until the scrape is done.
{
var freshSeriesName = episode.SeriesName ?? episode.Series?.Name;
if (!string.IsNullOrEmpty(freshSeriesName))
{
if (LooksLikeFolderName(freshSeriesName))
{
// Still the Sonarr folder name — clear so we keep waiting
if (!string.IsNullOrEmpty(notification.SeriesName))
{
_logger.LogInformation(
"[DEBUG Refresh] SeriesName '{Name}' looks like a folder name, clearing to defer",
freshSeriesName);
notification.SeriesName = null;
changed = true;
}
}
else if (freshSeriesName != notification.SeriesName)
{
_logger.LogInformation(
"[DEBUG Refresh] SeriesName changed: '{Old}' -> '{New}'",
notification.SeriesName,
freshSeriesName);
notification.SeriesName = freshSeriesName;
changed = true;
}
}
}
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString())
{
// Same fallback: SeriesId property may be empty, but Series object may exist
var resolvedSeriesId = episode.SeriesId != Guid.Empty
? episode.SeriesId
: episode.Series?.Id ?? Guid.Empty;
if (resolvedSeriesId != Guid.Empty)
{
notification.SeriesId = resolvedSeriesId.ToString();
changed = true;
}
}
if (!notification.SeasonNumber.HasValue && episode.ParentIndexNumber.HasValue)
{
notification.SeasonNumber = episode.ParentIndexNumber;
changed = true;
}
if (!notification.EpisodeNumber.HasValue && episode.IndexNumber.HasValue)
{
notification.EpisodeNumber = episode.IndexNumber;
changed = true;
}
// Always refresh the item name — it may have been a placeholder at queue time
if (!string.IsNullOrEmpty(episode.Name) && episode.Name != notification.Name)
{
_logger.LogInformation(
"[DEBUG Refresh] Name changed: '{Old}' -> '{New}'",
notification.Name,
episode.Name);
notification.Name = episode.Name;
changed = true;
}
// Refresh image if missing
if (string.IsNullOrEmpty(notification.ImagePath) && episode.SeriesId != Guid.Empty)
{
var series = _libraryManager.GetItemById(episode.SeriesId);
var seriesImage = series?.GetImagePath(ImageType.Primary, 0);
if (!string.IsNullOrEmpty(seriesImage))
{
notification.ImagePath = seriesImage;
changed = true;
}
}
if (changed)
{
_historyService.UpdateNotification(notification);
_logger.LogInformation(
"Refreshed metadata for {Name}: Series={SeriesName}, SeriesId={SeriesId}, S{Season}E{Episode}",
notification.Name,
notification.SeriesName,
notification.SeriesId,
notification.SeasonNumber,
notification.EpisodeNumber);
}
}
/// <summary>
/// Processes pending notifications (called by timer).
/// </summary>
@@ -247,14 +552,114 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return;
}
_logger.LogDebug("Processing {Count} pending notifications", pendingNotifications.Count);
_logger.LogInformation(
"Processing {Count} pending notifications (delay={Delay}min, grouping={Grouping}min, cutoff={Cutoff})",
pendingNotifications.Count,
delayMinutes,
groupingWindowMinutes,
cutoff);
// Refresh metadata for episodes that were queued before metadata was available
foreach (var notification in pendingNotifications.Where(n => n.ItemType == "Episode"))
{
RefreshNotificationMetadata(notification);
}
// Late known-item detection: re-check now that metadata is fully populated.
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
// been available, causing GenerateContentKey() to return null and known items
// to go undetected. By now (after delay + grouping window), metadata is ready.
// This catches reorganized items (path/metadata changes) and quality upgrades.
{
var suppressedIds = new List<int>();
foreach (var notification in pendingNotifications)
{
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
{
var revalidateItem = _libraryManager.GetItemById(revalidateId);
var result = _historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem, _libraryManager);
if (result == ItemHistoryService.RevalidationResult.Reorganized)
{
// Always suppress reorganized items (same content, path/ID changed)
suppressedIds.Add(notification.Id);
_logger.LogInformation(
"Suppressed {Name}: recognized as reorganized known item at send time",
notification.Name);
}
else if (result == ItemHistoryService.RevalidationResult.Upgrade && config.SuppressUpgrades)
{
// Only suppress upgrades when configured to do so
suppressedIds.Add(notification.Id);
_logger.LogInformation(
"Suppressed {Name}: quality upgrade detected at send time",
notification.Name);
}
}
}
if (suppressedIds.Count > 0)
{
_historyService.RemoveNotifications(suppressedIds);
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
_logger.LogInformation(
"Suppressed {Count} notifications at send time ({Reason})",
suppressedIds.Count,
"reorganized/upgrade");
}
if (pendingNotifications.Count == 0)
{
return;
}
}
// Group episodes by series
var emptyGuid = Guid.Empty.ToString();
var episodesBySeries = pendingNotifications
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId))
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId) && n.SeriesId != emptyGuid)
.GroupBy(n => n.SeriesId!)
.ToList();
// Episodes without SeriesName or SeriesId must NEVER be sent.
// Wait up to 30 minutes for metadata to resolve, then drop.
var maxMetadataWait = DateTime.UtcNow.AddMinutes(-30);
var incompleteEpisodes = pendingNotifications
.Where(n => n.ItemType == "Episode"
&& (string.IsNullOrEmpty(n.SeriesName)
|| string.IsNullOrEmpty(n.SeriesId)
|| n.SeriesId == emptyGuid))
.ToList();
if (incompleteEpisodes.Count > 0)
{
// Split into: still waiting vs. timed out
var timedOut = incompleteEpisodes.Where(n => n.QueuedAt < maxMetadataWait).ToList();
var stillWaiting = incompleteEpisodes.Where(n => n.QueuedAt >= maxMetadataWait).ToList();
if (timedOut.Count > 0)
{
var dropIds = timedOut.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(dropIds);
pendingNotifications.RemoveAll(n => dropIds.Contains(n.Id));
_logger.LogWarning(
"Dropped {Count} episode notifications after 30min without series metadata: {Names}",
timedOut.Count,
string.Join(", ", timedOut.Select(n => n.Name)));
}
if (stillWaiting.Count > 0)
{
// Remove from this processing cycle, keep in queue for next attempt
var waitIds = stillWaiting.Select(n => n.Id).ToHashSet();
pendingNotifications.RemoveAll(n => waitIds.Contains(n.Id));
_logger.LogInformation(
"Deferring {Count} episode notifications, waiting for series metadata: {Names}",
stillWaiting.Count,
string.Join(", ", stillWaiting.Select(n => n.Name)));
}
}
// Process each series group
foreach (var seriesGroup in episodesBySeries)
{
@@ -264,19 +669,34 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
// 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}",
_logger.LogInformation(
"Waiting for grouping window for series {SeriesName} ({SeriesId}), oldest queued: {Oldest}, grouping cutoff: {Cutoff}",
seriesGroup.First().SeriesName,
seriesGroup.Key,
oldestInGroup);
oldestInGroup,
groupingCutoff);
continue;
}
await _discordService.SendGroupedEpisodeNotificationAsync(
_logger.LogInformation(
"Sending grouped notification for {SeriesName}: {Count} episodes",
seriesGroup.First().SeriesName,
seriesGroup.Count());
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
seriesGroup,
CancellationToken.None);
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(idsToRemove);
if (success)
{
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(idsToRemove);
_logger.LogInformation("Removed {Count} processed episode notifications from queue", idsToRemove.Count);
}
else
{
_logger.LogWarning("Discord send failed for {SeriesName}, keeping notifications in queue for retry", seriesGroup.First().SeriesName);
}
}
// Process movies
@@ -286,8 +706,19 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
foreach (var movie in movies)
{
await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
_historyService.RemoveNotifications(new[] { movie.Id });
_logger.LogInformation("Sending movie notification for: {Name}", movie.Name);
var success = await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
if (success)
{
_historyService.RemoveNotifications(new[] { movie.Id });
_logger.LogInformation("Removed processed movie notification from queue: {Name}", movie.Name);
}
else
{
_logger.LogWarning("Discord send failed for movie {Name}, keeping in queue for retry", movie.Name);
}
}
}
catch (Exception ex)
@@ -296,6 +727,19 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
}
}
/// <summary>
/// Detects Sonarr-style folder names that haven't been replaced by a metadata scrape yet.
/// Matches patterns like "[tmdbid-139060]", "[tvdbid-412656]", "[imdbid-tt1234]".
/// </summary>
private static readonly Regex FolderNamePattern = new(
@"\[(tmdbid|tvdbid|imdbid)-[^\]]+\]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static bool LooksLikeFolderName(string? name)
{
return !string.IsNullOrEmpty(name) && FolderNamePattern.IsMatch(name);
}
/// <inheritdoc />
public void Dispose()
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
@@ -37,8 +38,8 @@ public class DiscordNotificationService
/// </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(
/// <returns>True if the notification was sent successfully.</returns>
public async Task<bool> SendGroupedEpisodeNotificationAsync(
IEnumerable<PendingNotification> notifications,
CancellationToken cancellationToken)
{
@@ -46,17 +47,17 @@ public class DiscordNotificationService
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
return false;
}
var notificationList = notifications.ToList();
if (notificationList.Count == 0)
{
return;
return false;
}
var first = notificationList.First();
var seriesName = first.SeriesName ?? "Unknown Series";
var seriesName = first.SeriesName!;
// Group by season
var bySeason = notificationList
@@ -69,14 +70,11 @@ public class DiscordNotificationService
var title = $"📺 {seriesName}";
var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}";
// Get image from first notification
var imageUrl = first.ImageUrl;
await SendDiscordWebhookAsync(
return await SendDiscordWebhookAsync(
config,
title,
description,
imageUrl,
first.ImagePath,
BuildExternalLinks(first.ProviderIdsJson),
cancellationToken);
}
@@ -155,7 +153,8 @@ public class DiscordNotificationService
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task SendMovieNotificationAsync(
/// <returns>True if the notification was sent successfully.</returns>
public async Task<bool> SendMovieNotificationAsync(
PendingNotification notification,
CancellationToken cancellationToken)
{
@@ -163,7 +162,7 @@ public class DiscordNotificationService
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
return false;
}
var title = $"🎬 {notification.Name}";
@@ -178,11 +177,11 @@ public class DiscordNotificationService
description = description.Substring(0, 297) + "...";
}
await SendDiscordWebhookAsync(
return await SendDiscordWebhookAsync(
config,
title,
description,
notification.ImageUrl,
notification.ImagePath,
BuildExternalLinks(notification.ProviderIdsJson),
cancellationToken);
}
@@ -236,13 +235,14 @@ public class DiscordNotificationService
}
/// <summary>
/// Sends the actual Discord webhook request.
/// Sends the actual Discord webhook request, with optional image attachment.
/// </summary>
private async Task SendDiscordWebhookAsync(
/// <returns>True if the webhook was sent successfully.</returns>
private async Task<bool> SendDiscordWebhookAsync(
PluginConfiguration config,
string title,
string description,
string? imageUrl,
string? imagePath,
string externalLinks,
CancellationToken cancellationToken)
{
@@ -253,14 +253,25 @@ public class DiscordNotificationService
["color"] = config.EmbedColor
};
if (!string.IsNullOrEmpty(imageUrl))
// If we have a local image file, reference it as attachment
var hasImage = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath);
if (hasImage)
{
embed["thumbnail"] = new Dictionary<string, string> { ["url"] = imageUrl };
embed["thumbnail"] = new Dictionary<string, string> { ["url"] = "attachment://poster.jpg" };
}
if (!string.IsNullOrEmpty(externalLinks))
{
embed["footer"] = new Dictionary<string, string> { ["text"] = externalLinks };
var fields = new List<Dictionary<string, object>>
{
new Dictionary<string, object>
{
["name"] = "Links",
["value"] = externalLinks,
["inline"] = false
}
};
embed["fields"] = fields;
}
embed["timestamp"] = DateTime.UtcNow.ToString("o");
@@ -272,14 +283,33 @@ public class DiscordNotificationService
};
var json = JsonSerializer.Serialize(payload);
_logger.LogDebug("Sending Discord webhook: {Json}", json);
_logger.LogInformation("Sending Discord webhook for: {Title}", title);
try
{
using var client = _httpClientFactory.CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
HttpResponseMessage response;
var response = await client.PostAsync(config.DiscordWebhookUrl, content, cancellationToken);
if (hasImage)
{
_logger.LogInformation("Attaching image from: {Path}", imagePath);
// Send as multipart/form-data with image attachment
using var form = new MultipartFormDataContent();
form.Add(new StringContent(json, Encoding.UTF8, "application/json"), "payload_json");
var imageBytes = await File.ReadAllBytesAsync(imagePath!, cancellationToken);
var imageContent = new ByteArrayContent(imageBytes);
imageContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg");
form.Add(imageContent, "files[0]", "poster.jpg");
response = await client.PostAsync(config.DiscordWebhookUrl, form, cancellationToken);
}
else
{
// Send as plain JSON (no image)
using var content = new StringContent(json, Encoding.UTF8, "application/json");
response = await client.PostAsync(config.DiscordWebhookUrl, content, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
@@ -288,15 +318,16 @@ public class DiscordNotificationService
"Discord webhook failed with status {Status}: {Body}",
response.StatusCode,
responseBody);
return false;
}
else
{
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
}
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Discord webhook");
return false;
}
}
}

View File

@@ -38,7 +38,13 @@ public class ItemHistoryService : IDisposable
var dbPath = Path.Combine(pluginDataPath, "smartnotify.db");
_logger.LogInformation("SmartNotify database path: {Path}", dbPath);
_database = new LiteDatabase(dbPath);
// Use UTC for all DateTime to avoid container timezone vs Jellyfin time mismatches
var mapper = new BsonMapper { SerializeNullValues = false };
mapper.RegisterType<DateTime>(
serialize: value => new BsonValue(value.ToUniversalTime()),
deserialize: bson => bson.AsDateTime.ToUniversalTime());
_database = new LiteDatabase(dbPath, mapper);
_knownItems = _database.GetCollection<KnownMediaItem>("known_items");
_pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications");
@@ -58,18 +64,25 @@ public class ItemHistoryService : IDisposable
{
if (item is Episode episode)
{
// For episodes: use series provider IDs + season + episode number
// Prefer episode's own provider ID (e.g. AniDB episode ID).
// This is stable even when seasons are reorganized in Jellyfin.
var episodeKey = GetProviderKey(episode.ProviderIds);
if (!string.IsNullOrEmpty(episodeKey))
{
return $"episode|{episodeKey}";
}
// Fallback: series provider key + season + episode number
var series = episode.Series;
if (series == null)
{
_logger.LogDebug("Episode {Name} has no series, cannot generate content key", episode.Name);
_logger.LogDebug("Episode {Name} has no series and no provider IDs, 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";
}
@@ -78,7 +91,7 @@ public class ItemHistoryService : IDisposable
if (episodeNum == 0)
{
_logger.LogDebug("Episode {Name} has no episode number", episode.Name);
_logger.LogDebug("Episode {Name} has no episode number and no provider IDs", episode.Name);
return null;
}
@@ -201,48 +214,87 @@ public class ItemHistoryService : IDisposable
return false;
}
/// <summary>
/// Checks if an item is already tracked in the database.
/// Checks both by Jellyfin ID and by content key (provider IDs),
/// so moved/rescanned files with new Jellyfin IDs are still recognized.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <param name="item">The item to check (used for content key lookup).</param>
/// <returns>True if the item is already known.</returns>
public bool IsKnownItem(Guid itemId, BaseItem? item = null)
{
var jellyfinId = itemId.ToString();
if (_knownItems.Exists(x => x.JellyfinItemId == jellyfinId))
{
return true;
}
// Fallback: check by content key (provider IDs) so moved files are still recognized
if (item != null)
{
var contentKey = GenerateContentKey(item);
if (contentKey != null && _knownItems.Exists(x => x.ContentKey == contentKey))
{
_logger.LogInformation(
"Item {Name} recognized as known by content key {Key} (new Jellyfin ID: {Id})",
item.Name,
contentKey,
jellyfinId);
return true;
}
}
return false;
}
/// <summary>
/// Records a known item in the database.
/// Always records the JellyfinItemId even if metadata is not yet available,
/// using a placeholder ContentKey that gets updated later.
/// </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;
// Update the record — only update ContentKey if we have a real one
if (contentKey != null)
{
existing.ContentKey = contentKey;
}
existing.FilePath = item.Path;
existing.Name = item.Name;
_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)
if (contentKey != 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;
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
// New content - create record (use placeholder key if metadata not yet available)
var record = new KnownMediaItem
{
JellyfinItemId = jellyfinId,
ContentKey = contentKey,
ContentKey = contentKey ?? $"unresolved|{jellyfinId}",
ItemType = item.GetType().Name,
Name = item.Name,
FirstSeen = DateTime.UtcNow,
@@ -263,7 +315,97 @@ public class ItemHistoryService : IDisposable
}
_knownItems.Insert(record);
_logger.LogDebug("Recorded new content: {Key}", contentKey);
_logger.LogDebug("Recorded new content: {Key}", record.ContentKey);
}
/// <summary>
/// Result of revalidating a pending notification at send time.
/// </summary>
public enum RevalidationResult
{
/// <summary>Item is genuinely new content.</summary>
New,
/// <summary>Item is a known item that was reorganized (path/metadata change, old ID gone).</summary>
Reorganized,
/// <summary>Item is a quality upgrade (same content, old file still exists).</summary>
Upgrade
}
/// <summary>
/// Re-checks if a pending notification is actually a known item (reorganized or upgraded).
/// Called at send time when metadata is fully populated.
/// At queue time, items often have empty ProviderIds and no season/episode numbers,
/// so the content key couldn't be generated. Now that metadata is available, we can
/// properly identify known items.
/// </summary>
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
/// <param name="item">The resolved library item (may have updated metadata).</param>
/// <param name="libraryManager">The library manager to check if old items still exist.</param>
/// <returns>The revalidation result indicating if this is new, reorganized, or an upgrade.</returns>
public RevalidationResult RevalidatePendingItem(string jellyfinItemId, BaseItem? item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
{
if (item == null)
{
return RevalidationResult.New;
}
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return RevalidationResult.New;
}
// Update unresolved content key on the item's own record
var ownRecord = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId);
if (ownRecord != null && ownRecord.ContentKey.StartsWith("unresolved|", StringComparison.Ordinal))
{
ownRecord.ContentKey = contentKey;
_knownItems.Update(ownRecord);
_logger.LogDebug("Resolved content key for {Name}: {Key}", item.Name, contentKey);
}
// Check if this content was already known under a different item ID
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
if (existing != null)
{
// Determine if the old item still exists in the library
var oldItemExists = false;
if (Guid.TryParse(existing.JellyfinItemId, out var oldId))
{
oldItemExists = libraryManager.GetItemById(oldId) != null;
}
var resultType = oldItemExists
? RevalidationResult.Upgrade
: RevalidationResult.Reorganized;
_logger.LogInformation(
"Late detection for {Name}: content key {Key} already known (first seen: {FirstSeen}, result: {Result})",
item.Name,
contentKey,
existing.FirstSeen,
resultType);
// Update the existing record to point to the new item
existing.JellyfinItemId = jellyfinItemId;
existing.FilePath = item.Path;
_knownItems.Update(existing);
// Clean up any duplicate record that was created for this item
var duplicate = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId && x.Id != existing.Id);
if (duplicate != null)
{
_knownItems.Delete(duplicate.Id);
}
return resultType;
}
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
RecordItem(item);
return RevalidationResult.New;
}
/// <summary>
@@ -295,6 +437,15 @@ public class ItemHistoryService : IDisposable
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan);
}
/// <summary>
/// Updates an existing notification in the queue (e.g. after metadata refresh).
/// </summary>
/// <param name="notification">The notification to update.</param>
public void UpdateNotification(PendingNotification notification)
{
_pendingNotifications.Update(notification);
}
/// <summary>
/// Removes processed notifications.
/// </summary>

View File

@@ -139,10 +139,15 @@ public class PendingNotification
public NotificationType Type { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// Gets or sets the image URL (for public servers).
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the local image file path (for attachment-based sending).
/// </summary>
public string? ImagePath { get; set; }
/// <summary>
/// Gets or sets the provider IDs JSON.
/// </summary>

118
README.md
View File

@@ -1,115 +1,65 @@
# SmartNotify - Jellyfin Plugin
**Intelligente Discord-Benachrichtigungen für Jellyfin 10.11+**
**Discord-Benachrichtigungen für neue Filme und Episoden in Jellyfin 10.11+**
## Das Problem
Kennt das Problem: Du tauschst eine Serie gegen bessere Qualität aus und Jellyfin meldet jede Folge als "neu". SmartNotify erkennt den Unterschied zwischen wirklich neuen Inhalten und Qualitäts-Upgrades — und nervt dich nicht mit Spam.
Wenn du eine Serie gegen eine bessere Qualität austauschst, schreit Jellyfin "NEUE EPISODE!" - obwohl es nur ein Upgrade ist. Das nervt.
## Was kann es?
## 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
- **Upgrade-Erkennung** — Erkennt ob eine Datei neu ist oder nur ein Qualitäts-Upgrade. Keine falschen Benachrichtigungen mehr beim Austausch von Dateien.
- **Episoden-Gruppierung** — Statt 12 einzelner Nachrichten bekommst du eine: *"Staffel 1: Episode 1-12"*. Bei Lücken entsprechend: *"Episode 1-4, 6, 8-12"*.
- **Discord-Embeds** — Benachrichtigungen mit Bild, Beschreibung und direkten Links zu IMDb, TMDb, AniDB, AniList und TVDB.
- **Shokofin/VFS-kompatibel** — Die Erkennung basiert auf Provider-IDs statt Jellyfin-internen IDs, die sich bei VFS ständig ändern.
## Installation
## Manuelle Installation
In Jellyfin unter **Dashboard** > **Plugins** > **Repositories** die folgende URL als Repository hinzufügen:
1. Plugin von Releases herunterladen
2. ZIP entpacken in `plugins/SmartNotify/`
3. Jellyfin neustarten
```
https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json
```
## Konfiguration
Danach im **Katalog** SmartNotify installieren und Jellyfin neustarten.
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
### Manuell
### Einstellungen
Plugin-ZIP von den [Releases](https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases) herunterladen, nach `plugins/SmartNotify/` entpacken und Jellyfin neustarten.
## Einrichtung
Nach der Installation unter **Dashboard** > **Plugins** > **SmartNotify**:
1. Discord Webhook URL eintragen
2. Server URL eintragen (wird für Bilder in Discord gebraucht)
3. Fertig — Standardeinstellungen passen für die meisten Setups
### Optionale 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 |
| Verzögerung | 5 min | Wartezeit damit Jellyfin Metadaten laden kann |
| Gruppierungsfenster | 30 min | Wie lange auf weitere Episoden gewartet wird bevor gesendet wird |
| Upgrades unterdrücken | An | Keine Benachrichtigung wenn nur die Qualität besser wird |
| Film-Benachrichtigungen | An | Benachrichtigungen für neue Filme |
| Episoden-Benachrichtigungen | An | Benachrichtigungen für neue Episoden |
| Bot-Name | Jellyfin SmartNotify | Anzeigename in Discord |
| Embed-Farbe | Blau | Farbe des Discord-Embeds |
## Beispiel-Benachrichtigungen
## Beispiele
### Einzelne Episode
```
📺 Demon Slayer
Neue Episoden hinzugefügt:
Staffel 1: Episode 5
```
### Mehrere Episoden (gruppiert)
**Mehrere Episoden:**
```
📺 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
**Neuer Film:**
```
🎬 Your Name (2016)
Kimi no Na wa - Ein Junge und ein Mädchen...
```
## 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
## Lizenz
MIT

View File

@@ -9,52 +9,44 @@
"imageUrl": "",
"versions": [
{
"version": "0.0.11.0",
"changelog": "### Bug Fixes\n\n* fix: claude ist auch für 100€ im Monat noch dumm",
"version": "0.1.4.0",
"changelog": "## 0.1.4 (2026-04-07)\n\n### Bug Fixes\n\nLangsamer Start von Jellyfin wegen Überprüfung bestehender Einträge.\nLäuft nun asynchron.",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.11/smartnotify_0.0.11.zip",
"checksum": "e71374a93cc8a9bf6e43b561f89f062e",
"timestamp": "2026-03-01T16:47:14Z"
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.4/smartnotify_0.1.4.zip",
"checksum": "6241bdf445f91732a917a8e56c956226",
"timestamp": "2026-04-07T06:40:42Z"
},
{
"version": "0.0.9.0",
"changelog": "### Bug Fixes\n\n* fix: angeblich..\n\n### Chores\n\n* chore: update manifest for v0.0.8",
"version": "0.1.3.0",
"changelog": "## 0.1.3 (2026-04-05)\n\n### Bug Fixes\n\nGebe keine Notification wenn der Name der Serie tmdbid oder ähnliches enthält.\nDies verhindert das die Notification kommt wenn noch nicht die Metadaten gezogen wurden.",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.9/smartnotify_0.0.9.zip",
"checksum": "9a1870e82fc3270ca37960a19b289cbd",
"timestamp": "2026-03-01T16:18:31Z"
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.3/smartnotify_0.1.3.zip",
"checksum": "0c8a24dcfed8884f768e5b2aad5f251c",
"timestamp": "2026-04-05T16:13:21Z"
},
{
"version": "0.0.8.0",
"changelog": "### Bug Fixes\n\n* fix: notsupported again\n\n### Chores\n\n* chore: update manifest for v0.0.7",
"version": "0.1.2.0",
"changelog": "## 0.1.2 (2026-04-04)\n\n### Bug Fixes\n\n* Problem behoben das der Serienname vor der Benachrichtigung nicht aktualisiert wurde.",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.8/smartnotify_0.0.8.zip",
"checksum": "21b5826fb120dd0db1ffacc2badb17f7",
"timestamp": "2026-03-01T16:07:10Z"
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.2/smartnotify_0.1.2.zip",
"checksum": "56e2b0005f5bc3368c9a7f53b2f806a0",
"timestamp": "2026-04-04T09:39:49Z"
},
{
"version": "0.0.7.0",
"changelog": "### Bug Fixes\n\n* fix: build lief net!\n* fix: build lief nicht\n* fix: removed Claudes dummes gefriemel\n* fix: notSupported\n\n### Chores\n\n* chore(main): release 0.0.6\n* chore: update manifest for v0.0.5",
"version": "0.1.1.0",
"changelog": "## 0.1.1 (2026-04-03)\n\n### Bug Fixes\n\n* fix: unknown series\n\n### Chores\n\n* chore: workflow fix\n* chore: removed old versions\n* chore: update manifest for v0.1.0",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.7/smartnotify_0.0.7.zip",
"checksum": "6f8ef2061ce35e90c1827a5c8e9a1fe3",
"timestamp": "2026-03-01T15:57:08Z"
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.1/smartnotify_0.1.1.zip",
"checksum": "459569cbae49ba569291011ac9a54202",
"timestamp": "2026-04-03T17:32:41Z"
},
{
"version": "0.0.5.0",
"changelog": "### Bug Fixes\n\n* fix: remove Jellyfin Trash from Build\n\n### Chores\n\n* chore: loop gefixt\n* chore: update manifest for v0.0.4",
"version": "0.1.0.0",
"changelog": "## 0.1.0 (2026-03-05)\n\n**Features**\n\nIntelligente Discord-Benachrichtigungen\n\nAutomatische Benachrichtigungen bei neuen Filmen und Episoden via Discord Webhook\nSchöne Discord-Embeds mit Thumbnail, Beschreibung und Links zu externen Datenbanken (IMDb, TMDb, AniDB, AniList, TVDB)\n\n**Smarte Episoden-Gruppierung**\n\nEpisoden werden intelligent gebündelt statt einzeln gemeldet — z.B. \"Staffel 1: Episode 1-12\" statt 12 einzelne Nachrichten\nKonfigurierbares Zeitfenster für die Gruppierung\n\n**Qualitäts-Upgrade-Erkennung**\n\nErkennt automatisch ob eine Datei neu ist oder nur ein Qualitäts-Upgrade einer bestehenden Datei\nKeine Spam-Benachrichtigungen mehr beim Ersetzen von Dateien durch bessere Versionen\nStabile Erkennung über Provider-IDs (AniDB, TMDb etc.) — funktioniert auch mit Shokofin/VFS\n\n**Robuste Metadaten-Verarbeitung**\n\nVerzögerte Verarbeitung damit Jellyfin Zeit hat Metadaten zu laden\nDreistufige Validierung: beim Hinzufügen, beim Einreihen und beim Senden\nAutomatische Unterdrückung von reorganisierten Items (Pfad-/Metadata-Änderungen)\n\n**Konfigurierbar**\n\nBenachrichtigungen für Filme und Episoden einzeln aktivierbar\nUpgrade-Unterdrückung optional\nAnpassbare Verzögerung, Gruppierungsfenster, Bot-Name und Embed-Farbe",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.5/smartnotify_0.0.5.zip",
"checksum": "74288c4e9b7b25d113fd1e45cf40d0a2",
"timestamp": "2026-03-01T15:48:20Z"
},
{
"version": "0.0.4.0",
"changelog": "### Bug Fixes\n\n* fix: json fehler\n* fix: Dumme Readme",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.4/smartnotify_0.0.4.zip",
"checksum": "d454711f1e6f59de0ab539134a4fd9a7",
"timestamp": "2026-03-01T15:29:46Z"
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.0/smartnotify_0.1.0.zip",
"checksum": "2db7d09e4fd669a5cd25a6649397ea98",
"timestamp": "2026-03-05T16:15:11Z"
}
]
}