├── .github └── workflows │ ├── lint_pr.yml │ └── release.yml ├── .gitignore ├── Jellyfin.Plugin.Streamyfin.Tests ├── DatabaseTests.cs ├── Jellyfin.Plugin.Streamyfin.Tests.csproj ├── LocalizationTests.cs ├── NotificationTests.cs └── SerializationTests.cs ├── Jellyfin.Plugin.Streamyfin.sln ├── Jellyfin.Plugin.Streamyfin ├── Api │ └── StreamyfinController.cs ├── Configuration │ ├── Config.cs │ ├── Notifications │ │ └── Notifications.cs │ ├── Other.cs │ ├── PluginConfiguration.cs │ └── Settings │ │ ├── Enums.cs │ │ └── Settings.cs ├── Extensions │ ├── StringExtensions.cs │ └── UserManagerExtensions.cs ├── Jellyfin.Plugin.Streamyfin.csproj ├── LocalizationHelper.cs ├── Pages │ ├── Application │ │ ├── index.html │ │ └── index.js │ ├── Libraries │ │ ├── editor.worker.js │ │ ├── js-yaml.min.js │ │ ├── json-editor.min.js │ │ ├── json.worker.js │ │ ├── monaco-editor.bundle.js │ │ └── yaml.worker.js │ ├── Notifications │ │ ├── index.html │ │ └── index.js │ ├── Other │ │ ├── index.html │ │ └── index.js │ ├── YamlEditor │ │ ├── index.html │ │ └── index.js │ └── shared.js ├── Plugin.cs ├── PluginServiceRegistrator.cs ├── PushNotifications │ ├── Enums │ │ ├── InterruptionLevel.cs │ │ └── Status.cs │ ├── Events │ │ ├── BaseEvent.cs │ │ ├── ItemAdded │ │ │ ├── EpisodeTimer.cs │ │ │ └── ItemAddedService.cs │ │ ├── PlaybackStartEvent.cs │ │ ├── SessionStartEvent.cs │ │ └── UserLockedOutEvent.cs │ ├── MediaNotificationHelper.cs │ ├── Models │ │ ├── ExpoNotificationRequest.cs │ │ ├── ExpoNotificationResponse.cs │ │ └── Notification.cs │ └── NotificationHelper.cs ├── Resources │ └── Strings.resx ├── SerializationHelper.cs ├── Storage │ ├── Database.cs │ ├── Enums │ │ └── TempStoreMode.cs │ ├── Extensions.cs │ └── Models │ │ └── DeviceToken.cs └── StreamyfinManager.cs ├── Makefile ├── NOTIFICATIONS.md ├── README.md ├── assets ├── home.jpg ├── jellyseerr.png └── notifications.png ├── examples └── full.yml ├── jellyfin.ruleset ├── local.sh ├── logo.png ├── manifest.json ├── packages ├── NJsonSchema.Annotations.dll ├── NJsonSchema.dll ├── Namotion.Reflection.dll ├── Newtonsoft.Json.Schema.dll └── YamlDotNet.dll └── scripts ├── update-version.js └── validate-and-update-manifest.js /.github/workflows/lint_pr.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | 11 | permissions: 12 | pull-requests: write 13 | 14 | jobs: 15 | main: 16 | name: Validate PR title 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: amannn/action-semantic-pull-request@v5 20 | id: lint_pr_title 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | - uses: marocchino/sticky-pull-request-comment@v2 24 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 25 | with: 26 | header: pr-title-lint-error 27 | message: | 28 | Hey there and thank you for opening this pull request! 👋🏼 29 | 30 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 31 | 32 | Details: 33 | 34 | ``` 35 | ${{ steps.lint_pr_title.outputs.error_message }} 36 | ``` 37 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 38 | uses: marocchino/sticky-pull-request-comment@v2 39 | with: 40 | header: pr-title-lint-error 41 | delete: true -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Create release" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | pull-requests: write 8 | contents: write 9 | 10 | jobs: 11 | main: 12 | name: Create release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | # env: 19 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | - name: Setup .NET 21 | uses: actions/setup-dotnet@v4 22 | with: 23 | dotnet-version: 8.0.x 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: "lts/*" 29 | 30 | - name: build and release application 31 | run: | 32 | git config --global user.name 'lostb1t' 33 | git config --global user.email 'coding-mosses0z@icloud.com' 34 | make release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | .idea/ 5 | artifacts 6 | Release 7 | *.zip 8 | node_modules 9 | dist/ 10 | env.local 11 | github 12 | Jellyfin.Plugin.Streamyfin.sln.DotSettings.user -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.Tests/DatabaseTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Reflection; 5 | using Jellyfin.Plugin.Streamyfin.Storage; 6 | using Jellyfin.Plugin.Streamyfin.Storage.Models; 7 | using Xunit; 8 | using Xunit.Abstractions; 9 | using Xunit.Sdk; 10 | using Assert = ICU4N.Impl.Assert; 11 | 12 | namespace Jellyfin.Plugin.Streamyfin.Tests; 13 | 14 | /// 15 | /// Run before and after every db test 16 | /// 17 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 18 | public class CleanupDatabaseBeforeAndAfter: BeforeAfterTestAttribute 19 | { 20 | private readonly Database db = new(Directory.GetCurrentDirectory()); 21 | 22 | public override void Before(MethodInfo methodUnderTest) 23 | { 24 | db.Purge(); 25 | } 26 | 27 | public override void After(MethodInfo methodUnderTest) 28 | { 29 | db.Purge(); 30 | } 31 | } 32 | 33 | /// 34 | /// Ensure [Jellyfin.Plugin.Streamyfin.Storage.Database] can properly run transactions as expected 35 | /// 36 | [CleanupDatabaseBeforeAndAfter] 37 | public class DatabaseTests(ITestOutputHelper output): IDisposable 38 | { 39 | private readonly Database db = new(Directory.GetCurrentDirectory()); 40 | 41 | /// 42 | /// Ensure when adding a device token for a specific device that we delete any previous old token first 43 | /// 44 | [Fact] 45 | public void TestAddingDeviceTokenForTheSameDevice() 46 | { 47 | var deviceId = Guid.NewGuid(); 48 | var userId = Guid.NewGuid(); 49 | 50 | var token = db.AddDeviceToken( 51 | new DeviceToken 52 | { 53 | DeviceId = deviceId, 54 | Token = "testToken", 55 | UserId = userId 56 | } 57 | ); 58 | 59 | // Adding a "new" token should update the timestamp 60 | var updatedToken = db.AddDeviceToken(token); 61 | var newTokenReference = db.GetDeviceTokenForDeviceId(token.DeviceId); 62 | 63 | Assert.Assrt( 64 | $"Timestamp was updated", 65 | updatedToken.Timestamp == newTokenReference.Timestamp 66 | ); 67 | Assert.Assrt( 68 | $"DeviceId fetched correctly", 69 | deviceId == newTokenReference.DeviceId 70 | ); 71 | Assert.Assrt( 72 | $"UserId fetched correctly", 73 | userId == newTokenReference.UserId 74 | ); 75 | } 76 | 77 | /// 78 | /// Make sure we are actually recording each token 79 | /// 80 | [Fact] 81 | public void TestAllTokensPersistSeparately() 82 | { 83 | for (int i = 0; i < 5; i++) 84 | { 85 | db.AddDeviceToken( 86 | new DeviceToken 87 | { 88 | DeviceId = Guid.NewGuid(), 89 | Token = $"token{i.ToString(CultureInfo.InvariantCulture)}", 90 | UserId = Guid.NewGuid() 91 | } 92 | ); 93 | } 94 | 95 | var tokens = db.TotalDevicesCount(); 96 | 97 | Assert.Assrt( 98 | $"All tokens persisted", 99 | tokens == 5 100 | ); 101 | } 102 | 103 | public void Dispose() 104 | { 105 | output.WriteLine($"Deleting database {db.DbFilePath}"); 106 | File.Delete(db.DbFilePath); 107 | } 108 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.Tests/Jellyfin.Plugin.Streamyfin.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net8.0 4 | enable 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.Tests/LocalizationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.Tests; 5 | 6 | 7 | /// 8 | /// Ensure resource file is accessed correctly 9 | /// 10 | public class LocalizationTests 11 | { 12 | private LocalizationHelper _helper = new(); 13 | 14 | /// 15 | /// Test to make sure fallback is english resource 16 | /// 17 | [Fact] 18 | public void TestFallbackResource() 19 | { 20 | Assert.Equal( 21 | expected: "Playback started", 22 | actual: _helper.GetString("PlaybackStartTitle", CultureInfo.CreateSpecificCulture("ab-AX")) 23 | ); 24 | } 25 | 26 | /// 27 | /// Strings that don't exist should return the key we used 28 | /// 29 | [Fact] 30 | public void TestKeyThatDoesNotExist() 31 | { 32 | Assert.Equal( 33 | expected: "ThisStringDoesNotExist", 34 | actual: _helper.GetString("ThisStringDoesNotExist") 35 | ); 36 | } 37 | 38 | /// 39 | /// Test string formats 40 | /// 41 | [Fact] 42 | public void TestStringFormatLocalization() 43 | { 44 | Assert.Equal( 45 | expected: "Test watching", 46 | actual: _helper.GetFormatted("UserWatching", args: "Test") 47 | ); 48 | } 49 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.Tests/NotificationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Jellyfin.Plugin.Streamyfin.PushNotifications; 3 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Jellyfin.Plugin.Streamyfin.Tests; 8 | 9 | /// 10 | /// Ensure special types are properly serialized/deserialized when converting between Object - Json - Yaml 11 | /// 12 | public class NotificationTests(ITestOutputHelper output) 13 | { 14 | private static readonly SerializationHelper _serializationHelper = new(); 15 | private readonly NotificationHelper _notificationHelper = new(null, null, _serializationHelper); 16 | 17 | // Replace with your own android emulator / ios simulator token 18 | // Do not use a real devices token. If you do, you can invalidate the token by re-installing streamyfin on your device. 19 | private const string VirtualToken = "..."; 20 | 21 | /// 22 | /// Assert we can send a single notification and receive a proper ExpoNotificationResponse 23 | /// 24 | [Fact] 25 | public void SingleExpoPushNotificationTest() 26 | { 27 | var request = new ExpoNotificationRequest 28 | { 29 | To = new List { VirtualToken }, 30 | Title = "Expo Push Test", 31 | Subtitle = "iOS subtitle", 32 | Body = "All platforms should see this body", 33 | }; 34 | 35 | var task = _notificationHelper.Send(request); 36 | task.Wait(); 37 | 38 | Assert.NotNull(task.Result); 39 | output.WriteLine(_serializationHelper.ToJson(task.Result)); 40 | } 41 | 42 | /// 43 | /// Assert we can send a batch of notifications and receive a proper ExpoNotificationResponse 44 | /// 45 | [Fact] 46 | public void BatchExpoPushNotificationTest() 47 | { 48 | var notifications = new List(); 49 | 50 | for (var i = 0; i < 5; i++) 51 | { 52 | notifications.Add( 53 | new ExpoNotificationRequest 54 | { 55 | To = new List { VirtualToken }, 56 | Title = $"Expo Push Test {i}", 57 | Subtitle = $"iOS subtitle {i}", 58 | Body = "All platforms should see this body", 59 | } 60 | ); 61 | } 62 | 63 | var task = _notificationHelper.Send(notifications.ToArray()); 64 | task.Wait(); 65 | 66 | Assert.NotNull(task.Result); 67 | Assert.Equal( 68 | expected: 5, 69 | actual: task.Result.Data.Count 70 | ); 71 | 72 | output.WriteLine(_serializationHelper.ToJson(task.Result)); 73 | } 74 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.Tests/SerializationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Data.Enums; 4 | using Jellyfin.Plugin.Streamyfin.Configuration; 5 | using Jellyfin.Plugin.Streamyfin.Configuration.Settings; 6 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 7 | using MediaBrowser.Controller.Entities.Movies; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | using Assert = ICU4N.Impl.Assert; 11 | using Settings = Jellyfin.Plugin.Streamyfin.Configuration.Settings.Settings; 12 | 13 | namespace Jellyfin.Plugin.Streamyfin.Tests; 14 | 15 | /// 16 | /// Ensure special types are properly serialized/deserialized when converting between Object - Json - Yaml 17 | /// 18 | public class SerializationTests(ITestOutputHelper output) 19 | { 20 | private readonly SerializationHelper _serializationHelper = new(); 21 | 22 | /// 23 | /// Ensure Json Schema forces enum names as values imported from external namespaces 24 | /// 25 | [Fact] 26 | public void EnumJsonSchemaTest() 27 | { 28 | var schema = SerializationHelper.GetJsonSchema(); 29 | output.WriteLine(schema); 30 | 31 | Assert.Assrt( 32 | msg: "SubtitlePlaybackMode enumNames are string values", 33 | val: schema.Contains( 34 | """ 35 | "SubtitlePlaybackMode": { 36 | "type": "string", 37 | "description": "An enum representing a subtitle playback mode.", 38 | "x-enumNames": [ 39 | "Default", 40 | "Always", 41 | "OnlyForced", 42 | "None", 43 | "Smart" 44 | ], 45 | "enum": [ 46 | "Default", 47 | "Always", 48 | "OnlyForced", 49 | "None", 50 | "Smart" 51 | ] 52 | } 53 | """ 54 | , StringComparison.Ordinal) 55 | ); 56 | // TODO: Not required, more of a nit... 57 | // Spend time figuring out why converter is not ensuring this enum stays int for schema 58 | // Assert.Assrt( 59 | // msg: "RemuxConcurrentLimit enum values are still integers", 60 | // val: schema.ToJson().Contains( 61 | // """ 62 | // "RemuxConcurrentLimit": { 63 | // "type": "integer", 64 | // "description": "", 65 | // "x-enumNames": [ 66 | // "One", 67 | // "Two", 68 | // "Three", 69 | // "Four" 70 | // ], 71 | // "enum": [ 72 | // 1, 73 | // 2, 74 | // 3, 75 | // 4 76 | // ] 77 | // } 78 | // """ 79 | // , StringComparison.Ordinal) 80 | // ); 81 | } 82 | 83 | /// 84 | /// Ensures all types of enums are deserialized correctly 85 | /// 86 | [Fact] 87 | public void EnumConfigJsonDeserializationTest() 88 | { 89 | DeserializeConfig( 90 | """ 91 | { 92 | "settings": { 93 | "subtitleMode": { 94 | "locked": true, 95 | "value": "Default" 96 | }, 97 | "defaultVideoOrientation": { 98 | "locked": true, 99 | "value": "LandscapeLeft" 100 | }, 101 | "downloadMethod": { 102 | "locked": true, 103 | "value": "remux" 104 | }, 105 | "remuxConcurrentLimit": { 106 | "locked": true, 107 | "value": 2 108 | } 109 | } 110 | } 111 | """ 112 | ); 113 | } 114 | 115 | /// 116 | /// Ensures all types of enums are deserialized correctly 117 | /// 118 | [Fact] 119 | public void EnumConfigYamlDeserializationTest() 120 | { 121 | DeserializeConfig( 122 | """ 123 | settings: 124 | subtitleMode: 125 | locked: true 126 | value: Default 127 | defaultVideoOrientation: 128 | locked: true 129 | value: LandscapeLeft 130 | downloadMethod: 131 | locked: true 132 | value: remux 133 | remuxConcurrentLimit: 134 | locked: true 135 | value: Two 136 | """ 137 | ); 138 | } 139 | 140 | /// 141 | /// Ensures all types of enums are json serialized correctly 142 | /// 143 | [Fact] 144 | public void ConfigJsonSerializationTest() 145 | { 146 | SerializeConfig( 147 | value: _serializationHelper.SerializeToJson(GetTestConfig()), 148 | expected: 149 | """ 150 | { 151 | "settings": { 152 | "subtitleMode": { 153 | "locked": false, 154 | "value": 0 155 | }, 156 | "defaultVideoOrientation": { 157 | "locked": false, 158 | "value": 6 159 | }, 160 | "downloadMethod": { 161 | "locked": false, 162 | "value": "remux" 163 | }, 164 | "remuxConcurrentLimit": { 165 | "locked": false, 166 | "value": 2 167 | } 168 | } 169 | } 170 | """ 171 | ); 172 | } 173 | 174 | /// 175 | /// Ensures all types of enums are yaml serialized correctly 176 | /// 177 | [Fact] 178 | public void ConfigYamlSerializationTest() 179 | { 180 | SerializeConfig( 181 | value: _serializationHelper.SerializeToYaml(GetTestConfig()), 182 | expected: 183 | """ 184 | settings: 185 | subtitleMode: 186 | locked: false 187 | value: Default 188 | defaultVideoOrientation: 189 | locked: false 190 | value: LandscapeLeft 191 | downloadMethod: 192 | locked: false 193 | value: remux 194 | remuxConcurrentLimit: 195 | locked: false 196 | value: Two 197 | """ 198 | ); 199 | } 200 | 201 | /// 202 | /// Ensures array of notifications are deserialized correctly 203 | /// 204 | [Fact] 205 | public void DeserializeNotification() 206 | { 207 | var notification = _serializationHelper.Deserialize>( 208 | """ 209 | [ 210 | { 211 | "title": "Test Title", 212 | "body": "Test Body", 213 | "userId": "2c585c0706ac46779a2c38ca896b556f" 214 | } 215 | ] 216 | """ 217 | )[0]; 218 | 219 | Assert.Assrt( 220 | msg: "title deserialized", 221 | notification.Title == "Test Title" 222 | ); 223 | 224 | Assert.Assrt( 225 | msg: "body deserialized", 226 | notification.Body == "Test Body" 227 | ); 228 | 229 | Assert.Assrt( 230 | msg: "guid deserialized", 231 | notification.UserId?.ToString("N") == "2c585c0706ac46779a2c38ca896b556f" 232 | ); 233 | } 234 | 235 | private static Config GetTestConfig() 236 | { 237 | return new Config 238 | { 239 | settings = new Settings 240 | { 241 | downloadMethod = new Lockable 242 | { 243 | value = DownloadMethod.remux 244 | }, 245 | subtitleMode = new Lockable 246 | { 247 | value = SubtitlePlaybackMode.Default 248 | }, 249 | defaultVideoOrientation = new Lockable 250 | { 251 | value = OrientationLock.LandscapeLeft 252 | }, 253 | remuxConcurrentLimit = new Lockable 254 | { 255 | value = RemuxConcurrentLimit.Two 256 | } 257 | } 258 | }; 259 | } 260 | 261 | private void SerializeConfig(string value, string expected) 262 | { 263 | output.WriteLine($"Serialized:\n {value}"); 264 | output.WriteLine($"Expected:\n {expected}"); 265 | Assert.Assrt("Config serialized matches expected", value.Trim() == expected.Trim()); 266 | } 267 | 268 | private void DeserializeConfig(string value) 269 | { 270 | output.WriteLine($"Deserializing config from:\n {value}"); 271 | Config config = _serializationHelper.Deserialize(value); 272 | 273 | Assert.Assrt( 274 | $"RemuxConcurrentLimit matches: {SubtitlePlaybackMode.Default} == {config.settings?.subtitleMode?.value}", 275 | SubtitlePlaybackMode.Default == config.settings?.subtitleMode?.value 276 | ); 277 | Assert.Assrt( 278 | $"OrientationLock matches: {OrientationLock.LandscapeLeft} == {config.settings?.defaultVideoOrientation?.value}", 279 | OrientationLock.LandscapeLeft == config.settings?.defaultVideoOrientation?.value 280 | ); 281 | Assert.Assrt( 282 | $"DownloadMethod matches: {DownloadMethod.remux} == {config.settings?.downloadMethod?.value}", 283 | DownloadMethod.remux == config.settings?.downloadMethod?.value 284 | ); 285 | Assert.Assrt( 286 | $"RemuxConcurrentLimit matches: {RemuxConcurrentLimit.One} == {config.settings?.remuxConcurrentLimit?.value}", 287 | RemuxConcurrentLimit.Two == config.settings?.remuxConcurrentLimit?.value 288 | ); 289 | } 290 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Streamyfin", "Jellyfin.Plugin.Streamyfin\Jellyfin.Plugin.Streamyfin.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" 3 | EndProject 4 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Streamyfin.Tests", "Jellyfin.Plugin.Streamyfin.Tests\Jellyfin.Plugin.Streamyfin.Tests.csproj", "{C80D8B83-11A0-48C1-82C4-B23A8DA0FB78}" 5 | EndProject 6 | Global 7 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 8 | Debug|Any CPU = Debug|Any CPU 9 | Release|Any CPU = Release|Any CPU 10 | EndGlobalSection 11 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 12 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 13 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU 14 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU 15 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU 16 | {C80D8B83-11A0-48C1-82C4-B23A8DA0FB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {C80D8B83-11A0-48C1-82C4-B23A8DA0FB78}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {C80D8B83-11A0-48C1-82C4-B23A8DA0FB78}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {C80D8B83-11A0-48C1-82C4-B23A8DA0FB78}.Release|Any CPU.Build.0 = Release|Any CPU 20 | EndGlobalSection 21 | EndGlobal 22 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Api/StreamyfinController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Linq; 5 | using Jellyfin.Plugin.Streamyfin.Configuration; 6 | using Jellyfin.Plugin.Streamyfin.Extensions; 7 | using Jellyfin.Plugin.Streamyfin.PushNotifications; 8 | using Jellyfin.Plugin.Streamyfin.Storage.Models; 9 | using MediaBrowser.Common.Api; 10 | using MediaBrowser.Controller.Configuration; 11 | using MediaBrowser.Controller.Dto; 12 | using MediaBrowser.Controller.Library; 13 | using Microsoft.AspNetCore.Authorization; 14 | using Microsoft.AspNetCore.Http; 15 | using Microsoft.AspNetCore.Mvc; 16 | using Microsoft.Extensions.Logging; 17 | 18 | namespace Jellyfin.Plugin.Streamyfin.Api; 19 | 20 | public class JsonStringResult : ContentResult 21 | { 22 | public JsonStringResult(string json) 23 | { 24 | Content = json; 25 | ContentType = "application/json"; 26 | } 27 | } 28 | 29 | public class ConfigYamlRes 30 | { 31 | public string Value { get; set; } = default!; 32 | } 33 | 34 | public class ConfigSaveResponse 35 | { 36 | public bool Error { get; set; } 37 | public string Message { get; set; } = default!; 38 | } 39 | 40 | //public class ConfigYamlReq { 41 | // public string? Value { get; set; } 42 | //} 43 | 44 | /// 45 | /// CollectionImportController. 46 | /// 47 | [ApiController] 48 | [Route("streamyfin")] 49 | public class StreamyfinController : ControllerBase 50 | { 51 | private readonly ILogger _logger; 52 | private readonly ILoggerFactory _loggerFactory; 53 | private readonly IServerConfigurationManager _config; 54 | private readonly IUserManager _userManager; 55 | private readonly ILibraryManager _libraryManager; 56 | private readonly IDtoService _dtoService; 57 | private readonly SerializationHelper _serializationHelperService; 58 | private readonly NotificationHelper _notificationHelper; 59 | 60 | public StreamyfinController( 61 | ILoggerFactory loggerFactory, 62 | IDtoService dtoService, 63 | IServerConfigurationManager config, 64 | IUserManager userManager, 65 | ILibraryManager libraryManager, 66 | SerializationHelper serializationHelper, 67 | NotificationHelper notificationHelper 68 | ) 69 | { 70 | _loggerFactory = loggerFactory; 71 | _logger = loggerFactory.CreateLogger(); 72 | _dtoService = dtoService; 73 | _config = config; 74 | _userManager = userManager; 75 | _libraryManager = libraryManager; 76 | _serializationHelperService = serializationHelper; 77 | _notificationHelper = notificationHelper; 78 | 79 | _logger.LogInformation("StreamyfinController Loaded"); 80 | } 81 | 82 | [HttpPost("config/yaml")] 83 | [Authorize(Policy = Policies.RequiresElevation)] 84 | [ProducesResponseType(StatusCodes.Status200OK)] 85 | public ActionResult saveConfig( 86 | [FromBody, Required] ConfigYamlRes config 87 | ) 88 | { 89 | Config p; 90 | try 91 | { 92 | p = _serializationHelperService.Deserialize(config.Value); 93 | } 94 | catch (Exception e) 95 | { 96 | 97 | return new ConfigSaveResponse { Error = true, Message = e.ToString() }; 98 | } 99 | 100 | var c = StreamyfinPlugin.Instance!.Configuration; 101 | c.Config = p; 102 | StreamyfinPlugin.Instance!.UpdateConfiguration(c); 103 | 104 | return new ConfigSaveResponse { Error = false }; 105 | } 106 | 107 | [HttpGet("config")] 108 | [Authorize] 109 | [ProducesResponseType(StatusCodes.Status200OK)] 110 | public ActionResult getConfig() 111 | { 112 | var config = StreamyfinPlugin.Instance!.Configuration.Config; 113 | return new JsonStringResult(_serializationHelperService.SerializeToJson(config)); 114 | } 115 | 116 | [HttpGet("config/schema")] 117 | [ProducesResponseType(StatusCodes.Status200OK)] 118 | public ActionResult getConfigSchema( 119 | ) 120 | { 121 | return new JsonStringResult(SerializationHelper.GetJsonSchema()); 122 | } 123 | 124 | [HttpGet("config/yaml")] 125 | [Authorize] 126 | [ProducesResponseType(StatusCodes.Status200OK)] 127 | public ActionResult getConfigYaml() 128 | { 129 | return new ConfigYamlRes 130 | { 131 | Value = _serializationHelperService.SerializeToYaml(StreamyfinPlugin.Instance!.Configuration.Config) 132 | }; 133 | } 134 | 135 | [HttpGet("config/default")] 136 | [Authorize] 137 | [ProducesResponseType(StatusCodes.Status200OK)] 138 | public ActionResult getDefaultConfig() 139 | { 140 | return new ConfigYamlRes 141 | { 142 | Value = _serializationHelperService.SerializeToYaml(PluginConfiguration.DefaultConfig()) 143 | }; 144 | } 145 | 146 | /// 147 | /// Post expo push tokens for a specific user & device 148 | /// 149 | /// 150 | [HttpPost("device")] 151 | [Authorize] 152 | [ProducesResponseType(StatusCodes.Status200OK)] 153 | public ActionResult PostDeviceToken([FromBody, Required] DeviceToken deviceToken) 154 | { 155 | _logger.LogInformation("Posting device token for deviceId: {0}", deviceToken.DeviceId); 156 | return new JsonResult( 157 | _serializationHelperService.ToJson(StreamyfinPlugin.Instance!.Database.AddDeviceToken(deviceToken)) 158 | ); 159 | } 160 | 161 | /// 162 | /// Delete expo push tokens for a specific device 163 | /// 164 | /// 165 | [HttpDelete("device/{deviceId}")] 166 | [Authorize] 167 | [ProducesResponseType(StatusCodes.Status200OK)] 168 | public ActionResult DeleteDeviceToken([FromRoute, Required] Guid? deviceId) 169 | { 170 | if (deviceId == null) return BadRequest("Device id is required"); 171 | 172 | _logger.LogInformation("Deleting device token for deviceId: {0}", deviceId); 173 | StreamyfinPlugin.Instance!.Database.RemoveDeviceToken((Guid) deviceId); 174 | 175 | return new OkResult(); 176 | } 177 | 178 | /// 179 | /// Forward notifications to expos push service using persisted device tokens 180 | /// 181 | /// 182 | /// 183 | [HttpPost("notification")] 184 | [Authorize] 185 | [ProducesResponseType(StatusCodes.Status200OK)] 186 | [ProducesResponseType(StatusCodes.Status202Accepted)] 187 | public ActionResult PostNotifications([FromBody, Required] List notifications) 188 | { 189 | var db = StreamyfinPlugin.Instance?.Database; 190 | 191 | if (db?.TotalDevicesCount() == 0) 192 | { 193 | _logger.LogInformation("There are currently no devices setup to receive push notifications"); 194 | return new AcceptedResult(); 195 | } 196 | 197 | List? allTokens = null; 198 | var validNotifications = notifications 199 | .FindAll(n => 200 | { 201 | var title = n.Title ?? ""; 202 | var body = n.Body ?? ""; 203 | 204 | // Title and body are both valid 205 | if (!title.IsNullOrNonWord() && !body.IsNullOrNonWord()) 206 | { 207 | return true; 208 | } 209 | 210 | // Title can be empty, body is required. 211 | return string.IsNullOrEmpty(title) && !body.IsNullOrNonWord(); 212 | // every other scenario is invalid 213 | }) 214 | .Select(notification => 215 | { 216 | List tokens = []; 217 | var expoNotification = notification.ToExpoNotification(); 218 | 219 | // Get tokens for target user 220 | if (notification.UserId != null || !string.IsNullOrWhiteSpace(notification.Username)) 221 | { 222 | Guid? userId = null; 223 | 224 | if (notification.UserId != null) 225 | { 226 | userId = notification.UserId; 227 | } 228 | else if (notification.Username != null) 229 | { 230 | userId = _userManager.Users.ToList().Find(u => u.Username == notification.Username)?.Id; 231 | } 232 | if (userId != null) 233 | { 234 | _logger.LogInformation("Getting device tokens associated to userId: {0}", userId); 235 | tokens.AddRange( 236 | db?.GetUserDeviceTokens((Guid) userId) 237 | ?? [] 238 | ); 239 | } 240 | } 241 | // Get all available tokens 242 | else if (!notification.IsAdmin) 243 | { 244 | _logger.LogInformation("No user target provided. Getting all device tokens..."); 245 | allTokens ??= db?.GetAllDeviceTokens() ?? []; 246 | tokens.AddRange(allTokens); 247 | _logger.LogInformation("All known device tokens count: {0}", allTokens.Count); 248 | } 249 | 250 | // Get all available tokens for admins 251 | if (notification.IsAdmin) 252 | { 253 | _logger.LogInformation("Notification being posted for admins"); 254 | tokens.AddRange(_userManager.GetAdminDeviceTokens()); 255 | } 256 | 257 | expoNotification.To = tokens.Select(t => t.Token).Distinct().ToList(); 258 | 259 | return expoNotification; 260 | }) 261 | .Where(n => n.To.Count > 0) 262 | .ToArray(); 263 | 264 | _logger.LogInformation("Received {0} valid notifications", validNotifications.Length); 265 | 266 | if (validNotifications.Length == 0) 267 | { 268 | return new AcceptedResult(); 269 | } 270 | 271 | _logger.LogInformation("Posting notifications..."); 272 | var task = _notificationHelper.Send(validNotifications); 273 | task.Wait(); 274 | return new JsonResult(_serializationHelperService.ToJson(task.Result)); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/Config.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using NJsonSchema.Annotations; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.Configuration; 5 | 6 | public class Config 7 | { 8 | [NotNull] 9 | public Notifications.Notifications? notifications { get; set; } 10 | 11 | [NotNull] 12 | public Settings.Settings? settings { get; set; } 13 | 14 | [NotNull] 15 | [JsonPropertyName(name: "other")] 16 | public Other? Other { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/Notifications/Notifications.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Jellyfin.Plugin.Streamyfin.Configuration.Notifications; 6 | 7 | 8 | /// 9 | /// Configuration for a notification 10 | /// 11 | public class NotificationConfiguration 12 | { 13 | [Display(Name = "Enabled", Description = "if true, the notifications for this event are enabled.")] 14 | [JsonPropertyName(name: "enabled")] 15 | public bool Enabled { get; set; } 16 | 17 | [Display(Name = "Recent event threshold", Description = "How long we want to wait until allowing a duplicate event from being processed in seconds")] 18 | [JsonPropertyName(name: "recentEventThreshold")] 19 | public double? RecentEventThreshold { get; set; } 20 | } 21 | 22 | public class UserNotificationConfig : NotificationConfiguration 23 | { 24 | [Display(Name = "Jellyfin User Ids", Description = "List of jellyfin user ids that this notification is for.")] 25 | [JsonPropertyName(name: "userIds")] 26 | public string[] UserIds { get; set; } 27 | 28 | [Display(Name = "Jellyfin Usernames", Description = "List of jellyfin usernames that this notification is for.")] 29 | [JsonPropertyName(name: "usernames")] 30 | public string[] Usernames { get; set; } 31 | 32 | [Display(Name = "Forward to admins", Description = "if true, the notification will be forwarded to admins alongside any defined users.")] 33 | [JsonPropertyName(name: "forwardToAdmins")] 34 | public bool ForwardToAdmins { get; set; } 35 | } 36 | 37 | public class Notifications 38 | { 39 | [NotNull] 40 | [Display(Name = "Session Started", Description = "Admins get notified when a jellyfin user is online.")] 41 | [JsonPropertyName(name: "sessionStarted")] 42 | public NotificationConfiguration? SessionStarted { get; set; } 43 | 44 | [NotNull] 45 | [Display(Name = "Playback Started", Description = "Admins get notified when a jellyfin user is starts playback.")] 46 | [JsonPropertyName(name: "playbackStarted")] 47 | public NotificationConfiguration? PlaybackStarted { get; set; } 48 | 49 | [NotNull] 50 | [Display(Name = "User locked out", Description = "Admins and locked out user get notified jellyfin locks their account")] 51 | [JsonPropertyName(name: "userLockedOut")] 52 | public NotificationConfiguration? UserLockedOut { get; set; } 53 | 54 | [NotNull] 55 | [Display(Name = "Item added", Description = "Get notified when jellyfin adds new Movies or Episodes")] 56 | [JsonPropertyName(name: "itemAdded")] 57 | public NotificationConfiguration? ItemAdded { get; set; } 58 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/Other.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Jellyfin.Plugin.Streamyfin.Configuration; 6 | 7 | public class Other 8 | { 9 | [NotNull] 10 | [Display(Name = "Home page", Description = "The plugin page you want to always load first.")] 11 | [JsonPropertyName(name: "homePage")] 12 | public string? HomePage { get; set; } 13 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA2227 2 | #pragma warning disable CS0219 3 | 4 | using System.Collections.Generic; 5 | using Jellyfin.Data.Entities.Libraries; 6 | using Jellyfin.Data.Enums; 7 | using Jellyfin.Plugin.Streamyfin.Configuration.Settings; 8 | using MediaBrowser.Model.Plugins; 9 | using MediaBrowser.Model.Querying; 10 | 11 | namespace Jellyfin.Plugin.Streamyfin.Configuration; 12 | 13 | 14 | /// 15 | /// Plugin configuration. 16 | /// 17 | public class PluginConfiguration : BasePluginConfiguration 18 | { 19 | //public string Yaml { get; set; } 20 | public Config Config { get; set; } 21 | private readonly SerializationHelper _serializationHelper; 22 | 23 | public PluginConfiguration( 24 | SerializationHelper serializationHelper 25 | ) 26 | { 27 | _serializationHelper = serializationHelper; 28 | } 29 | 30 | 31 | public PluginConfiguration() 32 | { 33 | Config = DefaultConfig(); 34 | } 35 | 36 | public static Config DefaultConfig() => new() 37 | { 38 | notifications = DefaultNotifications(), 39 | settings = DefaultSettings() 40 | }; 41 | 42 | public static Notifications.Notifications DefaultNotifications() => new() 43 | { 44 | SessionStarted = new() 45 | { 46 | Enabled = true 47 | }, 48 | PlaybackStarted = new() 49 | { 50 | Enabled = true 51 | }, 52 | UserLockedOut = new () 53 | { 54 | Enabled = true 55 | }, 56 | ItemAdded = new() 57 | { 58 | Enabled = true 59 | } 60 | }; 61 | 62 | public static Settings.Settings DefaultSettings() => new() 63 | { 64 | forwardSkipTime = new() { value = 30 }, 65 | rewindSkipTime = new() { value = 15 }, 66 | rememberAudioSelections = new() { value = false }, 67 | subtitleMode = new() { value = SubtitlePlaybackMode.Default }, 68 | rememberSubtitleSelections = new() { value = false }, 69 | subtitleSize = new() { value = 80 }, 70 | autoRotate = new() { value = true }, 71 | defaultVideoOrientation = new() { value = OrientationLock.Default }, 72 | safeAreaInControlsEnabled = new() { value = true }, 73 | showCustomMenuLinks = new() { value = false }, 74 | hiddenLibraries = new() { value = new[] { "Enter library id(s)" } }, 75 | disableHapticFeedback = new() { value = false }, 76 | downloadMethod = new() { value = DownloadMethod.remux }, 77 | remuxConcurrentLimit = new() { value = RemuxConcurrentLimit.One }, 78 | autoDownload = new() { value = false }, 79 | optimizedVersionsServerUrl = new() { value = "Enter optimized server url" }, 80 | jellyseerrServerUrl = new() { value = "Enter jellyseerr server url" }, 81 | searchEngine = new() { value = SearchEngine.Jellyfin }, 82 | marlinServerUrl = new() { value = "Enter marlin server url" }, 83 | libraryOptions = new() { value = new LibraryOptions() }, 84 | home = new() 85 | { 86 | value = new Home 87 | { 88 | sections = new Section[] { 89 | new() { 90 | title = "Continue Watching", 91 | orientation = SectionOrientation.vertical, 92 | items = new() 93 | { 94 | filters = [ItemFilter.IsResumable], 95 | includeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie], 96 | limit = 25, 97 | } 98 | }, 99 | new() { 100 | title = "Nextup", 101 | orientation = SectionOrientation.horizontal, 102 | nextUp = new() 103 | { 104 | limit = 25, 105 | } 106 | }, 107 | new() { 108 | title = "Recently Added", 109 | orientation = SectionOrientation.vertical, 110 | items = new() 111 | { 112 | sortBy = [ItemSortBy.DateCreated], 113 | sortOrder = [SortOrder.Descending], 114 | includeItemTypes = [BaseItemKind.Series, BaseItemKind.Movie], 115 | limit = 25, 116 | } 117 | }, 118 | new() { 119 | title = "Latest", 120 | orientation = SectionOrientation.horizontal, 121 | latest = new() 122 | { 123 | limit = 25, 124 | } 125 | }, 126 | new() { 127 | title = "Favorites", 128 | orientation = SectionOrientation.vertical, 129 | items = new() 130 | { 131 | sortBy = [ItemSortBy.Default], 132 | sortOrder = [SortOrder.Ascending], 133 | filters = [ItemFilter.IsFavorite, ItemFilter.IsUnplayed], 134 | includeItemTypes = [BaseItemKind.Series, BaseItemKind.Movie], 135 | limit = 25, 136 | } 137 | }, 138 | } 139 | } 140 | }, 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/Settings/Enums.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA1008 2 | 3 | using Newtonsoft.Json.Converters; 4 | using Newtonsoft.Json; 5 | 6 | namespace Jellyfin.Plugin.Streamyfin.Configuration; 7 | 8 | 9 | [JsonConverter(typeof(StringEnumConverter))] 10 | public enum DeviceProfile 11 | { 12 | Expo, 13 | Native, 14 | Old 15 | }; 16 | 17 | [JsonConverter(typeof(StringEnumConverter))] 18 | public enum SearchEngine 19 | { 20 | Marlin, 21 | Jellyfin 22 | }; 23 | 24 | [JsonConverter(typeof(StringEnumConverter))] 25 | public enum DownloadMethod 26 | { 27 | optimized, 28 | remux 29 | }; 30 | 31 | [JsonConverter(typeof(StringEnumConverter))] 32 | public enum OrientationLock { 33 | /** 34 | * The default orientation. On iOS, this will allow all orientations except `Orientation.PORTRAIT_DOWN`. 35 | * On Android, this lets the system decide the best orientation. 36 | */ 37 | Default = 0, 38 | /** 39 | * Right-side up portrait only. 40 | */ 41 | PortraitUp = 3, 42 | /** 43 | * Left landscape only. 44 | */ 45 | LandscapeLeft = 6, 46 | /** 47 | * Right landscape only. 48 | */ 49 | LandscapeRight = 7, 50 | } 51 | 52 | [JsonConverter(typeof(StringEnumConverter))] 53 | public enum DisplayType 54 | { 55 | row, 56 | list 57 | }; 58 | 59 | [JsonConverter(typeof(StringEnumConverter))] 60 | public enum CardStyle 61 | { 62 | compact, 63 | detailed 64 | }; 65 | 66 | [JsonConverter(typeof(StringEnumConverter))] 67 | public enum ImageStyle 68 | { 69 | poster, 70 | cover 71 | }; 72 | 73 | [JsonConverter(typeof(StringEnumConverter))] 74 | public enum DownloadQuality 75 | { 76 | Original, 77 | Low, 78 | High 79 | } 80 | 81 | // Limit Int range. Don't use Converter for this since we want them to enter int value 82 | public enum RemuxConcurrentLimit 83 | { 84 | One = 1, 85 | Two = 2, 86 | Three = 3, 87 | Four = 4, 88 | } 89 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Configuration/Settings/Settings.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using Jellyfin.Data.Enums; 4 | using MediaBrowser.Model.Querying; 5 | using NJsonSchema.Annotations; 6 | using System.Xml.Serialization; 7 | using System.Collections.ObjectModel; 8 | 9 | namespace Jellyfin.Plugin.Streamyfin.Configuration.Settings; 10 | 11 | public class DownloadOption 12 | { 13 | public required string label { get; set; } 14 | public required DownloadQuality value { get; set; } 15 | }; 16 | 17 | public class LibraryOptions 18 | { 19 | public DisplayType display { get; set; } = DisplayType.list; 20 | public CardStyle cardStyle { get; set; } = CardStyle.detailed; 21 | public ImageStyle imageStyle { get; set; } = ImageStyle.cover; 22 | public bool showTitles { get; set; } = true; 23 | public bool showStats { get; set; } = true; 24 | }; 25 | 26 | /// 27 | /// Assign a lock to given type value 28 | /// 29 | /// 30 | public class Lockable 31 | { 32 | public bool locked { get; set; } = false; 33 | public required T value { get; set; } 34 | } 35 | 36 | 37 | public class Home 38 | { 39 | [NotNull] 40 | [Display(Name = "Sections")] 41 | // public SerializableDictionary? sections { get; set; } 42 | public Section[]? sections { get; set; } 43 | } 44 | 45 | public class Section 46 | { 47 | [NotNull] 48 | public string title { get; set; } 49 | 50 | [NotNull] 51 | [Display(Name = "Media poster orientation")] 52 | public SectionOrientation? orientation { get; set; } 53 | 54 | [NotNull] 55 | [Display(Name = "Items", Description = "Customize the Items API query")] 56 | public Items? items { get; set; } 57 | 58 | [NotNull] 59 | [Display(Name = "Next up", Description = "Customize the Tv Shows Next Up API query")] 60 | public NextUp? nextUp { get; set; } 61 | 62 | [NotNull] 63 | [Display(Name = "Latest", Description = "Customize the Latest API query")] 64 | public Latest? latest { get; set; } 65 | } 66 | 67 | public enum SectionOrientation 68 | { 69 | vertical, 70 | horizontal 71 | } 72 | 73 | public enum SectionType 74 | { 75 | row, 76 | carousel, 77 | } 78 | 79 | public class Items 80 | { 81 | [Display(Name = "Sort by")] 82 | public ItemSortBy[]? sortBy { get; set; } 83 | 84 | [Display(Name = "Sort order")] 85 | public SortOrder[]? sortOrder { get; set; } 86 | 87 | [Display(Name = "Genres")] 88 | public Collection? genres { get; set; } 89 | 90 | [Display(Name = "Parent id")] 91 | public string? parentId { get; set; } 92 | 93 | [Display(Name = "Filters")] 94 | public ItemFilter[]? filters { get; set; } 95 | 96 | [Display(Name = "Include item types")] 97 | public BaseItemKind[]? includeItemTypes { get; set; } 98 | 99 | [Display(Name = "Page limit")] 100 | public int? limit { get; set; } 101 | } 102 | 103 | public class NextUp 104 | { 105 | [Display(Name = "Parent id")] 106 | public string? parentId { get; set; } 107 | 108 | [Display(Name = "Page limit")] 109 | public int? limit { get; set; } 110 | 111 | [Display(Name = "Enable resumable")] 112 | public bool? enableResumable { get; set; } 113 | 114 | [Display(Name = "Enable rewatching")] 115 | public bool? enableRewatching { get; set; } 116 | } 117 | 118 | public class Latest 119 | { 120 | [Display(Name = "Parent id")] 121 | public string? parentId { get; set; } 122 | 123 | [Display(Name = "Page limit")] 124 | public int? limit { get; set; } 125 | 126 | [Display(Name = "Group items")] 127 | public bool? groupItems { get; set; } 128 | 129 | [Display(Name = "Is played")] 130 | public bool? isPlayed { get; set; } 131 | 132 | [Display(Name = "Include item types")] 133 | public BaseItemKind[]? includeItemTypes { get; set; } 134 | 135 | } 136 | 137 | public class SectionSuggestions 138 | { 139 | public SuggestionsArgs? args { get; set; } 140 | } 141 | 142 | public class SuggestionsArgs 143 | { 144 | public BaseItemKind[]? type { get; set; } 145 | } 146 | 147 | /// 148 | /// Streamyfin application settings 149 | /// 150 | public class Settings 151 | { 152 | [NotNull] 153 | [Display(Name = "Home view", Description = "Customize the appearance of the apps home page")] 154 | public Lockable? home { get; set; } 155 | 156 | // Media Controls 157 | [NotNull] 158 | [Display(Name = "Forward skip time", Description = "The amount of time in seconds you want to be able to skip forward during playback")] 159 | public Lockable? forwardSkipTime { get; set; } // = 30; 160 | 161 | [NotNull] 162 | [Display(Name = "Rewind skip time", Description = "The amount of time in seconds you want to be able to rewind during playback")] 163 | public Lockable? rewindSkipTime { get; set; } // = 10; 164 | 165 | // Audio 166 | [NotNull] 167 | [Display(Name = "Remember audio selection", Description = "Allows you to set the audio language from the previous played item")] 168 | public Lockable? rememberAudioSelections { get; set; } // = true; 169 | // TODO create type converter for CultureDto 170 | // Currently fails since it doesnt have a parameterless constructor 171 | // public Lockable? defaultAudioLanguage { get; set; } 172 | 173 | // Subtitles 174 | // public Lockable? defaultSubtitleLanguage { get; set; } 175 | [NotNull] 176 | [Display(Name = "Subtitle playback mode", Description = "Setting to determine when subtitles will automatically play during video playback")] 177 | public Lockable? subtitleMode { get; set; } 178 | 179 | [NotNull] 180 | [Display(Name = "Remember subtitle selection", Description = "Allows you to set the subtitle language from the previous played item")] 181 | public Lockable? rememberSubtitleSelections { get; set; } // = true; 182 | 183 | [NotNull] 184 | [Display(Name = "Subtitle scale size", Description = "Adjust the subtitle size during video playback")] 185 | public Lockable? subtitleSize { get; set; } // = 80; 186 | 187 | // Other 188 | [NotNull] 189 | [Display(Name = "Auto rotate", Description = "Grant ability to auto rotate during video playback")] 190 | public Lockable? autoRotate { get; set; } // true 191 | 192 | [NotNull] 193 | [Display(Name = "Default video orientation", Description = "Lock orientation during video playback")] 194 | public Lockable? defaultVideoOrientation { get; set; } 195 | 196 | [NotNull] 197 | [Display(Name = "Safe Area in video controls", Description = "Enable or disable the safe area for video controls")] 198 | public Lockable? safeAreaInControlsEnabled { get; set; } // = true; 199 | 200 | [NotNull] 201 | [Display(Name = "Show custom menu links", Description = "Show custom menu links in jellyfins web configuration")] 202 | public Lockable? showCustomMenuLinks { get; set; } // = false; 203 | 204 | [NotNull] 205 | [Display(Name = "Hidden libraries", Description = "Enter all library Ids you want hidden from users")] 206 | public Lockable? hiddenLibraries { get; set; } // = []; 207 | 208 | [NotNull] 209 | [Display(Name = "Disable haptic feedback")] 210 | public Lockable? disableHapticFeedback { get; set; } // = false; 211 | 212 | // Downloads 213 | [NotNull] 214 | [Display(Name = "Offline download method", Description = "Enter the method you want your users to use when download media for offline usage")] 215 | public Lockable? downloadMethod { get; set; } 216 | 217 | [NotNull] 218 | [Display(Name = "Remux concurrent limit", Description = "Restrict the amount of downloads a device can do simultaneously")] 219 | public Lockable? remuxConcurrentLimit { get; set; } 220 | 221 | [NotNull] 222 | [Display(Name = "Optimized auto download", Description = "Grant the ability to auto download in the background when using the optimized server.")] 223 | public Lockable? autoDownload { get; set; } // = false; 224 | 225 | [NotNull] 226 | [Display(Name = "Optimized server url", Description = "Enter the url for your optimized server.")] 227 | public Lockable? optimizedVersionsServerUrl { get; set; } 228 | 229 | // region Plugins 230 | // Jellyseerr 231 | [NotNull] 232 | [Display(Name = "Jellyseerr Server URL", Description = "Enter the url for your jellyseerr server. **Jellyfin authentication is required**")] 233 | public Lockable? jellyseerrServerUrl { get; set; } 234 | 235 | // Marlin Search 236 | [NotNull] 237 | [Display(Name = "Default search engine", Description = "Enter the search engine you want to use in streamyfin")] 238 | public Lockable? searchEngine { get; set; } // = SearchEngine.Jellyfin; 239 | 240 | [NotNull] 241 | [Display(Name = "Marlin server URL", Description = "Enter url for your marlin server")] 242 | public Lockable? marlinServerUrl { get; set; } 243 | 244 | // endregion Plugins 245 | 246 | // Misc. 247 | [NotNull] 248 | [Display(Name = "Library options", Description = "Customize how you want streamfins library tab to look")] 249 | public Lockable? libraryOptions { get; set; } 250 | 251 | // TODO: These are used outside of settings. Review usages/delete any unused later. 252 | // public Lockable? forceLandscapeInVideoPlayer { get; set; } 253 | // public Lockable? deviceProfile { get; set; } // = DeviceProfile.Expo; 254 | // public Lockable? deviceProfile { get; set; } // = []; 255 | // public Lockable? openInVLC { get; set; } 256 | // public Lockable? downloadQuality { get; set; } 257 | // public Lockable? playDefaultAudioTrack { get; set; } // = true; 258 | // public Lockable? showHomeTitles { get; set; } // = true; 259 | } 260 | 261 | [XmlRoot("dictionary")] 262 | public class SerializableDictionary 263 | : Dictionary, IXmlSerializable 264 | { 265 | #region IXmlSerializable Members 266 | public System.Xml.Schema.XmlSchema GetSchema() 267 | { 268 | return null; 269 | } 270 | 271 | public void ReadXml(System.Xml.XmlReader reader) 272 | { 273 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 274 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 275 | 276 | bool wasEmpty = reader.IsEmptyElement; 277 | reader.Read(); 278 | 279 | if (wasEmpty) 280 | return; 281 | 282 | while (reader.NodeType != System.Xml.XmlNodeType.EndElement) 283 | { 284 | reader.ReadStartElement("item"); 285 | 286 | reader.ReadStartElement("key"); 287 | TKey key = (TKey)keySerializer.Deserialize(reader); 288 | reader.ReadEndElement(); 289 | 290 | reader.ReadStartElement("value"); 291 | TValue value = (TValue)valueSerializer.Deserialize(reader); 292 | reader.ReadEndElement(); 293 | 294 | this.Add(key, value); 295 | 296 | reader.ReadEndElement(); 297 | reader.MoveToContent(); 298 | } 299 | reader.ReadEndElement(); 300 | } 301 | 302 | public void WriteXml(System.Xml.XmlWriter writer) 303 | { 304 | XmlSerializer keySerializer = new XmlSerializer(typeof(TKey)); 305 | XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue)); 306 | 307 | foreach (TKey key in this.Keys) 308 | { 309 | writer.WriteStartElement("item"); 310 | 311 | writer.WriteStartElement("key"); 312 | keySerializer.Serialize(writer, key); 313 | writer.WriteEndElement(); 314 | 315 | writer.WriteStartElement("value"); 316 | TValue value = this[key]; 317 | valueSerializer.Serialize(writer, value); 318 | writer.WriteEndElement(); 319 | 320 | writer.WriteEndElement(); 321 | } 322 | } 323 | #endregion 324 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.Extensions; 5 | 6 | public static class StringExtensions 7 | { 8 | public static string Escape(this string? input) => 9 | input?.Replace("\"", "\\\"", StringComparison.Ordinal) ?? string.Empty; 10 | 11 | public static bool IsNullOrNonWord(this string? value) => 12 | string.IsNullOrWhiteSpace(value) || Regex.Count(value, "\\w+") == 0; 13 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Extensions/UserManagerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Data.Enums; 5 | using Jellyfin.Plugin.Streamyfin.Storage.Models; 6 | using MediaBrowser.Controller.Library; 7 | 8 | namespace Jellyfin.Plugin.Streamyfin.Extensions; 9 | 10 | public static class UserManagerExtensions 11 | { 12 | public static List GetAdminDeviceTokens(this IUserManager? manager) => ( 13 | manager?.Users 14 | .Where(u => u.HasPermission(PermissionKind.IsAdministrator)) 15 | .SelectMany(u => 16 | StreamyfinPlugin.Instance?.Database.GetUserDeviceTokens(u.Id) ?? Enumerable.Empty()) 17 | ?? Array.Empty() 18 | ).ToList(); 19 | 20 | public static List GetAdminTokens(this IUserManager? manager) => 21 | manager?.GetAdminDeviceTokens().Select(deviceToken => deviceToken.Token).ToList() ?? []; 22 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Jellyfin.Plugin.Streamyfin.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | Jellyfin.Plugin.Streamyfin 5 | 0.52.0.0 6 | 0.52.0.0 7 | true 8 | false 9 | enable 10 | AllEnabledByDefault 11 | ../jellyfin.ruleset 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/LocalizationHelper.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA1869 2 | 3 | using System.Globalization; 4 | using System.Resources; 5 | 6 | 7 | namespace Jellyfin.Plugin.Streamyfin; 8 | 9 | /// 10 | /// Serialization settings for json and yaml 11 | /// 12 | public class LocalizationHelper 13 | { 14 | private ResourceManager _resourceManager; 15 | 16 | public LocalizationHelper() 17 | { 18 | _resourceManager = new ResourceManager( 19 | baseName: "Jellyfin.Plugin.Streamyfin.Resources.Strings", 20 | assembly: typeof(LocalizationHelper).Assembly 21 | ); 22 | } 23 | 24 | /// 25 | /// Get string resource or fallback to key to avoid nullable strings 26 | /// 27 | /// 28 | /// 29 | /// 30 | public string GetString(string key, CultureInfo? cultureInfo = null) => 31 | _resourceManager.GetString(key, cultureInfo) ?? key; 32 | 33 | /// 34 | /// Get a string resource that requires string formatting 35 | /// 36 | /// 37 | /// 38 | /// 39 | /// 40 | public string GetFormatted(string key, CultureInfo? cultureInfo = null, params object[] args) { 41 | var resource = _resourceManager.GetString(key, cultureInfo); 42 | return resource == null ? key : string.Format(cultureInfo, resource, args); 43 | } 44 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/Application/index.js: -------------------------------------------------------------------------------- 1 | const subtitlePlaybackValue = () => document.getElementById('subtitle-playback-value'); 2 | const defaultOrientationValue = () => document.getElementById('default-orientation-value'); 3 | const downloadMethodValue = () => document.getElementById('download-method-value'); 4 | const remuxConcurrentLimitValue = () => document.getElementById('remux-concurrent-limit-value'); 5 | const searchEngineValue = () => document.getElementById('search-engine-value'); 6 | 7 | const saveBtn = () => document.getElementById('save-settings-btn'); 8 | 9 | // region helpers 10 | const getValues = () => ({ 11 | settings: Array.from(document.querySelectorAll('[data-key-name][data-prop-name]')).reduce((acc, el) => { 12 | if (el.offsetParent === null) return acc; 13 | 14 | const setting = el.getAttribute('data-key-name'); 15 | const property = el.getAttribute('data-prop-name'); 16 | 17 | const value = window.Streamyfin.shared.getElValue(el); 18 | acc[setting] = acc[setting] ?? {} 19 | 20 | if (value != null && !(property === 'locked' && acc[setting]['value'] === undefined)) { 21 | acc[setting][property] = value; 22 | } 23 | else delete acc[setting] 24 | 25 | return acc 26 | }, {}) 27 | }) 28 | 29 | const createOption = (value) => new Option(value, value) 30 | const setOptions = (schema) => { 31 | if (!schema) return; 32 | 33 | const {SubtitlePlaybackMode, OrientationLock, DownloadMethod, RemuxConcurrentLimit, SearchEngine} = schema.definitions; 34 | 35 | subtitlePlaybackValue().options.length = 0; 36 | SubtitlePlaybackMode.enum.forEach(value => subtitlePlaybackValue().add(createOption(value))); 37 | 38 | defaultOrientationValue().options.length = 0; 39 | OrientationLock.enum.forEach(value => defaultOrientationValue().add(createOption(value))); 40 | 41 | downloadMethodValue().options.length = 0; 42 | DownloadMethod.enum.forEach(value => downloadMethodValue().add(createOption(value))); 43 | 44 | remuxConcurrentLimitValue().options.length = 0; 45 | RemuxConcurrentLimit.enum.forEach(value => remuxConcurrentLimitValue().add(createOption(value))); 46 | 47 | searchEngineValue().options.length = 0; 48 | SearchEngine.enum.forEach(value => searchEngineValue().add(createOption(value))); 49 | } 50 | 51 | const updateSettingConfig = (name, config, valueName, value) => ({ 52 | ...(config ?? {}), 53 | settings: { 54 | ...(config?.settings ?? {}), 55 | [name]: { 56 | ...(config?.settings?.[name] ?? {}), 57 | [valueName]: value, 58 | } 59 | } 60 | }) 61 | // endregion helpers 62 | 63 | export default function (view, params) { 64 | 65 | // init code here 66 | view.addEventListener('viewshow', (e) => { 67 | import("/web/configurationpage?name=shared.js").then((shared) => { 68 | shared.setPage("Application"); 69 | 70 | setOptions(shared.getJsonSchema()); 71 | shared.setDomValues(document, shared.getConfig()?.settings) 72 | 73 | shared.setOnSchemaUpdatedListener('application', setOptions) 74 | shared.setOnConfigUpdatedListener('application', (config) => { 75 | console.log("updating dom for application settings") 76 | const {settings} = config; 77 | 78 | shared.setDomValues(document, settings); 79 | }) 80 | 81 | document.querySelectorAll('[data-key-name][data-prop-name]').forEach(el => { 82 | shared.keyedEventListener(el, 'change', function () { 83 | shared.setConfig(updateSettingConfig( 84 | el.getAttribute('data-key-name'), 85 | shared.getConfig(), 86 | el.getAttribute('data-prop-name'), 87 | shared.getElValue(el) 88 | )); 89 | }) 90 | }) 91 | 92 | shared.keyedEventListener(saveBtn(), 'click', function () { 93 | e.preventDefault(); 94 | const config = shared.getConfig(); 95 | 96 | shared.setConfig({ 97 | ...config, 98 | ...getValues() 99 | }); 100 | shared.saveConfig() 101 | }) 102 | }) 103 | }); 104 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/Notifications/index.html: -------------------------------------------------------------------------------- 1 |
4 |
5 |
6 |
7 |

