initial commit

This commit is contained in:
crobibero 2020-09-11 15:02:28 -06:00
commit 9f3a93b25c
25 changed files with 1701 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

330
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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

View File

@ -0,0 +1,18 @@
namespace Jellyfin.Plugin.Webhook.Configuration
{
/// <summary>
/// Constants.
/// </summary>
public static class Constants
{
/// <summary>
/// Recheck interval ms.
/// </summary>
public const int RecheckIntervalMs = 10_000;
/// <summary>
/// Max retries.
/// </summary>
public const int MaxRetries = 10;
}
}

View File

@ -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
{
/// <summary>
/// Webhook plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
DiscordOptions = Array.Empty<DiscordOption>();
GotifyOptions = Array.Empty<GotifyOption>();
}
/// <summary>
/// Gets or sets the jellyfin server url.
/// </summary>
public string ServerUrl { get; set; }
/// <summary>
/// Gets or sets the discord options.
/// </summary>
public DiscordOption[] DiscordOptions { get; set; }
/// <summary>
/// Gets or sets the gotify options.
/// </summary>
public GotifyOption[] GotifyOptions { get; set; }
}
}

View File

@ -0,0 +1,103 @@
<div data-role="page" id="webhookConfigurationPage" class="page type-interior pluginConfigurationPage fullWidthContent"
data-controller="__plugin/WebhookJS">
<div class="content-primary">
<div class="verticalSection">
<div class="sectionTitleContainer">
<h2 class="sectionTitle">Webhook</h2>
<a is="emby-linkbutton" class="raised raised-mini" style="margin-left: 2em;" target="_blank"
href="https://github.com/crobibero/jellyfin-plugin-webhook">
<i class="md-icon button-icon button-icon-left secondaryText"></i>
<span>Help</span>
</a>
</div>
</div>
<form class="webhookConfigurationForm">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtServerUrl" required="required" label="Server Url:"/>
<span>For linking to content. Include base url.</span>
</div>
<button id="btnAddDiscord" is="emby-button" type="button" class="raised button block">
<span>Add Discord Destination</span>
</button>
<br/>
<div id="configurationWrapper">
</div>
<br/>
<button id="saveConfig" is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</form>
</div>
<template id="template-base">
<div class="inputContainer">
<input is="emby-input" type="text" data-name="txtWebhookUri" required="required" label="Webhook Url:"/>
<span>For linking to content. Include base url.</span>
</div>
<div id="notificationTypeGroup" style="padding-left: 35px;">
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableMovies"/>
<span>Movies</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableEpisodes"/>
<span>Episodes</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableSeasons"/>
<span>Season</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableSeries"/>
<span>Series</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableAlbums"/>
<span>Albums</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" data-name="chkEnableSongs"/>
<span>Songs</span>
</label>
</div>
<div class="inputContainer">
<label>Template:</label>
<div>
<textarea data-name="txtTemplate" style="width: 100%; height: 400px"></textarea>
</div>
</div>
</template>
<template id="template-discord">
<div class="inputContainer">
<input is="emby-input" type="text" data-name="txtAvatarUrl" label="Avatar Url:"/>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" data-name="txtUsername" label="Webhook Username:"/>
</div>
<div class="selectContainer">
<select is="emby-select" data-name="ddlMentionType" label="Mention Type:">
<option value="Everyone">@everyone</option>
<option value="Here">@here</option>
<option value="None">None</option>
</select>
</div>
<div class="inputContainer">
<label>Embed Color</label>
<input type="color" data-name="EmbedColor" is="emby-input" style="height: 2.2em;"/>
<input type="text" is="emby-input" data-name="txtEmbedColor"/>
</div>
</template>
<style>
.checkboxContainer {
max-width: fit-content;
}
#embedColor:hover {
cursor: pointer;
}
</style>
</div>

View File

@ -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;
}
});

View File

@ -0,0 +1,63 @@
using System;
using HandlebarsDotNet;
using Jellyfin.Plugin.Webhook.Helpers;
namespace Jellyfin.Plugin.Webhook.Destinations
{
/// <summary>
/// Base options for destination.
/// </summary>
public abstract class BaseOption
{
private Func<object, string> _compiledTemplate;
/// <summary>
/// Gets or sets the webhook uri.
/// </summary>
public string WebhookUri { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on movies.
/// </summary>
public bool EnableMovies { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on episodes.
/// </summary>
public bool EnableEpisodes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on series.
/// </summary>
public bool EnableSeries { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on seasons.
/// </summary>
public bool EnableSeasons { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on albums.
/// </summary>
public bool EnableAlbums { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to notify on songs.
/// </summary>
public bool EnableSongs { get; set; }
/// <summary>
/// Gets or sets the handlebars template.
/// </summary>
public string Template { get; set; }
/// <summary>
/// Gets the compiled handlebars template.
/// </summary>
/// <returns>The compiled handlebars template.</returns>
public Func<object, string> GetCompiledTemplate()
{
return _compiledTemplate ??= Handlebars.Compile(HandlebarsFunctionHelpers.Base64Decode(Template));
}
}
}

View File

@ -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
{
/// <inheritdoc />
public class DiscordDestination : IDestination<DiscordOption>
{
private readonly ILogger<DiscordDestination> _logger;
private readonly IHttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="DiscordDestination"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{DiscordDestination}"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
public DiscordDestination(ILogger<DiscordDestination> logger, IHttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <inheritdoc />
public async Task SendAsync(DiscordOption options, Dictionary<string, object> 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
};
}
}
}

View File

@ -0,0 +1,23 @@
namespace Jellyfin.Plugin.Webhook.Destinations.Discord
{
/// <summary>
/// Discord mention type.
/// </summary>
public enum DiscordMentionType
{
/// <summary>
/// Mention @everyone.
/// </summary>
Everyone = 2,
/// <summary>
/// Mention @here.
/// </summary>
Here = 1,
/// <summary>
/// Mention none.
/// </summary>
None = 0
}
}

View File

@ -0,0 +1,28 @@
namespace Jellyfin.Plugin.Webhook.Destinations.Discord
{
/// <summary>
/// Discord specific options.
/// </summary>
public class DiscordOption : BaseOption
{
/// <summary>
/// Gets or sets the embed color.
/// </summary>
public string EmbedColor { get; set; }
/// <summary>
/// Gets or sets the avatar url.
/// </summary>
public string AvatarUrl { get; set; }
/// <summary>
/// Gets or sets the bot username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets the mention type.
/// </summary>
public DiscordMentionType MentionType { get; set; }
}
}

View File

@ -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
{
/// <summary>
/// Gotify destination.
/// </summary>
public class GotifyDestination : IDestination<GotifyOption>
{
private readonly ILogger<GotifyDestination> _logger;
private readonly IHttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="GotifyDestination"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{GotifyDestination}"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
public GotifyDestination(ILogger<GotifyDestination> logger, IHttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <inheritdoc />
public Task SendAsync(GotifyOption options, Dictionary<string, object> message)
{
throw new System.NotImplementedException();
}
}
}

View File

@ -0,0 +1,9 @@
namespace Jellyfin.Plugin.Webhook.Destinations.Gotify
{
/// <summary>
/// Gotify specific options.
/// </summary>
public class GotifyOption : BaseOption
{
}
}

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Webhook.Destinations
{
/// <summary>
/// Destination interface.
/// </summary>
/// <typeparam name="TDestinationOptions">The type of options.</typeparam>
public interface IDestination<in TDestinationOptions>
where TDestinationOptions : BaseOption
{
/// <summary>
/// Send message to destination.
/// </summary>
/// <param name="options">The destination options.</param>
/// <param name="message">The message to send.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SendAsync(TDestinationOptions options, Dictionary<string, object> message);
}
}

View File

@ -0,0 +1,75 @@
using System;
using HandlebarsDotNet;
namespace Jellyfin.Plugin.Webhook.Helpers
{
/// <summary>
/// Handlebar helpers.
/// </summary>
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);
}
};
/// <summary>
/// Register handlebars helpers.
/// </summary>
public static void RegisterHelpers()
{
Handlebars.RegisterHelper("if_equals", StringEqualityHelper);
Handlebars.RegisterHelper("if_exist", StringExistHelper);
Handlebars.RegisterHelper("link_to", (writer, context, parameters) =>
{
writer.WriteSafeString($"<a href='{(object)context.url}'>{(object)context.text}</a>");
});
}
/// <summary>
/// Base 64 decode.
/// </summary>
/// <remarks>
/// The template is stored as base64 in config.
/// </remarks>
/// <param name="base64EncodedData">The encoded data.</param>
/// <returns>The decoded string.</returns>
public static string Base64Decode(string base64EncodedData)
{
var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Webhook.Helpers
{
/// <summary>
/// Periodic async helper.
/// </summary>
public static class PeriodicAsyncHelper
{
/// <summary>
/// Runs an async function periodically.
/// </summary>
/// <param name="taskFactory">The task factory.</param>
/// <param name="interval">The run interval.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static async Task PeriodicAsync(
Func<Task> taskFactory,
TimeSpan interval,
CancellationToken cancellationToken = default)
{
while (!cancellationToken.IsCancellationRequested)
{
var delayTask = Task.Delay(interval, cancellationToken);
await taskFactory().ConfigureAwait(false);
await delayTask.ConfigureAwait(false);
}
}
}
}

View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CA1707;CA1819</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Handlebars.Net" Version="1.10.1" />
<PackageReference Include="Jellyfin.Controller" Version="10.6.0" />
</ItemGroup>
<!-- Code Analyzers-->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\Web\config.html" />
<EmbeddedResource Include="Configuration\Web\config.html" />
<None Remove="Configuration\Web\config.js" />
<EmbeddedResource Include="Configuration\Web\config.js" />
</ItemGroup>
<PropertyGroup>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,30 @@
using System;
namespace Jellyfin.Plugin.Webhook.Models
{
/// <summary>
/// Queued item container.
/// </summary>
public class QueuedItemContainer
{
/// <summary>
/// Initializes a new instance of the <see cref="QueuedItemContainer"/> class.
/// </summary>
/// <param name="id">The item id.</param>
public QueuedItemContainer(Guid id)
{
ItemId = id;
RetryCount = 0;
}
/// <summary>
/// Gets or sets the current retry count.
/// </summary>
public int RetryCount { get; set; }
/// <summary>
/// Gets or sets the current item id.
/// </summary>
public Guid ItemId { get; set; }
}
}

View File

@ -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
{
/// <summary>
/// Notifier when a library item is added.
/// </summary>
public class LibraryAddedNotifier : INotificationService, IDisposable
{
private readonly ILogger<LibraryAddedNotifier> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IApplicationHost _applicationHost;
private readonly ConcurrentDictionary<Guid, QueuedItemContainer> _itemProcessQueue;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly DiscordDestination _discordDestination;
private readonly GotifyDestination _gotifyDestination;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryAddedNotifier"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
public LibraryAddedNotifier(ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpClient httpClient, IApplicationHost applicationHost)
{
_logger = loggerFactory.CreateLogger<LibraryAddedNotifier>();
_libraryManager = libraryManager ?? throw new ArgumentNullException(nameof(libraryManager));
_applicationHost = applicationHost;
_itemProcessQueue = new ConcurrentDictionary<Guid, QueuedItemContainer>();
_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<DiscordDestination>(),
httpClient);
_gotifyDestination = new GotifyDestination(
loggerFactory.CreateLogger<GotifyDestination>(),
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();*/
}
/// <inheritdoc />
public string Name => WebhookPlugin.Instance.Name;
/// <inheritdoc />
public Task SendNotification(UserNotification request, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public bool IsEnabledForUser(User user) => true;
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose.
/// </summary>
/// <param name="disposing">Dispose all assets.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_libraryManager.ItemAdded -= ItemAddedHandler;
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
}
}
private bool NotifyOnItem<T>(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<T>(IDestination<T> destination, T option, Dictionary<string, object> 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<string, object> GetDataObject(BaseItem item)
{
var data = new Dictionary<string, object>(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;
}
}
}

View File

@ -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}})"
}
]
}

