commit 9f3a93b25c2feed917753bac240c58c64f0fd754 Author: crobibero Date: Fri Sep 11 15:02:28 2020 -0600 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e759b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,330 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ diff --git a/Jellyfin.Plugin.Webhook.sln b/Jellyfin.Plugin.Webhook.sln new file mode 100644 index 0000000..b0375a0 --- /dev/null +++ b/Jellyfin.Plugin.Webhook.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Webhook", "Jellyfin.Plugin.Webhook\Jellyfin.Plugin.Webhook.csproj", "{4BE7DEEF-3ACC-4023-8066-5EA3B5CA1A7C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4BE7DEEF-3ACC-4023-8066-5EA3B5CA1A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BE7DEEF-3ACC-4023-8066-5EA3B5CA1A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BE7DEEF-3ACC-4023-8066-5EA3B5CA1A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BE7DEEF-3ACC-4023-8066-5EA3B5CA1A7C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Jellyfin.Plugin.Webhook/Configuration/Constants.cs b/Jellyfin.Plugin.Webhook/Configuration/Constants.cs new file mode 100644 index 0000000..0bcbcc2 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Configuration/Constants.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Plugin.Webhook.Configuration +{ + /// + /// Constants. + /// + public static class Constants + { + /// + /// Recheck interval ms. + /// + public const int RecheckIntervalMs = 10_000; + + /// + /// Max retries. + /// + public const int MaxRetries = 10; + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Webhook/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..8289b6b --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Configuration/PluginConfiguration.cs @@ -0,0 +1,37 @@ +using System; +using Jellyfin.Plugin.Webhook.Destinations.Discord; +using Jellyfin.Plugin.Webhook.Destinations.Gotify; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.Webhook.Configuration +{ + /// + /// Webhook plugin configuration. + /// + public class PluginConfiguration : BasePluginConfiguration + { + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + DiscordOptions = Array.Empty(); + GotifyOptions = Array.Empty(); + } + + /// + /// Gets or sets the jellyfin server url. + /// + public string ServerUrl { get; set; } + + /// + /// Gets or sets the discord options. + /// + public DiscordOption[] DiscordOptions { get; set; } + + /// + /// Gets or sets the gotify options. + /// + public GotifyOption[] GotifyOptions { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Configuration/Web/config.html b/Jellyfin.Plugin.Webhook/Configuration/Web/config.html new file mode 100644 index 0000000..014c485 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Configuration/Web/config.html @@ -0,0 +1,103 @@ +
+
+
+
+

Webhook

+ + + Help + +
+
+ +
+
+ + For linking to content. Include base url. +
+ +
+
+ +
+
+ +
+
+ + + + + + +
\ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Configuration/Web/config.js b/Jellyfin.Plugin.Webhook/Configuration/Web/config.js new file mode 100644 index 0000000..a3e67c6 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Configuration/Web/config.js @@ -0,0 +1,235 @@ +define([ + "loading", + "dialogHelper", + "emby-input", + "emby-button", + "emby-collapse", + "formDialogStyle", + "flexStyles" +], function (loading, dialogHelper) { + var pluginId = "529397D0-A0AA-43DB-9537-7CFDE936C1E3"; + var discordDefaultEmbedColor = "#AA5CC3"; + var defaultDiscordTemplate = `{ + "content": "{{MentionType}}", + "avatar_url": "{{AvatarUrl}}", + "username": "{{Username}}", + "embeds": [ +\t{ +\t\t"color": "{{EmbedColor}}", +\t\t"footer": { +\t\t\t"text": "From {{ServerName}}", +\t\t\t"iconUrl": "{{AvatarUrl}}" +\t\t}, + {{if_equals ItemType 'Season'}} + "title": "{{SeriesName}} {{Name}} has been added to {{ServerName}}", + {{else}} + {{if_equals ItemType 'Episode'}} + "title": "{{SeriesName}} S{{SeasonNumber}}E{{EpisodeNumber}} {{Name}} has been added to {{ServerName}}", + {{else}} + "title": "{{Name}} ({{Year}}) has been added to {{ServerName}}", + {{/if_equals}} + {{/if_equals}} + "url": "{{ServerUrl}}/web/index.html#!/details?id={{ItemId}}&serverId={{ServerId}}", + "thumbnail":{ + "url": "{{ServerUrl}}/Items/{{ItemId}}/Images/Primary" + } +\t} + ] +}`; + + var configurationWrapper = document.querySelector("#configurationWrapper"); + var templateBase = document.querySelector("#template-base"); + + var btnAddDiscord = document.querySelector("#btnAddDiscord"); + var templateDiscord = document.querySelector("#template-discord"); + + // Add click handlers + btnAddDiscord.addEventListener("click", addDiscordConfig); + document.querySelector("#saveConfig").addEventListener("click", saveConfig); + + /** + * Adds the base config template. + * @param template {HTMLElement} Inner template. + * @param name {string} Config name. + * @returns {HTMLElement} Wrapped template. + */ + function addBaseConfig(template, name){ + var collapse = document.createElement("div"); + collapse.setAttribute("is", "emby-collapse"); + collapse.setAttribute("title", name); + collapse.dataset.configWrapper = "1"; + var collapseContent = document.createElement("div"); + collapseContent.classList.add("collapseContent"); + + // Append template content. + collapseContent.appendChild(template); + + // Append removal button. + var btnRemove = document.createElement("button"); + btnRemove.innerText = "Remove"; + btnRemove.setAttribute("is", "emby-button"); + btnRemove.classList.add("raised", "button-warning", "block"); + btnRemove.addEventListener("click", removeConfig); + + collapseContent.appendChild(btnRemove); + collapse.appendChild(collapseContent); + + return collapse; + } + + /** + * Adds discord configuration. + * @param config {Object} + */ + function addDiscordConfig(config){ + var template = document.createElement("div"); + template.dataset.type = "discord"; + template.appendChild(templateBase.cloneNode(true).content); + template.appendChild(templateDiscord.cloneNode(true).content); + + var txtColor = template.querySelector("[data-name=txtEmbedColor]"); + var selColor = template.querySelector("[data-name=EmbedColor]"); + txtColor.addEventListener("input", function(){ selColor.value = this.value; }); + selColor.addEventListener("change", function(){ txtColor.value = this.value; }); + + var base = addBaseConfig(template, "Discord"); + configurationWrapper.appendChild(base); + + // Load configuration. + setDiscordConfig(config, base); + } + + /** + * Loads config into element. + * @param config {Object} + * @param element {HTMLElement} + */ + function setBaseConfig(config, element){ + element.querySelector("[data-name=chkEnableMovies]").checked = config.EnableMovies || true; + element.querySelector("[data-name=chkEnableEpisodes]").checked = config.EnableEpisodes || true; + element.querySelector("[data-name=chkEnableSeasons]").checked = config.EnableSeasons || true; + element.querySelector("[data-name=chkEnableSeries]").checked = config.EnableSeries || true; + element.querySelector("[data-name=chkEnableAlbums]").checked = config.EnableAlbums || true; + element.querySelector("[data-name=chkEnableSongs]").checked = config.EnableSongs || true; + element.querySelector("[data-name=txtWebhookUri]").value = config.WebhookUri || ""; + element.querySelector("[data-name=txtTemplate]").value = atob(config.Template || "") || defaultDiscordTemplate; + } + + /** + * Loads config into element. + * @param config {Object} + * @param element {HTMLElement} + */ + function setDiscordConfig(config, element){ + setBaseConfig(config, element); + + element.querySelector("[data-name=txtAvatarUrl]").value = config.AvatarUrl || ""; + element.querySelector("[data-name=txtUsername]").value = config.Username || ""; + element.querySelector("[data-name=ddlMentionType]").value = config.MentionType || "None"; + element.querySelector("[data-name=txtEmbedColor]").value = config.EmbedColor || discordDefaultEmbedColor; + element.querySelector("[data-name=EmbedColor]").value = config.EmbedColor || discordDefaultEmbedColor; + } + + /** + * Get base config. + * @param element {HTMLElement} + * @returns {Object} configuration result. + */ + function getBaseConfig(element){ + var config = {}; + + config.EnableMovies = element.querySelector("[data-name=chkEnableMovies]").checked || false; + config.EnableEpisodes = element.querySelector("[data-name=chkEnableEpisodes]").checked || false; + config.EnableSeasons = element.querySelector("[data-name=chkEnableSeasons]").checked || false; + config.EnableSeries = element.querySelector("[data-name=chkEnableSeries]").checked || false; + config.EnableAlbums = element.querySelector("[data-name=chkEnableAlbums]").checked || false; + config.EnableSongs = element.querySelector("[data-name=chkEnableSongs]").checked || false; + config.WebhookUri = element.querySelector("[data-name=txtWebhookUri]").value || ""; + config.Template = btoa(element.querySelector("[data-name=txtTemplate]").value || ""); + + return config; + } + + /** + * Get discord specific config. + * @param e {HTMLElement} + * @returns {Object} configuration result. + */ + function getDiscordConfig(e){ + var config = getBaseConfig(e); + config.AvatarUrl = e.querySelector("[data-name=txtAvatarUrl]").value || ""; + config.Username = e.querySelector("[data-name=txtUsername]").value || ""; + config.MentionType = e.querySelector("[data-name=ddlMentionType]").value || ""; + config.EmbedColor = e.querySelector("[data-name=txtEmbedColor]").value || ""; + return config; + } + + /** + * Removes config from dom. + * @param e {Event} + */ + function removeConfig(e){ + e.preventDefault(); + console.log(e); + findParentBySelector(e.target, '[data-config-wrapper]').remove(); + } + + function saveConfig(e){ + e.preventDefault(); + + loading.show(); + + var config = {}; + config.ServerUrl = document.querySelector("#txtServerUrl").value; + config.DiscordOptions = []; + var discordConfigs = document.querySelectorAll("[data-type=discord]"); + for(var i = 0; i < discordConfigs.length; i++){ + config.DiscordOptions.push(getDiscordConfig(discordConfigs[i])); + } + + console.log(config); + ApiClient.updatePluginConfiguration(pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); + } + + function loadConfig(){ + loading.show(); + + ApiClient.getPluginConfiguration(pluginId).then(function (config) { + document.querySelector("#txtServerUrl").value = config.ServerUrl || ""; + for(var i = 0; i < config.DiscordOptions.length; i++){ + addDiscordConfig(config.DiscordOptions[i]); + } + }); + + loading.hide(); + } + + loadConfig(); + + + /*** Utils ***/ + function collectionHas(a, b) { + for (let i = 0, len = a.length; i < len; i++) { + if (a[i] === b) { + return true; + } + } + return false; + } + + /** + * + * @param elm {EventTarget} + * @param selector {string} + * @returns {HTMLElement} + */ + function findParentBySelector(elm, selector) { + const all = document.querySelectorAll(selector); + let cur = elm.parentNode; + //keep going up until you find a match + while (cur && !collectionHas(all, cur)) { + cur = cur.parentNode; + } + return cur; + } +}); \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs b/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs new file mode 100644 index 0000000..ca117ce --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/BaseOption.cs @@ -0,0 +1,63 @@ +using System; +using HandlebarsDotNet; +using Jellyfin.Plugin.Webhook.Helpers; + +namespace Jellyfin.Plugin.Webhook.Destinations +{ + /// + /// Base options for destination. + /// + public abstract class BaseOption + { + private Func _compiledTemplate; + + /// + /// Gets or sets the webhook uri. + /// + public string WebhookUri { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on movies. + /// + public bool EnableMovies { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on episodes. + /// + public bool EnableEpisodes { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on series. + /// + public bool EnableSeries { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on seasons. + /// + public bool EnableSeasons { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on albums. + /// + public bool EnableAlbums { get; set; } + + /// + /// Gets or sets a value indicating whether to notify on songs. + /// + public bool EnableSongs { get; set; } + + /// + /// Gets or sets the handlebars template. + /// + public string Template { get; set; } + + /// + /// Gets the compiled handlebars template. + /// + /// The compiled handlebars template. + public Func GetCompiledTemplate() + { + return _compiledTemplate ??= Handlebars.Compile(HandlebarsFunctionHelpers.Base64Decode(Template)); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordDestination.cs b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordDestination.cs new file mode 100644 index 0000000..cf84fa4 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordDestination.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Net.Mime; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Webhook.Destinations.Discord +{ + /// + public class DiscordDestination : IDestination + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public DiscordDestination(ILogger logger, IHttpClient httpClient) + { + _logger = logger; + _httpClient = httpClient; + } + + /// + public async Task SendAsync(DiscordOption options, Dictionary data) + { + try + { + // Add discord specific properties. + data["MentionType"] = GetMentionType(options.MentionType); + data["EmbedColor"] = FormatColorCode(options.EmbedColor); + data["AvatarUrl"] = options.AvatarUrl; + data["Username"] = options.Username; + + var body = options.GetCompiledTemplate()(data); + _logger.LogTrace("SendAsync Body: {body}", body); + var requestOptions = new HttpRequestOptions + { + Url = options.WebhookUri, + RequestContent = body, + RequestContentType = MediaTypeNames.Application.Json + }; + + using var response = await _httpClient.Post(requestOptions).ConfigureAwait(false); + } + catch (HttpException e) + { + _logger.LogWarning(e, "Error sending notification."); + } + } + + private static int FormatColorCode(string hexCode) + { + return int.Parse(hexCode.Substring(1, 6), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + private static string GetMentionType(DiscordMentionType mentionType) + { + return mentionType switch + { + DiscordMentionType.Everyone => "@everyone", + DiscordMentionType.Here => "@here", + _ => string.Empty + }; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordMentionType.cs b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordMentionType.cs new file mode 100644 index 0000000..cf89c94 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordMentionType.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Plugin.Webhook.Destinations.Discord +{ + /// + /// Discord mention type. + /// + public enum DiscordMentionType + { + /// + /// Mention @everyone. + /// + Everyone = 2, + + /// + /// Mention @here. + /// + Here = 1, + + /// + /// Mention none. + /// + None = 0 + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordOption.cs b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordOption.cs new file mode 100644 index 0000000..d5ecd88 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/Discord/DiscordOption.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Plugin.Webhook.Destinations.Discord +{ + /// + /// Discord specific options. + /// + public class DiscordOption : BaseOption + { + /// + /// Gets or sets the embed color. + /// + public string EmbedColor { get; set; } + + /// + /// Gets or sets the avatar url. + /// + public string AvatarUrl { get; set; } + + /// + /// Gets or sets the bot username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the mention type. + /// + public DiscordMentionType MentionType { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyDestination.cs b/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyDestination.cs new file mode 100644 index 0000000..295fa83 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyDestination.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Jellyfin.Plugin.Webhook.Destinations.Discord; +using MediaBrowser.Common.Net; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Webhook.Destinations.Gotify +{ + /// + /// Gotify destination. + /// + public class GotifyDestination : IDestination + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public GotifyDestination(ILogger logger, IHttpClient httpClient) + { + _logger = logger; + _httpClient = httpClient; + } + + /// + public Task SendAsync(GotifyOption options, Dictionary message) + { + throw new System.NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyOption.cs b/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyOption.cs new file mode 100644 index 0000000..eb7e1b7 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/Gotify/GotifyOption.cs @@ -0,0 +1,9 @@ +namespace Jellyfin.Plugin.Webhook.Destinations.Gotify +{ + /// + /// Gotify specific options. + /// + public class GotifyOption : BaseOption + { + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Destinations/IDestination.cs b/Jellyfin.Plugin.Webhook/Destinations/IDestination.cs new file mode 100644 index 0000000..03060fc --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Destinations/IDestination.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Webhook.Destinations +{ + /// + /// Destination interface. + /// + /// The type of options. + public interface IDestination + where TDestinationOptions : BaseOption + { + /// + /// Send message to destination. + /// + /// The destination options. + /// The message to send. + /// A representing the asynchronous operation. + Task SendAsync(TDestinationOptions options, Dictionary message); + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Helpers/HandlebarsFunctionHelpers.cs b/Jellyfin.Plugin.Webhook/Helpers/HandlebarsFunctionHelpers.cs new file mode 100644 index 0000000..a9d8789 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Helpers/HandlebarsFunctionHelpers.cs @@ -0,0 +1,75 @@ +using System; +using HandlebarsDotNet; + +namespace Jellyfin.Plugin.Webhook.Helpers +{ + /// + /// Handlebar helpers. + /// + public static class HandlebarsFunctionHelpers + { + private static readonly HandlebarsBlockHelper StringEqualityHelper = (output, options, context, arguments) => + { + if (arguments.Length != 2) + { + throw new HandlebarsException("{{if_equals}} helper must have exactly two arguments"); + } + + var left = arguments[0] as string; + var right = arguments[1] as string; + if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase)) + { + options.Template(output, context); + } + else + { + options.Inverse(output, context); + } + }; + + private static readonly HandlebarsBlockHelper StringExistHelper = (output, options, context, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{if_equals}} helper must have exactly one argument"); + } + + var arg = arguments[0] as string; + if (string.IsNullOrEmpty(arg)) + { + options.Inverse(output, context); + } + else + { + options.Template(output, context); + } + }; + + /// + /// Register handlebars helpers. + /// + public static void RegisterHelpers() + { + Handlebars.RegisterHelper("if_equals", StringEqualityHelper); + Handlebars.RegisterHelper("if_exist", StringExistHelper); + Handlebars.RegisterHelper("link_to", (writer, context, parameters) => + { + writer.WriteSafeString($"{(object)context.text}"); + }); + } + + /// + /// Base 64 decode. + /// + /// + /// The template is stored as base64 in config. + /// + /// The encoded data. + /// The decoded string. + public static string Base64Decode(string base64EncodedData) + { + var base64EncodedBytes = Convert.FromBase64String(base64EncodedData); + return System.Text.Encoding.UTF8.GetString(base64EncodedBytes); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Helpers/PeriodicAsyncHelper.cs b/Jellyfin.Plugin.Webhook/Helpers/PeriodicAsyncHelper.cs new file mode 100644 index 0000000..7517a6a --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Helpers/PeriodicAsyncHelper.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Webhook.Helpers +{ + /// + /// Periodic async helper. + /// + public static class PeriodicAsyncHelper + { + /// + /// Runs an async function periodically. + /// + /// The task factory. + /// The run interval. + /// The cancellation token. + /// A representing the asynchronous operation. + public static async Task PeriodicAsync( + Func taskFactory, + TimeSpan interval, + CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + var delayTask = Task.Delay(interval, cancellationToken); + await taskFactory().ConfigureAwait(false); + await delayTask.ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Jellyfin.Plugin.Webhook.csproj b/Jellyfin.Plugin.Webhook/Jellyfin.Plugin.Webhook.csproj new file mode 100644 index 0000000..3e5caa3 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Jellyfin.Plugin.Webhook.csproj @@ -0,0 +1,35 @@ + + + + netstandard2.1 + 1.0.0.0 + 1.0.0.0 + true + true + CA1707;CA1819 + + + + + + + + + + + + + + + + + + + + + + + ../jellyfin.ruleset + + + diff --git a/Jellyfin.Plugin.Webhook/Models/QueuedItemContainer.cs b/Jellyfin.Plugin.Webhook/Models/QueuedItemContainer.cs new file mode 100644 index 0000000..0752be3 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Models/QueuedItemContainer.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Plugin.Webhook.Models +{ + /// + /// Queued item container. + /// + public class QueuedItemContainer + { + /// + /// Initializes a new instance of the class. + /// + /// The item id. + public QueuedItemContainer(Guid id) + { + ItemId = id; + RetryCount = 0; + } + + /// + /// Gets or sets the current retry count. + /// + public int RetryCount { get; set; } + + /// + /// Gets or sets the current item id. + /// + public Guid ItemId { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Notifiers/LibraryAddedNotifier.cs b/Jellyfin.Plugin.Webhook/Notifiers/LibraryAddedNotifier.cs new file mode 100644 index 0000000..6d64b28 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Notifiers/LibraryAddedNotifier.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Plugin.Webhook.Destinations; +using Jellyfin.Plugin.Webhook.Destinations.Discord; +using Jellyfin.Plugin.Webhook.Destinations.Gotify; +using Jellyfin.Plugin.Webhook.Helpers; +using Jellyfin.Plugin.Webhook.Models; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Notifications; +using Microsoft.Extensions.Logging; +using Constants = Jellyfin.Plugin.Webhook.Configuration.Constants; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; +using Season = MediaBrowser.Controller.Entities.TV.Season; +using Series = MediaBrowser.Controller.Entities.TV.Series; + +namespace Jellyfin.Plugin.Webhook.Notifiers +{ + /// + /// Notifier when a library item is added. + /// + public class LibraryAddedNotifier : INotificationService, IDisposable + { + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IApplicationHost _applicationHost; + + private readonly ConcurrentDictionary _itemProcessQueue; + private readonly CancellationTokenSource _cancellationTokenSource; + + private readonly DiscordDestination _discordDestination; + private readonly GotifyDestination _gotifyDestination; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LibraryAddedNotifier(ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpClient httpClient, IApplicationHost applicationHost) + { + _logger = loggerFactory.CreateLogger(); + _libraryManager = libraryManager ?? throw new ArgumentNullException(nameof(libraryManager)); + _applicationHost = applicationHost; + + _itemProcessQueue = new ConcurrentDictionary(); + _libraryManager.ItemAdded += ItemAddedHandler; + + HandlebarsFunctionHelpers.RegisterHelpers(); + _cancellationTokenSource = new CancellationTokenSource(); + PeriodicAsyncHelper.PeriodicAsync( + async () => + { + try + { + await ProcessItemsAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error"); + } + }, TimeSpan.FromMilliseconds(Constants.RecheckIntervalMs), + _cancellationTokenSource.Token) + .ConfigureAwait(false); + + _discordDestination = new DiscordDestination( + loggerFactory.CreateLogger(), + httpClient); + + _gotifyDestination = new GotifyDestination( + loggerFactory.CreateLogger(), + httpClient); + + /*var c = new PluginConfiguration + { + ServerUrl = "http://localhost:8096", + DiscordOptions = new[] + { + new DiscordOptions + { + AvatarUrl = "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/NSIS/modern-install.png", + EmbedColor = "#aa5cc3", + EnableAlbums = true, + EnableSeasons = true, + EnableEpisodes = true, + EnableMovies = true, + EnableSeries = true, + EnableSongs = true, + Username = "jellybot", + MentionType = DiscordMentionType.Here, + Template = "ewogICJjb250ZW50IjogInt7TWVudGlvblR5cGV9fSIsCiAgImF2YXRhcl91cmwiOiAie3tBdmF0YXJVcmx9fSIsCiAgInVzZXJuYW1lIjogInt7VXNlcm5hbWV9fSIKICAiZW1iZWRzIjogWwoJewoJCSJjb2xvciI6ICJ7e0VtYmVkQ29sb3J9fSIsCgkJImZvb3RlciI6IHsKCQkJInRleHQiOiAiRnJvbSB7e1NlcnZlck5hbWV9fSIsCgkJCSJpY29uVXJsIjogInt7QXZhdGFyVXJsfX0iCgkJfSwKCQkidGltZXN0YW1wIjogInt7VXRjVGltZXN0YW1wfX0iLAogICAgICAgIHt7I2lzX2VxdWFscyBJdGVtVHlwZSAnU2Vhc29uJ319CiAgICAgICAgICAgICJ0aXRsZSI6ICJ7e1Nlcmllc05hbWV9fSB7e05hbWV9fSBoYXMgYmVlbiBhZGRlZCB0byB7e1NlcnZlck5hbWV9fSIKICAgICAgICB7e2Vsc2V9fQogICAgICAgIHt7I2lzX2VxdWFscyBJdGVtVHlwZSAnRXBpc29kZSd9fQogICAgICAgICAgICAidGl0bGUiOiAie3tTZXJpZXNOYW1lfX0gU3t7U2Vhc29uTnVtYmVyfX1Fe3tFcGlzb2RlTnVtYmVyfX0ge3tOYW1lfX0gaGFzIGJlZW4gYWRkZWQgdG8ge3tTZXJ2ZXJOYW1lfX0iCiAgICAgICAge3tlbHNlfX0KICAgICAgICAgICAgInRpdGxlIjogInt7TmFtZX19ICh7e1llYXJ9fSkgaGFzIGJlZW4gYWRkZWQgdG8ge3tTZXJ2ZXJOYW1lfX0iICAgICAgICAKICAgICAgICB7ey9pc19lcXVhbHN9fSAgICAgICAKICAgICAgICB7ey9pc19lcXVhbHN9fQogICAgICAgICJ1cmwiOiAie3tTZXJ2ZXJVcmx9fS93ZWIvaW5kZXguaHRtbC8jIS9kZXRhaWxzP2lkPXt7SXRlbUlkfX0mc2VydmVySWQ9e3tTZXJ2ZXJJZH19IiwKICAgICAgICAidGh1bWJuYWlsIjp7CiAgICAgICAgICAgICJ1cmwiOiAie3tTZXJ2ZXJVcmx9fS9JdGVtcy97e0l0ZW1JZH19L0ltYWdlcy9QcmltYXJ5IgogICAgICAgIH0KCX0KICBdCn0=", + WebhookUri = "https://discordapp.com/api/webhooks/752725480880210000/xJSIE0Pp8E73ojyqXjwPk5EKD3syDabQWBQbmFvqlN9YVnVWPOxcr24oCrJ43Zd415gq" + }, + } + }; + + WebhookPlugin.Instance.Configuration.ServerUrl = c.ServerUrl; + WebhookPlugin.Instance.Configuration.DiscordOptions = c.DiscordOptions; + WebhookPlugin.Instance.SaveConfiguration();*/ + } + + /// + public string Name => WebhookPlugin.Instance.Name; + + /// + public Task SendNotification(UserNotification request, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public bool IsEnabledForUser(User user) => true; + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose. + /// + /// Dispose all assets. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _libraryManager.ItemAdded -= ItemAddedHandler; + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + } + } + + private bool NotifyOnItem(T baseOptions, Type itemType) + where T : BaseOption + { + _logger.LogDebug("NotifyOnItem"); + if (baseOptions.EnableAlbums && itemType == typeof(MusicAlbum)) + { + return true; + } + + if (baseOptions.EnableMovies && itemType == typeof(Movie)) + { + return true; + } + + if (baseOptions.EnableEpisodes && itemType == typeof(Episode)) + { + return true; + } + + if (baseOptions.EnableSeries && itemType == typeof(Series)) + { + return true; + } + + if (baseOptions.EnableSeasons && itemType == typeof(Season)) + { + return true; + } + + if (baseOptions.EnableSongs && itemType == typeof(Audio)) + { + return true; + } + + return false; + } + + private void ItemAddedHandler(object sender, ItemChangeEventArgs itemChangeEventArgs) + { + // Never notify on virtual items. + if (itemChangeEventArgs.Item.IsVirtualItem) + { + return; + } + + _itemProcessQueue.TryAdd(itemChangeEventArgs.Item.Id, new QueuedItemContainer(itemChangeEventArgs.Item.Id)); + _logger.LogDebug("Queued {itemName} for notification.", itemChangeEventArgs.Item.Name); + } + + private async Task ProcessItemsAsync() + { + _logger.LogDebug("ProcessItemsAsync"); + // Attempt to process all items in queue. + var currentItems = _itemProcessQueue.ToArray(); + foreach (var (key, container) in currentItems) + { + var item = _libraryManager.GetItemById(key); + _logger.LogDebug("Item {itemName}", item.Name); + + // Metadata not refreshed yet and under retry limit. + if (item.ProviderIds.Keys.Count == 0 && container.RetryCount < Constants.MaxRetries) + { + _logger.LogDebug("Requeue {itemName}, no provider ids.", item.Name); + container.RetryCount++; + _itemProcessQueue.AddOrUpdate(key, container, (_, __) => container); + continue; + } + + _logger.LogDebug("Notifying for {itemName}", item.Name); + + // Send notification to each configured destination. + var itemData = GetDataObject(item); + var itemType = item.GetType(); + foreach (var option in WebhookPlugin.Instance.Configuration.DiscordOptions) + { + await SendNotification(_discordDestination, option, itemData, itemType).ConfigureAwait(false); + } + + foreach (var option in WebhookPlugin.Instance.Configuration.GotifyOptions) + { + await SendNotification(_gotifyDestination, option, itemData, itemType).ConfigureAwait(false); + } + + // Remove item from queue. + _itemProcessQueue.TryRemove(key, out _); + } + } + + private async Task SendNotification(IDestination destination, T option, Dictionary itemData, Type itemType) + where T : BaseOption + { + if (NotifyOnItem(option, itemType)) + { + try + { + await destination.SendAsync( + option, + itemData) + .ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to send webhook."); + } + } + } + + private Dictionary GetDataObject(BaseItem item) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + data["Timestamp"] = DateTime.Now; + data["UtcTimestamp"] = DateTime.UtcNow; + data["Name"] = item.Name; + data["ItemId"] = item.Id; + data["ServerId"] = _applicationHost.SystemId; + data["ServerUrl"] = WebhookPlugin.Instance.Configuration.ServerUrl; + data["ServerName"] = _applicationHost.Name; + data["ItemType"] = item.GetType().Name; + + if (!item.ProductionYear.HasValue) + { + data["Year"] = item.ProductionYear; + } + + switch (item) + { + case Season _: + if (!string.IsNullOrEmpty(item.Parent?.Name)) + { + data["SeriesName"] = item.Parent.Name; + } + + if (item.Parent?.ProductionYear.HasValue ?? false) + { + data["Year"] = item.Parent.ProductionYear; + } + + if (item.IndexNumber.HasValue) + { + data["SeasonNumber"] = item.IndexNumber; + data["SeasonNumber00"] = item.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); + data["SeasonNumber000"] = item.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + } + + break; + case Episode _: + if (!string.IsNullOrEmpty(item.Parent?.Parent?.Name)) + { + data["SeriesName"] = item.Parent.Parent.Name; + } + + if (item.Parent?.IndexNumber.HasValue ?? false) + { + data["SeasonNumber"] = item.Parent.IndexNumber; + data["SeasonNumber00"] = item.Parent.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); + data["SeasonNumber000"] = item.Parent.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + } + + if (item.IndexNumber.HasValue) + { + data["EpisodeNumber"] = item.IndexNumber; + data["EpisodeNumber00"] = item.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); + data["EpisodeNumber000"] = item.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + } + + if (item.Parent?.Parent?.ProductionYear.HasValue ?? false) + { + data["Year"] = item.Parent.Parent.ProductionYear; + } + + break; + } + + foreach (var (providerKey, providerValue) in item.ProviderIds) + { + data[$"Provider_{providerKey.ToLowerInvariant()}"] = providerValue; + } + + return data; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/Templates/Discord.handlebars b/Jellyfin.Plugin.Webhook/Templates/Discord.handlebars new file mode 100644 index 0000000..212a0ea --- /dev/null +++ b/Jellyfin.Plugin.Webhook/Templates/Discord.handlebars @@ -0,0 +1,53 @@ +{ + "content": "{{MentionType}}", + "avatar_url": "{{AvatarUrl}}", + "username": "{{Username}}", + "embeds": [ + { + "color": "{{EmbedColor}}", + "footer": { + "text": "From {{ServerName}}", + "iconUrl": "{{AvatarUrl}}" + }, + {{if_equals ItemType 'Season'}} + "title": "{{SeriesName}} {{Name}} has been added to {{ServerName}}", + {{else}} + {{if_equals ItemType 'Episode'}} + "title": "{{SeriesName}} S{{SeasonNumber00}}E{{EpisodeNumber00}} {{Name}} has been added to {{ServerName}}", + {{else}} + "title": "{{Name}} ({{Year}}) has been added to {{ServerName}}", + {{/if_equals}} + {{/if_equals}} + "thumbnail":{ + "url": "{{ServerUrl}}/Items/{{ItemId}}/Images/Primary" + }, + "description": "External Links:\n + {{~if_exist Provider_imdb~}} + [IMDb](https://www.imdb.com/title/{{Provider_imdb}}/)\n + {{~/if_exist~}} + {{~if_exist Provider_tmdb~}} + {{~if_equals ItemType 'Movie'~}} + [TMDb](https://www.themoviedb.org/movie/{{Provider_tmdb}})\n + {{~else~}} + [TMDb](https://www.themoviedb.org/tv/{{Provider_tmdb}})\n + {{~/if_equals~}} + {{~/if_exist~}} + {{~if_exist Provider_musicbrainzartist~}} + [MusicBrainz](https://musicbrainz.org/artist/{{Provider_musicbrainzartist}})\n + {{~/if_exist~}} + {{if_exist Provider_audiodbartist}} + [AudioDb](https://theaudiodb.com/artist/{{Provider_audiodbartist}})\n + {{~/if_exist~}} + {{~if_exist Provider_musicbrainztrack~}} + [MusicBrainz Track](https://musicbrainz.org/track/{{Provider_musicbrainztrack}})\n + {{~/if_exist~}} + {{~if_exist Provider_musicbrainzalbum~}} + [MusicBrainz Album](https://musicbrainz.org/release/{{Provider_musicbrainzalbum}})\n + {{~/if_exist~}} + {{if_exist Provider_theaudiodbalbum}} + [TADb Album](https://theaudiodb.com/album/{{Provider_theaudiodbalbum}})\n + {{~/if_exist~}} + [Jellyfin]({{ServerUrl}}/web/index.html#!/details?id={{ItemId}}&serverId={{ServerId}})" + } + ] +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Webhook/WebhookPlugin.cs b/Jellyfin.Plugin.Webhook/WebhookPlugin.cs new file mode 100644 index 0000000..5d28333 --- /dev/null +++ b/Jellyfin.Plugin.Webhook/WebhookPlugin.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Plugin.Webhook.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.Webhook +{ + /// + /// Plugin entrypoint. + /// + public class WebhookPlugin : BasePlugin, IHasWebPages + { + private readonly Guid _id = new Guid("529397D0-A0AA-43DB-9537-7CFDE936C1E3"); + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public WebhookPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + /// Gets current plugin instance. + /// + public static WebhookPlugin Instance { get; private set; } + + /// + public override Guid Id => _id; + + /// + public override string Name => "Webhook"; + + /// + public override string Description => "Sends notifications to various services via webhooks."; + + /// + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.Web.config.html" + }; + + yield return new PluginPageInfo + { + Name = $"{Name}JS", + EmbeddedResourcePath = GetType().Namespace + ".Configuration.Web.config.js" + }; + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..110c2e3 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +

Jellyfin Webhook Plugin

+

Part of the Jellyfin Project

+ +### +Repository Url: +https://repo.codyrobibero.dev/manifest.json + +Use Handlebars templating engine to format notifications however you wish. + +See `Templates` for sample templates. + +Destinations: +- Discord + +TODO +- Gotify +- Pushbullet +- Pushover +- Prowl +- Teli? \ No newline at end of file diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..e87cd54 --- /dev/null +++ b/build.yaml @@ -0,0 +1,15 @@ +--- +name: "Webhook" +guid: "71552A5A-5C5C-4350-A2AE-EBE451A30173" +version: "1.0.0.0" +targetAbi: "10.6.0.0" +owner: "crobibero" +overview: "Sends notifications." +description: > + Sends notifications to destinations via webhooks. +category: "Notifications" +artifacts: +- "Jellyfin.Plugin.Webhook.dll" +- "Handlebars.dll" +changelog: > + Initial release diff --git a/jellyfin.ruleset b/jellyfin.ruleset new file mode 100644 index 0000000..45ab725 --- /dev/null +++ b/jellyfin.ruleset @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +