74 Commits

Author SHA1 Message Date
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
b67dc3aa1a Merge pull request 'chore(main): release 0.0.12' (#13) 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 53s
Reviewed-on: #13
2026-03-01 18:06:16 +01:00
Gitea Actions
33a07a19c2 chore(main): release 0.0.12
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 17:06:05 +00:00
25fb75ae0b 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:05:50 +01:00
c9d5fd80ce fix: glaube ich net 2026-03-01 18:05:49 +01:00
Gitea Actions
8a445cc507 chore: update manifest for v0.0.11
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 16:47:14 +00:00
f20364b25f Merge pull request 'chore(main): release 0.0.11' (#12) from release-please--branches--main into main
All checks were successful
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 47s
Create Release PR / Create Release PR (push) Has been skipped
Reviewed-on: #12
2026-03-01 17:46:20 +01:00
Gitea Actions
b191779fde chore(main): release 0.0.11
All checks were successful
Create Release / Publish Release (pull_request) Successful in 8s
2026-03-01 16:46:08 +00:00
7425a18241 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-01 17:45:51 +01:00
25ed431d5a fix: claude ist auch für 100€ im Monat noch dumm 2026-03-01 17:45:49 +01:00
38635bcc9a Merge pull request 'chore(main): release 0.0.10' (#11) 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 58s
Reviewed-on: #11
2026-03-01 17:30:04 +01:00
Gitea Actions
522a382e95 chore(main): release 0.0.10
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 16:29:42 +00:00
aad403246e 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 17:29:34 +01:00
1a63494c83 fix: again 2026-03-01 17:29:33 +01:00
Gitea Actions
ab435b58e4 chore: update manifest for v0.0.9
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 16:18:31 +00:00
160623338f Merge pull request 'chore(main): release 0.0.9' (#10) 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 38s
Reviewed-on: #10
2026-03-01 17:17:44 +01:00
Gitea Actions
abeb593ecb chore(main): release 0.0.9
All checks were successful
Create Release / Publish Release (pull_request) Successful in 8s
2026-03-01 16:17:06 +00:00
412f0cd639 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 17:16:59 +01:00
acb352ef39 fix: angeblich.. 2026-03-01 17:16:57 +01:00
Gitea Actions
727e0c39ae chore: update manifest for v0.0.8
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 16:07:10 +00:00
8eaea397f3 Merge pull request 'chore(main): release 0.0.8' (#9) 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 48s
Reviewed-on: #9
2026-03-01 17:06:12 +01:00
Gitea Actions
a1a7fb6290 chore(main): release 0.0.8
All checks were successful
Create Release / Publish Release (pull_request) Successful in 9s
2026-03-01 16:03:05 +00:00
0646c7f731 fix: notsupported again
All checks were successful
Create Release PR / Create Release PR (push) Successful in 11s
2026-03-01 17:02:47 +01:00
Gitea Actions
7adae0587b chore: update manifest for v0.0.7
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 15:57:08 +00:00
4e99b6bb80 Merge pull request 'chore(main): release 0.0.7' (#8) 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: #8
2026-03-01 16:56:19 +01:00
Gitea Actions
789569560f chore(main): release 0.0.7
All checks were successful
Create Release / Publish Release (pull_request) Successful in 6s
2026-03-01 15:56:05 +00:00
ea9895e0df fix: build lief net!
All checks were successful
Create Release PR / Create Release PR (push) Successful in 9s
2026-03-01 16:55:57 +01:00
4fd2adc4ec fix: build lief nicht
All checks were successful
Create Release PR / Create Release PR (push) Successful in 7s
2026-03-01 16:55:19 +01:00
30ea11a943 Merge pull request 'chore(main): release 0.0.6' (#7) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Reviewed-on: #7
2026-03-01 16:53:57 +01:00
Gitea Actions
50a124e77d chore(main): release 0.0.6 2026-03-01 15:53:46 +00:00
a364bd2fc0 fix: removed Claudes dummes gefriemel
All checks were successful
Create Release PR / Create Release PR (push) Successful in 9s
2026-03-01 16:53:35 +01:00
82413c1c37 fix: notSupported
All checks were successful
Create Release PR / Create Release PR (push) Successful in 9s
2026-03-01 16:52:01 +01:00
Gitea Actions
9b0f612ad2 chore: update manifest for v0.0.5
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 15:48:20 +00:00
e6dc9973b8 Merge pull request 'chore(main): release 0.0.5' (#6) 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: #6
2026-03-01 16:47:27 +01:00
Gitea Actions
93ba23da58 chore(main): release 0.0.5
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 15:46:49 +00:00
3842ed4a21 fix: remove Jellyfin Trash from Build
All checks were successful
Create Release PR / Create Release PR (push) Successful in 10s
2026-03-01 16:46:40 +01:00
1ae1c5ef03 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
Some checks failed
Create Release PR / Create Release PR (push) Has been cancelled
2026-03-01 16:31:53 +01:00
530b862199 chore: loop gefixt 2026-03-01 16:31:44 +01:00
Gitea Actions
948d522a4a chore: update manifest for v0.0.4
All checks were successful
Create Release PR / Create Release PR (push) Successful in 10s
2026-03-01 15:29:46 +00:00
eea223b193 Merge pull request 'chore(main): release 0.0.4' (#4) 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 42s
Reviewed-on: #4
2026-03-01 16:28:57 +01:00
Gitea Actions
debd71cee1 chore(main): release 0.0.4
All checks were successful
Create Release / Publish Release (pull_request) Successful in 6s
2026-03-01 15:28:27 +00:00
4c7ddf7c61 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 8s
2026-03-01 16:28:20 +01:00
336828efba fix: json fehler 2026-03-01 16:28:11 +01:00
ad4ecc6daa fix: Dumme Readme 2026-03-01 16:28:10 +01:00
727afb0ad2 Merge pull request 'chore(main): release 0.0.3' (#3) 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 37s
Reviewed-on: #3
2026-03-01 16:24:54 +01:00
Gitea Actions
594f3a0345 chore(main): release 0.0.3
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-01 15:23:27 +00:00
e381c2c8df 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 16:23:17 +01:00
a687f260f3 fix: claude macht blödsinn 2026-03-01 16:22:28 +01:00
15 changed files with 715 additions and 156 deletions

View File

@@ -10,19 +10,24 @@ jobs:
build: build:
name: Build Plugin + Update Manifest name: Build Plugin + Update Manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GT_TOKEN }} token: ${{ secrets.GT_TOKEN }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Set version from tag - name: Set version from tag
id: vars id: vars
run: | run: |
@@ -30,28 +35,28 @@ jobs:
VERSION="${TAG#v}" # Remove 'v' prefix VERSION="${TAG#v}" # Remove 'v' prefix
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "TAG=$TAG" >> "$GITHUB_ENV" echo "TAG=$TAG" >> "$GITHUB_ENV"
- name: Restore dependencies - name: Install JPRM
run: dotnet restore
- name: Build plugin
run: | run: |
dotnet publish Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj \ pip install jprm
--configuration Release \ mkdir -p ./artifacts
--output ./publish \
-p:Version=${VERSION}.0 - name: Build plugin with JPRM
- name: Create plugin ZIP
run: | run: |
cd publish ARTIFACT="$(jprm --verbosity=debug plugin build . \
zip -r ../smartnotify_${VERSION}.zip . --dotnet-framework="net9.0" \
cd .. -v "${VERSION}.0" \
--dotnet-configuration Release \
-o ./artifacts)"
echo "ARTIFACT=$ARTIFACT" >> "$GITHUB_ENV"
cp "$ARTIFACT" "smartnotify_${VERSION}.zip"
# Calculate MD5 checksum # Calculate MD5 checksum
CHECKSUM=$(md5sum smartnotify_${VERSION}.zip | cut -d' ' -f1) CHECKSUM=$(md5sum "smartnotify_${VERSION}.zip" | cut -d' ' -f1)
echo "CHECKSUM=$CHECKSUM" >> "$GITHUB_ENV" echo "CHECKSUM=$CHECKSUM" >> "$GITHUB_ENV"
echo "Artifact: $ARTIFACT"
echo "Checksum: $CHECKSUM" echo "Checksum: $CHECKSUM"
- name: Upload ZIP to Release - name: Upload ZIP to Release
env: env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }} GIT_TOKEN: ${{ secrets.GT_TOKEN }}
@@ -59,13 +64,13 @@ jobs:
# Get release ID # Get release ID
RELEASE_ID=$(curl -s "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/tags/${TAG}" \ RELEASE_ID=$(curl -s "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/tags/${TAG}" \
-H "Authorization: token $GIT_TOKEN" | jq -r '.id') -H "Authorization: token $GIT_TOKEN" | jq -r '.id')
# Upload asset # Upload asset
curl -X POST "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/${RELEASE_ID}/assets?name=smartnotify_${VERSION}.zip" \ curl -X POST "https://git.tdpi.dev/api/v1/repos/TDPI/jellyfin-plugin-smartnotify/releases/${RELEASE_ID}/assets?name=smartnotify_${VERSION}.zip" \
-H "Authorization: token $GIT_TOKEN" \ -H "Authorization: token $GIT_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary @smartnotify_${VERSION}.zip --data-binary @smartnotify_${VERSION}.zip
- name: Update manifest.json - name: Update manifest.json
env: env:
GIT_TOKEN: ${{ secrets.GT_TOKEN }} GIT_TOKEN: ${{ secrets.GT_TOKEN }}
@@ -73,14 +78,14 @@ jobs:
# Get changelog for this version # Get changelog for this version
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md | tail -n +2) CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md | tail -n +2)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Update manifest.json with new version # Update manifest.json with new version
python3 << EOF python3 << EOF
import json import json
with open('manifest.json', 'r') as f: with open('manifest.json', 'r') as f:
manifest = json.load(f) manifest = json.load(f)
new_version = { new_version = {
"version": "${VERSION}.0", "version": "${VERSION}.0",
"changelog": """${CHANGELOG}""".strip(), "changelog": """${CHANGELOG}""".strip(),
@@ -89,26 +94,26 @@ jobs:
"checksum": "${CHECKSUM}", "checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}" "timestamp": "${TIMESTAMP}"
} }
# Insert at beginning of versions array # Insert at beginning of versions array
manifest[0]["versions"].insert(0, new_version) manifest[0]["versions"].insert(0, new_version)
# Keep only last 10 versions # Keep only last 10 versions
manifest[0]["versions"] = manifest[0]["versions"][:10] manifest[0]["versions"] = manifest[0]["versions"][:10]
with open('manifest.json', 'w') as f: with open('manifest.json', 'w') as f:
json.dump(manifest, f, indent=2, ensure_ascii=False) json.dump(manifest, f, indent=2, ensure_ascii=False)
print("Manifest updated successfully") print("Manifest updated successfully")
EOF EOF
# Commit and push manifest # Commit and push manifest
git config user.name "Gitea Actions" git config user.name "Gitea Actions"
git config user.email "actions@git.tdpi.dev" git config user.email "actions@git.tdpi.dev"
git add manifest.json git add manifest.json
git commit -m "chore: update manifest for ${TAG}" git commit -m "chore: update manifest for ${TAG}"
git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git HEAD:main git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git HEAD:main
- name: Summary - name: Summary
run: | run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY echo "## Build Summary" >> $GITHUB_STEP_SUMMARY

View File

@@ -11,7 +11,7 @@ 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')" 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:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.claude
buildedplugin

View File

@@ -1,3 +1,3 @@
{ {
".": "0.0.2" ".": "0.0.17"
} }

View File

@@ -1,5 +1,166 @@
# Changelog # Changelog
## 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
* fix: glaube ich net
### Chores
* chore: update manifest for v0.0.11
## 0.0.11 (2026-03-01)
### Bug Fixes
* fix: claude ist auch für 100€ im Monat noch dumm
## 0.0.10 (2026-03-01)
### Bug Fixes
* fix: again
### Chores
* chore: update manifest for v0.0.9
## 0.0.9 (2026-03-01)
### Bug Fixes
* fix: angeblich..
### Chores
* chore: update manifest for v0.0.8
## 0.0.8 (2026-03-01)
### Bug Fixes
* fix: notsupported again
### Chores
* chore: update manifest for v0.0.7
## 0.0.7 (2026-03-01)
### Bug Fixes
* fix: build lief net!
* fix: build lief nicht
* fix: removed Claudes dummes gefriemel
* fix: notSupported
### Chores
* chore(main): release 0.0.6
* chore: update manifest for v0.0.5
## 0.0.6 (2026-03-01)
### Bug Fixes
* fix: removed Claudes dummes gefriemel
* fix: notSupported
### Chores
* chore: update manifest for v0.0.5
## 0.0.5 (2026-03-01)
### Bug Fixes
* fix: remove Jellyfin Trash from Build
### Chores
* chore: loop gefixt
* chore: update manifest for v0.0.4
## 0.0.4 (2026-03-01)
### Bug Fixes
* fix: json fehler
* fix: Dumme Readme
## 0.0.3 (2026-03-01)
### Bug Fixes
* fix: claude macht blödsinn
## 0.0.2 (2026-03-01) ## 0.0.2 (2026-03-01)
### Bug Fixes ### Bug Fixes

View File

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

View File

@@ -88,7 +88,7 @@
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtNotificationDelayMinutes">Verzögerung (Minuten)</label> <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"> <div class="fieldDescription">
Wartezeit bevor eine Benachrichtigung gesendet wird. Wartezeit bevor eine Benachrichtigung gesendet wird.
Erlaubt Metadaten-Aktualisierung und Erkennung von Ersetzungen. Erlaubt Metadaten-Aktualisierung und Erkennung von Ersetzungen.
@@ -97,7 +97,7 @@
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtGroupingWindowMinutes">Gruppierungsfenster (Minuten)</label> <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"> <div class="fieldDescription">
Zeitfenster in dem Episoden derselben Serie zusammengefasst werden. Zeitfenster in dem Episoden derselben Serie zusammengefasst werden.
Z.B. bei 30 Minuten: Wenn Episode 1-12 innerhalb von 30 Minuten hinzugefügt 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('#chkEnableEpisodeNotifications').checked = config.EnableEpisodeNotifications;
document.querySelector('#chkEnableMovieNotifications').checked = config.EnableMovieNotifications; document.querySelector('#chkEnableMovieNotifications').checked = config.EnableMovieNotifications;
document.querySelector('#chkSuppressUpgrades').checked = config.SuppressUpgrades; document.querySelector('#chkSuppressUpgrades').checked = config.SuppressUpgrades;
document.querySelector('#txtNotificationDelayMinutes').value = config.NotificationDelayMinutes || 5; document.querySelector('#txtNotificationDelayMinutes').value = config.NotificationDelayMinutes ?? 0;
document.querySelector('#txtGroupingWindowMinutes').value = config.GroupingWindowMinutes || 30; document.querySelector('#txtGroupingWindowMinutes').value = config.GroupingWindowMinutes ?? 0;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -149,8 +149,8 @@
config.EnableEpisodeNotifications = document.querySelector('#chkEnableEpisodeNotifications').checked; config.EnableEpisodeNotifications = document.querySelector('#chkEnableEpisodeNotifications').checked;
config.EnableMovieNotifications = document.querySelector('#chkEnableMovieNotifications').checked; config.EnableMovieNotifications = document.querySelector('#chkEnableMovieNotifications').checked;
config.SuppressUpgrades = document.querySelector('#chkSuppressUpgrades').checked; config.SuppressUpgrades = document.querySelector('#chkSuppressUpgrades').checked;
config.NotificationDelayMinutes = parseInt(document.querySelector('#txtNotificationDelayMinutes').value) || 5; config.NotificationDelayMinutes = parseInt(document.querySelector('#txtNotificationDelayMinutes').value) || 0;
config.GroupingWindowMinutes = parseInt(document.querySelector('#txtGroupingWindowMinutes').value) || 30; config.GroupingWindowMinutes = parseInt(document.querySelector('#txtGroupingWindowMinutes').value) || 0;
ApiClient.updatePluginConfiguration(SmartNotifyConfig.pluginUniqueId, config).then(function () { ApiClient.updatePluginConfiguration(SmartNotifyConfig.pluginUniqueId, config).then(function () {
Dashboard.processPluginConfigurationUpdateResult(); Dashboard.processPluginConfigurationUpdateResult();
}); });

View File

@@ -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.2.0</AssemblyVersion> <AssemblyVersion>0.0.17.0</AssemblyVersion>
<FileVersion>0.0.2.0</FileVersion> <FileVersion>0.0.17.0</FileVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -13,17 +13,18 @@
<AnalysisMode>Default</AnalysisMode> <AnalysisMode>Default</AnalysisMode>
</PropertyGroup> </PropertyGroup>
<!-- Jellyfin 10.11 references -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.*" /> <PackageReference Include="Jellyfin.Controller" Version="10.11.0">
<PackageReference Include="Jellyfin.Model" Version="10.11.*" /> <ExcludeAssets>runtime</ExcludeAssets>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" /> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" /> <PackageReference Include="Jellyfin.Model" Version="10.11.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="LiteDB" Version="5.0.21" /> <PackageReference Include="LiteDB" Version="5.0.21" />
</ItemGroup> </ItemGroup>
<!-- Embedded Resources -->
<ItemGroup> <ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup> </ItemGroup>

View File

@@ -1,13 +1,16 @@
using System; using System;
using System.Linq;
using System.Text.Json; using System.Text.Json;
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 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;
@@ -46,6 +49,10 @@ 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".
// This prevents mass notifications on first run or after DB reset.
SeedExistingLibraryItems();
// Subscribe to library events // Subscribe to library events
_libraryManager.ItemAdded += OnItemAdded; _libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemRemoved += OnItemRemoved; _libraryManager.ItemRemoved += OnItemRemoved;
@@ -61,6 +68,44 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return Task.CompletedTask; 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;
foreach (var item in existingItems)
{
if (!_historyService.IsKnownItem(item.Id))
{
_historyService.RecordItem(item);
seeded++;
}
}
_logger.LogInformation(
"Seeded {Count} existing library items into SmartNotify DB (total in library: {Total})",
seeded,
existingItems.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding existing library items");
}
}
/// <inheritdoc /> /// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
@@ -87,6 +132,14 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return; 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;
}
_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;
@@ -127,6 +180,17 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return; return;
} }
// Check 0: Is this exact item (same Jellyfin ID) already known in our DB?
if (_historyService.IsKnownItem(item.Id))
{
_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) // Check 1: Is this a quality upgrade? (Same content, different file)
var isUpgrade = _historyService.IsQualityUpgrade(item); var isUpgrade = _historyService.IsQualityUpgrade(item);
@@ -185,10 +249,11 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
Overview = item.Overview Overview = item.Overview
}; };
// Set image URL // Set local image path for attachment-based sending
if (!string.IsNullOrEmpty(serverUrl)) 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) if (item is Episode episode)
@@ -199,10 +264,21 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
notification.EpisodeNumber = episode.IndexNumber; notification.EpisodeNumber = episode.IndexNumber;
notification.Year = episode.ProductionYear; notification.Year = episode.ProductionYear;
// Use series image if episode doesn't have one // Use series provider IDs for external links — episode provider IDs
if (!string.IsNullOrEmpty(serverUrl) && episode.SeriesId != Guid.Empty) // (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths
if (episode.SeriesId != Guid.Empty)
{ {
notification.ImageUrl = $"{serverUrl}/Items/{episode.SeriesId}/Images/Primary"; var series = _libraryManager.GetItemById(episode.SeriesId);
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;
}
} }
} }
else if (item is Movie movie) else if (item is Movie movie)
@@ -224,6 +300,76 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
_logger.LogDebug("Item removed: {Name} (ID: {Id})", e.Item.Name, e.Item.Id); _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)
{
return;
}
var changed = false;
if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series")
{
notification.SeriesName = episode.SeriesName;
changed = true;
}
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString())
{
if (episode.SeriesId != Guid.Empty)
{
notification.SeriesId = episode.SeriesId.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;
}
// 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> /// <summary>
/// Processes pending notifications (called by timer). /// Processes pending notifications (called by timer).
/// </summary> /// </summary>
@@ -247,14 +393,75 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return; 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 upgrade 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 upgrades
// to go undetected. By now (after delay + grouping window), metadata is ready.
if (config.SuppressUpgrades)
{
var suppressedIds = new List<int>();
foreach (var notification in pendingNotifications)
{
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
{
var revalidateItem = _libraryManager.GetItemById(revalidateId);
if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem))
{
suppressedIds.Add(notification.Id);
_logger.LogInformation(
"Late suppression: {Name} detected as upgrade at send time",
notification.Name);
}
}
}
if (suppressedIds.Count > 0)
{
_historyService.RemoveNotifications(suppressedIds);
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
_logger.LogInformation(
"Suppressed {Count} upgrade notifications at send time",
suppressedIds.Count);
}
if (pendingNotifications.Count == 0)
{
return;
}
}
// Group episodes by series // Group episodes by series
var emptyGuid = Guid.Empty.ToString();
var episodesBySeries = pendingNotifications 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!) .GroupBy(n => n.SeriesId!)
.ToList(); .ToList();
// Handle episodes that still have no series info (send individually as fallback)
var orphanEpisodes = pendingNotifications
.Where(n => n.ItemType == "Episode" && (string.IsNullOrEmpty(n.SeriesId) || n.SeriesId == emptyGuid))
.ToList();
if (orphanEpisodes.Count > 0)
{
_logger.LogWarning(
"{Count} episode notifications have no series info even after refresh",
orphanEpisodes.Count);
}
// Process each series group // Process each series group
foreach (var seriesGroup in episodesBySeries) foreach (var seriesGroup in episodesBySeries)
{ {
@@ -264,19 +471,59 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
// Only process if the oldest notification is outside the grouping window // Only process if the oldest notification is outside the grouping window
if (oldestInGroup > groupingCutoff) if (oldestInGroup > groupingCutoff)
{ {
_logger.LogDebug( _logger.LogInformation(
"Waiting for grouping window for series {SeriesId}, oldest: {Oldest}", "Waiting for grouping window for series {SeriesName} ({SeriesId}), oldest queued: {Oldest}, grouping cutoff: {Cutoff}",
seriesGroup.First().SeriesName,
seriesGroup.Key, seriesGroup.Key,
oldestInGroup); oldestInGroup,
groupingCutoff);
continue; continue;
} }
await _discordService.SendGroupedEpisodeNotificationAsync( _logger.LogInformation(
"Sending grouped notification for {SeriesName}: {Count} episodes",
seriesGroup.First().SeriesName,
seriesGroup.Count());
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
seriesGroup, seriesGroup,
CancellationToken.None); CancellationToken.None);
var idsToRemove = seriesGroup.Select(n => n.Id).ToList(); if (success)
_historyService.RemoveNotifications(idsToRemove); {
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 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
@@ -286,8 +533,19 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
foreach (var movie in movies) foreach (var movie in movies)
{ {
await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None); _logger.LogInformation("Sending movie notification for: {Name}", movie.Name);
_historyService.RemoveNotifications(new[] { movie.Id });
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) catch (Exception ex)

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
@@ -37,8 +38,8 @@ public class DiscordNotificationService
/// </summary> /// </summary>
/// <param name="notifications">The notifications to group and send.</param> /// <param name="notifications">The notifications to group and send.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the async operation.</returns> /// <returns>True if the notification was sent successfully.</returns>
public async Task SendGroupedEpisodeNotificationAsync( public async Task<bool> SendGroupedEpisodeNotificationAsync(
IEnumerable<PendingNotification> notifications, IEnumerable<PendingNotification> notifications,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -46,13 +47,13 @@ public class DiscordNotificationService
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl)) if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{ {
_logger.LogWarning("Discord webhook URL not configured"); _logger.LogWarning("Discord webhook URL not configured");
return; return false;
} }
var notificationList = notifications.ToList(); var notificationList = notifications.ToList();
if (notificationList.Count == 0) if (notificationList.Count == 0)
{ {
return; return false;
} }
var first = notificationList.First(); var first = notificationList.First();
@@ -69,14 +70,11 @@ public class DiscordNotificationService
var title = $"📺 {seriesName}"; var title = $"📺 {seriesName}";
var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}"; var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}";
// Get image from first notification return await SendDiscordWebhookAsync(
var imageUrl = first.ImageUrl;
await SendDiscordWebhookAsync(
config, config,
title, title,
description, description,
imageUrl, first.ImagePath,
BuildExternalLinks(first.ProviderIdsJson), BuildExternalLinks(first.ProviderIdsJson),
cancellationToken); cancellationToken);
} }
@@ -155,7 +153,8 @@ public class DiscordNotificationService
/// </summary> /// </summary>
/// <param name="notification">The notification.</param> /// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</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, PendingNotification notification,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -163,7 +162,7 @@ public class DiscordNotificationService
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl)) if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{ {
_logger.LogWarning("Discord webhook URL not configured"); _logger.LogWarning("Discord webhook URL not configured");
return; return false;
} }
var title = $"🎬 {notification.Name}"; var title = $"🎬 {notification.Name}";
@@ -178,11 +177,11 @@ public class DiscordNotificationService
description = description.Substring(0, 297) + "..."; description = description.Substring(0, 297) + "...";
} }
await SendDiscordWebhookAsync( return await SendDiscordWebhookAsync(
config, config,
title, title,
description, description,
notification.ImageUrl, notification.ImagePath,
BuildExternalLinks(notification.ProviderIdsJson), BuildExternalLinks(notification.ProviderIdsJson),
cancellationToken); cancellationToken);
} }
@@ -236,13 +235,14 @@ public class DiscordNotificationService
} }
/// <summary> /// <summary>
/// Sends the actual Discord webhook request. /// Sends the actual Discord webhook request, with optional image attachment.
/// </summary> /// </summary>
private async Task SendDiscordWebhookAsync( /// <returns>True if the webhook was sent successfully.</returns>
private async Task<bool> SendDiscordWebhookAsync(
PluginConfiguration config, PluginConfiguration config,
string title, string title,
string description, string description,
string? imageUrl, string? imagePath,
string externalLinks, string externalLinks,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -253,14 +253,25 @@ public class DiscordNotificationService
["color"] = config.EmbedColor ["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)) 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"); embed["timestamp"] = DateTime.UtcNow.ToString("o");
@@ -272,14 +283,33 @@ public class DiscordNotificationService
}; };
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
_logger.LogDebug("Sending Discord webhook: {Json}", json); _logger.LogInformation("Sending Discord webhook for: {Title}", title);
try try
{ {
using var client = _httpClientFactory.CreateClient(); 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) if (!response.IsSuccessStatusCode)
{ {
@@ -288,15 +318,16 @@ public class DiscordNotificationService
"Discord webhook failed with status {Status}: {Body}", "Discord webhook failed with status {Status}: {Body}",
response.StatusCode, response.StatusCode,
responseBody); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to send Discord webhook"); _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"); var dbPath = Path.Combine(pluginDataPath, "smartnotify.db");
_logger.LogInformation("SmartNotify database path: {Path}", dbPath); _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"); _knownItems = _database.GetCollection<KnownMediaItem>("known_items");
_pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications"); _pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications");
@@ -58,18 +64,25 @@ public class ItemHistoryService : IDisposable
{ {
if (item is Episode episode) 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; var series = episode.Series;
if (series == null) 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; return null;
} }
var seriesKey = GetProviderKey(series.ProviderIds); var seriesKey = GetProviderKey(series.ProviderIds);
if (string.IsNullOrEmpty(seriesKey)) if (string.IsNullOrEmpty(seriesKey))
{ {
// Fallback to series name if no provider IDs
seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown"; seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown";
} }
@@ -78,7 +91,7 @@ public class ItemHistoryService : IDisposable
if (episodeNum == 0) 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; return null;
} }
@@ -201,53 +214,69 @@ public class ItemHistoryService : IDisposable
return false; return false;
} }
/// <summary>
/// Checks if an item with the given Jellyfin ID is already tracked in the database.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <returns>True if the item is already known.</returns>
public bool IsKnownItem(Guid itemId)
{
var jellyfinId = itemId.ToString();
return _knownItems.Exists(x => x.JellyfinItemId == jellyfinId);
}
/// <summary> /// <summary>
/// Records a known item in the database. /// 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> /// </summary>
/// <param name="item">The item to record.</param> /// <param name="item">The item to record.</param>
public void RecordItem(BaseItem item) public void RecordItem(BaseItem item)
{ {
var contentKey = GenerateContentKey(item); var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return;
}
var jellyfinId = item.Id.ToString(); var jellyfinId = item.Id.ToString();
// Check if we already have this exact Jellyfin item // Check if we already have this exact Jellyfin item
var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId); var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId);
if (existing != null) if (existing != null)
{ {
// Update the record // Update the record — only update ContentKey if we have a real one
existing.ContentKey = contentKey; if (contentKey != null)
{
existing.ContentKey = contentKey;
}
existing.FilePath = item.Path; existing.FilePath = item.Path;
existing.Name = item.Name;
_knownItems.Update(existing); _knownItems.Update(existing);
return; return;
} }
// Check if we have this content key already (upgrade scenario) // Check if we have this content key already (upgrade scenario)
var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey); if (contentKey != null)
if (byContentKey != null)
{ {
// Content exists, this is an upgrade - update the record var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey);
byContentKey.JellyfinItemId = jellyfinId; if (byContentKey != null)
byContentKey.FilePath = item.Path; {
_knownItems.Update(byContentKey); // Content exists, this is an upgrade - update the record
_logger.LogDebug("Updated existing content record for {Key} with new file", contentKey); byContentKey.JellyfinItemId = jellyfinId;
return; 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 var record = new KnownMediaItem
{ {
JellyfinItemId = jellyfinId, JellyfinItemId = jellyfinId,
ContentKey = contentKey, ContentKey = contentKey ?? $"unresolved|{jellyfinId}",
ItemType = item.GetType().Name, ItemType = item.GetType().Name,
Name = item.Name, Name = item.Name,
FirstSeen = DateTime.UtcNow, FirstSeen = DateTime.UtcNow,
FilePath = item.Path, FilePath = item.Path,
ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>()) ProviderIdsJson = System.Text.Json.JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>())
}; };
if (item is Episode ep) if (item is Episode ep)
@@ -263,7 +292,57 @@ public class ItemHistoryService : IDisposable
} }
_knownItems.Insert(record); _knownItems.Insert(record);
_logger.LogDebug("Recorded new content: {Key}", contentKey); _logger.LogDebug("Recorded new content: {Key}", record.ContentKey);
}
/// <summary>
/// Re-checks if a pending notification is actually a quality upgrade.
/// Called at send time when metadata is fully populated.
/// </summary>
/// <param name="jellyfinItemId">The Jellyfin item ID string.</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>
public bool RevalidatePendingItem(string jellyfinItemId, BaseItem? item)
{
if (item == null)
{
return false;
}
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return false;
}
// 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)
{
_logger.LogInformation(
"Late upgrade detection for {Name}: content key {Key} already known (first seen: {FirstSeen})",
item.Name,
contentKey,
existing.FirstSeen);
// 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 true;
}
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
RecordItem(item);
return false;
} }
/// <summary> /// <summary>
@@ -295,6 +374,15 @@ public class ItemHistoryService : IDisposable
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan); 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> /// <summary>
/// Removes processed notifications. /// Removes processed notifications.
/// </summary> /// </summary>