View File

@ -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
{
/// <summary>
/// Plugin entrypoint.
/// </summary>
public class WebhookPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly Guid _id = new Guid("529397D0-A0AA-43DB-9537-7CFDE936C1E3");
/// <summary>
/// Initializes a new instance of the <see cref="WebhookPlugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public WebhookPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets current plugin instance.
/// </summary>
public static WebhookPlugin Instance { get; private set; }
/// <inheritdoc />
public override Guid Id => _id;
/// <inheritdoc />
public override string Name => "Webhook";
/// <inheritdoc />
public override string Description => "Sends notifications to various services via webhooks.";
/// <inheritdoc />
public IEnumerable<PluginPageInfo> 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"
};
}
}
}

0
LICENSE Normal file
View File

20
README.md Normal file
View File

@ -0,0 +1,20 @@
<h1 align="center">Jellyfin Webhook Plugin</h1>
<h3 align="center">Part of the <a href="https://jellyfin.org/">Jellyfin Project</a></h3>
###
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?

15
build.yaml Normal file
View File

@ -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

68
jellyfin.ruleset Normal file
View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="Rules for Jellyfin.Server" Description="Code analysis rules for Jellyfin.Server.csproj" ToolsVersion="14.0">
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
<!-- disable warning SA1202: 'public' members must come before 'private' members -->
<Rule Id="SA1202" Action="Info" />
<!-- disable warning SA1204: Static members must appear before non-static members -->
<Rule Id="SA1204" Action="Info" />
<!-- disable warning SA1404: Code analysis suppression should have justification -->
<Rule Id="SA1404" Action="Info" />
<!-- disable warning SA1009: Closing parenthesis should be followed by a space. -->
<Rule Id="SA1009" Action="None" />
<!-- disable warning SA1101: Prefix local calls with 'this.' -->
<Rule Id="SA1101" Action="None" />
<!-- disable warning SA1108: Block statements should not contain embedded comments -->
<Rule Id="SA1108" Action="None" />
<!-- disable warning SA1128:: Put constructor initializers on their own line -->
<Rule Id="SA1128" Action="None" />
<!-- disable warning SA1130: Use lambda syntax -->
<Rule Id="SA1130" Action="None" />
<!-- disable warning SA1200: 'using' directive must appear within a namespace declaration -->
<Rule Id="SA1200" Action="None" />
<!-- disable warning SA1309: Fields must not begin with an underscore -->
<Rule Id="SA1309" Action="None" />
<!-- disable warning SA1413: Use trailing comma in multi-line initializers -->
<Rule Id="SA1413" Action="None" />
<!-- disable warning SA1512: Single-line comments must not be followed by blank line -->
<Rule Id="SA1512" Action="None" />
<!-- disable warning SA1515: Single-line comment should be preceded by blank line -->
<Rule Id="SA1515" Action="None" />
<!-- disable warning SA1600: Elements should be documented -->
<Rule Id="SA1600" Action="None" />
<!-- disable warning SA1633: The file header is missing or not located at the top of the file -->
<Rule Id="SA1633" Action="None" />
</Rules>
<Rules AnalyzerId="Microsoft.CodeAnalysis.FxCopAnalyzers" RuleNamespace="Microsoft.Design">
<!-- disable warning CA1031: Do not catch general exception types -->
<Rule Id="CA1031" Action="Info" />
<!-- disable warning CA1032: Implement standard exception constructors -->
<Rule Id="CA1032" Action="Info" />
<!-- disable warning CA1062: Validate arguments of public methods -->
<Rule Id="CA1062" Action="Info" />
<!-- disable warning CA1716: Identifiers should not match keywords -->
<Rule Id="CA1716" Action="Info" />
<!-- disable warning CA1720: Identifiers should not contain type names -->
<Rule Id="CA1720" Action="Info" />
<!-- disable warning CA1812: internal class that is apparently never instantiated.
If so, remove the code from the assembly.
If this class is intended to contain only static members, make it static -->
<Rule Id="CA1812" Action="Info" />
<!-- disable warning CA1822: Member does not access instance data and can be marked as static -->
<Rule Id="CA1822" Action="Info" />
<!-- disable warning CA2000: Dispose objects before losing scope -->
<Rule Id="CA2000" Action="Info" />
<!-- disable warning CA1054: Change the type of parameter url from string to System.Uri -->
<Rule Id="CA1054" Action="None" />
<!-- disable warning CA1055: URI return values should not be strings -->
<Rule Id="CA1055" Action="None" />
<!-- disable warning CA1056: URI properties should not be strings -->
<Rule Id="CA1056" Action="None" />
<!-- disable warning CA1303: Do not pass literals as localized parameters -->
<Rule Id="CA1303" Action="None" />
<!-- disable warning CA1308: Normalize strings to uppercase -->
<Rule Id="CA1308" Action="None" />
</Rules>
</RuleSet>