Streamyfin

8 |
9 |
10 |

Notifications

11 |
12 |
13 | 14 |

15 | These are jellyfin events as notifications for your android or ios device.

16 | 17 | If you need more extensibility, 18 | like consuming webhook from an external service, 19 | checkout the more advanced documentation 20 | 22 | NOTIFICATION.md 23 |

24 | 25 | Notification endpoint
26 | 27 |
28 |

29 | 30 |
31 | Item Added 32 |
33 | 37 |
38 | If enabled, all users will be notified when new movies or episodes are available. 39 |
40 |
41 | This notification consolidates the amount of episodes added for a season into one notification if possible to prevent spam. 42 |
43 |
44 |
45 | 46 | 47 |
48 | How long we want to wait in seconds until allowing a duplicate event from being processed 49 |
50 |
51 |
52 | 53 |
54 | Session Started 55 |
56 | 60 |
If enabled, admins will be notified when users are online
61 |
62 |
63 | 64 | 65 |
66 | How long we want to wait in seconds until allowing a duplicate event from being processed 67 |
68 |
69 |
70 | 71 |
72 | Playback Started 73 |
74 | 78 |
If enabled, admins will be notified when users begin to play media
79 |
80 |
81 | 82 | 83 |
84 | How long we want to wait in seconds until allowing a duplicate event from being processed 85 |
86 |
87 |
88 | 89 |
90 | User locked out 91 |
92 | 96 |
If enabled, admins & locked user will be notified when users requires a reset
97 |
98 |
99 | 100 | 101 |
102 | How long we want to wait in seconds until allowing a duplicate event from being processed 103 |
104 |
105 |
106 | 107 |
108 | 111 |
112 |
113 |
-------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/Notifications/index.js: -------------------------------------------------------------------------------- 1 | const saveBtn = document.getElementById('save-notification-btn'); 2 | 3 | const getValues = () => ({ 4 | notifications: Array.from(document.querySelectorAll('[data-key-name][data-prop-name]')).reduce((acc, el) => { 5 | if (el.offsetParent === null) return acc; 6 | 7 | const notification = el.getAttribute('data-key-name'); 8 | const property = el.getAttribute('data-prop-name'); 9 | 10 | 11 | console.log("Notification", notification, el.offsetParent) 12 | 13 | const value = window.Streamyfin.shared.getElValue(el); 14 | acc[notification] = acc[notification] ?? {} 15 | 16 | if (value != null) { 17 | acc[notification][property] = value; 18 | } 19 | else delete acc[notification] 20 | 21 | return acc 22 | }, {}) 23 | }) 24 | 25 | // region helpers 26 | const updateNotificationConfig = (name, config, valueName, value) => ({ 27 | ...(config ?? {}), 28 | notifications: { 29 | ...(config?.notifications ?? {}), 30 | [name]: { 31 | ...(config?.notifications?.[name] ?? {}), 32 | [valueName]: value, 33 | } 34 | } 35 | }) 36 | // endregion helpers 37 | 38 | export default function (view, params) { 39 | 40 | // init code here 41 | view.addEventListener('viewshow', (e) => { 42 | import("/web/configurationpage?name=shared.js").then((shared) => { 43 | shared.setPage("Notifications"); 44 | 45 | document.getElementById("notification-endpoint").innerText = shared.NOTIFICATION_URL 46 | 47 | shared.setDomValues(document, shared.getConfig()?.notifications) 48 | shared.setOnConfigUpdatedListener('notifications', (config) => { 49 | console.log("updating dom for notifications") 50 | 51 | const {notifications} = config; 52 | shared.setDomValues(document, notifications); 53 | }) 54 | 55 | document.querySelectorAll('[data-key-name][data-prop-name]').forEach(el => { 56 | shared.keyedEventListener(el, 'change', function () { 57 | shared.setConfig(updateNotificationConfig( 58 | el.getAttribute('data-key-name'), 59 | shared.getConfig(), 60 | el.getAttribute('data-prop-name'), 61 | shared.getElValue(el) 62 | )); 63 | }) 64 | }) 65 | 66 | shared.keyedEventListener(saveBtn, 'click', function (e) { 67 | e.preventDefault(); 68 | const config = shared.getConfig(); 69 | 70 | shared.setConfig({ 71 | ...config, 72 | ...getValues() 73 | }); 74 | shared.saveConfig() 75 | }) 76 | }) 77 | }); 78 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/Other/index.html: -------------------------------------------------------------------------------- 1 |
4 |
5 |
6 |
7 |

Streamyfin

8 |
9 |
10 |

Other

11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 |
The plugin page you want to always load first.
19 |
20 |
21 | 22 |
23 | 26 |
27 |
28 |
-------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/Other/index.js: -------------------------------------------------------------------------------- 1 | const homePage = () => document.getElementById('home-page'); 2 | const saveBtn = () => document.getElementById('save-other-btn'); 3 | 4 | const getValues = () => ({ 5 | other: { 6 | homePage: homePage()?.value 7 | } 8 | }) 9 | 10 | export default function (view, params) { 11 | 12 | // init code here 13 | view.addEventListener('viewshow', (e) => { 14 | import("/web/configurationpage?name=shared.js").then((shared) => { 15 | shared.setPage("Other"); 16 | 17 | homePage().options.length = 0; 18 | shared.StreamyfinTabs().forEach(tab => homePage().add(new Option(tab.name, tab.resource))) 19 | 20 | homePage().value = shared.getConfig()?.other?.homePage; 21 | 22 | shared.setOnConfigUpdatedListener('other', (config) => { 23 | console.log("updating dom for other") 24 | const {other} = config; 25 | 26 | homePage().value = other.homePage 27 | }) 28 | 29 | shared.keyedEventListener(saveBtn(), 'click', function (e) { 30 | e.preventDefault(); 31 | const config = shared.getConfig(); 32 | 33 | shared.setConfig({ 34 | ...config, 35 | ...getValues() 36 | }); 37 | shared.saveConfig() 38 | }) 39 | }) 40 | }); 41 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/YamlEditor/index.html: -------------------------------------------------------------------------------- 1 |
4 |
5 |
6 |
7 |

Streamyfin

8 |
9 |
11 |

Yaml Editor

12 |
13 | 15 | 16 | Help 17 | 18 | 20 | 21 | Examples 22 | 23 | 27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | 37 |
38 |
39 |
-------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/YamlEditor/index.js: -------------------------------------------------------------------------------- 1 | const yamlEditor = () => document.getElementById('yaml-editor'); 2 | const exampleBtn = () => document.getElementById("example-btn") 3 | const saveBtn = () => document.getElementById("save-btn"); 4 | 5 | export default function (view, params) { 6 | 7 | // init code here 8 | view.addEventListener('viewshow', (e) => { 9 | import("/web/configurationpage?name=shared.js").then((shared) => { 10 | shared.setPage("Yaml"); 11 | return shared; 12 | }).then(async (shared) => { 13 | // Import monaco after shared resources and wait until its done before continuing 14 | if (!window.monaco) { 15 | Dashboard.showLoadingMsg(); 16 | await import("/web/configurationpage?name=monaco-editor.bundle.js") 17 | } 18 | 19 | const Page = { 20 | editor: null, 21 | yaml: null, 22 | saveConfig: function (e) { 23 | e.preventDefault(); 24 | shared.setYamlConfig(Page.editor.getModel().getValue()) 25 | shared.saveConfig() 26 | }, 27 | loadConfig: function (config) { 28 | Dashboard.hideLoadingMsg(); 29 | const yamlModelUri = monaco.Uri.parse('streamyfin.yaml'); 30 | 31 | Page.editor = monaco.editor.create(yamlEditor(), { 32 | automaticLayout: true, 33 | language: 'yaml', 34 | suggest: { 35 | showWords: false 36 | }, 37 | model: monaco.editor.createModel(shared.tools.jsYaml.dump(config), 'yaml', yamlModelUri), 38 | }); 39 | 40 | Page.editor.onDidChangeModelContent(function (e) { 41 | if (e.eol === '\n' && e.changes[0].text.endsWith(" ")) { 42 | // need timeout so it triggers after auto formatting 43 | setTimeout(() => { 44 | Page.editor.trigger('', 'editor.action.triggerSuggest', {}); 45 | }, "100"); 46 | } 47 | }); 48 | 49 | }, 50 | resetConfig: function () { 51 | const example = shared.getDefaultConfig(); 52 | Page.editor.getModel().setValue(shared.tools.jsYaml.dump(example)); 53 | }, 54 | init: function () { 55 | console.log("init"); 56 | 57 | // Yaml Editor 58 | monaco.editor.setTheme('vs-dark'); 59 | 60 | 61 | Page.yaml = monacoYaml.configureMonacoYaml(monaco, { 62 | enableSchemaRequest: true, 63 | hover: true, 64 | completion: true, 65 | validate: true, 66 | format: true, 67 | titleHidden: true, 68 | schemas: [ 69 | { 70 | uri: shared.SCHEMA_URL, 71 | fileMatch: ["**/*"] 72 | }, 73 | ], 74 | }); 75 | 76 | saveBtn().addEventListener("click", Page.saveConfig); 77 | exampleBtn().addEventListener("click", Page.resetConfig); 78 | 79 | if (shared.getConfig() && Page.editor == null) { 80 | Page.loadConfig(shared.getConfig()); 81 | } 82 | 83 | shared.setOnConfigUpdatedListener('yaml-editor', (config) => { 84 | // only set if editor isn't instantiated 85 | if (Page.editor == null) { 86 | console.log("loading") 87 | Page.loadConfig(config) 88 | } else { 89 | Page.editor.getModel().setValue(shared.tools.jsYaml.dump(config)) 90 | } 91 | }) 92 | } 93 | }; 94 | 95 | if (!Page.editor && monaco?.editor?.getModels?.()?.length === 0) { 96 | Page.init(); 97 | } else { 98 | console.log("Monaco editor model already exists") 99 | } 100 | 101 | view.addEventListener('viewhide', function (e) { 102 | console.log("Hiding") 103 | Page?.editor?.dispose() 104 | Page?.yaml?.dispose() 105 | Page.editor = undefined; 106 | Page.yaml = undefined; 107 | monaco?.editor?.getModels?.()?.forEach(model => model.dispose()) 108 | monaco?.editor?.getEditors?.()?.forEach(editor => editor.dispose()); 109 | }); 110 | }) 111 | }); 112 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Pages/shared.js: -------------------------------------------------------------------------------- 1 | export const SCHEMA_URL = window.ApiClient.getUrl('streamyfin/config/schema'); 2 | export const YAML_URL = window.ApiClient.getUrl('streamyfin/config/yaml'); 3 | export const DEFAULT_URL = window.ApiClient.getUrl('streamyfin/config/default'); 4 | export const NOTIFICATION_URL = window.ApiClient.getUrl('streamyfin/notification'); 5 | export const tools = {jsYaml: undefined}; 6 | 7 | // region private variables 8 | let schema = undefined; 9 | let config = undefined; 10 | let defaultConfig = undefined; 11 | // endregion private variables 12 | 13 | // region listeners 14 | const registeredEventListeners = {} 15 | const onSchemaLoadedListeners = {}; 16 | const onConfigLoadedListeners = {}; 17 | 18 | export const setOnSchemaUpdatedListener = (key, listener) => { 19 | onSchemaLoadedListeners[key] = listener; 20 | } 21 | 22 | export const setOnConfigUpdatedListener = (key, listener) => { 23 | onConfigLoadedListeners[key] = listener; 24 | } 25 | 26 | const triggerConfigListeners = (value, raw) => { 27 | Object.values(onConfigLoadedListeners).forEach(listener => listener?.(config, raw)); 28 | } 29 | // endregion listeners 30 | 31 | // region getters/setters 32 | export const getJsonSchema = () => schema; 33 | const setSchema = (value) => { 34 | schema = value 35 | Object.values(onSchemaLoadedListeners).forEach(listener => listener?.(schema, value)); 36 | } 37 | 38 | export const getDefaultConfig = () => defaultConfig; 39 | export const getConfig = () => config; 40 | export const setConfig = (value) => { 41 | config = value 42 | triggerConfigListeners(config) 43 | } 44 | 45 | export const setYamlConfig = (value) => { 46 | config = tools.jsYaml.load(value) 47 | triggerConfigListeners(config, value) 48 | } 49 | // endregion getters/setters 50 | 51 | // region helpers 52 | export const setPage = (resource) => { 53 | const tabs = StreamyfinTabs(); 54 | 55 | const index = tabs.findIndex(tab => tab.resource === resource); 56 | 57 | if (index === -1) { 58 | console.error(`Failed to find tab for ${resource}`); 59 | return; 60 | } 61 | 62 | console.log(`${tabs[index].name} loaded`) 63 | 64 | LibraryMenu.setTabs(tabs[index].resource, index, StreamyfinTabs) 65 | } 66 | 67 | export const saveConfig = () => { 68 | Dashboard.showLoadingMsg(); 69 | 70 | if (config) { 71 | //todo: potentially just keep it as json? we only need to convert only for editor reasons 72 | // convert config back to yaml 73 | const data = JSON.stringify({ 74 | Value: tools.jsYaml.dump(config), 75 | }); 76 | 77 | window.ApiClient.ajax({type: 'POST', url: YAML_URL, data, contentType: 'application/json'}) 78 | .then(async (response) => { 79 | const {Error, Message} = await response.json(); 80 | 81 | if (Error) { 82 | Dashboard.hideLoadingMsg(); 83 | Dashboard.alert(Message); 84 | return; 85 | } 86 | 87 | Dashboard.processPluginConfigurationUpdateResult(); 88 | }) 89 | .catch((error) => console.error(error)) 90 | .finally(Dashboard.hideLoadingMsg); 91 | } 92 | 93 | } 94 | 95 | export const getElValue = (el) => { 96 | const isArray = el.getAttribute('data-is-array') === "true"; 97 | 98 | const valueKey = el.type === 'checkbox' ? 'checked' : el.type === 'number' ? 'valueAsNumber' : 'value'; 99 | let value = el[valueKey]; 100 | 101 | if (isArray && value !== undefined && value !== '') { 102 | value = value.split(',').map(v => v.trim()); 103 | } 104 | 105 | if (value === '') { 106 | value = null 107 | } 108 | 109 | if (typeof value === 'number' && isNaN(value)) { 110 | value = null 111 | } 112 | 113 | return value ?? null 114 | } 115 | 116 | export const setDomValues = (dom, obj) => { 117 | dom.querySelectorAll('[data-key-name][data-prop-name]').forEach(el => { 118 | const key = el.getAttribute('data-key-name'); 119 | const prop = el.getAttribute('data-prop-name'); 120 | 121 | el[el.type === 'checkbox' ? 'checked' : 'value'] = obj?.[key]?.[prop] ?? null; 122 | }) 123 | } 124 | 125 | // prevent duplicate listeners from being created everytime a tab is switched 126 | export const keyedEventListener = (el, type, listener) =>{ 127 | const elId = el.getAttribute("id"); 128 | 129 | if (!registeredEventListeners[elId]) { 130 | registeredEventListeners[elId] = { 131 | type, 132 | listener, 133 | }; 134 | el.addEventListener(type, listener); 135 | } 136 | } 137 | // endregion helpers 138 | 139 | export const StreamyfinTabs = () => [ 140 | { 141 | href: "configurationpage?name=Application", 142 | resource: "Application", 143 | name: "Application" 144 | }, 145 | { 146 | href: "configurationpage?name=Notifications", 147 | resource: "Notifications", 148 | name: "Notifications" 149 | }, 150 | { 151 | href: "configurationpage?name=Other", 152 | resource: "Other", 153 | name: "Other" 154 | }, 155 | { 156 | href: "configurationpage?name=Yaml", 157 | resource: "Yaml", 158 | name: "Yaml Editor" 159 | }, 160 | ]; 161 | 162 | // region on Shared init 163 | if (!window.Streamyfin?.shared) { 164 | // import json-yaml library 165 | import("/web/configurationpage?name=js-yaml.js").then((jsYaml) => { 166 | tools.jsYaml = jsYaml; 167 | 168 | //fetch default configuration 169 | window.ApiClient.ajax({type: 'GET', url: DEFAULT_URL, contentType: 'application/json'}) 170 | .then(async function (response) { 171 | const {Value} = await response.json(); 172 | defaultConfig = jsYaml.load(Value) 173 | }) 174 | .catch((error) => console.error(error)) 175 | 176 | // fetch schema 177 | // We want to define any pages first before setting any values 178 | fetch(SCHEMA_URL) 179 | .then(async (response) => setSchema(await response.json())) 180 | .then(() => { 181 | 182 | // fetch configuration 183 | window.ApiClient.ajax({type: 'GET', url: YAML_URL, contentType: 'application/json'}) 184 | .then(async function (response) { 185 | const {Value} = await response.json(); 186 | setYamlConfig(Value) 187 | }) 188 | .catch((error) => console.error(error)) 189 | }); 190 | }) 191 | 192 | // For developers when reviewing in console 193 | window.Streamyfin = { 194 | shared: { 195 | setOnSchemaUpdatedListener, 196 | setOnConfigUpdatedListener, 197 | setYamlConfig, 198 | setPage, 199 | saveConfig, 200 | getJsonSchema, 201 | getDefaultConfig, 202 | getConfig, 203 | setConfig, 204 | StreamyfinTabs, 205 | registeredEventListeners, 206 | keyedEventListener, 207 | getElValue, 208 | setDomValues, 209 | SCHEMA_URL, 210 | YAML_URL, 211 | DEFAULT_URL, 212 | NOTIFICATION_URL, 213 | tools 214 | } 215 | } 216 | } 217 | // endregion on Shared init 218 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Plugin.Streamyfin.Configuration; 5 | using Jellyfin.Plugin.Streamyfin.Storage; 6 | using MediaBrowser.Common.Configuration; 7 | using MediaBrowser.Common.Plugins; 8 | using MediaBrowser.Model.Plugins; 9 | using MediaBrowser.Model.Serialization; 10 | 11 | namespace Jellyfin.Plugin.Streamyfin; 12 | 13 | /// 14 | /// The main plugin. 15 | /// 16 | public class StreamyfinPlugin : BasePlugin, IHasWebPages 17 | { 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// Instance of the interface. 22 | /// Instance of the interface. 23 | public StreamyfinPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 24 | : base(applicationPaths, xmlSerializer) 25 | { 26 | Instance = this; 27 | Database = new Database(applicationPaths.DataPath); 28 | _prefix = GetType().Namespace; 29 | } 30 | 31 | public Database Database { get; } 32 | 33 | /// 34 | public override string Name => "Streamyfin"; 35 | 36 | private static string? _prefix; 37 | 38 | /// 39 | public override Guid Id => Guid.Parse("1e9e5d38-6e67-4615-8719-e98a5c34f004"); 40 | 41 | /// 42 | /// Gets the current plugin instance. 43 | /// 44 | public static StreamyfinPlugin? Instance { get; private set; } 45 | 46 | private List _pages () => 47 | [ 48 | new() 49 | { 50 | Name = "Application", 51 | EmbeddedResourcePath = _prefix + ".Pages.Application.index.html" 52 | }, 53 | 54 | new PluginPageInfo 55 | { 56 | Name = "Application.js", 57 | EmbeddedResourcePath = _prefix + ".Pages.Application.index.js" 58 | }, 59 | 60 | new PluginPageInfo 61 | { 62 | Name = "Notifications", 63 | EmbeddedResourcePath = _prefix + ".Pages.Notifications.index.html" 64 | }, 65 | 66 | new PluginPageInfo 67 | { 68 | Name = "Notifications.js", 69 | EmbeddedResourcePath = _prefix + ".Pages.Notifications.index.js" 70 | }, 71 | 72 | new PluginPageInfo 73 | { 74 | Name = "Other", 75 | EmbeddedResourcePath = _prefix + ".Pages.Other.index.html" 76 | }, 77 | 78 | new PluginPageInfo 79 | { 80 | Name = "Other.js", 81 | EmbeddedResourcePath = _prefix + ".Pages.Other.index.js" 82 | }, 83 | 84 | new PluginPageInfo 85 | { 86 | Name = "Yaml", 87 | EmbeddedResourcePath = _prefix + ".Pages.YamlEditor.index.html" 88 | }, 89 | 90 | new PluginPageInfo 91 | { 92 | Name = "Yaml.js", 93 | EmbeddedResourcePath = _prefix + ".Pages.YamlEditor.index.js" 94 | } 95 | ]; 96 | 97 | /// 98 | public IEnumerable GetPages() 99 | { 100 | if (Instance?.Configuration?.Config?.Other?.HomePage != null) 101 | { 102 | var homePage = _pages().FirstOrDefault(page => string.Equals(page.Name, Instance.Configuration.Config.Other.HomePage, StringComparison.Ordinal)); 103 | 104 | if (homePage != null) 105 | { 106 | List pages = [homePage]; 107 | pages.AddRange(_pages().Where(p => p.Name != homePage.Name)); 108 | 109 | foreach (var pluginPageInfo in pages) 110 | { 111 | yield return pluginPageInfo; 112 | } 113 | } 114 | else 115 | { 116 | foreach (var pluginPageInfo in _pages()) 117 | { 118 | yield return pluginPageInfo; 119 | } 120 | } 121 | } 122 | else 123 | { 124 | foreach (var pluginPageInfo in _pages()) 125 | { 126 | yield return pluginPageInfo; 127 | } 128 | } 129 | 130 | // region pages 131 | 132 | // endregion pages 133 | 134 | // region libraries 135 | 136 | // region monaco-editor 137 | yield return new PluginPageInfo 138 | { 139 | Name = "yaml.worker.js", 140 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.yaml.worker.js" 141 | }; 142 | 143 | yield return new PluginPageInfo 144 | { 145 | Name = "json.worker.js", 146 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.json.worker.js" 147 | }; 148 | 149 | yield return new PluginPageInfo 150 | { 151 | Name = "editor.worker.js", 152 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.editor.worker.js" 153 | }; 154 | 155 | yield return new PluginPageInfo 156 | { 157 | Name = "monaco-editor.bundle.js", 158 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.monaco-editor.bundle.js" 159 | }; 160 | // endregion monaco-editor 161 | 162 | yield return new PluginPageInfo 163 | { 164 | Name = "json-editor.js", 165 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.json-editor.min.js" 166 | }; 167 | 168 | yield return new PluginPageInfo 169 | { 170 | Name = "js-yaml.js", 171 | EmbeddedResourcePath = _prefix + ".Pages.Libraries.js-yaml.min.js" 172 | }; 173 | 174 | yield return new PluginPageInfo 175 | { 176 | Name = "shared.js", 177 | EmbeddedResourcePath = _prefix + ".Pages.shared.js" 178 | }; 179 | // endregion libraries 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PluginServiceRegistrator.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Events.Users; 2 | using Jellyfin.Plugin.Streamyfin.PushNotifications; 3 | using Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 4 | using MediaBrowser.Controller; 5 | using MediaBrowser.Controller.Events; 6 | using MediaBrowser.Controller.Events.Session; 7 | using MediaBrowser.Controller.Library; 8 | using MediaBrowser.Controller.Plugins; 9 | using Microsoft.Extensions.DependencyInjection; 10 | 11 | namespace Jellyfin.Plugin.Streamyfin; 12 | 13 | /// 14 | /// Provides service registration for the plugin 15 | /// 16 | public class PluginServiceRegistrator : IPluginServiceRegistrator 17 | { 18 | /// 19 | public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) 20 | { 21 | // Helpers 22 | serviceCollection.AddSingleton(); 23 | serviceCollection.AddSingleton(); 24 | serviceCollection.AddSingleton(); 25 | 26 | // Event listeners 27 | serviceCollection.AddScoped, SessionStartEvent>(); 28 | serviceCollection.AddScoped, PlaybackStartEvent>(); 29 | serviceCollection.AddScoped, UserLockedOutEvent>(); 30 | 31 | // Service 32 | serviceCollection.AddHostedService(); 33 | } 34 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Enums/InterruptionLevel.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Converters; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.enums; 5 | 6 | [JsonConverter(typeof(StringEnumConverter))] 7 | public enum InterruptionLevel 8 | { 9 | // The system presents the notification immediately, lights up the screen, and can play a sound. 10 | active, 11 | // The system presents the notification immediately, lights up the screen, and bypasses the mute switch to play a sound. 12 | critical, 13 | // The system adds the notification to the notification list without lighting up the screen or playing a sound. 14 | passive, 15 | // The system presents the notification immediately, lights up the screen, can play a sound, and breaks through system notification controls. 16 | timeSensitive 17 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Enums/Status.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Converters; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.enums; 5 | 6 | [JsonConverter(typeof(StringEnumConverter))] 7 | public enum Status 8 | { 9 | ok, 10 | error 11 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/BaseEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Linq; 4 | using Jellyfin.Plugin.Streamyfin.Configuration; 5 | using MediaBrowser.Controller; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 9 | 10 | public abstract class BaseEvent 11 | { 12 | private static readonly ConcurrentDictionary RecentEvents = new(); 13 | private static readonly TimeSpan RecentEventThreshold = TimeSpan.FromSeconds(5); 14 | private static readonly TimeSpan CleanupThreshold = TimeSpan.FromMinutes(5); 15 | 16 | protected static Config? Config => StreamyfinPlugin.Instance?.Configuration.Config; 17 | 18 | protected readonly ILogger _logger; 19 | protected readonly LocalizationHelper _localization; 20 | protected readonly IServerApplicationHost _applicationHost; 21 | protected readonly NotificationHelper _notificationHelper; 22 | 23 | protected BaseEvent( 24 | ILoggerFactory loggerFactory, 25 | LocalizationHelper localization, 26 | IServerApplicationHost applicationHost, 27 | NotificationHelper notificationHelper) 28 | { 29 | _logger = loggerFactory.CreateLogger(GetType()); 30 | _localization = localization; 31 | _applicationHost = applicationHost; 32 | _notificationHelper = notificationHelper; 33 | } 34 | 35 | /// 36 | /// Check if the event was recently processed before 37 | /// 38 | /// 39 | /// 40 | protected bool HasRecentlyProcessed(string sessionKey) 41 | { 42 | _logger.LogDebug("Checking recent events for key: {0}", sessionKey); 43 | 44 | var recentlyProcessed = 45 | RecentEvents.TryGetValue(sessionKey, out DateTime lastProcessedTime) && 46 | DateTime.UtcNow - lastProcessedTime < GetRecentEventThreshold(); 47 | 48 | if (!recentlyProcessed) 49 | { 50 | _logger.LogDebug("No recent events for key: {0}", sessionKey); 51 | // Update the cache with the latest event time 52 | RecentEvents[sessionKey] = DateTime.UtcNow; 53 | } 54 | else _logger.LogDebug("There are recent events for key: {0}", sessionKey); 55 | 56 | return recentlyProcessed; 57 | } 58 | 59 | /// 60 | /// Cleans up old session entries from the cache. 61 | /// 62 | protected void CleanupOldEntries() 63 | { 64 | DateTime threshold = DateTime.UtcNow - GetCleanupThreshold(); 65 | 66 | _logger.LogDebug("Checking for entries older than: {0}", threshold); 67 | 68 | RecentEvents 69 | .Where(kvp => kvp.Value < threshold) 70 | .Select(kvp => kvp.Key) 71 | .ToList() 72 | .ForEach(key => RecentEvents.TryRemove(key, out _)); 73 | } 74 | 75 | /// 76 | /// How long we want to wait until allowing an event with a matching sessionKey to be processed 77 | /// 78 | /// TimeSpan for how long to wait 79 | protected virtual TimeSpan GetRecentEventThreshold() 80 | { 81 | _logger.LogDebug("Getting default RecentEventsThreshold: {0}", RecentEventThreshold); 82 | return RecentEventThreshold; 83 | } 84 | 85 | /// 86 | /// Maximum age we want events stored our recentEvents cache to be 87 | /// 88 | /// TimeSpan for how long to wait 89 | protected virtual TimeSpan GetCleanupThreshold() 90 | { 91 | return GetRecentEventThreshold() + CleanupThreshold; 92 | } 93 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/ItemAdded/EpisodeTimer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using MediaBrowser.Controller.Entities.TV; 5 | 6 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events.ItemAdded; 7 | 8 | public class EpisodeTimer 9 | { 10 | public List Episodes { get; set; } 11 | private Timer Timer { get; set; } 12 | 13 | public EpisodeTimer( 14 | List episodes, 15 | TimerCallback callback) 16 | { 17 | Episodes = episodes; 18 | Timer = new Timer( 19 | callback: callback, 20 | state: null, 21 | dueTime: Timeout.InfiniteTimeSpan, 22 | period: Timeout.InfiniteTimeSpan 23 | ); 24 | } 25 | 26 | public void Add(Episode episode) 27 | { 28 | ResetTimer(); 29 | var index = Episodes.FindIndex(e => e.Id == episode.Id); 30 | 31 | if (index == -1) 32 | { 33 | Episodes.Add(episode); 34 | } 35 | else 36 | { 37 | Episodes[index] = episode; 38 | } 39 | } 40 | 41 | private void ResetTimer() => Timer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan); 42 | }; -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/ItemAdded/ItemAddedService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Jellyfin.Plugin.Streamyfin.Extensions; 8 | using Jellyfin.Plugin.Streamyfin.PushNotifications.Events.ItemAdded; 9 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 10 | using MediaBrowser.Controller; 11 | using MediaBrowser.Controller.Entities.Movies; 12 | using MediaBrowser.Controller.Entities.TV; 13 | using MediaBrowser.Controller.Library; 14 | using Microsoft.Extensions.Hosting; 15 | using Microsoft.Extensions.Logging; 16 | 17 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 18 | 19 | 20 | public class ItemAddedService : BaseEvent, IHostedService 21 | { 22 | private readonly ILibraryManager _libraryManager; 23 | private readonly ConcurrentDictionary _seasonItems; 24 | 25 | public ItemAddedService(ILibraryManager libraryManager, 26 | ILoggerFactory loggerFactory, 27 | LocalizationHelper localization, 28 | IServerApplicationHost applicationHost, 29 | NotificationHelper notificationHelper 30 | ) : base(loggerFactory, localization, applicationHost, notificationHelper) 31 | { 32 | _libraryManager = libraryManager; 33 | _seasonItems = new ConcurrentDictionary(); 34 | } 35 | 36 | private void ItemAddedHandler(object? sender, ItemChangeEventArgs itemChangeEventArgs) 37 | { 38 | if (itemChangeEventArgs.Item.IsVirtualItem || Config?.notifications?.ItemAdded is not { Enabled: true }) 39 | return; 40 | 41 | var item = itemChangeEventArgs.Item; 42 | _logger.LogInformation("Item added is {0} - {1}", item.GetType().Name, item.Name.Escape()); 43 | 44 | switch (item) 45 | { 46 | case Movie movie: 47 | var notification = MediaNotificationHelper.CreateMediaNotification( 48 | localization: _localization, 49 | title: _localization.GetFormatted("ItemAddedTitle", args: movie.GetType().Name), 50 | body: [], 51 | item: item 52 | ); 53 | 54 | if (notification != null) 55 | { 56 | _notificationHelper.SendToAll(notification); 57 | } 58 | break; 59 | case Episode episode: 60 | var seasonId = episode.FindSeasonId(); 61 | 62 | if (seasonId == Guid.Empty) 63 | { 64 | return; 65 | } 66 | 67 | _seasonItems.TryGetValue(seasonId, out var _countdown); 68 | 69 | var countdown = _countdown ?? new EpisodeTimer( 70 | episodes: [], 71 | callback: _ => OnEpisodeAddedTimerCallback(seasonId) 72 | ); 73 | 74 | countdown.Add(episode); 75 | _seasonItems.TryAdd(seasonId, countdown); 76 | break; 77 | default: 78 | _logger.LogInformation("Item type is not supported at this time. No notification will be sent out."); 79 | break; 80 | } 81 | } 82 | 83 | private void OnEpisodeAddedTimerCallback(Guid seasonId) 84 | { 85 | _seasonItems.TryRemove(seasonId, out var countdown); 86 | var total = countdown?.Episodes.Count ?? 0; 87 | 88 | if (countdown == null || countdown.Episodes.Count == 0) return; 89 | 90 | var episode = countdown.Episodes.First(); 91 | var refreshedSeason = _libraryManager.GetItemById(seasonId) as Season; 92 | 93 | if (refreshedSeason is null) 94 | { 95 | return; 96 | } 97 | 98 | var name = refreshedSeason.Series.Name.Escape(); 99 | 100 | string title; 101 | List body = []; 102 | var data = new Dictionary(); 103 | 104 | _logger.LogInformation("Episode timer finished. Captured {0} episodes for {1}.", total, name); 105 | 106 | if (total == 1) 107 | { 108 | var refreshedEpisode = _libraryManager.GetItemById(episode.Id) as Episode; 109 | if (refreshedEpisode is null) 110 | { 111 | return; 112 | } 113 | episode = refreshedEpisode; 114 | 115 | title = _localization.GetString("EpisodeAddedTitle"); 116 | data["id"] = episode.Id; // only provide for a single episode notification 117 | 118 | // Both episode & season information is available 119 | if (episode.IndexNumber != null && episode.Season.IndexNumber != null) 120 | { 121 | body.Add( 122 | _localization.GetFormatted( 123 | key: "EpisodeNumberAddedForSeason", 124 | args: [name, episode.IndexNumber, episode.Season.IndexNumber] 125 | ) 126 | ); 127 | } 128 | // only episode information is available 129 | else if (episode.IndexNumber != null) 130 | { 131 | body.Add( 132 | _localization.GetFormatted( 133 | key: "EpisodeAdded", 134 | args: [name, episode.IndexNumber] 135 | ) 136 | ); 137 | } 138 | // only season information is available 139 | else if (episode.Season.IndexNumber != null) 140 | { 141 | body.Add( 142 | _localization.GetFormatted( 143 | key: "EpisodeAddedForSeason", 144 | args: [name, episode.Season.IndexNumber] 145 | ) 146 | ); 147 | } 148 | } 149 | else 150 | { 151 | title = _localization.GetString("EpisodesAddedTitle"); 152 | 153 | if (refreshedSeason.IndexNumber != null) 154 | { 155 | body.Add(_localization.GetFormatted( 156 | key: "TotalEpisodesAddedForSeason", 157 | args: [name, total, refreshedSeason.IndexNumber] 158 | ) 159 | ); 160 | } 161 | else 162 | { 163 | body.Add(_localization.GetFormatted( 164 | key: "EpisodesAddedToSeries", 165 | args: [name, total] 166 | ) 167 | ); 168 | } 169 | } 170 | 171 | data["seasonIndex"] = refreshedSeason.IndexNumber; 172 | data["seriesId"] = refreshedSeason.SeriesId; 173 | data["type"] = episode.GetType().Name.Escape(); 174 | 175 | var notification = new ExpoNotificationRequest 176 | { 177 | Title = title, 178 | Body = string.Join("\n", body), 179 | Data = data 180 | }; 181 | 182 | _notificationHelper.SendToAll(notification).ConfigureAwait(false); 183 | } 184 | 185 | /// 186 | public Task StartAsync(CancellationToken cancellationToken) 187 | { 188 | _libraryManager.ItemAdded += ItemAddedHandler; 189 | return Task.CompletedTask; 190 | } 191 | 192 | /// 193 | public Task StopAsync(CancellationToken cancellationToken) 194 | { 195 | _libraryManager.ItemAdded -= ItemAddedHandler; 196 | _seasonItems.Clear(); 197 | return Task.CompletedTask; 198 | } 199 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/PlaybackStartEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Jellyfin.Plugin.Streamyfin.Extensions; 7 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 8 | using MediaBrowser.Controller; 9 | using MediaBrowser.Controller.Entities; 10 | using MediaBrowser.Controller.Entities.Movies; 11 | using MediaBrowser.Controller.Entities.TV; 12 | using MediaBrowser.Controller.Events; 13 | using MediaBrowser.Controller.Library; 14 | using Microsoft.Extensions.Logging; 15 | 16 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 17 | 18 | /// 19 | /// Session start notifier. 20 | /// 21 | public class PlaybackStartEvent( 22 | ILoggerFactory loggerFactory, 23 | LocalizationHelper localization, 24 | IServerApplicationHost applicationHost, 25 | NotificationHelper notificationHelper 26 | ) : BaseEvent(loggerFactory, localization, applicationHost, notificationHelper), IEventConsumer 27 | { 28 | 29 | /// 30 | public async Task OnEvent(PlaybackStartEventArgs? eventArgs) 31 | { 32 | if (eventArgs == null || Config?.notifications?.PlaybackStarted is not { Enabled: true }) 33 | { 34 | _logger.LogInformation("PlaybackStartEvent received but currently disabled."); 35 | return; 36 | } 37 | 38 | if (eventArgs.Item is null) 39 | { 40 | return; 41 | } 42 | 43 | if (eventArgs.Item.IsThemeMedia) 44 | { 45 | // Don't report theme song or local trailer playback. 46 | return; 47 | } 48 | 49 | if (eventArgs.Users.Count == 0) 50 | { 51 | // No users in playback session. 52 | return; 53 | } 54 | _logger.LogInformation("PlaybackStartEvent received."); 55 | 56 | CleanupOldEntries(); 57 | 58 | var notifications = eventArgs.Users 59 | .Select(user => 60 | MediaNotificationHelper.CreateMediaNotification( 61 | localization: _localization, 62 | title: _localization.GetString("PlaybackStartTitle"), 63 | body: [_localization.GetFormatted("UserWatching", args: user.Username)], 64 | item: eventArgs.Item 65 | ) 66 | ) 67 | .OfType() 68 | .Where(notification => !HasRecentlyProcessed(notification.Body)) 69 | .ToArray(); 70 | 71 | if (notifications.Length > 0) 72 | { 73 | _notificationHelper.SendToAdmins( 74 | excludedUserIds: eventArgs.Users.Select(u => u.Id).ToList(), 75 | notifications: notifications 76 | ); 77 | } 78 | else _logger.LogInformation("There are no valid notifications to send."); 79 | } 80 | 81 | /// 82 | protected override TimeSpan GetRecentEventThreshold() 83 | { 84 | if (Config?.notifications?.PlaybackStarted is { RecentEventThreshold: null }) 85 | return base.GetRecentEventThreshold(); 86 | 87 | var definedThreshold = (double) Config?.notifications?.PlaybackStarted?.RecentEventThreshold!; 88 | return TimeSpan.FromSeconds(double.Abs(definedThreshold)); 89 | } 90 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/SessionStartEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 5 | using MediaBrowser.Controller; 6 | using MediaBrowser.Controller.Events; 7 | using MediaBrowser.Controller.Events.Session; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 11 | 12 | /// 13 | /// Session start notifier. 14 | /// 15 | public class SessionStartEvent( 16 | ILoggerFactory loggerFactory, 17 | LocalizationHelper localization, 18 | IServerApplicationHost applicationHost, 19 | NotificationHelper notificationHelper 20 | ) : BaseEvent(loggerFactory, localization, applicationHost, notificationHelper), IEventConsumer 21 | { 22 | /// 23 | public async Task OnEvent(SessionStartedEventArgs? eventArgs) 24 | { 25 | if (eventArgs?.Argument == null || Config?.notifications?.SessionStarted is not { Enabled: true }) 26 | { 27 | _logger.LogInformation("SessionStartEvent received but currently disabled."); 28 | return; 29 | } 30 | 31 | // Clean up old session entries when a new session event is triggered 32 | CleanupOldEntries(); 33 | 34 | // Prevent the same notification per device 35 | string sessionKey = eventArgs.Argument.DeviceId; 36 | 37 | // Check if we've processed a similar event recently 38 | if (HasRecentlyProcessed(sessionKey)) 39 | { 40 | return; 41 | } 42 | 43 | ExpoNotificationRequest[] notifications = [ 44 | new() 45 | { 46 | Title = _localization.GetString("SessionStartTitle"), 47 | Body = _localization.GetFormatted("UserNowOnline", args: eventArgs.Argument.UserName) 48 | } 49 | ]; 50 | 51 | _notificationHelper 52 | .SendToAdmins( 53 | excludedUserIds: [eventArgs.Argument.UserId], 54 | notifications: notifications 55 | ); 56 | } 57 | 58 | /// 59 | protected override TimeSpan GetRecentEventThreshold() 60 | { 61 | if (Config?.notifications?.SessionStarted is { RecentEventThreshold: null }) 62 | return base.GetRecentEventThreshold(); 63 | 64 | var definedThreshold = (double) Config?.notifications?.SessionStarted?.RecentEventThreshold!; 65 | return TimeSpan.FromSeconds(double.Abs(definedThreshold)); 66 | } 67 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Events/UserLockedOutEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Jellyfin.Data.Events.Users; 4 | using Jellyfin.Plugin.Streamyfin.Extensions; 5 | using MediaBrowser.Controller; 6 | using MediaBrowser.Controller.Events; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.Events; 10 | 11 | /// 12 | /// Session start notifier. 13 | /// 14 | public class UserLockedOutEvent( 15 | ILoggerFactory loggerFactory, 16 | LocalizationHelper localization, 17 | IServerApplicationHost applicationHost, 18 | NotificationHelper notificationHelper 19 | ) : BaseEvent(loggerFactory, localization, applicationHost, notificationHelper), IEventConsumer 20 | { 21 | /// 22 | public async Task OnEvent(UserLockedOutEventArgs? eventArgs) 23 | { 24 | if (eventArgs?.Argument == null || Config?.notifications?.UserLockedOut is not { Enabled: true }) 25 | { 26 | _logger.LogInformation("UserLockedOutEvent received but currently disabled."); 27 | return; 28 | } 29 | 30 | var notification = new Notification 31 | { 32 | Title = _localization.GetString("UserLockedOutTitle"), 33 | Body = _localization.GetFormatted( 34 | key: "UserHasBeenLockedOut", 35 | args: eventArgs.Argument.Username.Escape() 36 | ), 37 | UserId = eventArgs.Argument.Id 38 | }; 39 | 40 | await _notificationHelper.SendToAdmins(notification).ConfigureAwait(false); 41 | } 42 | 43 | /// 44 | protected override TimeSpan GetRecentEventThreshold() 45 | { 46 | if (Config?.notifications?.UserLockedOut is { RecentEventThreshold: null }) 47 | return base.GetRecentEventThreshold(); 48 | 49 | var definedThreshold = (double) Config?.notifications?.UserLockedOut?.RecentEventThreshold!; 50 | return TimeSpan.FromSeconds(double.Abs(definedThreshold)); 51 | } 52 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/MediaNotificationHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Text.RegularExpressions; 5 | using Jellyfin.Plugin.Streamyfin.Extensions; 6 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Entities.Movies; 9 | using MediaBrowser.Controller.Entities.TV; 10 | 11 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications; 12 | 13 | static class MediaNotificationHelper 14 | { 15 | public static ExpoNotificationRequest? CreateMediaNotification( 16 | LocalizationHelper localization, 17 | string title, 18 | List body, 19 | BaseItem item) 20 | { 21 | string? name = null; 22 | var data = new Dictionary(); 23 | 24 | switch (item) 25 | { 26 | case Movie movie: 27 | // Potentially clean up any BaseItem without any corrected metadata 28 | var movieName = Regex.Replace(movie.Name.Escape(), "\\(\\d+\\)", "").Trim(); 29 | data["id"] = movie.Id.ToString(); 30 | 31 | name = localization.GetFormatted( 32 | key: "NameAndYear", 33 | args: [movieName, movie.ProductionYear] 34 | ); 35 | break; 36 | case Season season: 37 | if (!string.IsNullOrEmpty(season.Series.Name) && season.Series?.ProductionYear is not null) 38 | { 39 | name = localization.GetFormatted( 40 | key: "NameAndYear", 41 | args: [season.Series.Name.Escape(), season.Series.ProductionYear] 42 | ); 43 | } 44 | else if (!string.IsNullOrEmpty(season.Series?.Name)) 45 | { 46 | name = season.Series.Name.Escape(); 47 | } 48 | 49 | if (season.Series?.Id is not null) 50 | { 51 | data["seriesId"] = season.Series.Id.ToString(); 52 | } 53 | 54 | break; 55 | case Episode episode: 56 | data["id"] = episode.Id.ToString(); 57 | 58 | name = !string.IsNullOrEmpty(episode.Series?.Name) switch 59 | { 60 | // Name + Season + Episode 61 | true when episode.Season?.IndexNumber is not null && episode.IndexNumber is not null => 62 | localization.GetFormatted( 63 | key: "SeriesSeasonAndEpisode", 64 | args: 65 | [ 66 | episode.Series.Name.Escape(), 67 | episode.Season.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), 68 | episode.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture) 69 | ] 70 | ), 71 | // Name + Season 72 | true when episode.Season?.IndexNumber is not null => 73 | localization.GetFormatted( 74 | key: "SeriesSeason", 75 | args: 76 | [ 77 | episode.Series.Name.Escape(), 78 | episode.Season.IndexNumber.Value.ToString(CultureInfo.InvariantCulture) 79 | ] 80 | ), 81 | // Name + Episode 82 | true when episode.IndexNumber is not null => 83 | localization.GetFormatted( 84 | key: "SeriesEpisode", 85 | args: 86 | [ 87 | episode.Series.Name.Escape(), 88 | episode.IndexNumber?.ToString("00", CultureInfo.InvariantCulture) ?? string.Empty 89 | ] 90 | ), 91 | _ => episode.Series?.Name.Escape() 92 | }; 93 | 94 | data["seriesId"] = episode.SeriesId; 95 | data["seasonIndex"] = episode.Season?.IndexNumber; 96 | 97 | break; 98 | } 99 | 100 | if (string.IsNullOrWhiteSpace(name)) 101 | { 102 | return null; 103 | } 104 | 105 | body.Add(name); 106 | data["type"] = item.GetType().Name.Escape(); 107 | 108 | return new ExpoNotificationRequest 109 | { 110 | Title = title, 111 | Body = string.Join("\n", body), 112 | Data = data 113 | }; 114 | } 115 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Models/ExpoNotificationRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Jellyfin.Plugin.Streamyfin.PushNotifications.enums; 3 | using Newtonsoft.Json; 4 | 5 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.models; 6 | 7 | /// 8 | /// Expos Push Message format 9 | /// see: https://exp.host/--/api/v2/push/send 10 | /// 11 | public class ExpoNotificationRequest 12 | { 13 | /// 14 | /// An array of Expo push tokens specifying the recipient(s) of this message. 15 | /// 16 | [JsonProperty("to", DefaultValueHandling = DefaultValueHandling.Ignore)] 17 | public List To { get; set; } 18 | 19 | /// 20 | /// iOS Only 21 | /// When this is set to true, 22 | /// the notification will cause the iOS app to start in the background to run a background task. 23 | /// Your app needs to be configured to support this. 24 | /// https://docs.expo.dev/versions/latest/sdk/notifications/#background-notifications 25 | /// https://docs.expo.dev/versions/unversioned/sdk/notifications/#background-notification-configuration 26 | /// 27 | [JsonProperty("_contentAvailable", DefaultValueHandling = DefaultValueHandling.Ignore)] 28 | public bool? ContentAvailable { get; set; } 29 | 30 | 31 | /// 32 | /// A JSON object delivered to your app. It may be up to about 4KiB; 33 | /// the total notification payload sent to Apple and Google must be at most 4KiB or else you will get a / 34 | /// "Message Too Big" error. 35 | /// 36 | [JsonProperty("data", DefaultValueHandling = DefaultValueHandling.Ignore)] 37 | public object? Data { get; set; } 38 | 39 | /// 40 | /// The title to display in the notification. Often displayed above the notification body. 41 | /// Maps to AndroidNotification.title and aps.alert.title 42 | /// 43 | [JsonProperty(PropertyName = "title")] 44 | public string? Title { get; set; } 45 | 46 | /// 47 | /// The message to display in the notification. 48 | /// Maps to AndroidNotification.body and aps.alert.body. 49 | /// 50 | [JsonProperty(PropertyName = "body")] 51 | public string Body { get; set; } 52 | 53 | /// 54 | /// The number of seconds for which the message may be kept around for redelivery if it hasn't been delivered yet. 55 | /// Defaults to null to use the respective defaults of each provider (1 month for Android/FCM as well as iOS/APNs). 56 | /// 57 | [JsonProperty(PropertyName = "ttl", DefaultValueHandling = DefaultValueHandling.Ignore)] 58 | public int? TimeToLive { get; set; } 59 | 60 | /// 61 | /// Timestamp since the Unix epoch specifying when the message expires. 62 | /// Same effect as ttl (ttl takes precedence over expiration). 63 | /// 64 | [JsonProperty(PropertyName = "expiration", DefaultValueHandling = DefaultValueHandling.Ignore)] 65 | public int? Expiration { get; set; } 66 | 67 | /// 68 | /// 'default' | 'normal' | 'high' 69 | /// The delivery priority of the message. 70 | /// Specify default or omit this field to use the default priority on each platform / 71 | /// ("normal" on Android and "high" on iOS). 72 | /// 73 | [JsonProperty(PropertyName = "priority")] //'default' | 'normal' | 'high' 74 | public string Priority { get; set; } = "default"; 75 | 76 | 77 | /// 78 | /// iOS Only 79 | /// The subtitle to display in the notification below the title. 80 | /// Maps to aps.alert.subtitle. 81 | /// 82 | [JsonProperty(PropertyName = "subtitle", DefaultValueHandling = DefaultValueHandling.Ignore)] 83 | public string? Subtitle { get; set; } 84 | 85 | /// 86 | /// iOS Only 87 | /// Play a sound when the recipient receives this notification. 88 | /// Specify default to play the device's default notification sound, or omit this field to play no sound. 89 | /// Custom sounds need to be configured via the config plugin and then specified including the file extension. 90 | /// Example: bells_sound.wav. 91 | /// 92 | [JsonProperty(PropertyName = "sound", DefaultValueHandling = DefaultValueHandling.Ignore)] 93 | public string? Sound { get; set; } = "default"; 94 | 95 | /// 96 | /// iOS Only 97 | /// Number to display in the badge on the app icon. 98 | /// Specify zero to clear the badge. 99 | /// 100 | [JsonProperty(PropertyName = "badge", DefaultValueHandling = DefaultValueHandling.Ignore)] 101 | public int? BadgeCount { get; set; } 102 | 103 | /// 104 | /// iOS Only 105 | /// The importance and delivery timing of a notification. 106 | /// The string values correspond to the UNNotificationInterruptionLevel enumeration cases. 107 | /// https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel 108 | /// 109 | [JsonProperty(PropertyName = "interruptionLevel")] 110 | public InterruptionLevel InterruptionLevel { get; set; } = InterruptionLevel.active; 111 | 112 | /// 113 | /// Android Only 114 | /// ID of the Notification Channel through which to display this notification. 115 | /// If an ID is specified but the corresponding channel does not exist on the device / 116 | /// (that has not yet been created by your app), the notification will not be displayed to the user. 117 | /// 118 | [JsonProperty(PropertyName = "channelId", DefaultValueHandling = DefaultValueHandling.Ignore)] 119 | public string? ChannelId { get; set; } 120 | 121 | /// 122 | /// ID of the notification category that this notification is associated with. 123 | /// Find out more about notification categories here. 124 | /// https://docs.expo.dev/versions/latest/sdk/notifications/#manage-notification-categories-interactive-notifications 125 | /// 126 | [JsonProperty(PropertyName = "categoryId", DefaultValueHandling = DefaultValueHandling.Ignore)] 127 | public string? CategoryId { get; set; } 128 | 129 | /// 130 | /// Specifies whether this notification can be intercepted by the client app. Defaults to false. 131 | /// https://developer.apple.com/documentation/usernotifications/modifying_content_in_newly_delivered_notifications?language=objc 132 | /// 133 | [JsonProperty(PropertyName = "mutableContent")] 134 | public bool MutableContent { get; set; } 135 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Models/ExpoNotificationResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications.models; 5 | 6 | public class ExpoNotificationResponse 7 | { 8 | [JsonProperty(PropertyName = "data")] 9 | public List Data { get; set; } 10 | 11 | [JsonProperty(PropertyName = "errors")] 12 | public List Errors { get; set; } 13 | } 14 | 15 | public class TicketStatus 16 | { 17 | [JsonProperty(PropertyName = "status")] //"error" | "ok", 18 | public string Status { get; set; } 19 | 20 | [JsonProperty(PropertyName = "id")] 21 | public string Id { get; set; } 22 | 23 | [JsonProperty(PropertyName = "message")] 24 | public string Message { get; set; } 25 | 26 | [JsonProperty(PropertyName = "details")] 27 | public object Details { get; set; } 28 | } 29 | 30 | public class Errors 31 | { 32 | [JsonProperty(PropertyName = "code")] 33 | public string Code { get; set; } 34 | 35 | [JsonProperty(PropertyName = "message")] 36 | public string Message { get; set; } 37 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/Models/Notification.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 3 | using Newtonsoft.Json; 4 | 5 | public class Notification 6 | { 7 | /// 8 | /// Specific Jellyfin UserId that you want to target with this notification. 9 | /// This will attempt to notify all streamyfin clients that are logged in under this user. 10 | /// 11 | [JsonProperty(PropertyName = "userId")] 12 | public Guid? UserId { get; set; } 13 | 14 | /// 15 | /// Specific Jellyfin Username that you want to target with this notification. 16 | /// This will attempt to notify all streamyfin clients that are logged in under this username. 17 | /// 18 | [JsonProperty(PropertyName = "username")] 19 | public string? Username { get; set; } 20 | 21 | /// 22 | /// The title to display in the notification. Often displayed above the notification body. 23 | /// Maps to AndroidNotification.title and aps.alert.title 24 | /// 25 | [JsonProperty(PropertyName = "title", NullValueHandling = NullValueHandling.Ignore)] 26 | public string? Title { get; set; } 27 | 28 | /// 29 | /// iOS Only 30 | /// The subtitle to display in the notification below the title. 31 | /// Maps to aps.alert.subtitle. 32 | /// 33 | [JsonProperty(PropertyName = "subtitle")] 34 | public string? Subtitle { get; set; } 35 | 36 | /// 37 | /// The message to display in the notification. 38 | /// Maps to AndroidNotification.body and aps.alert.body. 39 | /// 40 | [JsonProperty(PropertyName = "body")] 41 | public string? Body { get; set; } 42 | 43 | /// 44 | /// Enforce that this notification is for Jellyfin admins only 45 | /// 46 | [JsonProperty(PropertyName = "isAdmin")] 47 | public bool IsAdmin { get; set; } 48 | 49 | public ExpoNotificationRequest ToExpoNotification() => new() 50 | { 51 | Title = Title, 52 | Subtitle = Subtitle, 53 | Body = Body 54 | }; 55 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/PushNotifications/NotificationHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Net.Http.Json; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Data.Entities; 9 | using Jellyfin.Plugin.Streamyfin.Extensions; 10 | using Jellyfin.Plugin.Streamyfin.PushNotifications.models; 11 | using MediaBrowser.Controller.Library; 12 | using Microsoft.Extensions.Logging; 13 | 14 | namespace Jellyfin.Plugin.Streamyfin.PushNotifications; 15 | 16 | public class NotificationHelper 17 | { 18 | private readonly ILogger? _logger; 19 | private readonly SerializationHelper _serializationHelper; 20 | private readonly IUserManager? _userManager; 21 | 22 | public NotificationHelper( 23 | ILoggerFactory? loggerFactory, 24 | IUserManager? userManager, 25 | SerializationHelper serializationHelper) 26 | { 27 | _logger = loggerFactory?.CreateLogger(); 28 | _userManager = userManager; 29 | _serializationHelper = serializationHelper; 30 | } 31 | 32 | /// 33 | /// Ability to send a batch of notifications directly to jellyfin admins 34 | /// 35 | /// 36 | /// 37 | public async Task SendToAdmins(params Notification[] notifications) 38 | { 39 | var adminTokens = _userManager.GetAdminTokens(); 40 | 41 | _logger?.LogInformation("Attempting to send {0} notifications to admins", notifications.Length); 42 | 43 | // No admin tokens found. 44 | if (adminTokens.Count == 0) 45 | { 46 | _logger?.LogInformation("No admins found"); 47 | return await Task.FromResult(null).ConfigureAwait(false); 48 | } 49 | 50 | var expoNotifications = notifications.Select(notification => 51 | { 52 | List userDeviceTokens = []; 53 | var expoNotification = notification.ToExpoNotification(); 54 | 55 | // Also send to target user if specified 56 | if (notification.UserId.HasValue) 57 | { 58 | userDeviceTokens = StreamyfinPlugin.Instance?.Database 59 | .GetUserDeviceTokens(notification.UserId.Value) 60 | .Select(token => token.Token) 61 | .ToList() ?? []; 62 | } 63 | 64 | expoNotification.To = adminTokens.Concat(userDeviceTokens).Distinct().ToList(); 65 | return expoNotification; 66 | }).ToArray(); 67 | 68 | return await Send(expoNotifications).ConfigureAwait(false); 69 | } 70 | 71 | public async Task SendToAll(params ExpoNotificationRequest[] notifications) 72 | { 73 | _logger?.LogInformation("Attempting to send {0} notifications to everyone", notifications.Length); 74 | 75 | var all = StreamyfinPlugin.Instance?.Database 76 | .GetAllDeviceTokens() 77 | .Select(token => token.Token) 78 | .Distinct() 79 | .ToList() ?? []; 80 | 81 | if (all.Count == 0) 82 | { 83 | _logger?.LogInformation("No devices found"); 84 | return await Task.FromResult(null).ConfigureAwait(false); 85 | } 86 | 87 | var ready = notifications 88 | .Select(notification => 89 | { 90 | notification.To = all; 91 | return notification; 92 | }).ToArray(); 93 | 94 | return await Send(ready).ConfigureAwait(false); 95 | } 96 | 97 | public async Task SendToAdmins( 98 | List? excludedUserIds = null, 99 | params ExpoNotificationRequest[] notifications) 100 | { 101 | _logger?.LogInformation("Attempting to send {0} notifications to admins", notifications.Length); 102 | 103 | var excludedIds = excludedUserIds ?? Array.Empty().ToList(); 104 | var adminTokens = _userManager.GetAdminDeviceTokens() 105 | .FindAll(deviceToken => !excludedIds.Contains(deviceToken.UserId)) 106 | .Select(deviceToken => deviceToken.Token) 107 | .Distinct() 108 | .ToList(); 109 | 110 | // No admin tokens found. 111 | if (adminTokens.Count == 0) 112 | { 113 | _logger?.LogInformation("No admins found"); 114 | return await Task.FromResult(null).ConfigureAwait(false); 115 | } 116 | 117 | var expoNotifications = notifications 118 | .Select(notification => 119 | { 120 | notification.To = adminTokens; 121 | return notification; 122 | }).ToArray(); 123 | 124 | return await Send(expoNotifications).ConfigureAwait(false); 125 | } 126 | 127 | public async Task Send(params ExpoNotificationRequest[] notifications) => 128 | await SendNotificationToExpo(_serializationHelper.ToJson(notifications)).ConfigureAwait(false); 129 | 130 | private async Task SendNotificationToExpo(string serializedRequest) 131 | { 132 | _logger?.LogDebug("Preparing to send notification"); 133 | using HttpClient client = new(); 134 | var httpRequest = GetHttpRequestMessage(serializedRequest); 135 | var rawResponse = await client.SendAsync(httpRequest).ConfigureAwait(false); 136 | _logger?.LogDebug("Received response"); 137 | httpRequest.Dispose(); 138 | 139 | return await rawResponse.Content.ReadFromJsonAsync().ConfigureAwait(false); 140 | } 141 | 142 | private static HttpRequestMessage GetHttpRequestMessage(string content) => new() 143 | { 144 | Method = HttpMethod.Post, 145 | RequestUri = new Uri("https://exp.host/--/api/v2/push/send"), 146 | Headers = 147 | { 148 | { "Host", "exp.host" }, 149 | { "Accept", "application/json" }, 150 | { "Accept-Encoding", "gzip, deflate" } 151 | }, 152 | Content = new StringContent( 153 | content: content, 154 | encoding: Encoding.UTF8, 155 | mediaType: "application/json" 156 | ) 157 | }; 158 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Resources/Strings.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | text/microsoft-resx 6 | 7 | 8 | 2.0 9 | 10 | 11 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral 12 | 13 | 14 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral 15 | 16 | 17 | 18 | 19 | Playback started 20 | 21 | 22 | 23 | {0} watching 24 | 25 | 26 | 27 | {0}, S{1}E{2} 28 | 29 | 30 | 31 | {0}, Season {1} 32 | 33 | 34 | 35 | {0}, Episode {1} 36 | 37 | 38 | 39 | 40 | 41 | Session started 42 | 43 | 44 | 45 | {0} is now online 46 | 47 | 48 | 49 | 50 | 51 | User locked out 52 | 53 | 54 | 55 | {0} has been locked out 56 | 57 | 58 | 59 | 60 | 61 | 62 | {0} added 63 | 64 | 65 | 66 | Episode added 67 | 68 | 69 | 70 | Episodes added 71 | 72 | 73 | 74 | 75 | {0} - Episode {1} added for Season {2} 76 | 77 | 78 | 79 | {0} - Episode {1} added 80 | 81 | 82 | 83 | {0} - Episode added for Season {1} 84 | 85 | 86 | 87 | {0} - {1} episodes added for Season {2} 88 | 89 | 90 | 91 | {0} - {1} episodes added 92 | 93 | 94 | 95 | 96 | 97 | {0} ({1}) 98 | 99 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/SerializationHelper.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA1869 2 | 3 | using System.Collections.Generic; 4 | using System.Collections.ObjectModel; 5 | using System.IO; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | using Jellyfin.Data.Enums; 9 | using Jellyfin.Extensions.Json; 10 | using Jellyfin.Plugin.Streamyfin.Configuration; 11 | using Newtonsoft.Json; 12 | using NJsonSchema; 13 | using NJsonSchema.Generation; 14 | using NJsonSchema.Generation.TypeMappers; 15 | using YamlDotNet.Serialization; 16 | using YamlDotNet.Serialization.NamingConventions; 17 | using JsonSchemaGenerator = NJsonSchema.Generation.JsonSchemaGenerator; 18 | using JsonSerializer = System.Text.Json.JsonSerializer; 19 | using NewtonsoftJsonSerializer = Newtonsoft.Json.JsonSerializer; 20 | 21 | 22 | namespace Jellyfin.Plugin.Streamyfin; 23 | 24 | /// 25 | /// Serialization settings for json and yaml 26 | /// 27 | public class SerializationHelper 28 | { 29 | private readonly IDeserializer _deserializer; 30 | private readonly ISerializer _yamlSerializer; 31 | private readonly NewtonsoftJsonSerializer _jsonSerializer; 32 | 33 | public SerializationHelper() 34 | { 35 | _yamlSerializer = new SerializerBuilder() 36 | .WithNamingConvention(CamelCaseNamingConvention.Instance) 37 | // We cannot use OmitDefaults since SubtitlePlaybackMode.Default gets removed. Create comb. of flags 38 | .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) 39 | .Build(); 40 | 41 | _deserializer = new DeserializerBuilder() 42 | .WithNamingConvention(CamelCaseNamingConvention.Instance) 43 | .Build(); 44 | 45 | _jsonSerializer = NewtonsoftJsonSerializer.CreateDefault(); 46 | } 47 | 48 | private JsonSerializerOptions GetJsonSerializerOptions() 49 | { 50 | var options = new JsonSerializerOptions(JsonDefaults.Options); 51 | // Prioritize these first since other converters & defaults change expected behavior 52 | options.Converters.Insert(0, new JsonNumberEnumConverter()); 53 | options.Converters.Insert(0, new JsonNumberEnumConverter()); 54 | options.Converters.Insert(0, new JsonNumberEnumConverter()); 55 | 56 | #if DEBUG 57 | options.WriteIndented = true; 58 | #endif 59 | return options; 60 | } 61 | 62 | /// 63 | /// Generate schema to json 64 | /// 65 | public static string GetJsonSchema() 66 | { 67 | var settings = new SystemTextJsonSchemaGeneratorSettings 68 | { 69 | TypeMappers = HTMLFormTypeMappers() 70 | }; 71 | #if DEBUG 72 | settings.SerializerOptions.WriteIndented = true; 73 | #endif 74 | settings.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); 75 | return JsonSchemaGenerator.FromType(settings).ToJson(); 76 | } 77 | 78 | /// 79 | /// Serialize to Yaml with Streamyfin expected options 80 | /// 81 | public string SerializeToYaml(T item) => _yamlSerializer.Serialize(item); 82 | 83 | /// 84 | /// Serialize to Json with Streamyfin expected using copied options 85 | /// 86 | public string SerializeToJson(T item) => 87 | JsonSerializer.Serialize(item, GetJsonSerializerOptions()); 88 | 89 | /// 90 | /// Serialize to Json with Streamyfin expected using copied options 91 | /// 92 | public string ToJson(T item) 93 | { 94 | var output = new StringWriter(); 95 | _jsonSerializer.Serialize(output, item); 96 | var outputAsString = output.ToString(); 97 | output.Dispose(); 98 | return outputAsString; 99 | } 100 | 101 | /// 102 | /// Deserialize Json/Yaml 103 | /// 104 | public T Deserialize(string value) => _deserializer.Deserialize(value); 105 | 106 | public static ICollection HTMLFormTypeMappers() => new Collection(new List 107 | { 108 | new PrimitiveTypeMapper( 109 | mappedType: typeof(bool), 110 | (s) => 111 | { 112 | s.Type = JsonObjectType.Boolean; 113 | s.Format = "checkbox"; 114 | s.ExtensionData = new Dictionary 115 | { 116 | { 117 | "options", 118 | new Options( 119 | inputAttrs: null, 120 | containerAttrs: new Dictionary 121 | { 122 | { "class", "checkboxContainer emby-checkbox-label" }, 123 | { "style", "text-align: center" }, 124 | } 125 | ) 126 | } 127 | }; 128 | } 129 | ), 130 | new PrimitiveTypeMapper( 131 | mappedType: typeof(string), 132 | (s) => 133 | { 134 | s.Type = JsonObjectType.String; 135 | s.ExtensionData = new Dictionary 136 | { 137 | { 138 | "options", 139 | new Options( 140 | inputAttrs: new Dictionary 141 | { 142 | { "class", "emby-input" }, 143 | }, 144 | containerAttrs: new Dictionary 145 | { 146 | { "class", "inputContainer" }, 147 | } 148 | ) 149 | } 150 | }; 151 | } 152 | ), 153 | new PrimitiveTypeMapper( 154 | mappedType: typeof(int), 155 | (s) => 156 | { 157 | s.Type = JsonObjectType.Integer; 158 | s.Format = "number"; 159 | s.ExtensionData = new Dictionary 160 | { 161 | { 162 | "options", 163 | new Options( 164 | inputAttrs: new Dictionary 165 | { 166 | { "class", "emby-input" }, 167 | }, 168 | containerAttrs: new Dictionary 169 | { 170 | { "class", "inputContainer" }, 171 | } 172 | ) 173 | } 174 | }; 175 | } 176 | ) 177 | } 178 | ); 179 | 180 | public class Options 181 | { 182 | [JsonProperty("inputAttributes", DefaultValueHandling = DefaultValueHandling.Ignore)] 183 | public Dictionary? InputAttrs { get; set; } 184 | 185 | [JsonProperty("containerAttributes", DefaultValueHandling = DefaultValueHandling.Ignore)] 186 | public Dictionary? ContainerAttrs { get; set; } 187 | 188 | public Options( 189 | Dictionary? inputAttrs = null, 190 | Dictionary? containerAttrs = null 191 | ) 192 | { 193 | if (inputAttrs is null && containerAttrs is null) 194 | return; 195 | 196 | InputAttrs = inputAttrs; 197 | ContainerAttrs = containerAttrs; 198 | } 199 | } 200 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Storage/Database.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using Microsoft.Data.Sqlite; 7 | using Jellyfin.Plugin.Streamyfin.Storage.Enums; 8 | using Jellyfin.Plugin.Streamyfin.Storage.Models; 9 | 10 | namespace Jellyfin.Plugin.Streamyfin.Storage; 11 | 12 | public class Database : IDisposable 13 | { 14 | private readonly string name = "streamyfin_plugin.db"; 15 | private bool _disposed; 16 | private ReaderWriterLockSlim WriteLock { get; } 17 | 18 | public string DbFilePath { get; set; } 19 | 20 | protected virtual int? CacheSize => null; 21 | 22 | protected virtual string LockingMode => "NORMAL"; 23 | 24 | protected virtual string JournalMode => "WAL"; 25 | 26 | protected virtual int? JournalSizeLimit => 134_217_728; // 128MiB 27 | 28 | protected virtual int? PageSize => null; 29 | 30 | protected virtual TempStoreMode TempStore => TempStoreMode.Memory; 31 | 32 | private const string DeviceTokensTable = "device_tokens"; 33 | 34 | public Database(string path) 35 | { 36 | Directory.CreateDirectory(path); 37 | DbFilePath = Path.Combine(path, name); 38 | Initialize(File.Exists(DbFilePath)); 39 | WriteLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); 40 | } 41 | 42 | private void Initialize(bool fileExists) 43 | { 44 | using var connection = CreateConnection(); 45 | 46 | string[] queries = 47 | [ 48 | $"create table if not exists {DeviceTokensTable} (DeviceId GUID PRIMARY KEY, Token TEXT NOT NULL, UserId GUID NOT NULL, Timestamp INTEGER NOT NULL)", 49 | $"create index if not exists idx_{DeviceTokensTable}_user on {DeviceTokensTable}(UserId)" 50 | ]; 51 | 52 | connection.RunQueries(queries); 53 | } 54 | 55 | /// 56 | /// Gets all the expo push tokens for the devices a user is currently signed in to 57 | /// 58 | /// Jellyfin user id 59 | /// List of DeviceToken 60 | public List GetUserDeviceTokens(Guid userId) 61 | { 62 | using (WriteLock.Read()) 63 | { 64 | var tokens = new List(); 65 | using var connection = CreateConnection(true); 66 | 67 | using (var statement = connection.PrepareStatement($"select * from {DeviceTokensTable} where UserId = @UserId;")) 68 | { 69 | statement.TryBind("@UserId", userId); 70 | 71 | tokens.AddRange( 72 | collection: statement.ExecuteQuery() 73 | .Select(row => 74 | new DeviceToken 75 | { 76 | DeviceId = row.GetGuid(0), 77 | Token = row.GetString(1), 78 | UserId = row.GetGuid(2), 79 | Timestamp = row.GetInt64(3) 80 | } 81 | ) 82 | ); 83 | } 84 | 85 | return tokens; 86 | } 87 | } 88 | 89 | /// 90 | /// Gets all known device tokens 91 | /// 92 | /// List of DeviceToken 93 | public List GetAllDeviceTokens() 94 | { 95 | using (WriteLock.Read()) 96 | { 97 | List tokens = []; 98 | using var connection = CreateConnection(true); 99 | 100 | using (var statement = connection.PrepareStatement($"select * from {DeviceTokensTable};")) 101 | { 102 | tokens.AddRange( 103 | collection: statement.ExecuteQuery() 104 | .Select(row => 105 | new DeviceToken 106 | { 107 | DeviceId = row.GetGuid(0), 108 | Token = row.GetString(1), 109 | UserId = row.GetGuid(2), 110 | Timestamp = row.GetInt64(3) 111 | } 112 | ) 113 | ); 114 | } 115 | 116 | return tokens; 117 | } 118 | } 119 | 120 | /// 121 | /// Gets the specific expo push token for a device 122 | /// 123 | /// Device id generated from streamyfin 124 | /// DeviceToken? 125 | public DeviceToken? GetDeviceTokenForDeviceId(Guid deviceId) 126 | { 127 | using (WriteLock.Read()) 128 | { 129 | using var connection = CreateConnection(true); 130 | 131 | using (var statement = connection.PrepareStatement($"select * from {DeviceTokensTable} where DeviceId = @DeviceId;")) 132 | { 133 | statement.TryBind("@DeviceId", deviceId); 134 | return statement.ExecuteQuery() 135 | .Select(row => 136 | new DeviceToken 137 | { 138 | DeviceId = row.GetGuid(0), 139 | Token = row.GetString(1), 140 | UserId = row.GetGuid(2), 141 | Timestamp = row.GetInt64(3) 142 | } 143 | ).FirstOrDefault(); 144 | } 145 | } 146 | } 147 | 148 | /// 149 | /// Adds a device token, unique to every deviceId. 150 | /// 151 | /// 152 | /// DeviceToken 153 | public DeviceToken AddDeviceToken(DeviceToken token) 154 | { 155 | using (WriteLock.Write()) 156 | { 157 | using var connection = CreateConnection(); 158 | return connection.RunInTransaction(db => 159 | { 160 | long timestamp = DateTime.UtcNow.ToFileTime(); 161 | 162 | using (var statement = db.PrepareStatement($"delete from {DeviceTokensTable} where DeviceId=@DeviceId;")) 163 | { 164 | statement.TryBind("@DeviceId", token.DeviceId); 165 | statement.ExecuteNonQuery(); 166 | } 167 | 168 | using (var statement = db.PrepareStatement($"insert into {DeviceTokensTable}(DeviceId, Token, UserId, Timestamp) values (@DeviceId, @Token, @UserId, @Timestamp);")) 169 | { 170 | statement.TryBind("@DeviceId", token.DeviceId); 171 | statement.TryBind("@Token", token.Token); 172 | statement.TryBind("@UserId", token.UserId); 173 | statement.TryBind("@Timestamp", timestamp); 174 | statement.ExecuteNonQuery(); 175 | } 176 | 177 | token.Timestamp = timestamp; 178 | return token; 179 | }); 180 | } 181 | } 182 | 183 | /// 184 | /// Get the total record count for DeviceTokensTable 185 | /// 186 | /// Total count for tokens 187 | public Int64 TotalDevicesCount() 188 | { 189 | using (WriteLock.Read()) 190 | { 191 | using var connection = CreateConnection(); 192 | return connection.RunInTransaction(db => 193 | { 194 | using (var statement = db.PrepareStatement($"select count(*) from {DeviceTokensTable};")) 195 | { 196 | return (Int64)(statement.ExecuteScalar() ?? 0); 197 | } 198 | }); 199 | } 200 | } 201 | 202 | /// 203 | /// Removes a device token using the device id 204 | /// 205 | /// Device id generated from streamyfin 206 | /// DeviceToken 207 | public void RemoveDeviceToken(Guid deviceId) 208 | { 209 | using (WriteLock.Write()) 210 | { 211 | using var connection = CreateConnection(); 212 | connection.RunInTransaction(db => 213 | { 214 | using var statement = db.PrepareStatement($"delete from {DeviceTokensTable} where DeviceId=@DeviceId;"); 215 | 216 | statement.TryBind("@DeviceId", deviceId); 217 | statement.ExecuteNonQuery(); 218 | }); 219 | } 220 | } 221 | 222 | private SqliteConnection CreateConnection(bool isReadOnly = false) 223 | { 224 | var connection = new SqliteConnection($"Filename={DbFilePath}"); 225 | connection.Open(); 226 | 227 | if (CacheSize.HasValue) 228 | { 229 | connection.Execute("PRAGMA cache_size=" + CacheSize.Value); 230 | } 231 | 232 | if (!string.IsNullOrWhiteSpace(LockingMode)) 233 | { 234 | connection.Execute("PRAGMA locking_mode=" + LockingMode); 235 | } 236 | 237 | if (!string.IsNullOrWhiteSpace(JournalMode)) 238 | { 239 | connection.Execute("PRAGMA journal_mode=" + JournalMode); 240 | } 241 | 242 | if (JournalSizeLimit.HasValue) 243 | { 244 | connection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); 245 | } 246 | 247 | if (PageSize.HasValue) 248 | { 249 | connection.Execute("PRAGMA page_size=" + PageSize.Value); 250 | } 251 | 252 | connection.Execute("PRAGMA temp_store=" + (int)TempStore); 253 | 254 | return connection; 255 | } 256 | 257 | /// 258 | /// Clear up all tables 259 | /// 260 | public void Purge() 261 | { 262 | using (WriteLock.Write()) 263 | { 264 | using var connection = CreateConnection(); 265 | connection.RunInTransaction(db => 266 | { 267 | using var statement = db.PrepareStatement($"delete from {DeviceTokensTable};"); 268 | statement.ExecuteNonQuery(); 269 | }); 270 | } 271 | } 272 | 273 | public void Dispose() 274 | { 275 | if (_disposed) 276 | { 277 | return; 278 | } 279 | 280 | _disposed = true; 281 | GC.SuppressFinalize(this); 282 | } 283 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Storage/Enums/TempStoreMode.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.Streamyfin.Storage.Enums; 2 | 3 | public enum TempStoreMode 4 | { 5 | /// 6 | /// The compile-time C preprocessor macro SQLITE_TEMP_STORE 7 | /// is used to determine where temporary tables and indices are stored. 8 | /// 9 | Default = 0, 10 | 11 | /// 12 | /// Temporary tables and indices are stored in a file. 13 | /// 14 | File = 1, 15 | 16 | /// 17 | /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. 18 | /// 19 | Memory = 2 20 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Storage/Extensions.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CS1591 2 | #pragma warning disable MT1013 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Data; 7 | using System.Globalization; 8 | using System.Threading; 9 | using Microsoft.Data.Sqlite; 10 | 11 | namespace Jellyfin.Plugin.Streamyfin.Storage; 12 | 13 | public static class Extensions 14 | { 15 | private const string DatetimeFormatUtc = "yyyy-MM-dd HH:mm:ss.FFFFFFFK"; 16 | private const string DatetimeFormatLocal = "yyyy-MM-dd HH:mm:ss.FFFFFFF"; 17 | 18 | /// 19 | /// An array of ISO-8601 DateTime formats that we support parsing. 20 | /// 21 | private static readonly string[] _datetimeFormats = new string[] 22 | { 23 | "THHmmssK", 24 | "THHmmK", 25 | "HH:mm:ss.FFFFFFFK", 26 | "HH:mm:ssK", 27 | "HH:mmK", 28 | DatetimeFormatUtc, 29 | "yyyy-MM-dd HH:mm:ssK", 30 | "yyyy-MM-dd HH:mmK", 31 | "yyyy-MM-ddTHH:mm:ss.FFFFFFFK", 32 | "yyyy-MM-ddTHH:mmK", 33 | "yyyy-MM-ddTHH:mm:ssK", 34 | "yyyyMMddHHmmssK", 35 | "yyyyMMddHHmmK", 36 | "yyyyMMddTHHmmssFFFFFFFK", 37 | "THHmmss", 38 | "THHmm", 39 | "HH:mm:ss.FFFFFFF", 40 | "HH:mm:ss", 41 | "HH:mm", 42 | DatetimeFormatLocal, 43 | "yyyy-MM-dd HH:mm:ss", 44 | "yyyy-MM-dd HH:mm", 45 | "yyyy-MM-ddTHH:mm:ss.FFFFFFF", 46 | "yyyy-MM-ddTHH:mm", 47 | "yyyy-MM-ddTHH:mm:ss", 48 | "yyyyMMddHHmmss", 49 | "yyyyMMddHHmm", 50 | "yyyyMMddTHHmmssFFFFFFF", 51 | "yyyy-MM-dd", 52 | "yyyyMMdd", 53 | "yy-MM-dd" 54 | }; 55 | 56 | public static IEnumerable Query(this SqliteConnection sqliteConnection, string commandText) 57 | { 58 | if (sqliteConnection.State != ConnectionState.Open) 59 | { 60 | sqliteConnection.Open(); 61 | } 62 | 63 | using var command = sqliteConnection.CreateCommand(); 64 | command.CommandText = commandText; 65 | using (var reader = command.ExecuteReader()) 66 | { 67 | while (reader.Read()) 68 | { 69 | yield return reader; 70 | } 71 | } 72 | } 73 | 74 | public static void Execute(this SqliteConnection sqliteConnection, string commandText) 75 | { 76 | using var command = sqliteConnection.CreateCommand(); 77 | command.CommandText = commandText; 78 | command.ExecuteNonQuery(); 79 | } 80 | 81 | public static void RunQueries(this SqliteConnection connection, string[] queries) 82 | { 83 | connection.RunInTransaction(conn => 84 | { 85 | foreach (var querie in queries) 86 | { 87 | conn.Execute(querie); 88 | } 89 | }); 90 | } 91 | 92 | public static string ToDateTimeParamValue(this DateTime dateValue) 93 | { 94 | var kind = DateTimeKind.Utc; 95 | 96 | return (dateValue.Kind == DateTimeKind.Unspecified) 97 | ? DateTime.SpecifyKind(dateValue, kind).ToString( 98 | GetDateTimeKindFormat(kind), 99 | CultureInfo.InvariantCulture) 100 | : dateValue.ToString( 101 | GetDateTimeKindFormat(dateValue.Kind), 102 | CultureInfo.InvariantCulture); 103 | } 104 | 105 | private static string GetDateTimeKindFormat(DateTimeKind kind) 106 | => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; 107 | 108 | public static bool TryReadDateTime(this SqliteDataReader reader, int index, out DateTime result) 109 | { 110 | if (reader.IsDBNull(index)) 111 | { 112 | result = default; 113 | return false; 114 | } 115 | 116 | var dateText = reader.GetString(index); 117 | 118 | if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, 119 | DateTimeStyles.AdjustToUniversal, out var dateTimeResult)) 120 | { 121 | // If the resulting DateTimeKind is Unspecified it is actually Utc. 122 | // This is required downstream for the Json serializer. 123 | if (dateTimeResult.Kind == DateTimeKind.Unspecified) 124 | { 125 | dateTimeResult = DateTime.SpecifyKind(dateTimeResult, DateTimeKind.Utc); 126 | } 127 | 128 | result = dateTimeResult; 129 | return true; 130 | } 131 | 132 | result = default; 133 | return false; 134 | } 135 | 136 | public static bool TryGetGuid(this SqliteDataReader reader, int index, out Guid result) 137 | { 138 | if (reader.IsDBNull(index)) 139 | { 140 | result = default; 141 | return false; 142 | } 143 | 144 | result = reader.GetGuid(index); 145 | return true; 146 | } 147 | 148 | public static bool TryGetString(this SqliteDataReader reader, int index, out string result) 149 | { 150 | result = string.Empty; 151 | 152 | if (reader.IsDBNull(index)) 153 | { 154 | return false; 155 | } 156 | 157 | result = reader.GetString(index); 158 | return true; 159 | } 160 | 161 | public static bool TryGetBoolean(this SqliteDataReader reader, int index, out bool result) 162 | { 163 | if (reader.IsDBNull(index)) 164 | { 165 | result = default; 166 | return false; 167 | } 168 | 169 | result = reader.GetBoolean(index); 170 | return true; 171 | } 172 | 173 | public static int GetInt(this SqliteDataReader reader, int index) 174 | { 175 | return reader.GetInt32(index); 176 | } 177 | 178 | public static bool TryGetInt32(this SqliteDataReader reader, int index, out int result) 179 | { 180 | if (reader.IsDBNull(index)) 181 | { 182 | result = default; 183 | return false; 184 | } 185 | 186 | result = reader.GetInt32(index); 187 | return true; 188 | } 189 | 190 | public static bool TryGetInt64(this SqliteDataReader reader, int index, out long result) 191 | { 192 | if (reader.IsDBNull(index)) 193 | { 194 | result = default; 195 | return false; 196 | } 197 | 198 | result = reader.GetInt64(index); 199 | return true; 200 | } 201 | 202 | public static bool TryGetSingle(this SqliteDataReader reader, int index, out float result) 203 | { 204 | if (reader.IsDBNull(index)) 205 | { 206 | result = default; 207 | return false; 208 | } 209 | 210 | result = reader.GetFloat(index); 211 | return true; 212 | } 213 | 214 | public static bool TryGetDouble(this SqliteDataReader reader, int index, out double result) 215 | { 216 | if (reader.IsDBNull(index)) 217 | { 218 | result = default; 219 | return false; 220 | } 221 | 222 | result = reader.GetDouble(index); 223 | return true; 224 | } 225 | 226 | public static void TryBind(this SqliteCommand statement, string name, Guid value) 227 | { 228 | statement.TryBind(name, value, true); 229 | } 230 | 231 | public static void TryBind(this SqliteCommand statement, string name, object value, bool isBlob = false) 232 | { 233 | var preparedValue = value ?? DBNull.Value; 234 | if (statement.Parameters.Contains(name)) 235 | { 236 | statement.Parameters[name].Value = preparedValue; 237 | } 238 | else 239 | { 240 | // Blobs aren't always detected automatically 241 | if (isBlob) 242 | { 243 | statement.Parameters.Add(new SqliteParameter(name, SqliteType.Blob) { Value = value }); 244 | } 245 | else 246 | { 247 | statement.Parameters.AddWithValue(name, preparedValue); 248 | } 249 | } 250 | } 251 | 252 | public static void TryBindNull(this SqliteCommand statement, string name) 253 | { 254 | statement.TryBind(name, DBNull.Value); 255 | } 256 | 257 | public static IEnumerable ExecuteQuery(this SqliteCommand command) 258 | { 259 | using (var reader = command.ExecuteReader()) 260 | { 261 | while (reader.Read()) 262 | { 263 | yield return reader; 264 | } 265 | } 266 | } 267 | 268 | public static int? SelectScalarInt(this SqliteCommand command) 269 | { 270 | var result = command.ExecuteScalar(); 271 | if (result == null || result == DBNull.Value) 272 | { 273 | return null; 274 | } 275 | 276 | return Convert.ToInt32(result, CultureInfo.InvariantCulture); 277 | } 278 | 279 | public static long? SelectScalarInt64(this SqliteCommand command) 280 | { 281 | var result = command.ExecuteScalar(); 282 | if (result == null || result == DBNull.Value) 283 | { 284 | return null; 285 | } 286 | 287 | return Convert.ToInt64(result, CultureInfo.InvariantCulture); 288 | } 289 | 290 | public static SqliteCommand PrepareStatement(this SqliteConnection sqliteConnection, string sql) 291 | { 292 | var command = sqliteConnection.CreateCommand(); 293 | command.CommandText = sql; 294 | return command; 295 | } 296 | 297 | public static void RunInTransaction(this SqliteConnection This, Action action) 298 | { 299 | This.RunInTransaction((Func)delegate(SqliteConnection connection) 300 | { 301 | action(connection); 302 | return null; 303 | }); 304 | } 305 | 306 | public static T RunInTransaction(this SqliteConnection This, Func f) 307 | { 308 | var transaction = This.BeginTransaction(); 309 | try 310 | { 311 | T result = f(This); 312 | transaction.Commit(); 313 | return result; 314 | } 315 | catch (Exception) 316 | { 317 | transaction.Rollback(); 318 | throw; 319 | } 320 | } 321 | 322 | public static bool TableExists(this SqliteConnection This, string name) 323 | { 324 | using var statement = This.PrepareStatement("select DISTINCT tbl_name from sqlite_master"); 325 | foreach (var row in statement.ExecuteQuery()) 326 | { 327 | if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) 328 | { 329 | return true; 330 | } 331 | } 332 | 333 | return false; 334 | } 335 | } 336 | 337 | public static class ReaderWriterLockSlimExtensions 338 | { 339 | public static IDisposable Read(this ReaderWriterLockSlim obj) 340 | { 341 | return new ReadLockToken(obj); 342 | } 343 | 344 | public static IDisposable Write(this ReaderWriterLockSlim obj) 345 | { 346 | return new WriteLockToken(obj); 347 | } 348 | 349 | private sealed class ReadLockToken : IDisposable 350 | { 351 | private ReaderWriterLockSlim _sync; 352 | 353 | public ReadLockToken(ReaderWriterLockSlim sync) 354 | { 355 | _sync = sync; 356 | sync.EnterReadLock(); 357 | } 358 | 359 | public void Dispose() 360 | { 361 | if (_sync != null) 362 | { 363 | _sync.ExitReadLock(); 364 | _sync = null; 365 | } 366 | } 367 | } 368 | 369 | private sealed class WriteLockToken : IDisposable 370 | { 371 | private ReaderWriterLockSlim _sync; 372 | 373 | public WriteLockToken(ReaderWriterLockSlim sync) 374 | { 375 | _sync = sync; 376 | sync.EnterWriteLock(); 377 | } 378 | 379 | public void Dispose() 380 | { 381 | if (_sync != null) 382 | { 383 | _sync.ExitWriteLock(); 384 | _sync = null; 385 | } 386 | } 387 | } 388 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/Storage/Models/DeviceToken.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Jellyfin.Plugin.Streamyfin.Storage.Models; 5 | 6 | public class DeviceToken 7 | { 8 | [JsonProperty(PropertyName = "token")] 9 | public string Token { get; set; } 10 | [JsonProperty(PropertyName = "deviceId")] 11 | public Guid DeviceId { get; set; } 12 | [JsonProperty(PropertyName = "userId")] 13 | public Guid UserId { get; set; } 14 | [JsonProperty(PropertyName = "timestamp")] 15 | public long Timestamp { get; set; } 16 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.Streamyfin/StreamyfinManager.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA2007 2 | #pragma warning disable CA1861 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Jellyfin.Data.Enums; 10 | using Jellyfin.Extensions; 11 | using MediaBrowser.Common.Configuration; 12 | using MediaBrowser.Controller.Collections; 13 | using MediaBrowser.Controller.Dto; 14 | using MediaBrowser.Controller.Entities; 15 | using MediaBrowser.Controller.Entities.Movies; 16 | using MediaBrowser.Controller.Library; 17 | using MediaBrowser.Controller.LiveTv; 18 | using MediaBrowser.Controller.Persistence; 19 | using MediaBrowser.Controller.Providers; 20 | using MediaBrowser.Model.Entities; 21 | using MediaBrowser.Model.IO; 22 | using MediaBrowser.Model.LiveTv; 23 | using Microsoft.Extensions.Logging; 24 | using Microsoft.Extensions.Hosting; 25 | using Jellyfin.Data.Entities; 26 | using Jellyfin.Plugin.Streamyfin.Configuration; 27 | using System.Security.Cryptography.X509Certificates; 28 | 29 | namespace Jellyfin.Plugin.Streamyfin; 30 | 31 | public class StreamyfinManager 32 | { 33 | private readonly ILogger _logger; 34 | private readonly IConfigurationManager _config; 35 | private readonly IFileSystem _fileSystem; 36 | private readonly IItemRepository _itemRepo; 37 | private readonly ILibraryManager _libraryManager; 38 | private readonly ICollectionManager _collectionManager; 39 | 40 | public StreamyfinManager( 41 | ILogger logger, 42 | IConfigurationManager config, 43 | IFileSystem fileSystem, 44 | IItemRepository itemRepo, 45 | ILibraryManager libraryManager, 46 | ICollectionManager collectionManager) 47 | { 48 | _logger = logger; 49 | _config = config; 50 | _fileSystem = fileSystem; 51 | _itemRepo = itemRepo; 52 | _libraryManager = libraryManager; 53 | _collectionManager = collectionManager; 54 | } 55 | 56 | } 57 | 58 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export VERSION := $(shell git describe --tags --abbrev=0 | awk -F. -v OFS=. '{ $$2 = $$2 + 1; $$3 = 0; $$4 = 0; print }') 2 | export GITHUB_REPO := streamyfin/jellyfin-plugin-streamyfin 3 | export FILE := streamyfin-${VERSION}.zip 4 | 5 | print: 6 | echo ${VERSION} 7 | 8 | k: zip 9 | 10 | zip: 11 | mkdir -p ./dist 12 | zip -r -j "./dist/${FILE}" Jellyfin.Plugin.Streamyfin/bin/Release/net8.0/Jellyfin.Plugin.Streamyfin.dll packages/ 13 | 14 | csum: 15 | md5sum "./dist/${FILE}" 16 | 17 | create-tag: 18 | git tag ${VERSION} 19 | git push origin ${VERSION} 20 | 21 | create-gh-release: 22 | gh release create ${VERSION} "./dist/${FILE}" --generate-notes --verify-tag 23 | 24 | update-version: 25 | sed -i 's/\(.*\)<\(.*\)Version>\(.*\)<\/\(.*\)Version>/\1<\2Version>${VERSION}<\/\4Version>/g' Jellyfin.Plugin.Streamyfin/Jellyfin.Plugin.Streamyfin.csproj 26 | 27 | update-manifest: 28 | node scripts/validate-and-update-manifest.js 29 | 30 | test: 31 | dotnet test Jellyfin.Plugin.Streamyfin.Tests 32 | 33 | build: 34 | dotnet build Jellyfin.Plugin.Streamyfin --configuration Release 35 | 36 | push-manifest: 37 | git commit -m 'new release: ${VERSION}' manifest.json Jellyfin.Plugin.Streamyfin/Jellyfin.Plugin.Streamyfin.csproj 38 | git push origin main 39 | 40 | release: print update-version build zip create-tag create-gh-release update-manifest push-manifest 41 | -------------------------------------------------------------------------------- /NOTIFICATIONS.md: -------------------------------------------------------------------------------- 1 | # Streamyfin client notifications 2 | 3 | Our plugin can consume any event and forward them to your streamyfin users! 4 | 5 | There are currently a few jellyfin events directly supported by our plugin! 6 | 7 | Events: 8 | - Item Added (Everyone) 9 | - Session Started (Admin only) 10 | - User Locked out (Admin + user who was locked out) 11 | - Playback started (Admin only) 12 | 13 | These can be enabled/disabled inside our plugins page as a setting! 14 | 15 | 16 | ## Custom webhook notifications 17 | If you want to directly start using the notification endpoint with other services, take a look at our examples on how to do so! 18 | 19 | Custom webhook examples: 20 | - [Jellyfin](#Jellyfin) 21 | - [Jellyseerr](#Jellyseerr) 22 | 23 | --- 24 | 25 | # Endpoint (Authorization required) 26 | 27 | `http(s)://server.instance/Streamyfin/notification` 28 | 29 | This endpoint requires two headers: 30 | 31 | key: `Content-Type`
32 | value: `application/json` 33 | 34 | key: `Authorization`
35 | value: `MediaBrowser Token="{apiKey}"` 36 | 37 | **You can generate a jellyfin API Key by going to** 38 | `Dashboard -> Advanced (bottom left) -> API Keys -> Click on (+) to genreate a key` 39 | 40 | ## Template 41 | ```json 42 | [ 43 | { 44 | "title": "string", // Notification title (required) 45 | "subtitle": "string", // Notification subtitle (Visible only to iOS users) 46 | "body": "string", // Notification body (required) 47 | "userId": "string", // Target jellyfin user id this notification is for 48 | "username": "string", // Target jellyfin username this notification is for 49 | "isAdmin": false // Boolean to determine if notification also targets admins. 50 | } 51 | ] 52 | ``` 53 | 54 | ## Notifying all users 55 | To do this all you have to do is populate title & body! Other fields are not required! 56 | 57 | --- 58 | 59 | # Examples 60 | 61 | ## Jellyfin 62 | You can use the [jellyfin-webhook-plugin](https://github.com/jellyfin/jellyfin-plugin-webhook) to create a notification based on any event they offer. 63 | 64 | - Visit the webhooks config page 65 | - Click "Add Generic Destination" 66 | - Webhook Url should be the url example from above 67 | - Selected notification type 68 | 69 | If we don't directly support an event you'll want to create a separate webhook destination for each event so we can avoid filtering on our end. 70 | 71 | **We are currently looking into supporting as many of the jellyfin events so that you don't have to worry about configuring them!** 72 | 73 | ### examples 74 | 75 | - [Item Added](#item-added-notification) 76 | - We currently support this on our end with the enhancement of: 77 | - reducing spam when multiple episodes are added for a season in a short period of time. 78 | - deep link into item page to start playing item from notification 79 | 80 | 81 | ### Item added notification 82 | - Select event "Item Added" 83 | - Paste in template below 84 | 85 | ```json 86 | [ 87 | { 88 | {{#if_equals ItemType 'Movie'}} 89 | "title": "{{{Name}}} ({{Year}}) added", 90 | "body": "Watch movie now" 91 | {{/if_equals}} 92 | {{#if_equals ItemType 'Season'}} 93 | "title": "{{{SeriesName}}} season added", 94 | "body": "Watch season '{{{Name}}}' now" 95 | {{/if_equals}} 96 | {{#if_equals ItemType 'Episode'}} 97 | "title": "{{{SeriesName}}} S{{SeasonNumber00}}E{{EpisodeNumber00}} added", 98 | "body": "Watch episode '{{{Name}}}' now" 99 | {{/if_equals}} 100 | } 101 | ] 102 | ``` 103 | 104 | --- 105 | 106 | ## Jellyseerr 107 | 108 | You can go to your jellyseerr instances notification settings to forward events 109 | 110 | - Go to Settings > Notifications > Webhook 111 | - Check "Enable Agent" 112 | - Enter notification endpoint as "Webhook URL" 113 | - Copy an example below 114 | 115 | [Template variable help](https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables) 116 | 117 | 118 | ## Issues notification 119 | 120 | - Copy json below and paste in as JSON Payload 121 | - Select Notification Types 122 | - Issue Reported 123 | - Issue Commented 124 | - Issue Resolved 125 | - Issue Reopened 126 | 127 | ```json 128 | [ 129 | { 130 | "title": "{{event}}", 131 | "body": "{{subject}}: {{message}}", 132 | "isAdmin": true 133 | }, 134 | { 135 | "title": "{{event}} - {{subject}}", 136 | "body": "{{commentedBy_username}}: {{comment_message}}", 137 | "isAdmin": true 138 | } 139 | ] 140 | ``` 141 | 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Companion plugin for Streamyfin

4 | 5 |

6 | Allows for a centralised configuration of the Streamyfin application. 7 |
8 | Configure and synchronize the apps settings or notifications! 9 |

10 | 11 |
12 | 13 | 14 | Features 15 | ------ 16 | 17 | With this plugin you allow the streamyfin application to do the following for all your users... 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Automatically log in users into jellyseerrCustom Home screen using collectionsNotification support
33 | 34 |

Plus many more features!

35 | 36 | 37 | Install Process 38 | ------ 39 | 40 | 1. In jellyfin, go to dashboard -> plugins -> repositories -> copy and paste this link: https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/main/manifest.json 41 | 2. Go to Catalog and search for Streamyfin 42 | 3. Click on it and install 43 | 4. Restart Jellyfin 44 | 45 | 46 | More information 47 | ------ 48 | 49 | ### Automatically log into jellyseerr 50 | 51 | We currently offer the ability to log in your users into jellyseerr for a seamless jellyseerr integration.
52 | Currently this is only supported if you use Jellyfin authentication to log into your jellyseerr instance. 53 | 54 | 55 | ### Customize home screen 56 | 57 | You are able to further customize your home screen beyond jellyfins current limits. 58 | This can be done alongside using the [collection import](https://github.com/lostb1t/jellyfin-plugin-collection-import) plugin, one can make very dynamic homescreens. 59 | 60 | Be inspired to create home screens by adding views for collections like 61 | `Trending`, `Popular`, `Most viewed`, etc.
62 | Just like you would see from major streaming applications like netflix! 63 | 64 | ### Notifications 65 | 66 | We're currently working hard to offer some of jellyfins events as notifications when you use streamyfin.
67 | Some notifications that work out the box with almost no configuration are: 68 | 69 | - `Item Added` tells you when new content is added to your server. This also summarizes the total episodes being added for a series season. This reduces the amount of notification spam. 70 | - `Session Started` allows you to see when your users are online in jellyfin 71 | - `Playback started` allows you to see when content is being played from your server 72 | - `User locked out` allows you to see when a user has been locked out from their account so you can be proactive on resetting if required. 73 | 74 | And if thats not enough we also offer a notification end point that you can use to utilize with any other service! 75 | 76 | [Read more about notifications](NOTIFICATIONS.md) 77 | 78 | ### Using the plugin configuration page 79 | 80 | We offer the ability to modify the configuration via yaml or by normal form. 81 | 82 | See [yaml examples](https://github.com/streamyfin/jellyfin-plugin-streamyfin/tree/main/examples) -------------------------------------------------------------------------------- /assets/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/assets/home.jpg -------------------------------------------------------------------------------- /assets/jellyseerr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/assets/jellyseerr.png -------------------------------------------------------------------------------- /assets/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/assets/notifications.png -------------------------------------------------------------------------------- /examples/full.yml: -------------------------------------------------------------------------------- 1 | # You can remove any settings you do not need configured. 2 | 3 | # Format Example 4 | # settingName: 5 | # locked: true | false # if true, locks the setting from modification in app. Default false. 6 | # value: value # Value you want the setting to be. Editor will give you type suggestion for a specific setting. 7 | 8 | # Example below shows all supported settings at this time. 9 | settings: 10 | # Media Controls 11 | forwardSkipTime: 12 | rewindSkipTime: 13 | 14 | # Audio Controls 15 | rememberAudioSelections: 16 | 17 | # Subtitles 18 | subtitleMode: 19 | rememberSubtitleSelections: 20 | subtitleSize: 21 | 22 | # Other 23 | autoRotate: 24 | defaultVideoOrientation: 25 | safeAreaInControlsEnabled: 26 | showCustomMenuLinks: 27 | hiddenLibraries: 28 | disableHapticFeedback: 29 | 30 | # Downloads 31 | downloadMethod: 32 | remuxConcurrentLimit: 33 | autoDownload: 34 | optimizedVersionsServerUrl: 35 | 36 | # Jellyseerr 37 | jellyseerrServerUrl: 38 | 39 | # Search 40 | searchEngine: 41 | marlinServerUrl: 42 | 43 | # Popular Lists 44 | usePopularPlugin: 45 | mediaListCollectionIds: 46 | 47 | # misc 48 | libraryOptions: 49 | locked: false 50 | value: 51 | display: list | row 52 | cardStyle: detailed | compact 53 | imageStyle: cover | poster 54 | showTitles: boolean 55 | showStats: boolean 56 | 57 | # Home 58 | home: 59 | locked: true 60 | value: 61 | sections: 62 | - title: Continue Watching 63 | orientation: vertical 64 | items: 65 | filters: 66 | - IsResumable 67 | includeItemTypes: 68 | - Episode 69 | - Movie 70 | limit: 25 71 | - title: Nextup 72 | orientation: horizontal 73 | nextUp: 74 | limit: 25 75 | - title: Recently Added 76 | orientation: vertical 77 | items: 78 | sortBy: 79 | - DateCreated 80 | sortOrder: 81 | - Descending 82 | includeItemTypes: 83 | - Series 84 | - Movie 85 | limit: 25 86 | # latest is can group episodes. Which items cannot do. 87 | - title: Latest 88 | orientation: horizontal 89 | latest: 90 | limit: 25 91 | - title: Favorites 92 | orientation: vertical 93 | items: 94 | sortBy: 95 | - Default 96 | sortOrder: 97 | - Ascending 98 | filters: 99 | - IsFavorite 100 | includeItemTypes: 101 | - Series 102 | - Movie 103 | limit: 25 104 | # It's possible to filter by collection. 105 | # Checkout https://github.com/lostb1t/jellyfin-plugin-collection-import to create collections from external lists 106 | - title: My collection 107 | items: 108 | # parentID is the collection id. You can find it in the url on the web collection page 109 | parentId: ab7b2bcacafa97a2ad7d72d86eb1b408 110 | includeItemTypes: 111 | - Movie 112 | - Series 113 | -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | False 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /local.sh: -------------------------------------------------------------------------------- 1 | if [ ! -f env.local ]; then 2 | echo "Looks like env.local doesn't exist. Creating... " 3 | echo "Please set env.local properties." 4 | 5 | cat > env.local <<- EOF 6 | CONFIGURATION=Release 7 | REMOTE=false # if true we will send files over via scp 8 | # USER= Set the user name to connect to HOST 9 | # HOST= Host for jellyfin remote server 10 | PLUGIN_PATH= 11 | EOF 12 | exit 1 13 | fi; 14 | 15 | source env.local 16 | 17 | if [ -z "$REMOTE" ]; then 18 | echo "Setting REMOTE in env.local is a requirement." 19 | exit 1 20 | else 21 | if [[ "$REMOTE" =~ ^(true|false)$ ]]; then 22 | if [ -z "$USER" ] || [ -z "$HOST" ]; then 23 | echo "You must set USER & HOST in env.local if using remote!" 24 | exit 1 25 | fi; 26 | else 27 | echo "REMOTE in env.local must be a boolean!" 28 | exit 1 29 | fi; 30 | fi; 31 | 32 | if [ -z "$PLUGIN_PATH" ]; then 33 | echo "Setting PLUGIN_PATH in env.local is a requirement." 34 | exit 1 35 | fi; 36 | 37 | PLUGIN_VERSION=$(grep '' < Jellyfin.Plugin.Streamyfin/Jellyfin.Plugin.Streamyfin.csproj | sed 's/.*\(.*\)<\/FileVersion>/\1/') 38 | echo "Current version: $PLUGIN_VERSION" 39 | 40 | dotnet build Jellyfin.Plugin.Streamyfin --configuration $CONFIGURATION 41 | 42 | rm -rf ./dist/ 43 | mkdir ./dist/ 44 | 45 | cp ./packages/* ./dist/ 46 | cp ./Jellyfin.Plugin.Streamyfin/bin/$CONFIGURATION/net8.0/Jellyfin.Plugin.Streamyfin.dll ./dist/ 47 | 48 | if [ "$REMOTE" = "true" ]; then 49 | scp -r ./dist/* $USER@$HOST:$PLUGIN_PATH/Streamyfin_$PLUGIN_VERSION 50 | else 51 | mkdir -p $PLUGIN_PATH 52 | cp -R ./dist/ $PLUGIN_PATH/Streamyfin_$PLUGIN_VERSION 53 | fi; 54 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/logo.png -------------------------------------------------------------------------------- /packages/NJsonSchema.Annotations.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/packages/NJsonSchema.Annotations.dll -------------------------------------------------------------------------------- /packages/NJsonSchema.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/packages/NJsonSchema.dll -------------------------------------------------------------------------------- /packages/Namotion.Reflection.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/packages/Namotion.Reflection.dll -------------------------------------------------------------------------------- /packages/Newtonsoft.Json.Schema.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/packages/Newtonsoft.Json.Schema.dll -------------------------------------------------------------------------------- /packages/YamlDotNet.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamyfin/jellyfin-plugin-streamyfin/10fece54dfe87c8be7164d172f8668a5b0b6a534/packages/YamlDotNet.dll -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const VERSION = process.env.VERSION; 4 | 5 | // Read csproj 6 | const csprojPath = './Jellyfin.Plugin.Streamyfin/Jellyfin.Plugin.Streamyfin.csproj'; 7 | if (!fs.existsSync(csprojPath)) { 8 | console.error('Jellyfin.Plugin.Streamyfin.csproj file not found'); 9 | process.exit(1); 10 | } 11 | 12 | // Read the .csproj file 13 | fs.readFile(csprojPath, 'utf8', (err, data) => { 14 | if (err) { 15 | return console.error('Failed to read .csproj file:', err); 16 | } 17 | 18 | let newAssemblyVersion = null; 19 | let newFileVersion = null; 20 | console.log(VERSION); 21 | // Use regex to find and increment versions 22 | const updatedData = data.replace(/(.*?)<\/AssemblyVersion>/, (match, version) => { 23 | newAssemblyVersion = VERSION; 24 | return `${newAssemblyVersion}`; 25 | }).replace(/(.*?)<\/FileVersion>/, (match, version) => { 26 | newFileVersion = VERSION; 27 | return `${newFileVersion}`; 28 | });; 29 | 30 | // Write the updated XML back to the .csproj file 31 | fs.writeFile(csprojPath, updatedData, 'utf8', (err) => { 32 | if (err) { 33 | return console.error('Failed to write .csproj file:', err); 34 | } 35 | console.log('Version incremented successfully!'); 36 | 37 | // Write the new versions to GitHub Actions environment files 38 | // fs.appendFileSync(process.env.GITHUB_ENV, `NEW_ASSEMBLY_VERSION=${newAssemblyVersion}\n`); 39 | // fs.appendFileSync(process.env.GITHUB_ENV, `NEW_FILE_VERSION=${newFileVersion}\n`); 40 | }); 41 | }); 42 | 43 | -------------------------------------------------------------------------------- /scripts/validate-and-update-manifest.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const { URL } = require('url'); 5 | 6 | const repository = process.env.GITHUB_REPO; 7 | const version = process.env.VERSION; 8 | const file = process.env.FILE; 9 | const targetAbi = "10.9.9.0"; 10 | 11 | console.log(file); 12 | // Read manifest.json 13 | const manifestPath = './manifest.json'; 14 | if (!fs.existsSync(manifestPath)) { 15 | console.error('manifest.json file not found'); 16 | process.exit(1); 17 | } 18 | 19 | // Read README.md 20 | // const readmePath = './README.md'; 21 | // if (!fs.existsSync(readmePath)) { 22 | // console.error('README.md file not found'); 23 | // process.exit(1); 24 | // } 25 | 26 | const jsonData = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); 27 | 28 | const newVersion = { 29 | version, 30 | changelog: `- See the full changelog at [GitHub](https://github.com/${repository}/releases/tag/${version})\n`, 31 | targetAbi, 32 | sourceUrl: `https://github.com/${repository}/releases/download/${version}/${file}`, 33 | checksum: getMD5FromFile(), 34 | timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') 35 | }; 36 | 37 | async function updateManifest() { 38 | await validVersion(newVersion); 39 | 40 | // Add the new version to the manifest 41 | jsonData[0].versions.unshift(newVersion); 42 | 43 | // Write the updated manifest to file if validation is successful 44 | fs.writeFileSync(manifestPath, JSON.stringify(jsonData, null, 4)); 45 | console.log('Manifest updated successfully.'); 46 | //updateReadMeVersion(); 47 | process.exit(0); // Exit with no error 48 | } 49 | 50 | async function validVersion(version) { 51 | console.log(`Validating version ${version.version}...`); 52 | 53 | const isValidChecksum = await verifyChecksum(version.sourceUrl, version.checksum); 54 | if (!isValidChecksum) { 55 | console.error(`Checksum mismatch for URL: ${version.sourceUrl}`); 56 | process.exit(1); // Exit with an error code 57 | } else { 58 | console.log(`Version ${version.version} is valid.`); 59 | } 60 | } 61 | 62 | async function verifyChecksum(url, expectedChecksum) { 63 | try { 64 | const hash = await downloadAndHashFile(url); 65 | return hash === expectedChecksum; 66 | } catch (error) { 67 | console.error(`Error verifying checksum for URL: ${url}`, error); 68 | return false; 69 | } 70 | } 71 | 72 | async function downloadAndHashFile(url, redirects = 5) { 73 | if (redirects === 0) { 74 | throw new Error('Too many redirects'); 75 | } 76 | 77 | return new Promise((resolve, reject) => { 78 | https.get(url, (response) => { 79 | if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { 80 | // Follow redirect 81 | const redirectUrl = new URL(response.headers.location, url).toString(); 82 | downloadAndHashFile(redirectUrl, redirects - 1) 83 | .then(resolve) 84 | .catch(reject); 85 | } else if (response.statusCode === 200) { 86 | const hash = crypto.createHash('md5'); 87 | response.pipe(hash); 88 | response.on('end', () => { 89 | resolve(hash.digest('hex')); 90 | }); 91 | response.on('error', (err) => { 92 | reject(err); 93 | }); 94 | } else { 95 | reject(new Error(`Failed to get '${url}' (${response.statusCode})`)); 96 | } 97 | }).on('error', (err) => { 98 | reject(err); 99 | }); 100 | }); 101 | } 102 | 103 | function getMD5FromFile() { 104 | const fileBuffer = fs.readFileSync(`./dist/${file}`); 105 | return crypto.createHash('md5').update(fileBuffer).digest('hex'); 106 | } 107 | 108 | // function getReadMeVersion() { 109 | // let parts = targetAbi.split('.').map(Number); 110 | // parts.pop(); 111 | // return parts.join("."); 112 | // } 113 | 114 | // function updateReadMeVersion() { 115 | // const newVersion = getReadMeVersion(); 116 | // const readMeContent = fs.readFileSync(readmePath, 'utf8'); 117 | 118 | // const updatedContent = readMeContent 119 | // .replace(/Jellyfin.*\(or newer\)/, `Jellyfin ${newVersion} (or newer)`) 120 | // if (readMeContent != updatedContent) { 121 | // fs.writeFileSync(readmePath, updatedContent); 122 | // console.log('Updated README with new Jellyfin version.'); 123 | // } else { 124 | // console.log('README has already newest Jellyfin version.'); 125 | // } 126 | // } 127 | 128 | async function run() { 129 | await updateManifest(); 130 | } 131 | 132 | run(); 133 | --------------------------------------------------------------------------------