Compare commits
45 Commits
20f603b4ee
...
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e347f5c7 | ||
| 76161230e1 | |||
|
|
205cbd91af | ||
| a71ced086a | |||
| 85fd002f4e | |||
|
|
f9aacf2436 | ||
|
|
caee267f8b | ||
| 5a60a6f5b4 | |||
|
|
133c00fab0 | ||
| 0bafe691a0 | |||
| 82b34e288c | |||
|
|
8c77f82d2c | ||
|
|
cb0dfe2c21 | ||
| eaf6ea91e1 | |||
|
|
180a998be1 | ||
| 6fd2638414 | |||
| 0e10e3c089 | |||
|
|
e729c7b8d5 | ||
|
|
fb8161f976 | ||
| 0f5ce9726b | |||
|
|
8d93c17bde | ||
| b1444094ad | |||
| cc2d02983f | |||
| aa26dbb40b | |||
| 5954974add | |||
|
|
52b3588933 | ||
|
|
c032d2b651 | ||
| be5f71caff | |||
|
|
a5b75159e2 | ||
| 2386648a63 | |||
| 948c4f8768 | |||
| c3a7c504db | |||
| 9863778d8b | |||
| 562bfbec54 | |||
| 24c4bf9ba5 | |||
|
|
0c44037f93 | ||
| 7ff65d1546 | |||
|
|
bae22c908f | ||
| 27da8c90f7 | |||
| 96d67a8655 | |||
|
|
62a7547688 | ||
| 047dd82f3f | |||
|
|
76fb874b4d | ||
| 7fadc75c84 | |||
| d595b16573 |
@@ -11,24 +11,36 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
release-pr:
|
release-pr:
|
||||||
name: Create 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
|
if: env.SHOULD_RUN == 'true'
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GT_TOKEN }}
|
token: ${{ secrets.GT_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
|
if: env.SHOULD_RUN == 'true'
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: env.SHOULD_RUN == 'true'
|
||||||
run: |
|
run: |
|
||||||
pip install gitpython packaging
|
pip install gitpython packaging
|
||||||
|
|
||||||
- name: Create Release PR
|
- name: Create Release PR
|
||||||
|
if: env.SHOULD_RUN == 'true'
|
||||||
env:
|
env:
|
||||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||||
REPO: "TDPI/jellyfin-plugin-smartnotify"
|
REPO: "TDPI/jellyfin-plugin-smartnotify"
|
||||||
@@ -38,7 +50,7 @@ jobs:
|
|||||||
import subprocess
|
import subprocess
|
||||||
import json
|
import json
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
|
|
||||||
def get_commits_since_last_tag():
|
def get_commits_since_last_tag():
|
||||||
try:
|
try:
|
||||||
last_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], text=True).strip()
|
last_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], text=True).strip()
|
||||||
@@ -46,14 +58,14 @@ jobs:
|
|||||||
except:
|
except:
|
||||||
commits = subprocess.check_output(['git', 'log', '--pretty=format:%s'], text=True).strip().split('\n')
|
commits = subprocess.check_output(['git', 'log', '--pretty=format:%s'], text=True).strip().split('\n')
|
||||||
return [c for c in commits if c]
|
return [c for c in commits if c]
|
||||||
|
|
||||||
def analyze_commits(commits):
|
def analyze_commits(commits):
|
||||||
has_feat = any(c.startswith('feat') for c in commits)
|
has_feat = any(c.startswith('feat') for c in commits)
|
||||||
has_fix = any(c.startswith('fix') 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_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)
|
has_chore = any(c.startswith('chore') for c in commits)
|
||||||
return has_breaking, has_feat, has_fix, has_chore
|
return has_breaking, has_feat, has_fix, has_chore
|
||||||
|
|
||||||
def get_current_version():
|
def get_current_version():
|
||||||
try:
|
try:
|
||||||
with open('.release-please-manifest.json', 'r') as f:
|
with open('.release-please-manifest.json', 'r') as f:
|
||||||
@@ -61,11 +73,11 @@ jobs:
|
|||||||
return manifest.get('.', '1.0.0')
|
return manifest.get('.', '1.0.0')
|
||||||
except:
|
except:
|
||||||
return '1.0.0'
|
return '1.0.0'
|
||||||
|
|
||||||
def bump_version(current, has_breaking, has_feat, has_fix, has_chore):
|
def bump_version(current, has_breaking, has_feat, has_fix, has_chore):
|
||||||
v = pkg_version.parse(current)
|
v = pkg_version.parse(current)
|
||||||
major, minor, patch = v.major, v.minor, v.micro
|
major, minor, patch = v.major, v.minor, v.micro
|
||||||
|
|
||||||
if has_breaking:
|
if has_breaking:
|
||||||
return f'{major + 1}.0.0'
|
return f'{major + 1}.0.0'
|
||||||
elif has_feat:
|
elif has_feat:
|
||||||
@@ -73,57 +85,57 @@ jobs:
|
|||||||
elif has_fix or has_chore:
|
elif has_fix or has_chore:
|
||||||
return f'{major}.{minor}.{patch + 1}'
|
return f'{major}.{minor}.{patch + 1}'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
commits = get_commits_since_last_tag()
|
commits = get_commits_since_last_tag()
|
||||||
if not commits or commits == ['']:
|
if not commits or commits == ['']:
|
||||||
print('No commits to release')
|
print('No commits to release')
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
has_breaking, has_feat, has_fix, has_chore = analyze_commits(commits)
|
has_breaking, has_feat, has_fix, has_chore = analyze_commits(commits)
|
||||||
|
|
||||||
if not (has_breaking or has_feat or has_fix or has_chore):
|
if not (has_breaking or has_feat or has_fix or has_chore):
|
||||||
print('No release-worthy commits')
|
print('No release-worthy commits')
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
current_version = get_current_version()
|
current_version = get_current_version()
|
||||||
new_version = bump_version(current_version, has_breaking, has_feat, has_fix, has_chore)
|
new_version = bump_version(current_version, has_breaking, has_feat, has_fix, has_chore)
|
||||||
|
|
||||||
if not new_version:
|
if not new_version:
|
||||||
print('No version bump needed')
|
print('No version bump needed')
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
print(f'Bumping version from {current_version} to {new_version}')
|
print(f'Bumping version from {current_version} to {new_version}')
|
||||||
|
|
||||||
# Update version in manifest
|
# Update version in manifest
|
||||||
with open('.release-please-manifest.json', 'r') as f:
|
with open('.release-please-manifest.json', 'r') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
manifest['.'] = new_version
|
manifest['.'] = new_version
|
||||||
|
|
||||||
with open('.release-please-manifest.json', 'w') as f:
|
with open('.release-please-manifest.json', 'w') as f:
|
||||||
json.dump(manifest, f, indent=2)
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
# Update version in .csproj
|
# Update version in .csproj
|
||||||
csproj_path = 'Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj'
|
csproj_path = 'Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj'
|
||||||
with open(csproj_path, 'r') as f:
|
with open(csproj_path, 'r') as f:
|
||||||
csproj = f.read()
|
csproj = f.read()
|
||||||
|
|
||||||
csproj = re.sub(r'<AssemblyVersion>.*?</AssemblyVersion>', f'<AssemblyVersion>{new_version}.0</AssemblyVersion>', csproj)
|
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)
|
csproj = re.sub(r'<FileVersion>.*?</FileVersion>', f'<FileVersion>{new_version}.0</FileVersion>', csproj)
|
||||||
|
|
||||||
with open(csproj_path, 'w') as f:
|
with open(csproj_path, 'w') as f:
|
||||||
f.write(csproj)
|
f.write(csproj)
|
||||||
|
|
||||||
# Generate CHANGELOG
|
# Generate CHANGELOG
|
||||||
changelog_entry = f'## {new_version} ({subprocess.check_output(["date", "+%Y-%m-%d"], text=True).strip()})\n\n'
|
changelog_entry = f'## {new_version} ({subprocess.check_output(["date", "+%Y-%m-%d"], text=True).strip()})\n\n'
|
||||||
|
|
||||||
if has_breaking:
|
if has_breaking:
|
||||||
changelog_entry += '### BREAKING CHANGES\n\n'
|
changelog_entry += '### BREAKING CHANGES\n\n'
|
||||||
for c in commits:
|
for c in commits:
|
||||||
if '!' in c.split(':')[0] or 'BREAKING CHANGE' in c:
|
if '!' in c.split(':')[0] or 'BREAKING CHANGE' in c:
|
||||||
changelog_entry += f'* {c}\n'
|
changelog_entry += f'* {c}\n'
|
||||||
changelog_entry += '\n'
|
changelog_entry += '\n'
|
||||||
|
|
||||||
if has_feat:
|
if has_feat:
|
||||||
changelog_entry += '### Features\n\n'
|
changelog_entry += '### Features\n\n'
|
||||||
for c in commits:
|
for c in commits:
|
||||||
@@ -144,33 +156,42 @@ jobs:
|
|||||||
if c.startswith('chore'):
|
if c.startswith('chore'):
|
||||||
changelog_entry += f'* {c}\n'
|
changelog_entry += f'* {c}\n'
|
||||||
changelog_entry += '\n'
|
changelog_entry += '\n'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open('CHANGELOG.md', 'r') as f:
|
with open('CHANGELOG.md', 'r') as f:
|
||||||
old_changelog = f.read()
|
old_changelog = f.read()
|
||||||
except:
|
except:
|
||||||
old_changelog = '# Changelog\n\n'
|
old_changelog = '# Changelog\n\n'
|
||||||
|
|
||||||
with open('CHANGELOG.md', 'w') as f:
|
with open('CHANGELOG.md', 'w') as f:
|
||||||
f.write('# Changelog\n\n' + changelog_entry + '\n' + old_changelog.replace('# Changelog\n', '').lstrip())
|
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.name', 'Gitea Actions'])
|
||||||
subprocess.run(['git', 'config', 'user.email', 'actions@git.tdpi.dev'])
|
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', 'add', '.release-please-manifest.json', 'CHANGELOG.md', csproj_path])
|
||||||
subprocess.run(['git', 'commit', '-m', f'chore(main): release {new_version}'])
|
subprocess.run(['git', 'commit', '-m', f'chore(main): release {new_version}'])
|
||||||
|
|
||||||
with open('/tmp/new_version', 'w') as f:
|
with open('/tmp/new_version', 'w') as f:
|
||||||
f.write(new_version)
|
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
|
EOF
|
||||||
|
|
||||||
NEW_VERSION=$(cat /tmp/new_version 2>/dev/null || echo "")
|
NEW_VERSION=$(cat /tmp/new_version 2>/dev/null || echo "")
|
||||||
if [ -z "$NEW_VERSION" ]; then
|
if [ -z "$NEW_VERSION" ]; then
|
||||||
echo "No version bump needed"
|
echo "No version bump needed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BRANCH_NAME="release-please--branches--main"
|
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
|
# Push changes
|
||||||
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
|
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
|
||||||
git push -f origin HEAD:"$BRANCH_NAME"
|
git push -f origin HEAD:"$BRANCH_NAME"
|
||||||
@@ -179,21 +200,18 @@ jobs:
|
|||||||
git push origin HEAD:"$BRANCH_NAME"
|
git push origin HEAD:"$BRANCH_NAME"
|
||||||
PR_EXISTS=false
|
PR_EXISTS=false
|
||||||
fi
|
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
|
if [ "$PR_EXISTS" = true ]; then
|
||||||
PR_NUMBER=$(curl -s "https://git.tdpi.dev/api/v1/repos/$REPO/pulls?state=open&head=$BRANCH_NAME" \
|
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')
|
-H "Authorization: token $GIT_TOKEN" | jq -r '.[0].number')
|
||||||
|
|
||||||
if [ "$PR_NUMBER" != "null" ]; then
|
if [ "$PR_NUMBER" != "null" ]; then
|
||||||
curl -X PATCH "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
|
curl -X PATCH "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
|
||||||
-H "Authorization: token $GIT_TOKEN" \
|
-H "Authorization: token $GIT_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"title\": \"chore(main): release $NEW_VERSION\",
|
\"title\": \"chore(main): release $NEW_VERSION\",
|
||||||
\"body\": $CHANGELOG_CONTENT
|
\"body\": $PR_BODY_JSON
|
||||||
}"
|
}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -204,6 +222,6 @@ jobs:
|
|||||||
\"title\": \"chore(main): release $NEW_VERSION\",
|
\"title\": \"chore(main): release $NEW_VERSION\",
|
||||||
\"head\": \"$BRANCH_NAME\",
|
\"head\": \"$BRANCH_NAME\",
|
||||||
\"base\": \"main\",
|
\"base\": \"main\",
|
||||||
\"body\": $CHANGELOG_CONTENT
|
\"body\": $PR_BODY_JSON
|
||||||
}"
|
}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -26,6 +26,60 @@ jobs:
|
|||||||
VERSION=$(jq -r '."."' .release-please-manifest.json)
|
VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
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
|
- name: Create Git Tag
|
||||||
env:
|
env:
|
||||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||||
@@ -40,7 +94,7 @@ jobs:
|
|||||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md)
|
CHANGELOG=$(cat /tmp/pr_changelog)
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
PAYLOAD=$(jq -n \
|
||||||
--arg tag "v$VERSION" \
|
--arg tag "v$VERSION" \
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
.claude
|
.claude
|
||||||
buildedplugin
|
buildedplugin
|
||||||
|
MEMORY.md
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.0.17"
|
".": "0.1.4"
|
||||||
}
|
}
|
||||||
87
CHANGELOG.md
87
CHANGELOG.md
@@ -1,5 +1,92 @@
|
|||||||
# Changelog
|
# 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)
|
## 0.0.17 (2026-03-03)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
|
||||||
<AssemblyVersion>0.0.17.0</AssemblyVersion>
|
<AssemblyVersion>0.1.4.0</AssemblyVersion>
|
||||||
<FileVersion>0.0.17.0</FileVersion>
|
<FileVersion>0.1.4.0</FileVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Timers;
|
using System.Timers;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Plugin.SmartNotify.Services;
|
using Jellyfin.Plugin.SmartNotify.Services;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Entities.Movies;
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Timer = System.Timers.Timer;
|
using Timer = System.Timers.Timer;
|
||||||
@@ -49,11 +50,7 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("SmartNotify background service starting");
|
_logger.LogInformation("SmartNotify background service starting");
|
||||||
|
|
||||||
// Pre-populate DB with all existing library items so they're recognized as "known".
|
// Subscribe to library events (before seeding so we don't miss items added during seed).
|
||||||
// This prevents mass notifications on first run or after DB reset.
|
|
||||||
SeedExistingLibraryItems();
|
|
||||||
|
|
||||||
// Subscribe to library events
|
|
||||||
_libraryManager.ItemAdded += OnItemAdded;
|
_libraryManager.ItemAdded += OnItemAdded;
|
||||||
_libraryManager.ItemRemoved += OnItemRemoved;
|
_libraryManager.ItemRemoved += OnItemRemoved;
|
||||||
|
|
||||||
@@ -63,6 +60,10 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
_processTimer.AutoReset = true;
|
_processTimer.AutoReset = true;
|
||||||
_processTimer.Start();
|
_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");
|
_logger.LogInformation("SmartNotify is now monitoring library changes");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -85,19 +86,25 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
|
|
||||||
var existingItems = _libraryManager.GetItemList(query);
|
var existingItems = _libraryManager.GetItemList(query);
|
||||||
var seeded = 0;
|
var seeded = 0;
|
||||||
|
var alreadyKnown = 0;
|
||||||
|
|
||||||
foreach (var item in existingItems)
|
foreach (var item in existingItems)
|
||||||
{
|
{
|
||||||
if (!_historyService.IsKnownItem(item.Id))
|
if (!_historyService.IsKnownItem(item.Id, item))
|
||||||
{
|
{
|
||||||
_historyService.RecordItem(item);
|
_historyService.RecordItem(item);
|
||||||
seeded++;
|
seeded++;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
alreadyKnown++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Seeded {Count} existing library items into SmartNotify DB (total in library: {Total})",
|
"[DEBUG Seed] Seeded {Seeded} new items, {AlreadyKnown} already known, {Total} total in library",
|
||||||
seeded,
|
seeded,
|
||||||
|
alreadyKnown,
|
||||||
existingItems.Count);
|
existingItems.Count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -140,6 +147,60 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
return;
|
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);
|
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
|
||||||
|
|
||||||
var config = Plugin.Instance?.Configuration;
|
var config = Plugin.Instance?.Configuration;
|
||||||
@@ -180,8 +241,8 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 0: Is this exact item (same Jellyfin ID) already known in our DB?
|
// Check 0: Is this item already known in our DB? (by Jellyfin ID or content key)
|
||||||
if (_historyService.IsKnownItem(item.Id))
|
if (_historyService.IsKnownItem(item.Id, item))
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Item {Name} (ID: {Id}) is already known in DB, skipping notification",
|
"Item {Name} (ID: {Id}) is already known in DB, skipping notification",
|
||||||
@@ -258,17 +319,24 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
|
|
||||||
if (item is Episode episode)
|
if (item is Episode episode)
|
||||||
{
|
{
|
||||||
notification.SeriesName = episode.SeriesName;
|
var seriesObj = episode.Series;
|
||||||
notification.SeriesId = episode.SeriesId.ToString();
|
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.SeasonNumber = episode.ParentIndexNumber;
|
||||||
notification.EpisodeNumber = episode.IndexNumber;
|
notification.EpisodeNumber = episode.IndexNumber;
|
||||||
notification.Year = episode.ProductionYear;
|
notification.Year = episode.ProductionYear;
|
||||||
|
|
||||||
// Use series provider IDs for external links — episode provider IDs
|
// Use series provider IDs for external links — episode provider IDs
|
||||||
// (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths
|
// (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths
|
||||||
if (episode.SeriesId != Guid.Empty)
|
var resolvedSeriesId = notification.SeriesId;
|
||||||
|
if (resolvedSeriesId != Guid.Empty.ToString() && Guid.TryParse(resolvedSeriesId, out var seriesGuid))
|
||||||
{
|
{
|
||||||
var series = _libraryManager.GetItemById(episode.SeriesId);
|
var series = seriesObj ?? _libraryManager.GetItemById(seriesGuid);
|
||||||
if (series?.ProviderIds != null && series.ProviderIds.Count > 0)
|
if (series?.ProviderIds != null && series.ProviderIds.Count > 0)
|
||||||
{
|
{
|
||||||
notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds);
|
notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds);
|
||||||
@@ -280,6 +348,16 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
notification.ImagePath = 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)
|
else if (item is Movie movie)
|
||||||
{
|
{
|
||||||
@@ -313,22 +391,92 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
var item = _libraryManager.GetItemById(itemId);
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
if (item is not Episode episode)
|
if (item is not Episode episode)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[DEBUG Refresh] Item {Id} is {Type} (not Episode), skipping",
|
||||||
|
itemId,
|
||||||
|
item?.GetType().Name ?? "NULL");
|
||||||
return;
|
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;
|
var changed = false;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series")
|
// 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.
|
||||||
{
|
{
|
||||||
notification.SeriesName = episode.SeriesName;
|
var freshSeriesName = episode.SeriesName ?? episode.Series?.Name;
|
||||||
changed = true;
|
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())
|
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString())
|
||||||
{
|
{
|
||||||
if (episode.SeriesId != Guid.Empty)
|
// 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 = episode.SeriesId.ToString();
|
notification.SeriesId = resolvedSeriesId.ToString();
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,6 +493,17 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
changed = true;
|
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
|
// Refresh image if missing
|
||||||
if (string.IsNullOrEmpty(notification.ImagePath) && episode.SeriesId != Guid.Empty)
|
if (string.IsNullOrEmpty(notification.ImagePath) && episode.SeriesId != Guid.Empty)
|
||||||
{
|
{
|
||||||
@@ -406,11 +565,11 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
RefreshNotificationMetadata(notification);
|
RefreshNotificationMetadata(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Late upgrade detection: re-check now that metadata is fully populated.
|
// Late known-item detection: re-check now that metadata is fully populated.
|
||||||
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
|
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
|
||||||
// been available, causing GenerateContentKey() to return null and upgrades
|
// been available, causing GenerateContentKey() to return null and known items
|
||||||
// to go undetected. By now (after delay + grouping window), metadata is ready.
|
// to go undetected. By now (after delay + grouping window), metadata is ready.
|
||||||
if (config.SuppressUpgrades)
|
// This catches reorganized items (path/metadata changes) and quality upgrades.
|
||||||
{
|
{
|
||||||
var suppressedIds = new List<int>();
|
var suppressedIds = new List<int>();
|
||||||
foreach (var notification in pendingNotifications)
|
foreach (var notification in pendingNotifications)
|
||||||
@@ -418,11 +577,22 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
|
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
|
||||||
{
|
{
|
||||||
var revalidateItem = _libraryManager.GetItemById(revalidateId);
|
var revalidateItem = _libraryManager.GetItemById(revalidateId);
|
||||||
if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem))
|
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);
|
suppressedIds.Add(notification.Id);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Late suppression: {Name} detected as upgrade at send time",
|
"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);
|
notification.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,8 +603,9 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
_historyService.RemoveNotifications(suppressedIds);
|
_historyService.RemoveNotifications(suppressedIds);
|
||||||
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
|
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Suppressed {Count} upgrade notifications at send time",
|
"Suppressed {Count} notifications at send time ({Reason})",
|
||||||
suppressedIds.Count);
|
suppressedIds.Count,
|
||||||
|
"reorganized/upgrade");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingNotifications.Count == 0)
|
if (pendingNotifications.Count == 0)
|
||||||
@@ -450,16 +621,43 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
.GroupBy(n => n.SeriesId!)
|
.GroupBy(n => n.SeriesId!)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Handle episodes that still have no series info (send individually as fallback)
|
// Episodes without SeriesName or SeriesId must NEVER be sent.
|
||||||
var orphanEpisodes = pendingNotifications
|
// Wait up to 30 minutes for metadata to resolve, then drop.
|
||||||
.Where(n => n.ItemType == "Episode" && (string.IsNullOrEmpty(n.SeriesId) || n.SeriesId == emptyGuid))
|
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();
|
.ToList();
|
||||||
|
|
||||||
if (orphanEpisodes.Count > 0)
|
if (incompleteEpisodes.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
// Split into: still waiting vs. timed out
|
||||||
"{Count} episode notifications have no series info even after refresh",
|
var timedOut = incompleteEpisodes.Where(n => n.QueuedAt < maxMetadataWait).ToList();
|
||||||
orphanEpisodes.Count);
|
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
|
// Process each series group
|
||||||
@@ -501,31 +699,6 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process orphan episodes (no series info - send individually)
|
|
||||||
foreach (var orphan in orphanEpisodes)
|
|
||||||
{
|
|
||||||
var oldAge = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes);
|
|
||||||
if (orphan.QueuedAt > oldAge)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Sending individual episode notification for: {Name} (no series info)", orphan.Name);
|
|
||||||
|
|
||||||
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
|
|
||||||
new[] { orphan },
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
_historyService.RemoveNotifications(new[] { orphan.Id });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Discord send failed for orphan episode {Name}, keeping in queue for retry", orphan.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process movies
|
// Process movies
|
||||||
var movies = pendingNotifications
|
var movies = pendingNotifications
|
||||||
.Where(n => n.ItemType == "Movie")
|
.Where(n => n.ItemType == "Movie")
|
||||||
@@ -554,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 />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class DiscordNotificationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var first = notificationList.First();
|
var first = notificationList.First();
|
||||||
var seriesName = first.SeriesName ?? "Unknown Series";
|
var seriesName = first.SeriesName!;
|
||||||
|
|
||||||
// Group by season
|
// Group by season
|
||||||
var bySeason = notificationList
|
var bySeason = notificationList
|
||||||
|
|||||||
@@ -215,14 +215,37 @@ public class ItemHistoryService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if an item with the given Jellyfin ID is already tracked in the database.
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="itemId">The Jellyfin item ID.</param>
|
/// <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>
|
/// <returns>True if the item is already known.</returns>
|
||||||
public bool IsKnownItem(Guid itemId)
|
public bool IsKnownItem(Guid itemId, BaseItem? item = null)
|
||||||
{
|
{
|
||||||
var jellyfinId = itemId.ToString();
|
var jellyfinId = itemId.ToString();
|
||||||
return _knownItems.Exists(x => x.JellyfinItemId == jellyfinId);
|
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>
|
/// <summary>
|
||||||
@@ -296,34 +319,74 @@ public class ItemHistoryService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Re-checks if a pending notification is actually a quality upgrade.
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
|
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
|
||||||
/// <param name="item">The resolved library item (may have updated metadata).</param>
|
/// <param name="item">The resolved library item (may have updated metadata).</param>
|
||||||
/// <returns>True if this item is a late-detected upgrade that should be suppressed.</returns>
|
/// <param name="libraryManager">The library manager to check if old items still exist.</param>
|
||||||
public bool RevalidatePendingItem(string jellyfinItemId, BaseItem? item)
|
/// <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)
|
if (item == null)
|
||||||
{
|
{
|
||||||
return false;
|
return RevalidationResult.New;
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentKey = GenerateContentKey(item);
|
var contentKey = GenerateContentKey(item);
|
||||||
if (contentKey == null)
|
if (contentKey == null)
|
||||||
{
|
{
|
||||||
return false;
|
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
|
// Check if this content was already known under a different item ID
|
||||||
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
|
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
|
||||||
if (existing != null)
|
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(
|
_logger.LogInformation(
|
||||||
"Late upgrade detection for {Name}: content key {Key} already known (first seen: {FirstSeen})",
|
"Late detection for {Name}: content key {Key} already known (first seen: {FirstSeen}, result: {Result})",
|
||||||
item.Name,
|
item.Name,
|
||||||
contentKey,
|
contentKey,
|
||||||
existing.FirstSeen);
|
existing.FirstSeen,
|
||||||
|
resultType);
|
||||||
|
|
||||||
// Update the existing record to point to the new item
|
// Update the existing record to point to the new item
|
||||||
existing.JellyfinItemId = jellyfinItemId;
|
existing.JellyfinItemId = jellyfinItemId;
|
||||||
@@ -337,12 +400,12 @@ public class ItemHistoryService : IDisposable
|
|||||||
_knownItems.Delete(duplicate.Id);
|
_knownItems.Delete(duplicate.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return resultType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
|
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
|
||||||
RecordItem(item);
|
RecordItem(item);
|
||||||
return false;
|
return RevalidationResult.New;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
118
README.md
118
README.md
@@ -1,115 +1,65 @@
|
|||||||
# SmartNotify - Jellyfin Plugin
|
# 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
|
- **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"*.
|
||||||
SmartNotify erkennt automatisch:
|
- **Discord-Embeds** — Benachrichtigungen mit Bild, Beschreibung und direkten Links zu IMDb, TMDb, AniDB, AniList und TVDB.
|
||||||
- **Wirklich neue Inhalte** → Benachrichtigung wird gesendet
|
- **Shokofin/VFS-kompatibel** — Die Erkennung basiert auf Provider-IDs statt Jellyfin-internen IDs, die sich bei VFS ständig ändern.
|
||||||
- **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
|
## 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/`
|
https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json
|
||||||
3. Jellyfin neustarten
|
```
|
||||||
|
|
||||||
## Konfiguration
|
Danach im **Katalog** SmartNotify installieren und Jellyfin neustarten.
|
||||||
|
|
||||||
Nach der Installation im Jellyfin Dashboard:
|
### Manuell
|
||||||
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
|
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 |
|
| Einstellung | Standard | Beschreibung |
|
||||||
|-------------|----------|--------------|
|
|-------------|----------|--------------|
|
||||||
| Discord Webhook URL | - | Die Webhook-URL deines Discord-Kanals |
|
| Verzögerung | 5 min | Wartezeit damit Jellyfin Metadaten laden kann |
|
||||||
| Server URL | - | Öffentliche Jellyfin-URL (für Bilder) |
|
| Gruppierungsfenster | 30 min | Wie lange auf weitere Episoden gewartet wird bevor gesendet wird |
|
||||||
| Verzögerung | 5 min | Wartezeit für Metadaten-Updates |
|
| Upgrades unterdrücken | An | Keine Benachrichtigung wenn nur die Qualität besser wird |
|
||||||
| Gruppierungsfenster | 30 min | Zeitfenster für Episoden-Gruppierung |
|
| Film-Benachrichtigungen | An | Benachrichtigungen für neue Filme |
|
||||||
| Upgrades unterdrücken | ✓ | Keine Benachrichtigung bei Ersetzungen |
|
| 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
|
**Mehrere Episoden:**
|
||||||
```
|
|
||||||
📺 Demon Slayer
|
|
||||||
Neue Episoden hinzugefügt:
|
|
||||||
Staffel 1: Episode 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mehrere Episoden (gruppiert)
|
|
||||||
```
|
```
|
||||||
📺 Attack on Titan
|
📺 Attack on Titan
|
||||||
Neue Episoden hinzugefügt:
|
Neue Episoden hinzugefügt:
|
||||||
Staffel 4: Episode 1-12
|
Staffel 4: Episode 1-12
|
||||||
```
|
```
|
||||||
|
|
||||||
### Episoden mit Lücken
|
**Neuer Film:**
|
||||||
```
|
|
||||||
📺 One Piece
|
|
||||||
Neue Episoden hinzugefügt:
|
|
||||||
Staffel 1: Episode 1-4, 6, 8-12
|
|
||||||
```
|
|
||||||
|
|
||||||
### Film
|
|
||||||
```
|
```
|
||||||
🎬 Your Name (2016)
|
🎬 Your Name (2016)
|
||||||
Kimi no Na wa - Ein Junge und ein Mädchen...
|
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
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -9,36 +9,36 @@
|
|||||||
"imageUrl": "",
|
"imageUrl": "",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "0.0.16.0",
|
"version": "0.1.3.0",
|
||||||
"changelog": "### Bug Fixes\n\n* fix: ist das wirklich ein Fix und kein defix?\n\n### Chores\n\n* chore: update manifest for v0.0.15",
|
"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",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.16/smartnotify_0.0.16.zip",
|
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.3/smartnotify_0.1.3.zip",
|
||||||
"checksum": "fbe2b3ce339c92204961df605bfe276b",
|
"checksum": "0c8a24dcfed8884f768e5b2aad5f251c",
|
||||||
"timestamp": "2026-03-02T18:58:41Z"
|
"timestamp": "2026-04-05T16:13:21Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.15.0",
|
"version": "0.1.2.0",
|
||||||
"changelog": "### Bug Fixes\n\n* fix: timestamps!\n\n### Chores\n\n* chore: update manifest for v0.0.14",
|
"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",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.15/smartnotify_0.0.15.zip",
|
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.2/smartnotify_0.1.2.zip",
|
||||||
"checksum": "c3dc638240b5688de030f77eccaf9c50",
|
"checksum": "56e2b0005f5bc3368c9a7f53b2f806a0",
|
||||||
"timestamp": "2026-03-01T17:49:47Z"
|
"timestamp": "2026-04-04T09:39:49Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.14.0",
|
"version": "0.1.1.0",
|
||||||
"changelog": "### Bug Fixes\n\n* fix: claude hat bugs im Kopf\n\n### Chores\n\n* chore: update manifest for v0.0.13",
|
"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",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.14/smartnotify_0.0.14.zip",
|
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.1/smartnotify_0.1.1.zip",
|
||||||
"checksum": "428e0cc3a00c873381dc2f2f198f3907",
|
"checksum": "459569cbae49ba569291011ac9a54202",
|
||||||
"timestamp": "2026-03-01T17:36:26Z"
|
"timestamp": "2026-04-03T17:32:41Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.13.0",
|
"version": "0.1.0.0",
|
||||||
"changelog": "### Bug Fixes\n\n* fix: removed drecks versionen\n* fix: Bilder und dumme min TImings!\n\n### Chores\n\n* chore: update manifest for v0.0.12",
|
"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",
|
"targetAbi": "10.11.0.0",
|
||||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.13/smartnotify_0.0.13.zip",
|
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.1.0/smartnotify_0.1.0.zip",
|
||||||
"checksum": "2d303e8dc214a58e038f516076840d5b",
|
"checksum": "2db7d09e4fd669a5cd25a6649397ea98",
|
||||||
"timestamp": "2026-03-01T17:21:50Z"
|
"timestamp": "2026-03-05T16:15:11Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user