View File

@@ -139,10 +139,15 @@ public class PendingNotification
public NotificationType Type { get; set; } public NotificationType Type { get; set; }
/// <summary> /// <summary>
/// Gets or sets the image URL. /// Gets or sets the image URL (for public servers).
/// </summary> /// </summary>
public string? ImageUrl { get; set; } 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> /// <summary>
/// Gets or sets the provider IDs JSON. /// Gets or sets the provider IDs JSON.
/// </summary> /// </summary>

View File

@@ -93,13 +93,6 @@ Staffel 1: Episode 1-4, 6, 8-12
Kimi no Na wa - Ein Junge und ein Mädchen... Kimi no Na wa - Ein Junge und ein Mädchen...
``` ```
## Technische Details
- **Framework:** .NET 8.0
- **Jellyfin-Version:** 10.11+
- **Datenbank:** LiteDB (lokal, im Plugin-Ordner)
- **Events:** `ILibraryManager.ItemAdded`, `ILibraryManager.ItemRemoved`
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- Provider-IDs müssen vorhanden sein (AniDB, TMDB, etc.) - Provider-IDs müssen vorhanden sein (AniDB, TMDB, etc.)
@@ -117,38 +110,6 @@ https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.j
3. **Katalog** → SmartNotify installieren 3. **Katalog** → SmartNotify installieren
4. Jellyfin neustarten 4. Jellyfin neustarten
## CI/CD
Das Repository nutzt drei Gitea Workflows:
1. **create-release-pr.yaml** - Bei Push auf `main`:
- Analysiert Commits (feat/fix/chore)
- Bumpt Version nach Semantic Versioning
- Erstellt/aktualisiert Release-PR
2. **create-release.yaml** - Bei PR-Merge:
- Erstellt Git-Tag
- Erstellt Gitea Release
3. **build-publish.yaml** - Bei Release:
- Baut das Plugin mit `dotnet publish`
- Erstellt ZIP mit Checksum
- Lädt ZIP zum Release hoch
- Aktualisiert `manifest.json` automatisch
### Commit Convention
| Prefix | Beschreibung | Version Bump |
|--------|--------------|--------------|
| `feat:` | Neues Feature | Minor (1.x.0) |
| `fix:` | Bugfix | Patch (1.0.x) |
| `chore:` | Maintenance | Patch (1.0.x) |
| `feat!:` | Breaking Change | Major (x.0.0) |
## Lizenz ## Lizenz
MIT MIT
## Contributing
Pull Requests willkommen! Bitte teste auf Windows Dev-Umgebung.

14
build.yaml Normal file
View File

@@ -0,0 +1,14 @@
name: "SmartNotify"
guid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
targetAbi: "10.11.0.0"
framework: "net9.0"
overview: "Intelligent Discord notifications for new media"
description: >
Smart Discord notifications that detect quality upgrades vs truly new content.
Groups episodes intelligently (e.g., 'Episode 1-12 added').
category: "Notifications"
owner: "TDPI"
artifacts:
- "Jellyfin.Plugin.SmartNotify.dll"
- "LiteDB.dll"
changelog: ""

View File

@@ -7,6 +7,39 @@
"owner": "TDPI", "owner": "TDPI",
"category": "Notifications", "category": "Notifications",
"imageUrl": "", "imageUrl": "",
"versions": [] "versions": [
{
"version": "0.0.16.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",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.16/smartnotify_0.0.16.zip",
"checksum": "fbe2b3ce339c92204961df605bfe276b",
"timestamp": "2026-03-02T18:58:41Z"
},
{
"version": "0.0.15.0",
"changelog": "### Bug Fixes\n\n* fix: timestamps!\n\n### Chores\n\n* chore: update manifest for v0.0.14",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.15/smartnotify_0.0.15.zip",
"checksum": "c3dc638240b5688de030f77eccaf9c50",
"timestamp": "2026-03-01T17:49:47Z"
},
{
"version": "0.0.14.0",
"changelog": "### Bug Fixes\n\n* fix: claude hat bugs im Kopf\n\n### Chores\n\n* chore: update manifest for v0.0.13",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.14/smartnotify_0.0.14.zip",
"checksum": "428e0cc3a00c873381dc2f2f198f3907",
"timestamp": "2026-03-01T17:36:26Z"
},
{
"version": "0.0.13.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",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.13/smartnotify_0.0.13.zip",
"checksum": "2d303e8dc214a58e038f516076840d5b",
"timestamp": "2026-03-01T17:21:50Z"
}
]
} }
] ]