Compare commits
14 Commits
62a7547688
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c032d2b651 | ||
| be5f71caff | |||
|
|
a5b75159e2 | ||
| 2386648a63 | |||
| 948c4f8768 | |||
| c3a7c504db | |||
| 9863778d8b | |||
| 562bfbec54 | |||
| 24c4bf9ba5 | |||
|
|
0c44037f93 | ||
| 7ff65d1546 | |||
|
|
bae22c908f | ||
| 27da8c90f7 | |||
| 96d67a8655 |
@@ -38,7 +38,7 @@ jobs:
|
||||
import subprocess
|
||||
import json
|
||||
from packaging import version as pkg_version
|
||||
|
||||
|
||||
def get_commits_since_last_tag():
|
||||
try:
|
||||
last_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'], text=True).strip()
|
||||
@@ -46,14 +46,14 @@ jobs:
|
||||
except:
|
||||
commits = subprocess.check_output(['git', 'log', '--pretty=format:%s'], text=True).strip().split('\n')
|
||||
return [c for c in commits if c]
|
||||
|
||||
|
||||
def analyze_commits(commits):
|
||||
has_feat = any(c.startswith('feat') for c in commits)
|
||||
has_fix = any(c.startswith('fix') for c in commits)
|
||||
has_breaking = any('!' in c.split(':')[0] or 'BREAKING CHANGE' in c for c in commits)
|
||||
has_chore = any(c.startswith('chore') for c in commits)
|
||||
return has_breaking, has_feat, has_fix, has_chore
|
||||
|
||||
|
||||
def get_current_version():
|
||||
try:
|
||||
with open('.release-please-manifest.json', 'r') as f:
|
||||
@@ -61,11 +61,11 @@ jobs:
|
||||
return manifest.get('.', '1.0.0')
|
||||
except:
|
||||
return '1.0.0'
|
||||
|
||||
|
||||
def bump_version(current, has_breaking, has_feat, has_fix, has_chore):
|
||||
v = pkg_version.parse(current)
|
||||
major, minor, patch = v.major, v.minor, v.micro
|
||||
|
||||
|
||||
if has_breaking:
|
||||
return f'{major + 1}.0.0'
|
||||
elif has_feat:
|
||||
@@ -73,57 +73,57 @@ jobs:
|
||||
elif has_fix or has_chore:
|
||||
return f'{major}.{minor}.{patch + 1}'
|
||||
return None
|
||||
|
||||
|
||||
commits = get_commits_since_last_tag()
|
||||
if not commits or commits == ['']:
|
||||
print('No commits to release')
|
||||
exit(0)
|
||||
|
||||
|
||||
has_breaking, has_feat, has_fix, has_chore = analyze_commits(commits)
|
||||
|
||||
if not (has_breaking or has_feat or has_fix or has_chore):
|
||||
print('No release-worthy commits')
|
||||
exit(0)
|
||||
|
||||
|
||||
current_version = get_current_version()
|
||||
new_version = bump_version(current_version, has_breaking, has_feat, has_fix, has_chore)
|
||||
|
||||
|
||||
if not new_version:
|
||||
print('No version bump needed')
|
||||
exit(0)
|
||||
|
||||
|
||||
print(f'Bumping version from {current_version} to {new_version}')
|
||||
|
||||
|
||||
# Update version in manifest
|
||||
with open('.release-please-manifest.json', 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
|
||||
manifest['.'] = new_version
|
||||
|
||||
|
||||
with open('.release-please-manifest.json', 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
|
||||
# Update version in .csproj
|
||||
csproj_path = 'Jellyfin.Plugin.SmartNotify/Jellyfin.Plugin.SmartNotify.csproj'
|
||||
with open(csproj_path, 'r') as f:
|
||||
csproj = f.read()
|
||||
|
||||
|
||||
csproj = re.sub(r'<AssemblyVersion>.*?</AssemblyVersion>', f'<AssemblyVersion>{new_version}.0</AssemblyVersion>', csproj)
|
||||
csproj = re.sub(r'<FileVersion>.*?</FileVersion>', f'<FileVersion>{new_version}.0</FileVersion>', csproj)
|
||||
|
||||
|
||||
with open(csproj_path, 'w') as f:
|
||||
f.write(csproj)
|
||||
|
||||
|
||||
# Generate CHANGELOG
|
||||
changelog_entry = f'## {new_version} ({subprocess.check_output(["date", "+%Y-%m-%d"], text=True).strip()})\n\n'
|
||||
|
||||
|
||||
if has_breaking:
|
||||
changelog_entry += '### BREAKING CHANGES\n\n'
|
||||
for c in commits:
|
||||
if '!' in c.split(':')[0] or 'BREAKING CHANGE' in c:
|
||||
changelog_entry += f'* {c}\n'
|
||||
changelog_entry += '\n'
|
||||
|
||||
|
||||
if has_feat:
|
||||
changelog_entry += '### Features\n\n'
|
||||
for c in commits:
|
||||
@@ -144,33 +144,42 @@ jobs:
|
||||
if c.startswith('chore'):
|
||||
changelog_entry += f'* {c}\n'
|
||||
changelog_entry += '\n'
|
||||
|
||||
|
||||
try:
|
||||
with open('CHANGELOG.md', 'r') as f:
|
||||
old_changelog = f.read()
|
||||
except:
|
||||
old_changelog = '# Changelog\n\n'
|
||||
|
||||
|
||||
with open('CHANGELOG.md', 'w') as f:
|
||||
f.write('# Changelog\n\n' + changelog_entry + '\n' + old_changelog.replace('# Changelog\n', '').lstrip())
|
||||
|
||||
|
||||
subprocess.run(['git', 'config', 'user.name', 'Gitea Actions'])
|
||||
subprocess.run(['git', 'config', 'user.email', 'actions@git.tdpi.dev'])
|
||||
subprocess.run(['git', 'add', '.release-please-manifest.json', 'CHANGELOG.md', csproj_path])
|
||||
subprocess.run(['git', 'commit', '-m', f'chore(main): release {new_version}'])
|
||||
|
||||
|
||||
with open('/tmp/new_version', 'w') as f:
|
||||
f.write(new_version)
|
||||
|
||||
# Write changelog body (without header) for PR body
|
||||
with open('/tmp/changelog_body', 'w') as f:
|
||||
f.write(changelog_entry)
|
||||
EOF
|
||||
|
||||
|
||||
NEW_VERSION=$(cat /tmp/new_version 2>/dev/null || echo "")
|
||||
if [ -z "$NEW_VERSION" ]; then
|
||||
echo "No version bump needed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
BRANCH_NAME="release-please--branches--main"
|
||||
|
||||
|
||||
# Build PR body with changelog as editable template
|
||||
CHANGELOG_BODY=$(cat /tmp/changelog_body)
|
||||
PR_BODY="$(printf '> **Bearbeite diesen Text!** Der Changelog unten wurde automatisch aus Commits generiert.\n> Passe ihn an bevor du den PR mergest - dieser Text wird als Changelog verwendet.\n\n%s' "$CHANGELOG_BODY")"
|
||||
PR_BODY_JSON=$(echo "$PR_BODY" | jq -Rs .)
|
||||
|
||||
# Push changes
|
||||
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
|
||||
git push -f origin HEAD:"$BRANCH_NAME"
|
||||
@@ -179,21 +188,18 @@ jobs:
|
||||
git push origin HEAD:"$BRANCH_NAME"
|
||||
PR_EXISTS=false
|
||||
fi
|
||||
|
||||
# Get changelog content for PR body
|
||||
CHANGELOG_CONTENT=$(awk '/^## '"$NEW_VERSION"'/{flag=1} /^## / && flag && !/^## '"$NEW_VERSION"'/{exit} flag' CHANGELOG.md | jq -Rs .)
|
||||
|
||||
|
||||
if [ "$PR_EXISTS" = true ]; then
|
||||
PR_NUMBER=$(curl -s "https://git.tdpi.dev/api/v1/repos/$REPO/pulls?state=open&head=$BRANCH_NAME" \
|
||||
-H "Authorization: token $GIT_TOKEN" | jq -r '.[0].number')
|
||||
|
||||
|
||||
if [ "$PR_NUMBER" != "null" ]; then
|
||||
curl -X PATCH "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
|
||||
-H "Authorization: token $GIT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore(main): release $NEW_VERSION\",
|
||||
\"body\": $CHANGELOG_CONTENT
|
||||
\"body\": $PR_BODY_JSON
|
||||
}"
|
||||
fi
|
||||
else
|
||||
@@ -204,6 +210,6 @@ jobs:
|
||||
\"title\": \"chore(main): release $NEW_VERSION\",
|
||||
\"head\": \"$BRANCH_NAME\",
|
||||
\"base\": \"main\",
|
||||
\"body\": $CHANGELOG_CONTENT
|
||||
\"body\": $PR_BODY_JSON
|
||||
}"
|
||||
fi
|
||||
|
||||
@@ -26,6 +26,60 @@ jobs:
|
||||
VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get changelog from PR body
|
||||
id: changelog
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||
REPO: "TDPI/jellyfin-plugin-smartnotify"
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
run: |
|
||||
# Get PR body (the manually edited changelog)
|
||||
PR_BODY=$(curl -s "https://git.tdpi.dev/api/v1/repos/$REPO/pulls/$PR_NUMBER" \
|
||||
-H "Authorization: token $GIT_TOKEN" | jq -r '.body')
|
||||
|
||||
# Strip the instruction blockquote lines (lines starting with >)
|
||||
CHANGELOG=$(echo "$PR_BODY" | sed '/^[[:space:]]*>/d' | sed '/^$/N;/^\n$/d')
|
||||
|
||||
echo "$CHANGELOG" > /tmp/pr_changelog
|
||||
echo "Changelog from PR body:"
|
||||
echo "$CHANGELOG"
|
||||
|
||||
- name: Update CHANGELOG.md with PR body text
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||
run: |
|
||||
CHANGELOG=$(cat /tmp/pr_changelog)
|
||||
|
||||
# Replace the auto-generated changelog entry with the manually edited one
|
||||
python3 << PYEOF
|
||||
import re
|
||||
|
||||
version = "${VERSION}"
|
||||
changelog = open('/tmp/pr_changelog').read().strip()
|
||||
|
||||
with open('CHANGELOG.md', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find and replace the section for this version
|
||||
# Pattern: ## VERSION (DATE) ... until next ## or end
|
||||
pattern = r'(## ' + re.escape(version) + r' \([^)]+\)\n\n).*?(?=\n## |\Z)'
|
||||
replacement = r'\1' + changelog
|
||||
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
with open('CHANGELOG.md', 'w') as f:
|
||||
f.write(new_content)
|
||||
PYEOF
|
||||
|
||||
# Commit updated CHANGELOG
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@git.tdpi.dev"
|
||||
git add CHANGELOG.md
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "docs: update changelog for v${VERSION}"
|
||||
git push https://x-access-token:${GIT_TOKEN}@git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify.git HEAD:main
|
||||
}
|
||||
|
||||
- name: Create Git Tag
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||
@@ -40,7 +94,7 @@ jobs:
|
||||
GIT_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG=$(awk '/^## '"$VERSION"'/{flag=1} /^## / && flag && !/^## '"$VERSION"'/{exit} flag' CHANGELOG.md)
|
||||
CHANGELOG=$(cat /tmp/pr_changelog)
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "v$VERSION" \
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.claude
|
||||
buildedplugin
|
||||
buildedplugin
|
||||
MEMORY.md
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.0.18"
|
||||
".": "0.1.0"
|
||||
}
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 (2026-03-05)
|
||||
|
||||
## 0.1.0 (2026-03-05)
|
||||
|
||||
**Features**
|
||||
|
||||
Intelligente Discord-Benachrichtigungen
|
||||
|
||||
Automatische Benachrichtigungen bei neuen Filmen und Episoden via Discord Webhook
|
||||
Schöne Discord-Embeds mit Thumbnail, Beschreibung und Links zu externen Datenbanken (IMDb, TMDb, AniDB, AniList, TVDB)
|
||||
|
||||
**Smarte Episoden-Gruppierung**
|
||||
|
||||
Episoden werden intelligent gebündelt statt einzeln gemeldet — z.B. "Staffel 1: Episode 1-12" statt 12 einzelne Nachrichten
|
||||
Konfigurierbares Zeitfenster für die Gruppierung
|
||||
|
||||
**Qualitäts-Upgrade-Erkennung**
|
||||
|
||||
Erkennt automatisch ob eine Datei neu ist oder nur ein Qualitäts-Upgrade einer bestehenden Datei
|
||||
Keine Spam-Benachrichtigungen mehr beim Ersetzen von Dateien durch bessere Versionen
|
||||
Stabile Erkennung über Provider-IDs (AniDB, TMDb etc.) — funktioniert auch mit Shokofin/VFS
|
||||
|
||||
**Robuste Metadaten-Verarbeitung**
|
||||
|
||||
Verzögerte Verarbeitung damit Jellyfin Zeit hat Metadaten zu laden
|
||||
Dreistufige Validierung: beim Hinzufügen, beim Einreihen und beim Senden
|
||||
Automatische Unterdrückung von reorganisierten Items (Pfad-/Metadata-Änderungen)
|
||||
|
||||
**Konfigurierbar**
|
||||
|
||||
Benachrichtigungen für Filme und Episoden einzeln aktivierbar
|
||||
Upgrade-Unterdrückung optional
|
||||
Anpassbare Verzögerung, Gruppierungsfenster, Bot-Name und Embed-Farbe
|
||||
## 0.0.19 (2026-03-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix: debuging
|
||||
|
||||
### Chores
|
||||
|
||||
* chore: update manifest for v0.0.18
|
||||
|
||||
|
||||
## 0.0.18 (2026-03-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
|
||||
<AssemblyVersion>0.0.18.0</AssemblyVersion>
|
||||
<FileVersion>0.0.18.0</FileVersion>
|
||||
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||
<FileVersion>0.1.0.0</FileVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
@@ -85,19 +85,25 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
|
||||
var existingItems = _libraryManager.GetItemList(query);
|
||||
var seeded = 0;
|
||||
var alreadyKnown = 0;
|
||||
|
||||
foreach (var item in existingItems)
|
||||
{
|
||||
if (!_historyService.IsKnownItem(item.Id))
|
||||
if (!_historyService.IsKnownItem(item.Id, item))
|
||||
{
|
||||
_historyService.RecordItem(item);
|
||||
seeded++;
|
||||
}
|
||||
else
|
||||
{
|
||||
alreadyKnown++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Seeded {Count} existing library items into SmartNotify DB (total in library: {Total})",
|
||||
"[DEBUG Seed] Seeded {Seeded} new items, {AlreadyKnown} already known, {Total} total in library",
|
||||
seeded,
|
||||
alreadyKnown,
|
||||
existingItems.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -140,6 +146,60 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: log all available metadata on the item at ItemAdded time
|
||||
if (item is Episode debugEp)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[DEBUG ItemAdded] Episode: Name={Name}, Id={Id}, Path={Path}, " +
|
||||
"SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
||||
"Season={Season}, Episode={Episode}, " +
|
||||
"ProviderIds={ProviderIds}, " +
|
||||
"DateCreated={DateCreated}, PremiereDate={PremiereDate}",
|
||||
debugEp.Name,
|
||||
debugEp.Id,
|
||||
debugEp.Path,
|
||||
debugEp.SeriesName,
|
||||
debugEp.SeriesId,
|
||||
debugEp.ParentIndexNumber,
|
||||
debugEp.IndexNumber,
|
||||
debugEp.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugEp.ProviderIds) : "null",
|
||||
debugEp.DateCreated,
|
||||
debugEp.PremiereDate);
|
||||
|
||||
// Also try to access the Series object directly
|
||||
try
|
||||
{
|
||||
var debugSeries = debugEp.Series;
|
||||
if (debugSeries != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[DEBUG ItemAdded] Series object found: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
|
||||
debugSeries.Name,
|
||||
debugSeries.Id,
|
||||
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[DEBUG ItemAdded] Series object is NULL for episode {Name}", debugEp.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[DEBUG ItemAdded] Failed to access Series object for {Name}", debugEp.Name);
|
||||
}
|
||||
}
|
||||
else if (item is Movie debugMovie)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[DEBUG ItemAdded] Movie: Name={Name}, Id={Id}, Path={Path}, " +
|
||||
"ProviderIds={ProviderIds}, Year={Year}",
|
||||
debugMovie.Name,
|
||||
debugMovie.Id,
|
||||
debugMovie.Path,
|
||||
debugMovie.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugMovie.ProviderIds) : "null",
|
||||
debugMovie.ProductionYear);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
@@ -180,8 +240,8 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Check 0: Is this exact item (same Jellyfin ID) already known in our DB?
|
||||
if (_historyService.IsKnownItem(item.Id))
|
||||
// Check 0: Is this item already known in our DB? (by Jellyfin ID or content key)
|
||||
if (_historyService.IsKnownItem(item.Id, item))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Item {Name} (ID: {Id}) is already known in DB, skipping notification",
|
||||
@@ -280,6 +340,16 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
notification.ImagePath = seriesImage;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[DEBUG CreateNotification] Result: SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
||||
"S{Season}E{Episode}, ProviderIdsJson={ProviderIds}, ImagePath={ImagePath}",
|
||||
notification.SeriesName,
|
||||
notification.SeriesId,
|
||||
notification.SeasonNumber,
|
||||
notification.EpisodeNumber,
|
||||
notification.ProviderIdsJson,
|
||||
notification.ImagePath);
|
||||
}
|
||||
else if (item is Movie movie)
|
||||
{
|
||||
@@ -313,9 +383,45 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (item is not Episode episode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[DEBUG Refresh] Item {Id} is {Type} (not Episode), skipping",
|
||||
itemId,
|
||||
item?.GetType().Name ?? "NULL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: log what Jellyfin returns for this episode at refresh time
|
||||
_logger.LogInformation(
|
||||
"[DEBUG Refresh] Episode from library: Name={Name}, SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
||||
"Season={Season}, Episode={Episode}, ProviderIds={ProviderIds}",
|
||||
episode.Name,
|
||||
episode.SeriesName,
|
||||
episode.SeriesId,
|
||||
episode.ParentIndexNumber,
|
||||
episode.IndexNumber,
|
||||
episode.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(episode.ProviderIds) : "null");
|
||||
|
||||
try
|
||||
{
|
||||
var debugSeries = episode.Series;
|
||||
if (debugSeries != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[DEBUG Refresh] Series object: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
|
||||
debugSeries.Name,
|
||||
debugSeries.Id,
|
||||
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[DEBUG Refresh] Series object is STILL NULL for {Name}", episode.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[DEBUG Refresh] Failed to access Series for {Name}", episode.Name);
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series")
|
||||
@@ -406,11 +512,11 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
RefreshNotificationMetadata(notification);
|
||||
}
|
||||
|
||||
// Late upgrade detection: re-check now that metadata is fully populated.
|
||||
// Late known-item detection: re-check now that metadata is fully populated.
|
||||
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
|
||||
// been available, causing GenerateContentKey() to return null and upgrades
|
||||
// been available, causing GenerateContentKey() to return null and known items
|
||||
// to go undetected. By now (after delay + grouping window), metadata is ready.
|
||||
if (config.SuppressUpgrades)
|
||||
// This catches reorganized items (path/metadata changes) and quality upgrades.
|
||||
{
|
||||
var suppressedIds = new List<int>();
|
||||
foreach (var notification in pendingNotifications)
|
||||
@@ -418,11 +524,22 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
|
||||
{
|
||||
var revalidateItem = _libraryManager.GetItemById(revalidateId);
|
||||
if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem))
|
||||
var result = _historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem, _libraryManager);
|
||||
|
||||
if (result == ItemHistoryService.RevalidationResult.Reorganized)
|
||||
{
|
||||
// Always suppress reorganized items (same content, path/ID changed)
|
||||
suppressedIds.Add(notification.Id);
|
||||
_logger.LogInformation(
|
||||
"Late suppression: {Name} detected as upgrade at send time",
|
||||
"Suppressed {Name}: recognized as reorganized known item at send time",
|
||||
notification.Name);
|
||||
}
|
||||
else if (result == ItemHistoryService.RevalidationResult.Upgrade && config.SuppressUpgrades)
|
||||
{
|
||||
// Only suppress upgrades when configured to do so
|
||||
suppressedIds.Add(notification.Id);
|
||||
_logger.LogInformation(
|
||||
"Suppressed {Name}: quality upgrade detected at send time",
|
||||
notification.Name);
|
||||
}
|
||||
}
|
||||
@@ -433,8 +550,9 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
_historyService.RemoveNotifications(suppressedIds);
|
||||
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
|
||||
_logger.LogInformation(
|
||||
"Suppressed {Count} upgrade notifications at send time",
|
||||
suppressedIds.Count);
|
||||
"Suppressed {Count} notifications at send time ({Reason})",
|
||||
suppressedIds.Count,
|
||||
"reorganized/upgrade");
|
||||
}
|
||||
|
||||
if (pendingNotifications.Count == 0)
|
||||
|
||||
@@ -215,14 +215,37 @@ public class ItemHistoryService : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an item with the given Jellyfin ID is already tracked in the database.
|
||||
/// Checks if an item is already tracked in the database.
|
||||
/// Checks both by Jellyfin ID and by content key (provider IDs),
|
||||
/// so moved/rescanned files with new Jellyfin IDs are still recognized.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item ID.</param>
|
||||
/// <param name="item">The item to check (used for content key lookup).</param>
|
||||
/// <returns>True if the item is already known.</returns>
|
||||
public bool IsKnownItem(Guid itemId)
|
||||
public bool IsKnownItem(Guid itemId, BaseItem? item = null)
|
||||
{
|
||||
var jellyfinId = itemId.ToString();
|
||||
return _knownItems.Exists(x => x.JellyfinItemId == jellyfinId);
|
||||
if (_knownItems.Exists(x => x.JellyfinItemId == jellyfinId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: check by content key (provider IDs) so moved files are still recognized
|
||||
if (item != null)
|
||||
{
|
||||
var contentKey = GenerateContentKey(item);
|
||||
if (contentKey != null && _knownItems.Exists(x => x.ContentKey == contentKey))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Item {Name} recognized as known by content key {Key} (new Jellyfin ID: {Id})",
|
||||
item.Name,
|
||||
contentKey,
|
||||
jellyfinId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -296,34 +319,74 @@ public class ItemHistoryService : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-checks if a pending notification is actually a quality upgrade.
|
||||
/// Result of revalidating a pending notification at send time.
|
||||
/// </summary>
|
||||
public enum RevalidationResult
|
||||
{
|
||||
/// <summary>Item is genuinely new content.</summary>
|
||||
New,
|
||||
|
||||
/// <summary>Item is a known item that was reorganized (path/metadata change, old ID gone).</summary>
|
||||
Reorganized,
|
||||
|
||||
/// <summary>Item is a quality upgrade (same content, old file still exists).</summary>
|
||||
Upgrade
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-checks if a pending notification is actually a known item (reorganized or upgraded).
|
||||
/// Called at send time when metadata is fully populated.
|
||||
/// At queue time, items often have empty ProviderIds and no season/episode numbers,
|
||||
/// so the content key couldn't be generated. Now that metadata is available, we can
|
||||
/// properly identify known items.
|
||||
/// </summary>
|
||||
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
|
||||
/// <param name="item">The resolved library item (may have updated metadata).</param>
|
||||
/// <returns>True if this item is a late-detected upgrade that should be suppressed.</returns>
|
||||
public bool RevalidatePendingItem(string jellyfinItemId, BaseItem? item)
|
||||
/// <param name="libraryManager">The library manager to check if old items still exist.</param>
|
||||
/// <returns>The revalidation result indicating if this is new, reorganized, or an upgrade.</returns>
|
||||
public RevalidationResult RevalidatePendingItem(string jellyfinItemId, BaseItem? item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
return false;
|
||||
return RevalidationResult.New;
|
||||
}
|
||||
|
||||
var contentKey = GenerateContentKey(item);
|
||||
if (contentKey == null)
|
||||
{
|
||||
return false;
|
||||
return RevalidationResult.New;
|
||||
}
|
||||
|
||||
// Update unresolved content key on the item's own record
|
||||
var ownRecord = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId);
|
||||
if (ownRecord != null && ownRecord.ContentKey.StartsWith("unresolved|", StringComparison.Ordinal))
|
||||
{
|
||||
ownRecord.ContentKey = contentKey;
|
||||
_knownItems.Update(ownRecord);
|
||||
_logger.LogDebug("Resolved content key for {Name}: {Key}", item.Name, contentKey);
|
||||
}
|
||||
|
||||
// Check if this content was already known under a different item ID
|
||||
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
|
||||
if (existing != null)
|
||||
{
|
||||
// Determine if the old item still exists in the library
|
||||
var oldItemExists = false;
|
||||
if (Guid.TryParse(existing.JellyfinItemId, out var oldId))
|
||||
{
|
||||
oldItemExists = libraryManager.GetItemById(oldId) != null;
|
||||
}
|
||||
|
||||
var resultType = oldItemExists
|
||||
? RevalidationResult.Upgrade
|
||||
: RevalidationResult.Reorganized;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Late upgrade detection for {Name}: content key {Key} already known (first seen: {FirstSeen})",
|
||||
"Late detection for {Name}: content key {Key} already known (first seen: {FirstSeen}, result: {Result})",
|
||||
item.Name,
|
||||
contentKey,
|
||||
existing.FirstSeen);
|
||||
existing.FirstSeen,
|
||||
resultType);
|
||||
|
||||
// Update the existing record to point to the new item
|
||||
existing.JellyfinItemId = jellyfinItemId;
|
||||
@@ -337,12 +400,12 @@ public class ItemHistoryService : IDisposable
|
||||
_knownItems.Delete(duplicate.Id);
|
||||
}
|
||||
|
||||
return true;
|
||||
return resultType;
|
||||
}
|
||||
|
||||
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
|
||||
RecordItem(item);
|
||||
return false;
|
||||
return RevalidationResult.New;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"category": "Notifications",
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.0.19.0",
|
||||
"changelog": "### Bug Fixes\n\n* fix: debuging\n\n### Chores\n\n* chore: update manifest for v0.0.18",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.19/smartnotify_0.0.19.zip",
|
||||
"checksum": "a79dec5dc65282ff8bb5f130931f9480",
|
||||
"timestamp": "2026-03-04T17:22:49Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.18.0",
|
||||
"changelog": "### Bug Fixes\n\n* fix: build error",
|
||||
|
||||
Reference in New Issue
Block a user