├── .editorconfig ├── .github ├── release-drafter.yml ├── renovate.json └── workflows │ ├── build.yaml │ ├── changelog.yaml │ ├── command-dispatch.yaml │ ├── command-rebase.yaml │ ├── publish-unstable.yaml │ ├── publish.yaml │ ├── scan-codeql.yaml │ ├── sync-labels.yaml │ └── test.yaml ├── .gitignore ├── Directory.Build.props ├── Jellyfin.Plugin.NextPVR.sln ├── Jellyfin.Plugin.NextPVR ├── Configuration │ └── PluginConfiguration.cs ├── Entities │ ├── ClientKeys.cs │ ├── MyRecordingInfo.cs │ └── SerializableDictionary.cs ├── Helpers │ ├── ChannelHelper.cs │ ├── GenreMapper.cs │ └── UtilsHelper.cs ├── Jellyfin.Plugin.NextPVR.csproj ├── LiveTvService.cs ├── Plugin.cs ├── RecordingsChannel.cs ├── Responses │ ├── CancelDeleteRecordingResponse.cs │ ├── ChannelResponse.cs │ ├── InitializeResponse.cs │ ├── InstantiateResponse.cs │ ├── LastUpdateResponse.cs │ ├── ListingsResponse.cs │ ├── RecordingResponse.cs │ ├── RecurringResponse.cs │ ├── SettingResponse.cs │ └── TunerResponse.cs ├── ServiceRegistrator.cs └── Web │ ├── nextpvr.html │ └── nextpvr.js ├── LICENSE ├── README.md ├── build.yaml └── jellyfin.ruleset /.editorconfig: -------------------------------------------------------------------------------- 1 | # With more recent updates Visual Studio 2017 supports EditorConfig files out of the box 2 | # Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode 3 | # For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig 4 | ############################### 5 | # Core EditorConfig Options # 6 | ############################### 7 | root = true 8 | # All files 9 | [*] 10 | indent_style = space 11 | indent_size = 4 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | end_of_line = lf 16 | max_line_length = 9999 17 | 18 | # YAML indentation 19 | [*.{yml,yaml}] 20 | indent_size = 2 21 | 22 | # XML indentation 23 | [*.{csproj,xml}] 24 | indent_size = 2 25 | 26 | ############################### 27 | # .NET Coding Conventions # 28 | ############################### 29 | [*.{cs,vb}] 30 | # Organize usings 31 | dotnet_sort_system_directives_first = true 32 | # this. preferences 33 | dotnet_style_qualification_for_field = false:silent 34 | dotnet_style_qualification_for_property = false:silent 35 | dotnet_style_qualification_for_method = false:silent 36 | dotnet_style_qualification_for_event = false:silent 37 | # Language keywords vs BCL types preferences 38 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 39 | dotnet_style_predefined_type_for_member_access = true:silent 40 | # Parentheses preferences 41 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 42 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 43 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 44 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 45 | # Modifier preferences 46 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 47 | dotnet_style_readonly_field = true:suggestion 48 | # Expression-level preferences 49 | dotnet_style_object_initializer = true:suggestion 50 | dotnet_style_collection_initializer = true:suggestion 51 | dotnet_style_explicit_tuple_names = true:suggestion 52 | dotnet_style_null_propagation = true:suggestion 53 | dotnet_style_coalesce_expression = true:suggestion 54 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent 55 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 57 | dotnet_style_prefer_auto_properties = true:silent 58 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 59 | dotnet_style_prefer_conditional_expression_over_return = true:silent 60 | 61 | ############################### 62 | # Naming Conventions # 63 | ############################### 64 | # Style Definitions (From Roslyn) 65 | 66 | # Non-private static fields are PascalCase 67 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion 68 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields 69 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style 70 | 71 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field 72 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 73 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static 74 | 75 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case 76 | 77 | # Constants are PascalCase 78 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion 79 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants 80 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style 81 | 82 | dotnet_naming_symbols.constants.applicable_kinds = field, local 83 | dotnet_naming_symbols.constants.required_modifiers = const 84 | 85 | dotnet_naming_style.constant_style.capitalization = pascal_case 86 | 87 | # Static fields are camelCase and start with s_ 88 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion 89 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields 90 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style 91 | 92 | dotnet_naming_symbols.static_fields.applicable_kinds = field 93 | dotnet_naming_symbols.static_fields.required_modifiers = static 94 | 95 | dotnet_naming_style.static_field_style.capitalization = camel_case 96 | dotnet_naming_style.static_field_style.required_prefix = _ 97 | 98 | # Instance fields are camelCase and start with _ 99 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion 100 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields 101 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style 102 | 103 | dotnet_naming_symbols.instance_fields.applicable_kinds = field 104 | 105 | dotnet_naming_style.instance_field_style.capitalization = camel_case 106 | dotnet_naming_style.instance_field_style.required_prefix = _ 107 | 108 | # Locals and parameters are camelCase 109 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion 110 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters 111 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style 112 | 113 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local 114 | 115 | dotnet_naming_style.camel_case_style.capitalization = camel_case 116 | 117 | # Local functions are PascalCase 118 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion 119 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions 120 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style 121 | 122 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function 123 | 124 | dotnet_naming_style.local_function_style.capitalization = pascal_case 125 | 126 | # By default, name items with PascalCase 127 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion 128 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members 129 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style 130 | 131 | dotnet_naming_symbols.all_members.applicable_kinds = * 132 | 133 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 134 | 135 | ############################### 136 | # C# Coding Conventions # 137 | ############################### 138 | [*.cs] 139 | # var preferences 140 | csharp_style_var_for_built_in_types = true:silent 141 | csharp_style_var_when_type_is_apparent = true:silent 142 | csharp_style_var_elsewhere = true:silent 143 | # Expression-bodied members 144 | csharp_style_expression_bodied_methods = false:silent 145 | csharp_style_expression_bodied_constructors = false:silent 146 | csharp_style_expression_bodied_operators = false:silent 147 | csharp_style_expression_bodied_properties = true:silent 148 | csharp_style_expression_bodied_indexers = true:silent 149 | csharp_style_expression_bodied_accessors = true:silent 150 | # Pattern matching preferences 151 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 152 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 153 | # Null-checking preferences 154 | csharp_style_throw_expression = true:suggestion 155 | csharp_style_conditional_delegate_call = true:suggestion 156 | # Modifier preferences 157 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 158 | # Expression-level preferences 159 | csharp_prefer_braces = true:silent 160 | csharp_style_deconstructed_variable_declaration = true:suggestion 161 | csharp_prefer_simple_default_expression = true:suggestion 162 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 163 | csharp_style_inlined_variable_declaration = true:suggestion 164 | 165 | ############################### 166 | # C# Formatting Rules # 167 | ############################### 168 | # New line preferences 169 | csharp_new_line_before_open_brace = all 170 | csharp_new_line_before_else = true 171 | csharp_new_line_before_catch = true 172 | csharp_new_line_before_finally = true 173 | csharp_new_line_before_members_in_object_initializers = true 174 | csharp_new_line_before_members_in_anonymous_types = true 175 | csharp_new_line_between_query_expression_clauses = true 176 | # Indentation preferences 177 | csharp_indent_case_contents = true 178 | csharp_indent_switch_labels = true 179 | csharp_indent_labels = flush_left 180 | # Space preferences 181 | csharp_space_after_cast = false 182 | csharp_space_after_keywords_in_control_flow_statements = true 183 | csharp_space_between_method_call_parameter_list_parentheses = false 184 | csharp_space_between_method_declaration_parameter_list_parentheses = false 185 | csharp_space_between_parentheses = false 186 | csharp_space_before_colon_in_inheritance_clause = true 187 | csharp_space_after_colon_in_inheritance_clause = true 188 | csharp_space_around_binary_operators = before_and_after 189 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 190 | csharp_space_between_method_call_name_and_opening_parenthesis = false 191 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 192 | # Wrapping preferences 193 | csharp_preserve_single_line_statements = true 194 | csharp_preserve_single_line_blocks = true 195 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | _extends: jellyfin-meta-plugins 2 | 3 | template: | 4 | 5 | [Plugin build can be downloaded here](https://repo.jellyfin.org/releases/plugin/nextpvr/nextpvr_$NEXT_MAJOR_VERSION.0.0.0.zip). 6 | 7 | ## :sparkles: What's New 8 | 9 | $CHANGES 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>jellyfin/.github//renovate-presets/default" 5 | ] 6 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: '🏗️ Build Plugin' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**/*.md' 9 | pull_request: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**/*.md' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master 19 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yaml: -------------------------------------------------------------------------------- 1 | name: '📝 Create/Update Release Draft & Release Bump PR' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - build.yaml 9 | workflow_dispatch: 10 | repository_dispatch: 11 | types: 12 | - update-prep-command 13 | 14 | jobs: 15 | call: 16 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master 17 | with: 18 | repository-name: jellyfin/jellyfin-plugin-nextpvr 19 | secrets: 20 | token: ${{ secrets.JF_BOT_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/command-dispatch.yaml: -------------------------------------------------------------------------------- 1 | # Allows for the definition of PR and Issue /commands 2 | name: '📟 Slash Command Dispatcher' 3 | 4 | on: 5 | issue_comment: 6 | types: 7 | - created 8 | 9 | jobs: 10 | call: 11 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master 12 | secrets: 13 | token: ${{ secrets.JF_BOT_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/command-rebase.yaml: -------------------------------------------------------------------------------- 1 | name: '🔀 PR Rebase Command' 2 | 3 | on: 4 | repository_dispatch: 5 | types: 6 | - rebase-command 7 | 8 | jobs: 9 | call: 10 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master 11 | with: 12 | rebase-head: ${{ github.event.client_payload.pull_request.head.label }} 13 | repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }} 14 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 15 | secrets: 16 | token: ${{ secrets.JF_BOT_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish-unstable.yaml: -------------------------------------------------------------------------------- 1 | name: '🚀 Publish (Unstable) Plugin' 2 | 3 | on: 4 | push: 5 | branches: 6 | - unstable 7 | workflow_dispatch: 8 | 9 | jobs: 10 | call: 11 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish-unstable.yaml@master 12 | secrets: 13 | deploy-host: ${{ secrets.REPO_HOST }} 14 | deploy-user: ${{ secrets.REPO_USER }} 15 | deploy-key: ${{ secrets.REPO_KEY }} 16 | token: ${{ secrets.JF_BOT_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: '🚀 Publish Plugin' 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | workflow_dispatch: 8 | 9 | jobs: 10 | call: 11 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master 12 | with: 13 | version: ${{ github.event.release.tag_name }} 14 | is-unstable: ${{ github.event.release.prerelease }} 15 | secrets: 16 | deploy-host: ${{ secrets.REPO_HOST }} 17 | deploy-user: ${{ secrets.REPO_USER }} 18 | deploy-key: ${{ secrets.REPO_KEY }} 19 | -------------------------------------------------------------------------------- /.github/workflows/scan-codeql.yaml: -------------------------------------------------------------------------------- 1 | name: '🔬 Run CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - '**/*.md' 8 | pull_request: 9 | branches: [ master ] 10 | paths-ignore: 11 | - '**/*.md' 12 | schedule: 13 | - cron: '24 2 * * 4' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master 19 | with: 20 | repository-name: jellyfin/jellyfin-plugin-nextpvr 21 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yaml: -------------------------------------------------------------------------------- 1 | name: '🏷️ Sync labels' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | call: 10 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master 11 | secrets: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: '🧪 Test Plugin' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**/*.md' 9 | pull_request: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**/*.md' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | ################# 32 | ## Media Browser 33 | ################# 34 | ProgramData*/ 35 | ProgramData-Server*/ 36 | ProgramData-UI*/ 37 | 38 | ################# 39 | ## Visual Studio 40 | ################# 41 | 42 | .vs 43 | 44 | ## Ignore Visual Studio temporary files, build results, and 45 | ## files generated by popular Visual Studio add-ons. 46 | 47 | # User-specific files 48 | *.suo 49 | *.user 50 | *.sln.docstates 51 | 52 | # Build results 53 | 54 | [Dd]ebug/ 55 | [Rr]elease/ 56 | build/ 57 | [Bb]in/ 58 | [Oo]bj/ 59 | 60 | # MSTest test Results 61 | [Tt]est[Rr]esult*/ 62 | [Bb]uild[Ll]og.* 63 | 64 | *_i.c 65 | *_p.c 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.pch 70 | *.pdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.log 86 | *.scc 87 | *.scc 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.orig 92 | *.rej 93 | *.sdf 94 | *.opensdf 95 | *.ipch 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opensdf 102 | *.sdf 103 | *.cachefile 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | 110 | # Guidance Automation Toolkit 111 | *.gpState 112 | 113 | # ReSharper is a .NET coding add-in 114 | _ReSharper*/ 115 | *.[Rr]e[Ss]harper 116 | 117 | # TeamCity is a build add-in 118 | _TeamCity* 119 | 120 | # DotCover is a Code Coverage Tool 121 | *.dotCover 122 | 123 | # NCrunch 124 | *.ncrunch* 125 | .*crunch*.local.xml 126 | 127 | # Installshield output folder 128 | [Ee]xpress/ 129 | 130 | # DocProject is a documentation generator add-in 131 | DocProject/buildhelp/ 132 | DocProject/Help/*.HxT 133 | DocProject/Help/*.HxC 134 | DocProject/Help/*.hhc 135 | DocProject/Help/*.hhk 136 | DocProject/Help/*.hhp 137 | DocProject/Help/Html2 138 | DocProject/Help/html 139 | 140 | # Click-Once directory 141 | publish/ 142 | 143 | # Publish Web Output 144 | *.Publish.xml 145 | *.pubxml 146 | 147 | # NuGet Packages Directory 148 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 149 | packages/ 150 | 151 | # Windows Azure Build Output 152 | csx 153 | *.build.csdef 154 | 155 | # Windows Store app package directory 156 | AppPackages/ 157 | 158 | # Others 159 | sql/ 160 | *.Cache 161 | ClientBin/ 162 | [Ss]tyle[Cc]op.* 163 | ~$* 164 | *~ 165 | *.dbmdl 166 | *.[Pp]ublish.xml 167 | *.publishsettings 168 | 169 | # RIA/Silverlight projects 170 | Generated_Code/ 171 | 172 | # Backup & report files from converting an old project file to a newer 173 | # Visual Studio version. Backup files are not needed, because we have git ;-) 174 | _UpgradeReport_Files/ 175 | Backup*/ 176 | UpgradeLog*.XML 177 | UpgradeLog*.htm 178 | 179 | # SQL Server files 180 | App_Data/*.mdf 181 | App_Data/*.ldf 182 | 183 | ############# 184 | ## Windows detritus 185 | ############# 186 | 187 | # Windows image file caches 188 | Thumbs.db 189 | ehthumbs.db 190 | 191 | # Folder config file 192 | Desktop.ini 193 | 194 | # Recycle Bin used on file shares 195 | $RECYCLE.BIN/ 196 | 197 | # Mac crap 198 | .DS_Store 199 | 200 | 201 | ############# 202 | ## Python 203 | ############# 204 | 205 | *.py[co] 206 | 207 | # Packages 208 | *.egg 209 | *.egg-info 210 | dist/ 211 | build/ 212 | eggs/ 213 | parts/ 214 | var/ 215 | sdist/ 216 | develop-eggs/ 217 | .installed.cfg 218 | 219 | # Installer logs 220 | pip-log.txt 221 | 222 | # Unit test / coverage reports 223 | .coverage 224 | .tox 225 | 226 | #Translations 227 | *.mo 228 | 229 | #Mr Developer 230 | .mr.developer.cfg 231 | 232 | .idea 233 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11.0.0.0 4 | 11.0.0.0 5 | 11.0.0.0 6 | 7 | 8 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26730.3 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.NextPVR", "Jellyfin.Plugin.NextPVR\Jellyfin.Plugin.NextPVR.csproj", "{83789E82-1F7F-4AAF-A427-D1BC8F01FE72}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {83789E82-1F7F-4AAF-A427-D1BC8F01FE72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {83789E82-1F7F-4AAF-A427-D1BC8F01FE72}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {83789E82-1F7F-4AAF-A427-D1BC8F01FE72}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {83789E82-1F7F-4AAF-A427-D1BC8F01FE72}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {5EB2B122-6D60-49C8-9258-EC01CBA92BD6} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Plugin.NextPVR.Entities; 4 | 5 | using MediaBrowser.Model.Plugins; 6 | 7 | namespace Jellyfin.Plugin.NextPVR.Configuration; 8 | 9 | /// 10 | /// Class PluginConfiguration. 11 | /// 12 | public class PluginConfiguration : BasePluginConfiguration 13 | { 14 | public PluginConfiguration() 15 | { 16 | Pin = "0000"; 17 | WebServiceUrl = "http://localhost:8866"; 18 | EnableDebugLogging = false; 19 | NewEpisodes = false; 20 | RecordingDefault = "2"; 21 | RecordingTransport = 1; 22 | EnableInProgress = false; 23 | PollInterval = 20; 24 | BackendVersion = 0; 25 | // Initialise this 26 | GenreMappings = new SerializableDictionary> 27 | { 28 | ["GENRESPORT"] = 29 | [ 30 | "Sports", 31 | "Football", 32 | "Baseball", 33 | "Basketball", 34 | "Hockey", 35 | "Soccer" 36 | ], 37 | ["GENRENEWS"] = ["News"], 38 | ["GENREKIDS"] = ["Kids", "Children"], 39 | ["GENREMOVIE"] = ["Movie", "Film"], 40 | ["GENRELIVE"] = ["Awards"] 41 | }; 42 | } 43 | 44 | public string WebServiceUrl { get; set; } 45 | 46 | public string CurrentWebServiceURL { get; set; } 47 | 48 | public int BackendVersion { get; set; } 49 | 50 | public string Pin { get; set; } 51 | 52 | public string StoredSid { get; set; } 53 | 54 | public bool EnableDebugLogging { get; set; } 55 | 56 | public bool EnableInProgress { get; set; } 57 | 58 | public int PollInterval { get; set; } 59 | 60 | public bool NewEpisodes { get; set; } 61 | 62 | public bool ShowRepeat { get; set; } 63 | 64 | public bool GetEpisodeImage { get; set; } 65 | 66 | public string RecordingDefault { get; set; } 67 | 68 | public int RecordingTransport { get; set; } 69 | 70 | public int PrePaddingSeconds { get; set; } 71 | 72 | public int PostPaddingSeconds { get; set; } 73 | 74 | public DateTime RecordingModificationTime { get; set; } 75 | 76 | /// 77 | /// Gets or sets the genre mappings, to map localised NextPVR genres, to Jellyfin categories. 78 | /// 79 | public SerializableDictionary> GenreMappings { get; set; } 80 | } 81 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Entities/ClientKeys.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.NextPVR.Entities; 2 | 3 | public class ClientKeys 4 | { 5 | public string Sid { get; set; } 6 | 7 | public string Salt { get; set; } 8 | } 9 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Entities/MyRecordingInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using MediaBrowser.Model.LiveTv; 4 | 5 | namespace Jellyfin.Plugin.NextPVR.Entities; 6 | 7 | public class MyRecordingInfo 8 | { 9 | /// 10 | /// Gets or sets the id of the recording. 11 | /// 12 | public string Id { get; set; } 13 | 14 | /// 15 | /// Gets or sets the series timer identifier. 16 | /// 17 | /// The series timer identifier. 18 | public string SeriesTimerId { get; set; } 19 | 20 | /// 21 | /// Gets or sets the timer identifier. 22 | /// 23 | /// The timer identifier. 24 | public string TimerId { get; set; } 25 | 26 | /// 27 | /// Gets or sets the channelId of the recording. 28 | /// 29 | public string ChannelId { get; set; } 30 | 31 | /// 32 | /// Gets or sets the type of the channel. 33 | /// 34 | /// The type of the channel. 35 | public ChannelType ChannelType { get; set; } 36 | 37 | /// 38 | /// Gets or sets the name of the recording. 39 | /// 40 | public string Name { get; set; } 41 | 42 | /// 43 | /// Gets or sets the path. 44 | /// 45 | /// The path. 46 | public string Path { get; set; } 47 | 48 | /// 49 | /// Gets or sets the URL. 50 | /// 51 | /// The URL. 52 | public string Url { get; set; } 53 | 54 | /// 55 | /// Gets or sets the overview. 56 | /// 57 | /// The overview. 58 | public string Overview { get; set; } 59 | 60 | /// 61 | /// Gets or sets the start date of the recording, in UTC. 62 | /// 63 | public DateTime StartDate { get; set; } 64 | 65 | /// 66 | /// Gets or sets the end date of the recording, in UTC. 67 | /// 68 | public DateTime EndDate { get; set; } 69 | 70 | /// 71 | /// Gets or sets the program identifier. 72 | /// 73 | /// The program identifier. 74 | public string ProgramId { get; set; } 75 | 76 | /// 77 | /// Gets or sets the status. 78 | /// 79 | /// The status. 80 | public RecordingStatus Status { get; set; } 81 | 82 | /// 83 | /// Gets or sets the genres of the program. 84 | /// 85 | public List Genres { get; set; } = new(); 86 | 87 | /// 88 | /// Gets or sets a value indicating whether this instance is repeat. 89 | /// 90 | /// true if this instance is repeat; otherwise, false. 91 | public bool IsRepeat { get; set; } 92 | 93 | /// 94 | /// Gets or sets the episode title. 95 | /// 96 | /// The episode title. 97 | public string EpisodeTitle { get; set; } 98 | 99 | /// 100 | /// Gets or sets a value indicating whether this instance is hd. 101 | /// 102 | /// true if this instance is hd; otherwise, false. 103 | public bool? IsHd { get; set; } 104 | 105 | /// 106 | /// Gets or sets the audio. 107 | /// 108 | /// The audio. 109 | public ProgramAudio? Audio { get; set; } 110 | 111 | /// 112 | /// Gets or sets the original air date. 113 | /// 114 | /// The original air date. 115 | public DateTime? OriginalAirDate { get; set; } 116 | 117 | /// 118 | /// Gets or sets a value indicating whether this instance is movie. 119 | /// 120 | /// true if this instance is movie; otherwise, false. 121 | public bool IsMovie { get; set; } 122 | 123 | /// 124 | /// Gets or sets a value indicating whether this instance is sports. 125 | /// 126 | /// true if this instance is sports; otherwise, false. 127 | public bool IsSports { get; set; } 128 | 129 | /// 130 | /// Gets or sets a value indicating whether this instance is series. 131 | /// 132 | /// true if this instance is series; otherwise, false. 133 | public bool IsSeries { get; set; } 134 | 135 | /// 136 | /// Gets or sets a value indicating whether this instance is live. 137 | /// 138 | /// true if this instance is live; otherwise, false. 139 | public bool IsLive { get; set; } 140 | 141 | /// 142 | /// Gets or sets a value indicating whether this instance is news. 143 | /// 144 | /// true if this instance is news; otherwise, false. 145 | public bool IsNews { get; set; } 146 | 147 | /// 148 | /// Gets or sets a value indicating whether this instance is kids. 149 | /// 150 | /// true if this instance is kids; otherwise, false. 151 | public bool IsKids { get; set; } 152 | 153 | /// 154 | /// Gets or sets a value indicating whether this instance is premiere. 155 | /// 156 | /// true if this instance is premiere; otherwise, false. 157 | public bool IsPremiere { get; set; } 158 | 159 | /// 160 | /// Gets or sets the official rating. 161 | /// 162 | /// The official rating. 163 | public string OfficialRating { get; set; } 164 | 165 | /// 166 | /// Gets or sets the community rating. 167 | /// 168 | /// The community rating. 169 | public float? CommunityRating { get; set; } 170 | 171 | /// 172 | /// Gets or sets the image path if it can be accessed directly from the file system. 173 | /// 174 | /// The image path. 175 | public string ImagePath { get; set; } 176 | 177 | /// 178 | /// Gets or sets the the image url if it can be downloaded. 179 | /// 180 | /// The image URL. 181 | public string ImageUrl { get; set; } 182 | 183 | /// 184 | /// Gets or sets a value indicating whether this instance has image. 185 | /// 186 | /// null if [has image] contains no value, true if [has image]; otherwise, false. 187 | public bool? HasImage { get; set; } 188 | 189 | /// 190 | /// Gets or sets the show identifier. 191 | /// 192 | /// The show identifier. 193 | public string ShowId { get; set; } 194 | 195 | /// 196 | /// Gets or sets the date last updated. 197 | /// 198 | /// The date last updated. 199 | public DateTime DateLastUpdated { get; set; } 200 | 201 | /// 202 | /// Gets or sets the season number. 203 | /// 204 | /// The date last updated. 205 | public int? SeasonNumber { get; set; } 206 | 207 | /// 208 | /// Gets or sets the episode number. 209 | /// 210 | /// The date last updated. 211 | public int? EpisodeNumber { get; set; } 212 | 213 | /// 214 | /// Gets or sets the Year. 215 | /// 216 | /// The date last updated. 217 | public int? ProductionYear { get; set; } 218 | } 219 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Entities/SerializableDictionary.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Xml; 3 | using System.Xml.Schema; 4 | using System.Xml.Serialization; 5 | 6 | namespace Jellyfin.Plugin.NextPVR.Entities; 7 | 8 | [XmlRoot("dictionary")] 9 | public class SerializableDictionary : Dictionary, IXmlSerializable 10 | { 11 | public XmlSchema GetSchema() 12 | { 13 | return null; 14 | } 15 | 16 | public void ReadXml(XmlReader reader) 17 | { 18 | var keySerializer = new XmlSerializer(typeof(TKey)); 19 | var valueSerializer = new XmlSerializer(typeof(TValue)); 20 | 21 | var wasEmpty = reader.IsEmptyElement; 22 | reader.Read(); 23 | 24 | if (wasEmpty) 25 | { 26 | return; 27 | } 28 | 29 | while (reader.NodeType != XmlNodeType.EndElement) 30 | { 31 | reader.ReadStartElement("item"); 32 | 33 | reader.ReadStartElement("key"); 34 | var key = (TKey)keySerializer.Deserialize(reader); 35 | reader.ReadEndElement(); 36 | 37 | reader.ReadStartElement("value"); 38 | var value = (TValue)valueSerializer.Deserialize(reader); 39 | reader.ReadEndElement(); 40 | 41 | Add(key, value); 42 | 43 | reader.ReadEndElement(); 44 | reader.MoveToContent(); 45 | } 46 | 47 | reader.ReadEndElement(); 48 | } 49 | 50 | public void WriteXml(XmlWriter writer) 51 | { 52 | var keySerializer = new XmlSerializer(typeof(TKey)); 53 | var valueSerializer = new XmlSerializer(typeof(TValue)); 54 | 55 | foreach (TKey key in Keys) 56 | { 57 | writer.WriteStartElement("item"); 58 | writer.WriteStartElement("key"); 59 | keySerializer.Serialize(writer, key); 60 | writer.WriteEndElement(); 61 | 62 | writer.WriteStartElement("value"); 63 | var value = this[key]; 64 | valueSerializer.Serialize(writer, value); 65 | writer.WriteEndElement(); 66 | 67 | writer.WriteEndElement(); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Helpers/ChannelHelper.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.LiveTv; 2 | 3 | namespace Jellyfin.Plugin.NextPVR.Helpers; 4 | 5 | public static class ChannelHelper 6 | { 7 | public static ChannelType GetChannelType(int channelType) 8 | { 9 | ChannelType type = channelType switch 10 | { 11 | 1 => ChannelType.TV, 12 | 10 => ChannelType.Radio, 13 | _ => ChannelType.TV 14 | }; 15 | 16 | return type; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Helpers/GenreMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Plugin.NextPVR.Configuration; 5 | using Jellyfin.Plugin.NextPVR.Entities; 6 | using MediaBrowser.Controller.LiveTv; 7 | 8 | namespace Jellyfin.Plugin.NextPVR.Helpers; 9 | 10 | /// 11 | /// Provides methods to map MediaPortal genres to Emby categories. 12 | /// 13 | public class GenreMapper 14 | { 15 | private const string GenreMovie = "GENREMOVIE"; 16 | private const string GenreSport = "GENRESPORT"; 17 | private const string GenreNews = "GENRENEWS"; 18 | private const string GenreKids = "GENREKIDS"; 19 | private const string GenreLive = "GENRELIVE"; 20 | 21 | private readonly PluginConfiguration _configuration; 22 | private readonly List _movieGenres; 23 | private readonly List _sportGenres; 24 | private readonly List _newsGenres; 25 | private readonly List _kidsGenres; 26 | private readonly List _liveGenres; 27 | 28 | /// 29 | /// Initializes a new instance of the class. 30 | /// 31 | /// The configuration. 32 | public GenreMapper(PluginConfiguration configuration) 33 | { 34 | _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); 35 | 36 | _movieGenres = new List(); 37 | _sportGenres = new List(); 38 | _newsGenres = new List(); 39 | _kidsGenres = new List(); 40 | _liveGenres = new List(); 41 | LoadInternalLists(_configuration.GenreMappings); 42 | } 43 | 44 | private void LoadInternalLists(Dictionary> genreMappings) 45 | { 46 | if (genreMappings != null) 47 | { 48 | if (_configuration.GenreMappings.TryGetValue(GenreMovie, out var value) && value != null) 49 | { 50 | _movieGenres.AddRange(value); 51 | } 52 | 53 | if (_configuration.GenreMappings.TryGetValue(GenreSport, out value) && value != null) 54 | { 55 | _sportGenres.AddRange(value); 56 | } 57 | 58 | if (_configuration.GenreMappings.TryGetValue(GenreNews, out value) && value != null) 59 | { 60 | _newsGenres.AddRange(value); 61 | } 62 | 63 | if (_configuration.GenreMappings.TryGetValue(GenreKids, out value) && value != null) 64 | { 65 | _kidsGenres.AddRange(value); 66 | } 67 | 68 | if (_configuration.GenreMappings.TryGetValue(GenreLive, out value) && value != null) 69 | { 70 | _liveGenres.AddRange(value); 71 | } 72 | } 73 | } 74 | 75 | /// 76 | /// Populates the program genres. 77 | /// 78 | /// The program. 79 | public void PopulateProgramGenres(ProgramInfo program) 80 | { 81 | // Check there is a program and genres to map 82 | if (program?.Genres != null && program.Genres.Count > 0) 83 | { 84 | program.IsMovie = _movieGenres.Any(g => program.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 85 | program.IsSports = _sportGenres.Any(g => program.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 86 | program.IsNews = _newsGenres.Any(g => program.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 87 | program.IsKids = _kidsGenres.Any(g => program.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 88 | if (program.IsLive == false) 89 | { 90 | program.IsLive = _liveGenres.Any(g => program.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 91 | } 92 | } 93 | } 94 | 95 | /// 96 | /// Populates the recording genres. 97 | /// 98 | /// The recording. 99 | public void PopulateRecordingGenres(MyRecordingInfo recording) 100 | { 101 | // Check there is a recording and genres to map 102 | if (recording?.Genres != null && recording.Genres.Count > 0) 103 | { 104 | recording.IsMovie = _movieGenres.Any(g => recording.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 105 | recording.IsSports = _sportGenres.Any(g => recording.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 106 | recording.IsNews = _newsGenres.Any(g => recording.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 107 | recording.IsKids = _kidsGenres.Any(g => recording.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 108 | recording.IsLive = _liveGenres.Any(g => recording.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 109 | } 110 | } 111 | 112 | /// 113 | /// Populates the timer genres. 114 | /// 115 | /// The timer. 116 | public void PopulateTimerGenres(TimerInfo timer) 117 | { 118 | // Check there is a timer and genres to map 119 | if (timer?.Genres != null && timer.Genres.Length > 0) 120 | { 121 | timer.IsMovie = _movieGenres.Any(g => timer.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 122 | // timer.IsSports = _sportGenres.Any(g => timer.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 123 | // timer.IsNews = _newsGenres.Any(g => timer.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 124 | // timer.IsKids = _kidsGenres.Any(g => timer.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 125 | // timer.IsProgramSeries = _seriesGenres.Any(g => timer.Genres.Contains(g, StringComparer.InvariantCultureIgnoreCase)); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Helpers/UtilsHelper.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.LiveTv; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace Jellyfin.Plugin.NextPVR.Helpers; 5 | 6 | public static class UtilsHelper 7 | { 8 | public static void DebugInformation(ILogger logger, string message) 9 | { 10 | var config = Plugin.Instance.Configuration; 11 | bool enableDebugLogging = config.EnableDebugLogging; 12 | 13 | if (enableDebugLogging) 14 | { 15 | #pragma warning disable CA2254 16 | logger.LogDebug(message); 17 | #pragma warning restore CA2254 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Jellyfin.Plugin.NextPVR.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | true 6 | AllEnabledByDefault 7 | true 8 | ../jellyfin.ruleset 9 | CA2227;CA1002;CA2007;CS1591 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/LiveTvService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using Jellyfin.Plugin.NextPVR.Entities; 13 | using Jellyfin.Plugin.NextPVR.Helpers; 14 | using Jellyfin.Plugin.NextPVR.Responses; 15 | using MediaBrowser.Common.Net; 16 | using MediaBrowser.Controller.LiveTv; 17 | using MediaBrowser.Model.Dto; 18 | using MediaBrowser.Model.Entities; 19 | using MediaBrowser.Model.MediaInfo; 20 | using Microsoft.Extensions.Logging; 21 | using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; 22 | 23 | namespace Jellyfin.Plugin.NextPVR; 24 | 25 | /// 26 | /// Class LiveTvService. 27 | /// 28 | public class LiveTvService : ILiveTvService 29 | { 30 | private readonly IHttpClientFactory _httpClientFactory; 31 | private readonly bool _enableIPv6; 32 | private readonly ILogger _logger; 33 | private int _liveStreams; 34 | 35 | private string _baseUrl; 36 | 37 | public LiveTvService(IHttpClientFactory httpClientFactory, ILogger logger, IConfigurationManager configuration) 38 | { 39 | _enableIPv6 = configuration.GetNetworkConfiguration().EnableIPv6; 40 | _httpClientFactory = httpClientFactory; 41 | _logger = logger; 42 | LastUpdatedSidDateTime = DateTime.UtcNow; 43 | Instance = this; 44 | } 45 | 46 | public string Sid { get; set; } 47 | 48 | public DateTime RecordingModificationTime { get; set; } 49 | 50 | public static LiveTvService Instance { get; private set; } 51 | 52 | public bool IsActive => Sid is not null; 53 | 54 | public bool FlagRecordingChange { get; set; } 55 | 56 | private DateTimeOffset LastUpdatedSidDateTime { get; set; } 57 | 58 | /// 59 | /// Gets the name. 60 | /// 61 | /// The name. 62 | public string Name => "Next Pvr"; 63 | 64 | public string HomePageUrl => "https://www.nextpvr.com/"; 65 | 66 | /// 67 | /// Ensure that we are connected to the NextPvr server. 68 | /// 69 | /// The cancellation token. 70 | /// A representing the asynchronous operation. 71 | public async Task EnsureConnectionAsync(CancellationToken cancellationToken) 72 | { 73 | var config = Plugin.Instance.Configuration; 74 | { 75 | if (!Uri.IsWellFormedUriString(config.WebServiceUrl, UriKind.Absolute)) 76 | { 77 | _logger.LogError("Web service URL must be configured"); 78 | throw new InvalidOperationException("NextPVR web service URL must be configured."); 79 | } 80 | 81 | if (string.IsNullOrEmpty(config.Pin)) 82 | { 83 | _logger.LogError("PIN must be configured"); 84 | throw new InvalidOperationException("NextPVR PIN must be configured."); 85 | } 86 | 87 | if (string.IsNullOrEmpty(config.StoredSid)) 88 | { 89 | Sid = null; 90 | LastUpdatedSidDateTime = DateTimeOffset.MinValue; 91 | } 92 | 93 | if (string.IsNullOrEmpty(Sid) || ((!string.IsNullOrEmpty(Sid)) && (LastUpdatedSidDateTime.AddMinutes(5) < DateTimeOffset.UtcNow)) || RecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime) 94 | { 95 | try 96 | { 97 | await InitiateSession(cancellationToken).ConfigureAwait(false); 98 | } 99 | catch (Exception ex) 100 | { 101 | Sid = null; 102 | _logger.LogError(ex, "Error initiating session"); 103 | } 104 | } 105 | } 106 | 107 | return IsActive; 108 | } 109 | 110 | /// 111 | /// Initiate the nextPvr session. 112 | /// 113 | private async Task InitiateSession(CancellationToken cancellationToken) 114 | { 115 | _logger.LogInformation("Start InitiateSession"); 116 | _baseUrl = Plugin.Instance.Configuration.CurrentWebServiceURL; 117 | var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); 118 | httpClient.Timeout = TimeSpan.FromSeconds(5); 119 | bool updateConfiguration = false; 120 | bool validConfiguration = false; 121 | if (!string.IsNullOrEmpty(Plugin.Instance.Configuration.StoredSid) && !string.IsNullOrEmpty(Plugin.Instance.Configuration.CurrentWebServiceURL) ) 122 | { 123 | string request = $"{_baseUrl}/service?method=session.valid&device=jellyfin&sid={Plugin.Instance.Configuration.StoredSid}"; 124 | await using var stream = await httpClient.GetStreamAsync(request, cancellationToken).ConfigureAwait(false); 125 | validConfiguration = await new InitializeResponse().LoggedIn(stream, _logger).ConfigureAwait(false); 126 | } 127 | 128 | if (!validConfiguration) 129 | { 130 | UriBuilder builder = new UriBuilder(Plugin.Instance.Configuration.WebServiceUrl); 131 | if (!_enableIPv6 && builder.Host != "localhost" && builder.Host != "127.0.0.1") 132 | { 133 | if (builder.Host == "[::1]") 134 | { 135 | builder.Host = "127.0.0.1"; 136 | } 137 | 138 | try 139 | { 140 | Uri uri = new Uri(Plugin.Instance.Configuration.WebServiceUrl); 141 | var hosts = await Dns.GetHostEntryAsync(uri.Host, System.Net.Sockets.AddressFamily.InterNetwork, cancellationToken); 142 | if (hosts is not null) 143 | { 144 | var host = hosts.AddressList.FirstOrDefault()?.ToString(); 145 | if (builder.Host != host && host is not null) 146 | { 147 | _logger.LogInformation("Changed host from {OldHost} to {NewHost}", builder.Host, host); 148 | builder.Host = host; 149 | } 150 | } 151 | } 152 | catch (Exception ex) 153 | { 154 | _logger.LogError(ex, "Could not resolve {WebServiceUrl}", Plugin.Instance.Configuration.WebServiceUrl); 155 | } 156 | } 157 | 158 | _baseUrl = builder.ToString().TrimEnd('/'); 159 | await using var stream = await httpClient.GetStreamAsync($"{_baseUrl}/service?method=session.initiate&ver=1.0&device=jellyfin", cancellationToken).ConfigureAwait(false); 160 | var clientKeys = await new InstantiateResponse().GetClientKeys(stream, _logger).ConfigureAwait(false); 161 | var sid = clientKeys.Sid; 162 | var salt = clientKeys.Salt; 163 | validConfiguration = await Login(sid, salt, cancellationToken).ConfigureAwait(false); 164 | Plugin.Instance.Configuration.StoredSid = sid; 165 | updateConfiguration = true; 166 | } 167 | 168 | if (validConfiguration) 169 | { 170 | LastUpdatedSidDateTime = DateTimeOffset.UtcNow; 171 | Sid = Plugin.Instance.Configuration.StoredSid; 172 | _logger.LogInformation("Session initiated"); 173 | _logger.LogInformation("Sid: {Sid}", Sid); 174 | if (updateConfiguration) 175 | { 176 | Plugin.Instance.Configuration.CurrentWebServiceURL = _baseUrl; 177 | Plugin.Instance.Configuration.RecordingModificationTime = DateTime.UtcNow; 178 | Plugin.Instance.SaveConfiguration(); 179 | } 180 | 181 | RecordingModificationTime = Plugin.Instance.Configuration.RecordingModificationTime; 182 | 183 | await GetDefaultSettingsAsync(cancellationToken).ConfigureAwait(false); 184 | Plugin.Instance.Configuration.GetEpisodeImage = await GetBackendSettingAsync("/Settings/General/ArtworkFromSchedulesDirect", cancellationToken).ConfigureAwait(false) == "true"; 185 | } 186 | else 187 | { 188 | Sid = null; 189 | _logger.LogError("PIN not accepted"); 190 | throw new UnauthorizedAccessException("NextPVR PIN not accepted"); 191 | } 192 | } 193 | 194 | private async Task Login(string sid, string salt, CancellationToken cancellationToken) 195 | { 196 | _logger.LogInformation("Start Login procedure for Sid: {Sid} & Salt: {Salt}", sid, salt); 197 | var pin = Plugin.Instance.Configuration.Pin; 198 | _logger.LogInformation("PIN: {Pin}", pin == "0000" ? pin : "Not default"); 199 | 200 | var strb = new StringBuilder(); 201 | var md5Result = GetMd5Hash(strb.Append(':').Append(GetMd5Hash(pin)).Append(':').Append(salt).ToString()); 202 | 203 | var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); 204 | await using var stream = await httpClient.GetStreamAsync($"{_baseUrl}/service?method=session.login&md5={md5Result}&sid={sid}", cancellationToken); 205 | { 206 | return await new InitializeResponse().LoggedIn(stream, _logger).ConfigureAwait(false); 207 | } 208 | } 209 | 210 | private string GetMd5Hash(string value) 211 | { 212 | #pragma warning disable CA5351 213 | var hashValue = MD5.HashData(new UTF8Encoding().GetBytes(value)); 214 | #pragma warning restore CA5351 215 | // Bit convertor return the byte to string as all caps hex values separated by "-" 216 | return BitConverter.ToString(hashValue).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); 217 | } 218 | 219 | /// 220 | /// Gets the channels async. 221 | /// 222 | /// The cancellation token. 223 | /// Task{IEnumerable{ChannelInfo}}. 224 | public async Task> GetChannelsAsync(CancellationToken cancellationToken) 225 | { 226 | _logger.LogInformation("Start GetChannels Async, retrieve all channels"); 227 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 228 | 229 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 230 | .GetStreamAsync($"{_baseUrl}/service?method=channel.list&sid={Sid}", cancellationToken); 231 | 232 | return await new ChannelResponse(Plugin.Instance.Configuration.WebServiceUrl).GetChannels(stream, _logger).ConfigureAwait(false); 233 | } 234 | 235 | /// 236 | /// Gets the Recordings async. 237 | /// 238 | /// The cancellation token. 239 | /// Task{IEnumerable{RecordingInfo}}. 240 | public async Task> GetAllRecordingsAsync(CancellationToken cancellationToken) 241 | { 242 | _logger.LogInformation("Start GetRecordings Async, retrieve all 'Pending', 'Inprogress' and 'Completed' recordings "); 243 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 244 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 245 | .GetStreamAsync($"{_baseUrl}/service?method=recording.list&filter=ready&sid={Sid}", cancellationToken); 246 | return await new RecordingResponse(_baseUrl, _logger).GetRecordings(stream).ConfigureAwait(false); 247 | } 248 | 249 | /// 250 | /// Delete the Recording async from the disk. 251 | /// 252 | /// The recordingId. 253 | /// The cancellationToken. 254 | /// A representing the asynchronous operation. 255 | public async Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) 256 | { 257 | _logger.LogInformation("Start Delete Recording Async for recordingId: {RecordingId}", recordingId); 258 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 259 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 260 | .GetStreamAsync($"{_baseUrl}/service?method=recording.delete&recording_id={recordingId}&sid={Sid}", cancellationToken); 261 | 262 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 263 | 264 | if (error is null or true) 265 | { 266 | _logger.LogError("Failed to delete the recording for recordingId: {RecordingId}", recordingId); 267 | throw new JsonException($"Failed to delete the recording for recordingId: {recordingId}"); 268 | } 269 | else 270 | { 271 | FlagRecordingChange = true; 272 | } 273 | 274 | _logger.LogInformation("Deleted Recording with recordingId: {RecordingId}", recordingId); 275 | } 276 | 277 | /// 278 | /// Cancel pending scheduled Recording. 279 | /// 280 | /// The timerId. 281 | /// The cancellationToken. 282 | /// A representing the asynchronous operation. 283 | public async Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) 284 | { 285 | _logger.LogInformation("Start Cancel Recording Async for recordingId: {TimerId}", timerId); 286 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 287 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 288 | .GetStreamAsync($"{_baseUrl}/service?method=recording.delete&recording_id={timerId}&sid={Sid}", cancellationToken); 289 | 290 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 291 | 292 | if (error is null or true) 293 | { 294 | _logger.LogError("Failed to cancel the recording for recordingId: {TimerId}", timerId); 295 | throw new JsonException($"Failed to cancel the recording for recordingId: {timerId}"); 296 | } 297 | else 298 | { 299 | FlagRecordingChange = true; 300 | } 301 | 302 | _logger.LogInformation("Cancelled Recording for recordingId: {TimerId}", timerId); 303 | } 304 | 305 | /// 306 | /// Create a new scheduled recording. 307 | /// 308 | /// The TimerInfo. 309 | /// The cancellationToken. 310 | /// A representing the asynchronous operation. 311 | public async Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) 312 | { 313 | _logger.LogInformation("Start CreateTimer Async for ChannelId: {ChannelId} & Name: {Name}", info.ChannelId, info.Name); 314 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 315 | UtilsHelper.DebugInformation(_logger, $"TimerSettings CreateTimer: {info.ProgramId} for ChannelId: {info.ChannelId} & Name: {info.Name}"); 316 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 317 | .GetStreamAsync( 318 | string.Format( 319 | CultureInfo.InvariantCulture, 320 | "{0}/service?method=recording.save&sid={1}&event_id={2}&pre_padding={3}&post_padding={4}", 321 | _baseUrl, 322 | Sid, 323 | int.Parse(info.ProgramId, CultureInfo.InvariantCulture), 324 | info.PrePaddingSeconds / 60, 325 | info.PostPaddingSeconds / 60), 326 | cancellationToken); 327 | 328 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 329 | if (error is null or true) 330 | { 331 | _logger.LogError("Failed to create the timer with programId: {ProgramId}", info.ProgramId); 332 | throw new JsonException($"Failed to create the timer with programId: {info.ProgramId}"); 333 | } 334 | else if (info.StartDate <= DateTime.UtcNow) 335 | { 336 | FlagRecordingChange = true; 337 | } 338 | 339 | _logger.LogError("CreateTimer async for programId: {ProgramId}", info.ProgramId); 340 | } 341 | 342 | /// 343 | /// Get the pending Timers. 344 | /// 345 | /// The CancellationToken. 346 | /// A representing the asynchronous operation. 347 | public async Task> GetTimersAsync(CancellationToken cancellationToken) 348 | { 349 | _logger.LogInformation("Start GetTimer Async, retrieve the 'Pending' recordings"); 350 | if (await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false)) 351 | { 352 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 353 | .GetStreamAsync($"{_baseUrl}/service?method=recording.list&filter=pending&sid={Sid}", cancellationToken); 354 | 355 | return await new RecordingResponse(_baseUrl, _logger).GetTimers(stream).ConfigureAwait(false); 356 | } 357 | 358 | return new List(); 359 | } 360 | 361 | /// 362 | /// Get the recurrent recordings. 363 | /// 364 | /// The CancellationToken. 365 | /// A representing the asynchronous operation. 366 | public async Task> GetSeriesTimersAsync(CancellationToken cancellationToken) 367 | { 368 | _logger.LogInformation("Start GetSeriesTimer Async, retrieve the recurring recordings"); 369 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 370 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 371 | .GetStreamAsync($"{_baseUrl}/service?method=recording.recurring.list&sid={Sid}", cancellationToken); 372 | 373 | return await new RecurringResponse(_logger).GetSeriesTimers(stream).ConfigureAwait(false); 374 | } 375 | 376 | /// 377 | /// Create a recurrent recording. 378 | /// 379 | /// The recurring program info. 380 | /// The cancellation token. 381 | /// A representing the asynchronous operation. 382 | public async Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) 383 | { 384 | _logger.LogInformation("Start CreateSeriesTimer Async for ChannelId: {ChannelId} & Name: {Name}", info.ChannelId, info.Name); 385 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 386 | var url = $"{_baseUrl}/service?method=recording.recurring.save&sid={Sid}&pre_padding={info.PrePaddingSeconds / 60}&post_padding={info.PostPaddingSeconds / 60}&keep={info.KeepUpTo}"; 387 | 388 | int recurringType = int.Parse(Plugin.Instance.Configuration.RecordingDefault, CultureInfo.InvariantCulture); 389 | 390 | if (recurringType == 99) 391 | { 392 | url += string.Format(CultureInfo.InvariantCulture, "&name={0}&keyword=title+like+'{0}'", Uri.EscapeDataString(info.Name.Replace("'", "''", StringComparison.Ordinal))); 393 | } 394 | else 395 | { 396 | url += $"&event_id={info.ProgramId}&recurring_type={recurringType}"; 397 | } 398 | 399 | if (info.RecordNewOnly || Plugin.Instance.Configuration.NewEpisodes) 400 | { 401 | url += "&only_new=true"; 402 | } 403 | 404 | if (recurringType is 3 or 4) 405 | { 406 | url += "×lot=true"; 407 | } 408 | 409 | await CreateUpdateSeriesTimerAsync(info, url, cancellationToken); 410 | } 411 | 412 | private async Task CreateUpdateSeriesTimerAsync(SeriesTimerInfo info, string url, CancellationToken cancellationToken) 413 | { 414 | UtilsHelper.DebugInformation(_logger, $"TimerSettings CreateSeriesTimerAsync: {info.ProgramId} for ChannelId: {info.ChannelId} & Name: {info.Name}"); 415 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 416 | .GetStreamAsync(url, cancellationToken); 417 | 418 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 419 | if (error is null or true) 420 | { 421 | _logger.LogError("Failed to create or update the timer with Recurring ID: {TimerInfoId}", info.Id); 422 | throw new JsonException($"Failed to create or update the timer with Recurring ID: {info.Id}"); 423 | } 424 | 425 | _logger.LogInformation("CreateUpdateSeriesTimer async for Program ID: {ProgramId} Recurring ID {TimerInfoId}", info.ProgramId, info.Id); 426 | } 427 | 428 | /// 429 | /// Update the series Timer. 430 | /// 431 | /// The series program info. 432 | /// The CancellationToken. 433 | /// A representing the asynchronous operation. 434 | public async Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) 435 | { 436 | _logger.LogInformation("Start UpdateSeriesTimer Async for ChannelId: {ChannelId} & Name: {Name}", info.ChannelId, info.Name); 437 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 438 | 439 | var url = $"{_baseUrl}/service?method=recording.recurring.save&sid={Sid}&pre_padding={info.PrePaddingSeconds / 60}&post_padding={info.PostPaddingSeconds / 60}&keep={info.KeepUpTo}&recurring_id={info.Id}"; 440 | 441 | int recurringType = 2; 442 | 443 | if (info.RecordAnyChannel) 444 | { 445 | url += string.Format(CultureInfo.InvariantCulture, "&name={0}&keyword=title+like+'{0}'", Uri.EscapeDataString(info.Name.Replace("'", "''", StringComparison.Ordinal))); 446 | } 447 | else 448 | { 449 | if (info.RecordAnyTime) 450 | { 451 | if (info.RecordNewOnly) 452 | { 453 | recurringType = 1; 454 | } 455 | } 456 | else 457 | { 458 | recurringType = info.Days.Count == 7 ? 4 : 3; 459 | } 460 | 461 | url += $"&recurring_type={recurringType}"; 462 | } 463 | 464 | if (info.RecordNewOnly) 465 | { 466 | url += "&only_new=true"; 467 | } 468 | 469 | await CreateUpdateSeriesTimerAsync(info, url, cancellationToken).ConfigureAwait(false); 470 | } 471 | 472 | /// 473 | /// Update a single Timer. 474 | /// 475 | /// The program info. 476 | /// The CancellationToken. 477 | /// A representing the asynchronous operation. 478 | public async Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) 479 | { 480 | _logger.LogInformation("Start UpdateTimer Async for ChannelId: {ChannelId} & Name: {Name}", updatedTimer.ChannelId, updatedTimer.Name); 481 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 482 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 483 | .GetStreamAsync($"{_baseUrl}/service?method=recording.save&sid={Sid}&pre_padding={updatedTimer.PrePaddingSeconds / 60}&post_padding={updatedTimer.PostPaddingSeconds / 60}&recording_id={updatedTimer.Id}&event_id={updatedTimer.ProgramId}", cancellationToken); 484 | 485 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 486 | if (error is null or true) 487 | { 488 | _logger.LogError("Failed to update the timer with ID: {Id}", updatedTimer.Id); 489 | throw new JsonException($"Failed to update the timer with ID: {updatedTimer.Id}"); 490 | } 491 | 492 | _logger.LogInformation("UpdateTimer async for Program ID: {ProgramId} ID {Id}", updatedTimer.ProgramId, updatedTimer.Id); 493 | } 494 | 495 | /// 496 | /// Cancel the Series Timer. 497 | /// 498 | /// The Timer Id. 499 | /// The CancellationToken. 500 | /// A representing the asynchronous operation. 501 | public async Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) 502 | { 503 | _logger.LogInformation("Start Cancel SeriesRecording Async for recordingId: {TimerId}", timerId); 504 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 505 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 506 | .GetStreamAsync($"{_baseUrl}/service?method=recording.recurring.delete&recurring_id={timerId}&sid={Sid}", cancellationToken); 507 | 508 | bool? error = await new CancelDeleteRecordingResponse().RecordingError(stream, _logger).ConfigureAwait(false); 509 | 510 | if (error is null or true) 511 | { 512 | _logger.LogError("Failed to cancel the recording with recordingId: {TimerId}", timerId); 513 | throw new JsonException($"Failed to cancel the recording with recordingId: {timerId}"); 514 | } 515 | 516 | _logger.LogInformation("Cancelled Recording for recordingId: {TimerId}", timerId); 517 | } 518 | 519 | public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) 520 | { 521 | var source = await GetChannelStream(channelId, string.Empty, cancellationToken); 522 | return [source]; 523 | } 524 | 525 | public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) 526 | { 527 | _logger.LogInformation("Start ChannelStream"); 528 | EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 529 | _liveStreams++; 530 | 531 | string sidParameter = null; 532 | if (Plugin.Instance.Configuration.RecordingTransport != 3) 533 | { 534 | sidParameter = $"&sid={Sid}"; 535 | } 536 | 537 | string streamUrl = $"{_baseUrl}/live?channeloid={channelId}&client=jellyfin.{_liveStreams.ToString(CultureInfo.InvariantCulture)}{sidParameter}"; 538 | _logger.LogInformation("Streaming {Url}", streamUrl); 539 | var mediaSourceInfo = new MediaSourceInfo 540 | { 541 | Id = _liveStreams.ToString(CultureInfo.InvariantCulture), 542 | Path = streamUrl, 543 | Protocol = MediaProtocol.Http, 544 | RequiresOpening = true, 545 | MediaStreams = new List 546 | { 547 | new MediaStream 548 | { 549 | Type = MediaStreamType.Video, 550 | // IsInterlaced = true, 551 | // Set the index to -1 because we don't know the exact index of the video stream within the container 552 | Index = -1, 553 | }, 554 | new MediaStream 555 | { 556 | Type = MediaStreamType.Audio, 557 | // Set the index to -1 because we don't know the exact index of the audio stream within the container 558 | Index = -1 559 | } 560 | }, 561 | Container = "mpegts", 562 | SupportsProbing = true 563 | }; 564 | 565 | return Task.FromResult(mediaSourceInfo); 566 | } 567 | 568 | public Task CloseLiveStream(string id, CancellationToken cancellationToken) 569 | { 570 | _logger.LogInformation("Closing {Id}", id); 571 | return Task.CompletedTask; 572 | } 573 | 574 | public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) 575 | { 576 | SeriesTimerInfo defaultSettings = new SeriesTimerInfo 577 | { 578 | PrePaddingSeconds = Plugin.Instance.Configuration.PrePaddingSeconds, 579 | PostPaddingSeconds = Plugin.Instance.Configuration.PostPaddingSeconds 580 | }; 581 | return Task.FromResult(defaultSettings); 582 | } 583 | 584 | private async Task GetDefaultSettingsAsync(CancellationToken cancellationToken) 585 | { 586 | _logger.LogInformation("Start GetDefaultSettings Async"); 587 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 588 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 589 | .GetStreamAsync($"{_baseUrl}/service?method=setting.list&sid={Sid}", cancellationToken); 590 | await new SettingResponse().GetDefaultSettings(stream, _logger).ConfigureAwait(false); 591 | } 592 | 593 | public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) 594 | { 595 | _logger.LogInformation("Start GetPrograms Async, retrieve all Programs"); 596 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 597 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 598 | .GetStreamAsync($"{_baseUrl}/service?method=channel.listings&sid={Sid}&start={((DateTimeOffset)startDateUtc).ToUnixTimeSeconds()}&end={((DateTimeOffset)endDateUtc).ToUnixTimeSeconds()}&channel_id={channelId}", cancellationToken); 599 | return await new ListingsResponse(_baseUrl).GetPrograms(stream, channelId, _logger).ConfigureAwait(false); 600 | } 601 | 602 | public async Task GetLastUpdate(CancellationToken cancellationToken) 603 | { 604 | _logger.LogDebug("GetLastUpdateTime"); 605 | DateTimeOffset retTime = DateTimeOffset.FromUnixTimeSeconds(0); 606 | 607 | try 608 | { 609 | var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); 610 | httpClient.Timeout = TimeSpan.FromSeconds(5); 611 | var stream = await httpClient.GetStreamAsync($"{_baseUrl}/service?method=recording.lastupdated&ignore_resume=true&sid={Sid}", cancellationToken); 612 | retTime = await new LastUpdateResponse().GetUpdateTime(stream, _logger).ConfigureAwait(false); 613 | if (retTime == DateTimeOffset.FromUnixTimeSeconds(0)) 614 | { 615 | LastUpdatedSidDateTime = DateTimeOffset.MinValue; 616 | } 617 | else if (LastUpdatedSidDateTime != DateTimeOffset.MinValue) 618 | { 619 | LastUpdatedSidDateTime = DateTimeOffset.UtcNow; 620 | } 621 | 622 | UtilsHelper.DebugInformation(_logger, $"GetLastUpdateTime {retTime.ToUnixTimeSeconds()}"); 623 | } 624 | catch (Exception ex) 625 | { 626 | LastUpdatedSidDateTime = DateTimeOffset.MinValue; 627 | _logger.LogWarning(ex, "Could not connect to server"); 628 | Sid = null; 629 | } 630 | 631 | return retTime; 632 | } 633 | 634 | private async Task GetBackendSettingAsync(string key, CancellationToken cancellationToken) 635 | { 636 | _logger.LogInformation("GetBackendSetting"); 637 | await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false); 638 | await using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) 639 | .GetStreamAsync($"{_baseUrl}/service?method=setting.get&key={key}&sid={Sid}", cancellationToken); 640 | 641 | return await new SettingResponse().GetSetting(stream, _logger).ConfigureAwait(false); 642 | } 643 | 644 | public Task ResetTuner(string id, CancellationToken cancellationToken) 645 | { 646 | throw new NotImplementedException(); 647 | } 648 | } 649 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Plugin.NextPVR.Configuration; 4 | using MediaBrowser.Common.Configuration; 5 | using MediaBrowser.Common.Plugins; 6 | using MediaBrowser.Model.Plugins; 7 | using MediaBrowser.Model.Serialization; 8 | 9 | namespace Jellyfin.Plugin.NextPVR; 10 | 11 | /// 12 | /// Class Plugin. 13 | /// 14 | public class Plugin : BasePlugin, IHasWebPages 15 | { 16 | private readonly Guid _id = new Guid("9574ac10-bf23-49bc-949f-924f23cfa48f"); 17 | 18 | public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 19 | : base(applicationPaths, xmlSerializer) 20 | { 21 | Instance = this; 22 | } 23 | 24 | /// 25 | public override Guid Id => _id; 26 | 27 | /// 28 | public override string Name => "NextPVR"; 29 | 30 | /// 31 | public override string Description => "Provides live TV using NextPVR as the backend."; 32 | 33 | /// 34 | /// Gets the instance. 35 | /// 36 | /// The instance. 37 | public static Plugin Instance { get; private set; } 38 | 39 | /// 40 | public IEnumerable GetPages() 41 | { 42 | return new[] 43 | { 44 | new PluginPageInfo 45 | { 46 | Name = "nextpvr", 47 | EmbeddedResourcePath = GetType().Namespace + ".Web.nextpvr.html", 48 | }, 49 | new PluginPageInfo 50 | { 51 | Name = "nextpvrjs", 52 | EmbeddedResourcePath = GetType().Namespace + ".Web.nextpvr.js" 53 | } 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/RecordingsChannel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Extensions; 9 | using Jellyfin.Plugin.NextPVR.Entities; 10 | using MediaBrowser.Common.Configuration; 11 | using MediaBrowser.Common.Extensions; 12 | using MediaBrowser.Controller.Channels; 13 | using MediaBrowser.Controller.Entities; 14 | using MediaBrowser.Controller.Library; 15 | using MediaBrowser.Controller.Providers; 16 | using MediaBrowser.Model.Channels; 17 | using MediaBrowser.Model.Dto; 18 | using MediaBrowser.Model.Entities; 19 | using MediaBrowser.Model.IO; 20 | using MediaBrowser.Model.LiveTv; 21 | using MediaBrowser.Model.MediaInfo; 22 | using Microsoft.Extensions.Logging; 23 | 24 | namespace Jellyfin.Plugin.NextPVR; 25 | 26 | public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISupportsLatestMedia, ISupportsMediaProbe, IHasFolderAttributes, IDisposable, IHasItemChangeMonitor 27 | { 28 | private readonly IFileSystem _fileSystem; 29 | private readonly ILogger _logger; 30 | private readonly CancellationTokenSource _cancellationToken; 31 | private readonly string _recordingCacheDirectory; 32 | private static SemaphoreSlim _semaphore; 33 | 34 | private Timer _updateTimer; 35 | private DateTimeOffset _lastUpdate = DateTimeOffset.FromUnixTimeSeconds(0); 36 | 37 | private IEnumerable _allRecordings; 38 | private bool _useCachedRecordings = false; 39 | private DateTime _cachedRecordingModificationTime; 40 | private string _cacheKeyBase; 41 | private int _pollInterval = -1; 42 | 43 | public RecordingsChannel(IApplicationPaths applicationPaths, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger logger) 44 | { 45 | _fileSystem = fileSystem; 46 | _logger = logger; 47 | string channelId = libraryManager.GetNewItemId($"Channel {Name}", typeof(Channel)).ToString("N", CultureInfo.InvariantCulture); 48 | string version = $"{DataVersion}2".GetMD5().ToString("N", CultureInfo.InvariantCulture); 49 | _recordingCacheDirectory = Path.Join(applicationPaths.CachePath, "channels", channelId, version); 50 | CleanCache(true); 51 | _cancellationToken = new CancellationTokenSource(); 52 | _semaphore = new SemaphoreSlim(1, 1); 53 | } 54 | 55 | public string Name => "NextPVR Recordings"; 56 | 57 | public string Description => "NextPVR Recordings"; 58 | 59 | #pragma warning disable CA1819 60 | public string[] Attributes => ["Recordings"]; 61 | #pragma warning restore CA1819 62 | 63 | public string DataVersion => "1"; 64 | 65 | public string HomePageUrl => "https://www.nextpvr.com"; 66 | 67 | public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; 68 | 69 | public void Dispose() 70 | { 71 | Dispose(true); 72 | GC.SuppressFinalize(this); 73 | } 74 | 75 | protected virtual void Dispose(bool disposing) 76 | { 77 | if (disposing) 78 | { 79 | _updateTimer?.Dispose(); 80 | _cancellationToken?.Cancel(); 81 | _cancellationToken?.Dispose(); 82 | _updateTimer = null; 83 | } 84 | } 85 | 86 | public string GetCacheKey(string userId) 87 | { 88 | DateTimeOffset dto = LiveTvService.Instance.RecordingModificationTime; 89 | return $"{dto.ToUnixTimeSeconds()}-{_cacheKeyBase}"; 90 | } 91 | 92 | private void CleanCache(bool cleanAll = false) 93 | { 94 | if (!string.IsNullOrEmpty(_recordingCacheDirectory) && Directory.Exists(_recordingCacheDirectory)) 95 | { 96 | string[] cachedJson = Directory.GetFiles(_recordingCacheDirectory, "*.json"); 97 | _logger.LogInformation("Cleaning JSON cache {CacheDirectory} {FileCount}", _recordingCacheDirectory, cachedJson.Length); 98 | foreach (string fileName in cachedJson) 99 | { 100 | if (cleanAll || _fileSystem.GetLastWriteTimeUtc(fileName).Add(TimeSpan.FromHours(3)) <= DateTimeOffset.UtcNow) 101 | { 102 | _fileSystem.DeleteFile(fileName); 103 | } 104 | } 105 | } 106 | } 107 | 108 | public InternalChannelFeatures GetChannelFeatures() 109 | { 110 | return new InternalChannelFeatures 111 | { 112 | ContentTypes = [ChannelMediaContentType.Movie, ChannelMediaContentType.Episode, ChannelMediaContentType.Clip], 113 | MediaTypes = [ChannelMediaType.Audio, ChannelMediaType.Video], 114 | SupportsContentDownloading = true 115 | }; 116 | } 117 | 118 | public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) 119 | { 120 | if (type == ImageType.Primary) 121 | { 122 | return Task.FromResult(new DynamicImageResponse { Path = "https://repo.jellyfin.org/releases/plugin/images/jellyfin-plugin-nextpvr.png", Protocol = MediaProtocol.Http, HasImage = true }); 123 | } 124 | 125 | return Task.FromResult(new DynamicImageResponse { HasImage = false }); 126 | } 127 | 128 | public IEnumerable GetSupportedChannelImages() 129 | { 130 | return new List { ImageType.Primary }; 131 | } 132 | 133 | public bool IsEnabledFor(string userId) 134 | { 135 | return true; 136 | } 137 | 138 | private LiveTvService GetService() 139 | { 140 | LiveTvService service = LiveTvService.Instance; 141 | if (service is not null && (!service.IsActive || _cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime || service.FlagRecordingChange)) 142 | { 143 | try 144 | { 145 | CancellationToken cancellationToken = CancellationToken.None; 146 | service.EnsureConnectionAsync(cancellationToken).Wait(); 147 | if (service.IsActive) 148 | { 149 | _useCachedRecordings = false; 150 | if (_cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime) 151 | { 152 | _cachedRecordingModificationTime = Plugin.Instance.Configuration.RecordingModificationTime; 153 | } 154 | } 155 | } 156 | catch (Exception) 157 | { 158 | } 159 | } 160 | 161 | return service; 162 | } 163 | 164 | public bool CanDelete(BaseItem item) 165 | { 166 | if (_cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime) 167 | { 168 | return false; 169 | } 170 | 171 | return !item.IsFolder; 172 | } 173 | 174 | public Task DeleteItem(string id, CancellationToken cancellationToken) 175 | { 176 | if (_cachedRecordingModificationTime != Plugin.Instance.Configuration.RecordingModificationTime) 177 | { 178 | return Task.FromException(new InvalidOperationException("Recordings not reloaded")); 179 | } 180 | 181 | var service = GetService(); 182 | return service is null 183 | ? Task.CompletedTask 184 | : service.DeleteRecordingAsync(id, cancellationToken); 185 | } 186 | 187 | public async Task> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken) 188 | { 189 | var result = await GetChannelItems(new InternalChannelItemQuery(), _ => true, cancellationToken).ConfigureAwait(false); 190 | 191 | return result.Items.OrderByDescending(i => i.DateCreated ?? DateTime.MinValue); 192 | } 193 | 194 | public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) 195 | { 196 | await GetRecordingsAsync("GetChannelItems", cancellationToken); 197 | 198 | if (string.IsNullOrWhiteSpace(query.FolderId)) 199 | { 200 | return await GetRecordingGroups(query, cancellationToken); 201 | } 202 | 203 | if (query.FolderId.StartsWith("series_", StringComparison.OrdinalIgnoreCase)) 204 | { 205 | var hash = query.FolderId.Split('_')[1]; 206 | return await GetChannelItems(query, i => i.IsSeries && string.Equals(i.Name.GetMD5().ToString("N"), hash, StringComparison.Ordinal), cancellationToken); 207 | } 208 | 209 | if (string.Equals(query.FolderId, "kids", StringComparison.OrdinalIgnoreCase)) 210 | { 211 | return await GetChannelItems(query, i => i.IsKids, cancellationToken); 212 | } 213 | 214 | if (string.Equals(query.FolderId, "movies", StringComparison.OrdinalIgnoreCase)) 215 | { 216 | return await GetChannelItems(query, i => i.IsMovie, cancellationToken); 217 | } 218 | 219 | if (string.Equals(query.FolderId, "news", StringComparison.OrdinalIgnoreCase)) 220 | { 221 | return await GetChannelItems(query, i => i.IsNews, cancellationToken); 222 | } 223 | 224 | if (string.Equals(query.FolderId, "sports", StringComparison.OrdinalIgnoreCase)) 225 | { 226 | return await GetChannelItems(query, i => i.IsSports, cancellationToken); 227 | } 228 | 229 | if (string.Equals(query.FolderId, "others", StringComparison.OrdinalIgnoreCase)) 230 | { 231 | return await GetChannelItems(query, i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries, cancellationToken); 232 | } 233 | 234 | var result = new ChannelItemResult() { Items = new List() }; 235 | 236 | return result; 237 | } 238 | 239 | public async Task GetChannelItems(InternalChannelItemQuery query, Func filter, CancellationToken cancellationToken) 240 | { 241 | await GetRecordingsAsync("GetChannelItems", cancellationToken); 242 | List pluginItems = new List(); 243 | pluginItems.AddRange(_allRecordings.Where(filter).Select(ConvertToChannelItem)); 244 | var result = new ChannelItemResult() { Items = pluginItems }; 245 | 246 | return result; 247 | } 248 | 249 | private ChannelItemInfo ConvertToChannelItem(MyRecordingInfo item) 250 | { 251 | var path = string.IsNullOrEmpty(item.Path) ? item.Url : item.Path; 252 | 253 | var channelItem = new ChannelItemInfo 254 | { 255 | Name = string.IsNullOrEmpty(item.EpisodeTitle) ? item.Name : item.EpisodeTitle, 256 | SeriesName = !string.IsNullOrEmpty(item.EpisodeTitle) || item.IsSeries ? item.Name : null, 257 | StartDate = item.StartDate, 258 | EndDate = item.EndDate, 259 | OfficialRating = item.OfficialRating, 260 | CommunityRating = item.CommunityRating, 261 | ContentType = item.IsMovie ? ChannelMediaContentType.Movie : ChannelMediaContentType.Episode, 262 | Genres = item.Genres, 263 | ImageUrl = item.ImageUrl, 264 | Id = item.Id, 265 | ParentIndexNumber = item.SeasonNumber, 266 | IndexNumber = item.EpisodeNumber, 267 | MediaType = item.ChannelType == ChannelType.TV ? ChannelMediaType.Video : ChannelMediaType.Audio, 268 | MediaSources = new List 269 | { 270 | new MediaSourceInfo 271 | { 272 | Path = path, 273 | Container = item.Status == RecordingStatus.InProgress ? "ts" : null, 274 | Protocol = path.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? MediaProtocol.Http : MediaProtocol.File, 275 | BufferMs = 1000, 276 | AnalyzeDurationMs = 0, 277 | IsInfiniteStream = item.Status == RecordingStatus.InProgress, 278 | TranscodingContainer = "ts", 279 | RunTimeTicks = item.Status == RecordingStatus.InProgress ? null : (item.EndDate - item.StartDate).Ticks, 280 | } 281 | }, 282 | PremiereDate = item.OriginalAirDate, 283 | ProductionYear = item.ProductionYear, 284 | Type = ChannelItemType.Media, 285 | DateModified = item.Status == RecordingStatus.InProgress ? DateTime.Now : Plugin.Instance.Configuration.RecordingModificationTime, 286 | Overview = item.Overview, 287 | IsLiveStream = item.Status != RecordingStatus.InProgress ? false : Plugin.Instance.Configuration.EnableInProgress, 288 | Etag = item.Status.ToString() 289 | }; 290 | 291 | return channelItem; 292 | } 293 | 294 | private async Task GetRecordingsAsync(string name, CancellationToken cancellationToken) 295 | { 296 | var service = GetService(); 297 | if (service is null || !service.IsActive) 298 | { 299 | return false; 300 | } 301 | 302 | if (_useCachedRecordings == false || service.FlagRecordingChange) 303 | { 304 | if (_pollInterval == -1) 305 | { 306 | var interval = TimeSpan.FromSeconds(Plugin.Instance.Configuration.PollInterval); 307 | _updateTimer = new Timer(OnUpdateTimerCallbackAsync, null, TimeSpan.FromMinutes(2), interval); 308 | if (_updateTimer != null) 309 | { 310 | _pollInterval = Plugin.Instance.Configuration.PollInterval; 311 | } 312 | } 313 | 314 | if (await _semaphore.WaitAsync(30000, cancellationToken)) 315 | { 316 | try 317 | { 318 | _logger.LogDebug("{0} Reload cache", name); 319 | _allRecordings = await service.GetAllRecordingsAsync(cancellationToken).ConfigureAwait(false); 320 | int maxId = _allRecordings.Max(r => int.Parse(r.Id, CultureInfo.InvariantCulture)); 321 | int inProcessCount = _allRecordings.Count(r => r.Status == RecordingStatus.InProgress); 322 | string keyBase = $"{maxId}-{inProcessCount}-{_allRecordings.Count()}"; 323 | if (keyBase != _cacheKeyBase && !service.FlagRecordingChange) 324 | { 325 | _logger.LogDebug("External recording list change {0}", keyBase); 326 | CleanCache(true); 327 | } 328 | 329 | _cacheKeyBase = keyBase; 330 | _lastUpdate = DateTimeOffset.UtcNow; 331 | service.FlagRecordingChange = false; 332 | _useCachedRecordings = true; 333 | } 334 | catch (Exception) 335 | { 336 | } 337 | 338 | _semaphore.Release(); 339 | } 340 | } 341 | 342 | return _useCachedRecordings; 343 | } 344 | 345 | private async Task GetRecordingGroups(InternalChannelItemQuery query, CancellationToken cancellationToken) 346 | { 347 | List pluginItems = new List(); 348 | 349 | if (await GetRecordingsAsync("GetRecordingGroups", cancellationToken)) 350 | { 351 | var series = _allRecordings 352 | .Where(i => i.IsSeries) 353 | .ToLookup(i => i.Name, StringComparer.OrdinalIgnoreCase); 354 | 355 | pluginItems.AddRange(series.OrderBy(i => i.Key).Select(i => new ChannelItemInfo 356 | { 357 | Name = i.Key, 358 | FolderType = ChannelFolderType.Container, 359 | Id = "series_" + i.Key.GetMD5().ToString("N"), 360 | Type = ChannelItemType.Folder, 361 | DateCreated = i.Last().StartDate, 362 | ImageUrl = i.Last().ImageUrl.Replace("=poster", "=landscape", StringComparison.OrdinalIgnoreCase) 363 | })); 364 | 365 | var kids = _allRecordings.FirstOrDefault(i => i.IsKids); 366 | 367 | if (kids != null) 368 | { 369 | pluginItems.Add(new ChannelItemInfo 370 | { 371 | Name = "Kids", 372 | FolderType = ChannelFolderType.Container, 373 | Id = "kids", 374 | Type = ChannelItemType.Folder, 375 | ImageUrl = kids.ImageUrl 376 | }); 377 | } 378 | 379 | var movies = _allRecordings.FirstOrDefault(i => i.IsMovie); 380 | if (movies != null) 381 | { 382 | pluginItems.Add(new ChannelItemInfo 383 | { 384 | Name = "Movies", 385 | FolderType = ChannelFolderType.Container, 386 | Id = "movies", 387 | Type = ChannelItemType.Folder, 388 | ImageUrl = movies.ImageUrl 389 | }); 390 | } 391 | 392 | var news = _allRecordings.FirstOrDefault(i => i.IsNews); 393 | if (news != null) 394 | { 395 | pluginItems.Add(new ChannelItemInfo 396 | { 397 | Name = "News", 398 | FolderType = ChannelFolderType.Container, 399 | Id = "news", 400 | Type = ChannelItemType.Folder, 401 | ImageUrl = news.ImageUrl 402 | }); 403 | } 404 | 405 | var sports = _allRecordings.FirstOrDefault(i => i.IsSports); 406 | if (sports != null) 407 | { 408 | pluginItems.Add(new ChannelItemInfo 409 | { 410 | Name = "Sports", 411 | FolderType = ChannelFolderType.Container, 412 | Id = "sports", 413 | Type = ChannelItemType.Folder, 414 | ImageUrl = sports.ImageUrl 415 | }); 416 | } 417 | 418 | var other = _allRecordings.OrderByDescending(j => j.StartDate).FirstOrDefault(i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries); 419 | if (other != null) 420 | { 421 | pluginItems.Add(new ChannelItemInfo 422 | { 423 | Name = "Others", 424 | FolderType = ChannelFolderType.Container, 425 | Id = "others", 426 | Type = ChannelItemType.Folder, 427 | DateModified = other.StartDate, 428 | ImageUrl = other.ImageUrl 429 | }); 430 | } 431 | } 432 | 433 | var result = new ChannelItemResult() { Items = pluginItems }; 434 | return result; 435 | } 436 | 437 | private async void OnUpdateTimerCallbackAsync(object state) 438 | { 439 | LiveTvService service = LiveTvService.Instance; 440 | if (service is not null && service.IsActive) 441 | { 442 | var backendUpdate = await service.GetLastUpdate(_cancellationToken.Token).ConfigureAwait(false); 443 | if (backendUpdate > _lastUpdate) 444 | { 445 | _logger.LogDebug("Recordings reset {0}", backendUpdate); 446 | _useCachedRecordings = false; 447 | await GetRecordingsAsync("OnUpdateTimerCallbackAsync", _cancellationToken.Token); 448 | } 449 | } 450 | } 451 | 452 | public bool HasChanged(BaseItem item, IDirectoryService directoryService) 453 | { 454 | throw new NotImplementedException(); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/CancelDeleteRecordingResponse.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text.Json; 3 | using System.Threading.Tasks; 4 | using Jellyfin.Extensions.Json; 5 | using Jellyfin.Plugin.NextPVR.Helpers; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.NextPVR.Responses; 9 | 10 | public class CancelDeleteRecordingResponse 11 | { 12 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 13 | 14 | public async Task RecordingError(Stream stream, ILogger logger) 15 | { 16 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 17 | 18 | if (root.Stat != "ok") 19 | { 20 | UtilsHelper.DebugInformation(logger, $"RecordingError Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 21 | return true; 22 | } 23 | 24 | return false; 25 | } 26 | 27 | private sealed class RootObject 28 | { 29 | public string Stat { get; set; } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/ChannelResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using Jellyfin.Extensions.Json; 8 | using Jellyfin.Plugin.NextPVR.Helpers; 9 | using MediaBrowser.Controller.LiveTv; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.NextPVR.Responses; 13 | 14 | public class ChannelResponse 15 | { 16 | private readonly string _baseUrl; 17 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 18 | 19 | public ChannelResponse(string baseUrl) 20 | { 21 | _baseUrl = baseUrl; 22 | } 23 | 24 | public async Task> GetChannels(Stream stream, ILogger logger) 25 | { 26 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 27 | 28 | if (root == null) 29 | { 30 | logger.LogError("Failed to download channel information"); 31 | throw new JsonException("Failed to download channel information."); 32 | } 33 | 34 | if (root.Channels != null) 35 | { 36 | UtilsHelper.DebugInformation(logger, $"ChannelResponse: {JsonSerializer.Serialize(root, _jsonOptions)}"); 37 | return root.Channels.Select(i => new ChannelInfo 38 | { 39 | Name = i.ChannelName, 40 | Number = i.ChannelNumberFormated, 41 | Id = i.ChannelId.ToString(CultureInfo.InvariantCulture), 42 | ImageUrl = $"{_baseUrl}/service?method=channel.icon&channel_id={i.ChannelId}", 43 | ChannelType = ChannelHelper.GetChannelType(i.ChannelType), 44 | HasImage = i.ChannelIcon 45 | }); 46 | } 47 | 48 | return new List(); 49 | } 50 | 51 | // Classes created with http://json2csharp.com/ 52 | private sealed class Channel 53 | { 54 | public int ChannelId { get; set; } 55 | 56 | public int ChannelNumber { get; set; } 57 | 58 | public int ChannelMinor { get; set; } 59 | 60 | public string ChannelNumberFormated { get; set; } 61 | 62 | public int ChannelType { get; set; } 63 | 64 | public string ChannelName { get; set; } 65 | 66 | public string ChannelDetails { get; set; } 67 | 68 | public bool ChannelIcon { get; set; } 69 | } 70 | 71 | private sealed class RootObject 72 | { 73 | public List Channels { get; set; } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/InitializeResponse.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text.Json; 3 | using System.Threading.Tasks; 4 | using Jellyfin.Extensions.Json; 5 | using Jellyfin.Plugin.NextPVR.Helpers; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.NextPVR.Responses; 9 | 10 | public class InitializeResponse 11 | { 12 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 13 | 14 | public async Task LoggedIn(Stream stream, ILogger logger) 15 | { 16 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 17 | 18 | if (!string.IsNullOrEmpty(root.Stat)) 19 | { 20 | UtilsHelper.DebugInformation(logger, $"Connection validation: {JsonSerializer.Serialize(root, _jsonOptions)}"); 21 | return root.Stat == "ok"; 22 | } 23 | 24 | logger.LogError("Failed to validate your connection with NextPVR"); 25 | throw new JsonException("Failed to validate your connection with NextPVR."); 26 | } 27 | 28 | private sealed class RootObject 29 | { 30 | public string Stat { get; set; } 31 | 32 | public string Sid { get; set; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/InstantiateResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Threading.Tasks; 5 | using Jellyfin.Extensions.Json; 6 | using Jellyfin.Plugin.NextPVR.Entities; 7 | using Jellyfin.Plugin.NextPVR.Helpers; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.NextPVR.Responses; 11 | 12 | public class InstantiateResponse 13 | { 14 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 15 | 16 | public async Task GetClientKeys(Stream stream, ILogger logger) 17 | { 18 | try 19 | { 20 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 21 | 22 | if (root.Sid != null && root.Salt != null) 23 | { 24 | UtilsHelper.DebugInformation(logger, $"ClientKeys: {JsonSerializer.Serialize(root, _jsonOptions)}"); 25 | return root; 26 | } 27 | 28 | logger.LogError("Failed to validate the ClientKeys from NextPVR"); 29 | throw new JsonException("Failed to load the ClientKeys from NextPVR."); 30 | } 31 | catch 32 | { 33 | logger.LogError("Check NextPVR Version 5"); 34 | throw new UnauthorizedAccessException("Check NextPVR Version"); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/LastUpdateResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using System.Threading.Tasks; 6 | using Jellyfin.Extensions.Json; 7 | using Jellyfin.Plugin.NextPVR.Helpers; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.NextPVR.Responses; 11 | 12 | public class LastUpdateResponse 13 | { 14 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 15 | 16 | public async Task GetUpdateTime(Stream stream, ILogger logger) 17 | { 18 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 19 | UtilsHelper.DebugInformation(logger, $"LastUpdate Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 20 | return DateTimeOffset.FromUnixTimeSeconds(root.LastUpdate); 21 | } 22 | 23 | private sealed class RootObject 24 | { 25 | [JsonPropertyName("last_update")] 26 | public int LastUpdate { get; set; } 27 | 28 | public string Stat { get; set; } 29 | 30 | public int Code { get; set; } 31 | 32 | public string Msg { get; set; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/ListingsResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.Json; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Extensions.Json; 9 | using Jellyfin.Plugin.NextPVR.Helpers; 10 | using MediaBrowser.Controller.LiveTv; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace Jellyfin.Plugin.NextPVR.Responses; 14 | 15 | public class ListingsResponse 16 | { 17 | private readonly string _baseUrl; 18 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 19 | private string _channelId; 20 | 21 | public ListingsResponse(string baseUrl) 22 | { 23 | _baseUrl = baseUrl; 24 | } 25 | 26 | public async Task> GetPrograms(Stream stream, string channelId, ILogger logger) 27 | { 28 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 29 | UtilsHelper.DebugInformation(logger, $"GetPrograms Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 30 | _channelId = channelId; 31 | return root.Listings 32 | .Select(i => i) 33 | .Select(GetProgram); 34 | } 35 | 36 | private ProgramInfo GetProgram(Listing epg) 37 | { 38 | var genreMapper = new GenreMapper(Plugin.Instance.Configuration); 39 | var info = new ProgramInfo 40 | { 41 | ChannelId = _channelId, 42 | Id = epg.Id.ToString(CultureInfo.InvariantCulture), 43 | Overview = epg.Description, 44 | EpisodeTitle = epg.Subtitle, 45 | SeasonNumber = epg.Season, 46 | EpisodeNumber = epg.Episode, 47 | StartDate = DateTimeOffset.FromUnixTimeSeconds(epg.Start).DateTime, 48 | EndDate = DateTimeOffset.FromUnixTimeSeconds(epg.End).DateTime, 49 | Genres = new List(), // epg.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(), 50 | OriginalAirDate = epg.Original == null ? epg.Original : DateTime.SpecifyKind((DateTime)epg.Original, DateTimeKind.Local), 51 | ProductionYear = epg.Year, 52 | Name = epg.Name, 53 | OfficialRating = epg.Rating, 54 | IsPremiere = epg.Significance != null && epg.Significance.Contains("Premiere", StringComparison.OrdinalIgnoreCase), 55 | // CommunityRating = ParseCommunityRating(epg.StarRating), 56 | // Audio = ParseAudio(epg.Audio), 57 | // IsHD = string.Equals(epg.Quality, "hdtv", StringComparison.OrdinalIgnoreCase), 58 | IsLive = epg.Significance != null && epg.Significance.Contains("Live", StringComparison.OrdinalIgnoreCase), 59 | IsRepeat = !Plugin.Instance.Configuration.ShowRepeat || !epg.Firstrun, 60 | IsSeries = true, // !string.IsNullOrEmpty(epg.Subtitle), http://emby.media/community/index.php?/topic/21264-series-record-ability-missing-in-emby-epg/#entry239633 61 | HasImage = Plugin.Instance.Configuration.GetEpisodeImage, 62 | ImageUrl = Plugin.Instance.Configuration.GetEpisodeImage ? $"{_baseUrl}/service?method=channel.show.artwork&name={Uri.EscapeDataString(epg.Name)}" : null, 63 | BackdropImageUrl = Plugin.Instance.Configuration.GetEpisodeImage ? $"{_baseUrl}/service?method=channel.show.artwork&prefer=landscape&name={Uri.EscapeDataString(epg.Name)}" : null, 64 | }; 65 | 66 | if (epg.Genres != null) 67 | { 68 | info.Genres = epg.Genres; 69 | genreMapper.PopulateProgramGenres(info); 70 | if (info.IsMovie) 71 | { 72 | info.IsRepeat = false; 73 | info.IsSeries = false; 74 | } 75 | } 76 | 77 | return info; 78 | } 79 | 80 | // Classes created with http://json2csharp.com/ 81 | 82 | private sealed class Listing 83 | { 84 | public int Id { get; set; } 85 | 86 | public string Name { get; set; } 87 | 88 | public string Description { get; set; } 89 | 90 | public string Subtitle { get; set; } 91 | 92 | public List Genres { get; set; } 93 | 94 | public bool Firstrun { get; set; } 95 | 96 | public int Start { get; set; } 97 | 98 | public int End { get; set; } 99 | 100 | public string Rating { get; set; } 101 | 102 | public DateTime? Original { get; set; } 103 | 104 | public int? Season { get; set; } 105 | 106 | public int? Episode { get; set; } 107 | 108 | public int? Year { get; set; } 109 | 110 | public string Significance { get; set; } 111 | 112 | public string RecordingStatus { get; set; } 113 | 114 | public int RecordingId { get; set; } 115 | } 116 | 117 | private sealed class RootObject 118 | { 119 | public List Listings { get; set; } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/RecordingResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.Json; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Extensions.Json; 9 | using Jellyfin.Plugin.NextPVR.Entities; 10 | using Jellyfin.Plugin.NextPVR.Helpers; 11 | using MediaBrowser.Controller.LiveTv; 12 | using MediaBrowser.Model.LiveTv; 13 | using Microsoft.Extensions.Logging; 14 | 15 | namespace Jellyfin.Plugin.NextPVR.Responses; 16 | 17 | public class RecordingResponse 18 | { 19 | private readonly string _baseUrl; 20 | private readonly ILogger _logger; 21 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 22 | 23 | public RecordingResponse(string baseUrl, ILogger logger) 24 | { 25 | _baseUrl = baseUrl; 26 | _logger = logger; 27 | } 28 | 29 | public async Task> GetRecordings(Stream stream) 30 | { 31 | if (stream == null) 32 | { 33 | _logger.LogError("GetRecording stream == null"); 34 | throw new ArgumentNullException(nameof(stream)); 35 | } 36 | 37 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 38 | UtilsHelper.DebugInformation(_logger, $"GetRecordings Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 39 | 40 | IEnumerable recordings; 41 | try 42 | { 43 | recordings = root.Recordings 44 | .Select(i => i) 45 | .Where(i => i.Status != "failed" && i.Status != "conflict") 46 | .Select(GetRecordingInfo); 47 | } 48 | catch (Exception err) 49 | { 50 | _logger.LogWarning(err, "Get recordings"); 51 | throw; 52 | } 53 | 54 | return recordings.ToList(); 55 | } 56 | 57 | public async Task> GetTimers(Stream stream) 58 | { 59 | if (stream == null) 60 | { 61 | _logger.LogError("GetTimers stream == null"); 62 | throw new ArgumentNullException(nameof(stream)); 63 | } 64 | 65 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 66 | UtilsHelper.DebugInformation(_logger, $"GetTimers Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 67 | IEnumerable timers; 68 | try 69 | { 70 | timers = root.Recordings 71 | .Select(i => i) 72 | .Select(GetTimerInfo); 73 | } 74 | catch (Exception err) 75 | { 76 | _logger.LogWarning(err, "Get timers"); 77 | throw; 78 | } 79 | 80 | return timers; 81 | } 82 | 83 | private MyRecordingInfo GetRecordingInfo(Recording i) 84 | { 85 | var genreMapper = new GenreMapper(Plugin.Instance.Configuration); 86 | var info = new MyRecordingInfo(); 87 | info.Id = i.Id.ToString(CultureInfo.InvariantCulture); 88 | if (i.Recurring) 89 | { 90 | info.SeriesTimerId = i.RecurringParent.ToString(CultureInfo.InvariantCulture); 91 | } 92 | 93 | info.Status = ParseStatus(i.Status); 94 | if (i.File != null) 95 | { 96 | if (Plugin.Instance.Configuration.RecordingTransport == 2) 97 | { 98 | info.Url = i.File; 99 | } 100 | else 101 | { 102 | string sidParameter = null; 103 | if (Plugin.Instance.Configuration.RecordingTransport == 1 || Plugin.Instance.Configuration.BackendVersion < 60106) 104 | { 105 | sidParameter = $"&sid={LiveTvService.Instance.Sid}"; 106 | } 107 | 108 | if (info.Status == RecordingStatus.InProgress) 109 | { 110 | info.Url = $"{_baseUrl}/live?recording={i.Id}{sidParameter}&growing=true"; 111 | } 112 | else 113 | { 114 | info.Url = $"{_baseUrl}/live?recording={i.Id}{sidParameter}"; 115 | } 116 | } 117 | } 118 | 119 | info.StartDate = DateTimeOffset.FromUnixTimeSeconds(i.StartTime).DateTime; 120 | info.EndDate = DateTimeOffset.FromUnixTimeSeconds(i.StartTime + i.Duration).DateTime; 121 | 122 | info.ProgramId = i.EpgEventId.ToString(CultureInfo.InvariantCulture); 123 | info.EpisodeTitle = i.Subtitle; 124 | info.Name = i.Name; 125 | info.Overview = i.Desc; 126 | info.Genres = i.Genres; 127 | info.IsRepeat = !i.Firstrun; 128 | info.ChannelId = i.ChannelId.ToString(CultureInfo.InvariantCulture); 129 | info.ChannelType = ChannelType.TV; 130 | info.ImageUrl = _baseUrl + "/service?method=channel.show.artwork&prefer=landscape&name=" + Uri.EscapeDataString(i.Name); 131 | info.HasImage = true; 132 | if (i.Season.HasValue) 133 | { 134 | info.SeasonNumber = i.Season; 135 | info.EpisodeNumber = i.Episode; 136 | info.IsSeries = true; 137 | string se = string.Format(CultureInfo.InvariantCulture, "S{0:D2}E{1:D2} - ", i.Season, i.Episode); 138 | if (i.Subtitle.StartsWith(se, StringComparison.CurrentCulture)) 139 | { 140 | info.EpisodeTitle = i.Subtitle.Substring(se.Length); 141 | } 142 | } 143 | 144 | if (i.Original != null) 145 | { 146 | info.OriginalAirDate = i.Original; 147 | } 148 | 149 | info.ProductionYear = i.Year; 150 | info.OfficialRating = i.Rating; 151 | 152 | if (info.Genres != null) 153 | { 154 | info.Genres = i.Genres; 155 | genreMapper.PopulateRecordingGenres(info); 156 | } 157 | else 158 | { 159 | info.Genres = new List(); 160 | } 161 | 162 | return info; 163 | } 164 | 165 | private TimerInfo GetTimerInfo(Recording i) 166 | { 167 | var genreMapper = new GenreMapper(Plugin.Instance.Configuration); 168 | var info = new TimerInfo(); 169 | if (i.Recurring) 170 | { 171 | info.SeriesTimerId = i.RecurringParent.ToString(CultureInfo.InvariantCulture); 172 | info.IsSeries = true; 173 | } 174 | 175 | info.ChannelId = i.ChannelId.ToString(CultureInfo.InvariantCulture); 176 | info.Id = i.Id.ToString(CultureInfo.InvariantCulture); 177 | info.Status = ParseStatus(i.Status); 178 | info.StartDate = DateTimeOffset.FromUnixTimeSeconds(i.StartTime).DateTime; 179 | info.EndDate = DateTimeOffset.FromUnixTimeSeconds(i.StartTime + i.Duration).DateTime; 180 | info.PrePaddingSeconds = i.PrePadding * 60; 181 | info.PostPaddingSeconds = i.PostPadding * 60; 182 | info.ProgramId = i.EpgEventId.ToString(CultureInfo.InvariantCulture); 183 | info.Name = i.Name; 184 | info.Overview = i.Desc; 185 | info.EpisodeTitle = i.Subtitle; 186 | if (i.Season.HasValue) 187 | { 188 | info.SeasonNumber = i.Season; 189 | info.EpisodeNumber = i.Episode; 190 | info.IsSeries = true; 191 | string se = string.Format(CultureInfo.InvariantCulture, "S{0:D2}E{1:D2} - ", i.Season, i.Episode); 192 | if (i.Subtitle.StartsWith(se, StringComparison.CurrentCulture)) 193 | { 194 | info.EpisodeTitle = i.Subtitle.Substring(se.Length); 195 | } 196 | } 197 | 198 | info.OfficialRating = i.Rating; 199 | if (i.Original != null) 200 | { 201 | info.OriginalAirDate = i.Original; 202 | } 203 | 204 | info.ProductionYear = i.Year; 205 | 206 | if (i.Genres != null) 207 | { 208 | info.Genres = i.Genres.ToArray(); 209 | genreMapper.PopulateTimerGenres(info); 210 | } 211 | 212 | info.IsRepeat = !i.Firstrun; 213 | return info; 214 | } 215 | 216 | private RecordingStatus ParseStatus(string value) 217 | { 218 | if (string.Equals(value, "ready", StringComparison.OrdinalIgnoreCase)) 219 | { 220 | return RecordingStatus.Completed; 221 | } 222 | 223 | if (string.Equals(value, "recording", StringComparison.OrdinalIgnoreCase)) 224 | { 225 | return RecordingStatus.InProgress; 226 | } 227 | 228 | if (string.Equals(value, "failed", StringComparison.OrdinalIgnoreCase)) 229 | { 230 | return RecordingStatus.Error; 231 | } 232 | 233 | if (string.Equals(value, "conflict", StringComparison.OrdinalIgnoreCase)) 234 | { 235 | return RecordingStatus.ConflictedNotOk; 236 | } 237 | 238 | if (string.Equals(value, "deleted", StringComparison.OrdinalIgnoreCase)) 239 | { 240 | return RecordingStatus.Cancelled; 241 | } 242 | 243 | return RecordingStatus.New; 244 | } 245 | 246 | private sealed class Recording 247 | { 248 | public int Id { get; set; } 249 | 250 | public string Name { get; set; } 251 | 252 | public string Desc { get; set; } 253 | 254 | public string Subtitle { get; set; } 255 | 256 | public int StartTime { get; set; } 257 | 258 | public int Duration { get; set; } 259 | 260 | public int? Season { get; set; } 261 | 262 | public int? Episode { get; set; } 263 | 264 | public int EpgEventId { get; set; } 265 | 266 | public List Genres { get; set; } 267 | 268 | public string Status { get; set; } 269 | 270 | public string Rating { get; set; } 271 | 272 | public string Quality { get; set; } 273 | 274 | public string Channel { get; set; } 275 | 276 | public int ChannelId { get; set; } 277 | 278 | public bool Blue { get; set; } 279 | 280 | public bool Green { get; set; } 281 | 282 | public bool Yellow { get; set; } 283 | 284 | public bool Red { get; set; } 285 | 286 | public int PrePadding { get; set; } 287 | 288 | public int PostPadding { get; set; } 289 | 290 | public string File { get; set; } 291 | 292 | public int PlaybackPosition { get; set; } 293 | 294 | public bool Played { get; set; } 295 | 296 | public bool Recurring { get; set; } 297 | 298 | public int RecurringParent { get; set; } 299 | 300 | public bool Firstrun { get; set; } 301 | 302 | public string Reason { get; set; } 303 | 304 | public string Significance { get; set; } 305 | 306 | public DateTime? Original { get; set; } 307 | 308 | public int? Year { get; set; } 309 | } 310 | 311 | private sealed class RootObject 312 | { 313 | public List Recordings { get; set; } 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/RecurringResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.Json; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Extensions.Json; 9 | using Jellyfin.Plugin.NextPVR.Helpers; 10 | using MediaBrowser.Controller.LiveTv; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace Jellyfin.Plugin.NextPVR.Responses; 14 | 15 | internal sealed class RecurringResponse 16 | { 17 | private readonly ILogger _logger; 18 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 19 | 20 | public RecurringResponse(ILogger logger) 21 | { 22 | _logger = logger; 23 | } 24 | 25 | public async Task> GetSeriesTimers(Stream stream) 26 | { 27 | if (stream == null) 28 | { 29 | _logger.LogError("GetSeriesTimers stream == null"); 30 | throw new ArgumentNullException(nameof(stream)); 31 | } 32 | 33 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 34 | UtilsHelper.DebugInformation(_logger, $"GetSeriesTimers Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 35 | return root.Recurrings 36 | .Select(i => i) 37 | .Select(GetSeriesTimerInfo); 38 | } 39 | 40 | private SeriesTimerInfo GetSeriesTimerInfo(Recurring i) 41 | { 42 | var info = new SeriesTimerInfo 43 | { 44 | ChannelId = i.ChannelId.ToString(CultureInfo.InvariantCulture), 45 | Id = i.Id.ToString(CultureInfo.InvariantCulture), 46 | StartDate = DateTimeOffset.FromUnixTimeSeconds(i.StartTimeTicks).DateTime, 47 | EndDate = DateTimeOffset.FromUnixTimeSeconds(i.EndTimeTicks).DateTime, 48 | PrePaddingSeconds = i.PrePadding * 60, 49 | PostPaddingSeconds = i.PostPadding * 60, 50 | Name = i.Name ?? i.EpgTitle, 51 | RecordNewOnly = i.OnlyNewEpisodes 52 | }; 53 | 54 | if (info.ChannelId == "0") 55 | { 56 | info.RecordAnyChannel = true; 57 | } 58 | 59 | if (i.Days == null) 60 | { 61 | info.RecordAnyTime = true; 62 | } 63 | else 64 | { 65 | info.Days = (i.Days ?? string.Empty).Split(':') 66 | .Select(d => (DayOfWeek)Enum.Parse(typeof(DayOfWeek), d.Trim(), true)) 67 | .ToList(); 68 | } 69 | 70 | return info; 71 | } 72 | 73 | private sealed class Recurring 74 | { 75 | public int Id { get; set; } 76 | 77 | public int Type { get; set; } 78 | 79 | public string Name { get; set; } 80 | 81 | public int ChannelId { get; set; } 82 | 83 | public string Channel { get; set; } 84 | 85 | public string Period { get; set; } 86 | 87 | public int Keep { get; set; } 88 | 89 | public int PrePadding { get; set; } 90 | 91 | public int PostPadding { get; set; } 92 | 93 | public string EpgTitle { get; set; } 94 | 95 | public string DirectoryId { get; set; } 96 | 97 | public string Days { get; set; } 98 | 99 | public bool Enabled { get; set; } 100 | 101 | public bool OnlyNewEpisodes { get; set; } 102 | 103 | public int StartTimeTicks { get; set; } 104 | 105 | public int EndTimeTicks { get; set; } 106 | 107 | public string AdvancedRules { get; set; } 108 | } 109 | 110 | private sealed class RootObject 111 | { 112 | public List Recurrings { get; set; } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/SettingResponse.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | using System.Threading.Tasks; 5 | using Jellyfin.Extensions.Json; 6 | using Jellyfin.Plugin.NextPVR.Helpers; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.NextPVR.Responses; 10 | 11 | public class SettingResponse 12 | { 13 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 14 | 15 | public async Task GetDefaultSettings(Stream stream, ILogger logger) 16 | { 17 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 18 | UtilsHelper.DebugInformation(logger, $"GetDefaultTimerInfo Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 19 | Plugin.Instance.Configuration.PostPaddingSeconds = root.PostPadding; 20 | Plugin.Instance.Configuration.PrePaddingSeconds = root.PrePadding; 21 | Plugin.Instance.Configuration.ShowRepeat = root.ShowNewInGuide; 22 | Plugin.Instance.Configuration.BackendVersion = root.NextPvrVersion; 23 | return true; 24 | } 25 | 26 | public async Task GetSetting(Stream stream, ILogger logger) 27 | { 28 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 29 | UtilsHelper.DebugInformation(logger, $"GetSetting Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); 30 | return root.Value; 31 | } 32 | 33 | // Classes created with http://json2csharp.com/ 34 | 35 | private sealed class ScheduleSettings 36 | { 37 | public string Version { get; set; } 38 | 39 | [JsonPropertyName("nextPVRVersion")] 40 | public int NextPvrVersion { get; set; } 41 | 42 | public string ReadableVersion { get; set; } 43 | 44 | public bool LiveTimeshift { get; set; } 45 | 46 | public bool LiveTimeshiftBufferInfo { get; set; } 47 | 48 | public bool ChannelsUseSegmenter { get; set; } 49 | 50 | public bool RecordingsUseSegmenter { get; set; } 51 | 52 | public int WhatsNewDays { get; set; } 53 | 54 | public int SkipForwardSeconds { get; set; } 55 | 56 | public int SkipBackSeconds { get; set; } 57 | 58 | public int SkipFfSeconds { get; set; } 59 | 60 | public int SkipRwSeconds { get; set; } 61 | 62 | public string RecordingView { get; set; } 63 | 64 | public int PrePadding { get; set; } 65 | 66 | public int PostPadding { get; set; } 67 | 68 | public bool ConfirmOnDelete { get; set; } 69 | 70 | public bool ShowNewInGuide { get; set; } 71 | 72 | public int SlipSeconds { get; set; } 73 | 74 | public string RecordingDirectories { get; set; } 75 | 76 | public bool ChannelDetailsLevel { get; set; } 77 | 78 | public string Time { get; set; } 79 | 80 | public int TimeEpoch { get; set; } 81 | } 82 | 83 | private sealed class SettingValue 84 | { 85 | public string Value { get; set; } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Responses/TunerResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using Jellyfin.Extensions.Json; 8 | using MediaBrowser.Controller.LiveTv; 9 | using MediaBrowser.Model.LiveTv; 10 | 11 | namespace Jellyfin.Plugin.NextPVR.Responses; 12 | 13 | public class TunerResponse 14 | { 15 | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; 16 | 17 | public async Task> LiveTvTunerInfo(Stream stream) 18 | { 19 | var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false); 20 | return root.Tuners.Select(GetTunerInformation).ToList(); 21 | } 22 | 23 | private TunerHostInfo GetTunerInformation(Tuner i) 24 | { 25 | TunerHostInfo tunerinfo = new TunerHostInfo(); 26 | 27 | tunerinfo.FriendlyName = i.TunerName; 28 | /* 29 | tunerinfo.Status = GetStatus(i); 30 | 31 | if (i.Recordings.Count > 0) 32 | { 33 | tunerinfo.ChannelId = i.Recordings.Single().Recording.ChannelOid.ToString(CultureInfo.InvariantCulture); 34 | } 35 | */ 36 | return tunerinfo; 37 | } 38 | 39 | /* 40 | private LiveTvTunerStatus GetStatus(Tuner i) 41 | { 42 | if (i.Recordings.Count > 0) 43 | { 44 | return LiveTvTunerStatus.RecordingTv; 45 | } 46 | 47 | if (i.LiveTv.Count > 0) 48 | { 49 | return LiveTvTunerStatus.LiveTv; 50 | } 51 | 52 | return LiveTvTunerStatus.Available; 53 | } 54 | */ 55 | 56 | private sealed class Recording 57 | { 58 | public int TunerOid { get; set; } 59 | 60 | public string RecName { get; set; } 61 | 62 | public int ChannelOid { get; set; } 63 | 64 | public int RecordingOid { get; set; } 65 | } 66 | 67 | private sealed class Recordings 68 | { 69 | public Recording Recording { get; set; } 70 | } 71 | 72 | private sealed class Tuner 73 | { 74 | public string TunerName { get; set; } 75 | 76 | public string TunerStatus { get; set; } 77 | 78 | public List Recordings { get; set; } 79 | 80 | public List LiveTv { get; set; } 81 | } 82 | 83 | private sealed class RootObject 84 | { 85 | public List Tuners { get; set; } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/ServiceRegistrator.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller; 2 | using MediaBrowser.Controller.Channels; 3 | using MediaBrowser.Controller.LiveTv; 4 | using MediaBrowser.Controller.Plugins; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace Jellyfin.Plugin.NextPVR; 8 | 9 | /// 10 | /// Register NextPVR services. 11 | /// 12 | /// 13 | public class ServiceRegistrator : IPluginServiceRegistrator 14 | { 15 | /// 16 | public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) 17 | { 18 | serviceCollection.AddSingleton(); 19 | serviceCollection.AddSingleton(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Web/nextpvr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | NextPVR backend URL (format --> http://{hostname}:{port}). 10 | 11 | 12 | 13 | 14 | 15 | NextPVR PIN to access the backend. 16 | 17 | 18 | 19 | 20 | 21 | Enable NextPVR debug logging 22 | 23 | 24 | 25 | 26 | 27 | 28 | Force in-progress recordings 29 | 30 | 31 | 32 | 33 | 34 | 35 | Check to reload recording changes from the server in (seconds). Use 0 to disable polling. 36 | 37 | 38 | 39 | 40 | 41 | New episodes on this channel 42 | All episodes on this channel 43 | Daily, this timeslot 44 | Weekly, this timeslot 45 | All Episodes, All Channels 46 | 47 | 48 | 49 | 50 | 51 | Filename 52 | Streaming 53 | Unauthenticated streaming 54 | 55 | 56 | 57 | 58 | 59 | 60 | Default to new episodes only 61 | 62 | 63 | 64 | 65 | 66 | Against each jellyfin category match the NextPVR genres that belong to it 67 | 68 | 69 | 70 | Example: Movie,Film,TV Movie 71 | 72 | 73 | 74 | 75 | 76 | Example: Sport,Football 77 | 78 | 79 | 80 | 81 | 82 | Example: News Report,Daily News 83 | 84 | 85 | 86 | 87 | 88 | Example: Cartoon,Animation 89 | 90 | 91 | 92 | 93 | 94 | Example: Live Gameshow,Live Sports 95 | 96 | 97 | 98 | 99 | 100 | ${Save} 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.NextPVR/Web/nextpvr.js: -------------------------------------------------------------------------------- 1 | const NextPvrConfigurationPage = { 2 | pluginUniqueId: '9574ac10-bf23-49bc-949f-924f23cfa48f' 3 | }; 4 | 5 | var authentication = ""; 6 | var transport; 7 | var inprogress; 8 | 9 | function loadGenres(config, page) { 10 | if (config != null && config.GenreMappings) { 11 | if (config.GenreMappings['GENREMOVIE'] != null) { 12 | page.querySelector('#txtMovieGenre').value = config.GenreMappings['GENREMOVIE'].join(','); 13 | } 14 | if (config.GenreMappings['GENRESPORT'] != null) { 15 | page.querySelector('#txtSportsGenre').value = config.GenreMappings['GENRESPORT'].join(','); 16 | } 17 | if (config.GenreMappings['GENRENEWS'] != null) { 18 | page.querySelector('#txtNewsGenre').value = config.GenreMappings['GENRENEWS'].join(','); 19 | } 20 | if (config.GenreMappings['GENREKIDS'] != null) { 21 | page.querySelector('#txtKidsGenre').value = config.GenreMappings['GENREKIDS'].join(','); 22 | } 23 | if (config.GenreMappings['GENRELIVE'] != null) { 24 | page.querySelector('#txtLiveGenre').value = config.GenreMappings['GENRELIVE'].join(','); 25 | } 26 | } 27 | } 28 | export default function(view) { 29 | view.addEventListener('viewshow', function() { 30 | Dashboard.showLoadingMsg(); 31 | const page = this; 32 | 33 | ApiClient.getPluginConfiguration(NextPvrConfigurationPage.pluginUniqueId).then(function(config) { 34 | page.querySelector('#txtWebServiceUrl').value = config.WebServiceUrl || ''; 35 | page.querySelector('#txtPin').value = config.Pin || ''; 36 | page.querySelector('#numPoll').value = config.PollInterval; 37 | page.querySelector('#chkDebugLogging').checked = config.EnableDebugLogging; 38 | page.querySelector('#chkInProgress').checked = config.EnableInProgress; 39 | page.querySelector('#chkNewEpisodes').checked = config.NewEpisodes; 40 | page.querySelector('#selRecDefault').value = config.RecordingDefault; 41 | page.querySelector('#selRecTransport').value = config.RecordingTransport; 42 | loadGenres(config, page); 43 | authentication = config.WebServiceUrl + config.Pin; 44 | transport = config.RecordingTransport; 45 | inprogress = config.EnableInProgress; 46 | Dashboard.hideLoadingMsg(); 47 | }); 48 | }); 49 | view.querySelector('.nextpvrConfigurationForm').addEventListener('submit', function(e) { 50 | Dashboard.showLoadingMsg(); 51 | const form = this; 52 | 53 | ApiClient.getPluginConfiguration(NextPvrConfigurationPage.pluginUniqueId).then(function(config) { 54 | config.WebServiceUrl = form.querySelector('#txtWebServiceUrl').value; 55 | config.Pin = form.querySelector('#txtPin').value; 56 | config.EnableDebugLogging = form.querySelector('#chkDebugLogging').checked; 57 | config.EnableInProgress = form.querySelector('#chkInProgress').checked; 58 | config.NewEpisodes = form.querySelector('#chkNewEpisodes').checked; 59 | config.RecordingDefault = form.querySelector('#selRecDefault').value; 60 | config.RecordingTransport = form.querySelector('#selRecTransport').value; 61 | config.PollInterval = form.querySelector('#numPoll').value; 62 | if (authentication != config.WebServiceUrl + config.Pin) { 63 | config.StoredSid = ""; 64 | config.CurrentWebServiceURL = ""; 65 | // Date will be updated; 66 | var myJsDate = new Date(); 67 | config.RecordingModificationTime = myJsDate.toISOString(); 68 | } else if (transport != config.RecordingTransport || inprogress != config.EnableInProgress) { 69 | var myJsDate = new Date(); 70 | config.RecordingModificationTime = myJsDate.toISOString(); 71 | } 72 | authentication = config.WebServiceUrl + config.Pin; 73 | transport = config.RecordingTransport; 74 | inprogress = config.EnableInProgress; 75 | // Copy over the genre mapping fields 76 | config.GenreMappings = { 77 | 'GENREMOVIE': form.querySelector('#txtMovieGenre').value.split(','), 78 | 'GENRESPORT': form.querySelector('#txtSportsGenre').value.split(','), 79 | 'GENRENEWS': form.querySelector('#txtNewsGenre').value.split(','), 80 | 'GENREKIDS': form.querySelector('#txtKidsGenre').value.split(','), 81 | 'GENRELIVE': form.querySelector('#txtLiveGenre').value.split(',') 82 | }; 83 | 84 | ApiClient.updatePluginConfiguration(NextPvrConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult); 85 | }); 86 | e.preventDefault(); 87 | // Disable default form submission 88 | return false; 89 | }); 90 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Media Browser http://mediabrowser.tv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Jellyfin NextPVR Plugin 2 | Part of the Jellyfin Project 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ## About 20 | 21 | This plugin provides access to live TV, program guide, and recordings from a [NextPVR](http://www.nextpvr.com) server. 22 | 23 | ## Installation 24 | 25 | [See the official documentation for install instructions](https://jellyfin.org/docs/general/server/plugins/index.html#installing). 26 | 27 | ## Build 28 | 29 | 1. To build this plugin you will need [.Net 8.x](https://dotnet.microsoft.com/download/dotnet/8.0). 30 | 31 | 2. Build plugin with following command 32 | ``` 33 | dotnet publish --configuration Release --output bin 34 | ``` 35 | 36 | 3. Place the dll-file in the `plugins/nextpvr` folder (you might need to create the folders) of your JF install 37 | 38 | ## Releasing 39 | 40 | To release the plugin we recommend [JPRM](https://github.com/oddstr13/jellyfin-plugin-repository-manager) that will build and package the plugin. 41 | For additional context and for how to add the packaged plugin zip to a plugin manifest see the [JPRM documentation](https://github.com/oddstr13/jellyfin-plugin-repository-manager) for more info. 42 | 43 | ## Contributing 44 | 45 | We welcome all contributions and pull requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start. 46 | In general refer to our [contributing guidelines](https://github.com/jellyfin/.github/blob/master/CONTRIBUTING.md) for further information. 47 | 48 | ## Licence 49 | 50 | This plugins code and packages are distributed under the MIT License. See [LICENSE](./LICENSE) for more information. 51 | -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | name: "NextPVR" 2 | guid: "9574ac10-bf23-49bc-949f-924f23cfa48f" 3 | imageUrl: "https://repo.jellyfin.org/releases/plugin/images/jellyfin-plugin-nextpvr.png" 4 | version: 11 5 | targetAbi: "10.9.6.0" 6 | framework: "net8.0" 7 | overview: "Live TV plugin for NextPVR" 8 | description: > 9 | Provides access to live TV, program guide, and recordings from NextPVR. 10 | 11 | category: "LiveTV" 12 | owner: "jellyfin" 13 | artifacts: 14 | - "Jellyfin.Plugin.NextPVR.dll" 15 | changelog: |- 16 | - Recording enhancements (#53) @emveepee 17 | -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | --------------------------------------------------------------------------------
Against each jellyfin category match the NextPVR genres that belong to it
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |