Compare commits
74 Commits
v0.0.2
...
7fadc75c84
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fadc75c84 | |||
| d595b16573 | |||
| 20f603b4ee | |||
|
|
798cdcaf9c | ||
| af6ddeac0d | |||
| 87f1eff7df | |||
|
|
97a7bc5422 | ||
| f56cd701cb | |||
|
|
d6ebbda8ad | ||
| ec2ddf3728 | |||
| 3389eb254c | |||
|
|
b056e8a199 | ||
| fa52312228 | |||
|
|
07cd31097a | ||
| fe78850872 | |||
| f2903719a3 | |||
|
|
7fcc917ca5 | ||
| 06cbbec97b | |||
|
|
877cf59e44 | ||
| 895aafb987 | |||
|
|
3925e502ec | ||
| 732dbda337 | |||
|
|
579331c79f | ||
| 063f594753 | |||
| b383b5cd81 | |||
| 6c485aa0f7 | |||
|
|
5110c999b6 | ||
| b67dc3aa1a | |||
|
|
33a07a19c2 | ||
| 25fb75ae0b | |||
| c9d5fd80ce | |||
|
|
8a445cc507 | ||
| f20364b25f | |||
|
|
b191779fde | ||
| 7425a18241 | |||
| 25ed431d5a | |||
| 38635bcc9a | |||
|
|
522a382e95 | ||
| aad403246e | |||
| 1a63494c83 | |||
|
|
ab435b58e4 | ||
| 160623338f | |||
|
|
abeb593ecb | ||
| 412f0cd639 | |||
| acb352ef39 | |||
|
|
727e0c39ae | ||
| 8eaea397f3 | |||
|
|
a1a7fb6290 | ||
| 0646c7f731 | |||
|
|
7adae0587b | ||
| 4e99b6bb80 | |||
|
|
789569560f | ||
| ea9895e0df | |||
| 4fd2adc4ec | |||
| 30ea11a943 | |||
|
|
50a124e77d | ||
| a364bd2fc0 | |||
| 82413c1c37 | |||
|
|
9b0f612ad2 | ||
| e6dc9973b8 | |||
|
|
93ba23da58 | ||
| 3842ed4a21 | |||
| 1ae1c5ef03 | |||
| 530b862199 | |||
|
|
948d522a4a | ||
| eea223b193 | |||
|
|
debd71cee1 | ||
| 4c7ddf7c61 | |||
| 336828efba | |||
| ad4ecc6daa | |||
| 727afb0ad2 | |||
|
|
594f3a0345 | ||
| e381c2c8df | |||
| a687f260f3 |
@@ -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
|
||||||
|
|||||||
@@ -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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.claude
|
||||||
|
buildedplugin
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.0.2"
|
".": "0.0.17"
|
||||||
}
|
}
|
||||||
161
CHANGELOG.md
161
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -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
14
build.yaml
Normal 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: ""
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user