1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Integrate branch 'master' into feature/langugae_filters

This commit is contained in:
TheMelmacian 2025-02-27 22:57:04 +01:00
commit 73baf3b92a
216 changed files with 11791 additions and 7400 deletions

View file

@ -1,5 +0,0 @@
node_modules
coverage
dist
.idea
.vscode

View file

@ -1,338 +0,0 @@
const restrictedGlobals = require('confusing-browser-globals');
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@stylistic',
'@typescript-eslint',
'react',
'import',
'sonarjs'
],
env: {
node: true,
es6: true,
es2017: true,
es2020: true
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/errors',
'plugin:@eslint-community/eslint-comments/recommended',
'plugin:compat/recommended',
'plugin:sonarjs/recommended'
],
rules: {
'array-callback-return': ['error', { 'checkForEach': true }],
'curly': ['error', 'multi-line', 'consistent'],
'default-case-last': ['error'],
'max-params': ['error', 7],
'new-cap': [
'error',
{
'capIsNewExceptions': ['jQuery.Deferred'],
'newIsCapExceptionPattern': '\\.default$'
}
],
'no-duplicate-imports': ['error'],
'no-empty-function': ['error'],
'no-extend-native': ['error'],
'no-lonely-if': ['error'],
'no-nested-ternary': ['error'],
'no-redeclare': ['off'],
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
'no-restricted-globals': ['error'].concat(restrictedGlobals),
'no-return-assign': ['error'],
'no-return-await': ['error'],
'no-sequences': ['error', { 'allowInParentheses': false }],
'no-shadow': ['off'],
'@typescript-eslint/no-shadow': ['error'],
'no-throw-literal': ['error'],
'no-undef-init': ['error'],
'no-unneeded-ternary': ['error'],
'no-unused-expressions': ['off'],
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
'no-unused-private-class-members': ['error'],
'no-useless-rename': ['error'],
'no-useless-constructor': ['off'],
'@typescript-eslint/no-useless-constructor': ['error'],
'no-var': ['error'],
'no-void': ['error', { 'allowAsStatement': true }],
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
'one-var': ['error', 'never'],
'prefer-const': ['error', { 'destructuring': 'all' }],
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
'@typescript-eslint/prefer-for-of': ['error'],
'radix': ['error'],
'yoda': 'error',
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
'react/jsx-no-bind': ['error'],
'react/jsx-no-useless-fragment': ['error'],
'react/jsx-no-constructed-context-values': ['error'],
'react/no-array-index-key': ['error'],
'sonarjs/no-inverted-boolean-check': ['error'],
// TODO: Enable the following rules and fix issues
'sonarjs/cognitive-complexity': ['off'],
'sonarjs/no-duplicate-string': ['off'],
'@stylistic/block-spacing': ['error'],
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/comma-spacing': ['error'],
'@stylistic/eol-last': ['error'],
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/keyword-spacing': ['error'],
'@stylistic/max-statements-per-line': ['error'],
'@stylistic/no-floating-decimal': ['error'],
'@stylistic/no-multi-spaces': ['error'],
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
'@stylistic/no-trailing-spaces': ['error'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'@stylistic/padded-blocks': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'@stylistic/semi': ['error'],
'@stylistic/space-before-blocks': ['error'],
'@stylistic/space-infix-ops': ['error']
},
settings: {
react: {
version: 'detect'
},
'import/parsers': {
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
},
'import/resolver': {
node: {
extensions: [
'.js',
'.ts',
'.jsx',
'.tsx'
],
moduleDirectory: [
'node_modules',
'src'
]
}
},
polyfills: [
// Native Promises Only
'Promise',
// whatwg-fetch
'fetch',
// document-register-element
'document.registerElement',
// resize-observer-polyfill
'ResizeObserver',
// fast-text-encoding
'TextEncoder',
// intersection-observer
'IntersectionObserver',
// Core-js
'Object.assign',
'Object.is',
'Object.setPrototypeOf',
'Object.toString',
'Object.freeze',
'Object.seal',
'Object.preventExtensions',
'Object.isFrozen',
'Object.isSealed',
'Object.isExtensible',
'Object.getOwnPropertyDescriptor',
'Object.getPrototypeOf',
'Object.keys',
'Object.entries',
'Object.getOwnPropertyNames',
'Function.name',
'Function.hasInstance',
'Array.from',
'Array.arrayOf',
'Array.copyWithin',
'Array.fill',
'Array.find',
'Array.findIndex',
'Array.iterator',
'String.fromCodePoint',
'String.raw',
'String.iterator',
'String.codePointAt',
'String.endsWith',
'String.includes',
'String.repeat',
'String.startsWith',
'String.trim',
'String.anchor',
'String.big',
'String.blink',
'String.bold',
'String.fixed',
'String.fontcolor',
'String.fontsize',
'String.italics',
'String.link',
'String.small',
'String.strike',
'String.sub',
'String.sup',
'RegExp',
'Number',
'Math',
'Date',
'async',
'Symbol',
'Map',
'Set',
'WeakMap',
'WeakSet',
'ArrayBuffer',
'DataView',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'Reflect',
// Temporary while eslint-compat-plugin is buggy
'document.querySelector'
]
},
overrides: [
// Config files and development scripts
{
files: [
'./babel.config.js',
'./.eslintrc.js',
'./postcss.config.js',
'./webpack.*.js',
'./scripts/**/*.js'
]
},
// JavaScript source files
{
files: [
'./src/**/*.{js,jsx,ts,tsx}'
],
parserOptions: {
project: ['./tsconfig.json']
},
env: {
node: false,
amd: true,
browser: true,
es6: true,
es2017: true,
es2020: true
},
globals: {
// Browser globals
'MediaMetadata': 'readonly',
// Tizen globals
'tizen': 'readonly',
'webapis': 'readonly',
// WebOS globals
'webOS': 'readonly',
// Dependency globals
'$': 'readonly',
'jQuery': 'readonly',
// Jellyfin globals
'ApiClient': 'writable',
'Events': 'writable',
'chrome': 'writable',
'DlnaProfilePage': 'writable',
'DashboardPage': 'writable',
'Emby': 'readonly',
'Globalize': 'writable',
'Hls': 'writable',
'LibraryMenu': 'writable',
'LinkParser': 'writable',
'LiveTvHelpers': 'writable',
'Loading': 'writable',
'MetadataEditor': 'writable',
'ServerNotifications': 'writable',
'TaskButton': 'writable',
'UserParentalControlPage': 'writable',
'Windows': 'readonly',
// Build time definitions
__COMMIT_SHA__: 'readonly',
__JF_BUILD_VERSION__: 'readonly',
__PACKAGE_JSON_NAME__: 'readonly',
__PACKAGE_JSON_VERSION__: 'readonly',
__USE_SYSTEM_FONTS__: 'readonly',
__WEBPACK_SERVE__: 'readonly'
},
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allow'
},
{
selector: 'variable',
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
{
selector: 'typeLike',
format: [ 'PascalCase' ]
},
{
selector: 'enumMember',
format: [ 'PascalCase', 'UPPER_CASE' ]
},
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
// and api_key parameter
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: null,
filter: {
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
match: true
}
}
],
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
}
},
// TypeScript source files
{
files: [
'./src/**/*.{ts,tsx}'
],
extends: [
'eslint:recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
'plugin:@eslint-community/eslint-comments/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended'
],
rules: {
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-unused-vars': ['error'],
'sonarjs/cognitive-complexity': ['error']
}
}
]
};

View file

@ -1,32 +0,0 @@
---
name: Bug Report
about: You have noticed a general issue or regression, and would like to report it
labels: bug
---
**Describe The Bug**
<!-- A clear and concise description of what the bug is. -->
**Steps To Reproduce**
<!-- Steps to reproduce the behavior: -->
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected Behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Logs**
<!-- Please paste any log errors. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**System (please complete the following information):**
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
- Browser: [e.g. Firefox, Chrome, Safari]
- Jellyfin Version: [e.g. 10.6.0]
**Additional Context**
<!-- Add any other context about the problem here. -->

122
.github/ISSUE_TEMPLATE/1-bug-report.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Bug Report
description: You have noticed a general issue or regression, and would like to report it
labels:
- bug
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug.
You can also attach screenshots or screen recordings here to help explain your issue.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: |
Steps to reproduce the behavior:
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
validations:
required: true
- type: textarea
id: behaviour
attributes:
label: Expected/Actual behaviour
description: |
Describe the behavior you were expecting versus what actually occurred.
placeholder: |
I expected the app to... However, the actual behavior was that...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please paste any log errors.
placeholder: Paste logs…
- type: markdown
attributes:
value: |
## Environment
- type: markdown
attributes:
value: |
### Server
You will find these values in your Admin Dashboard
- type: input
id: server-version
attributes:
label: Server version
placeholder: 10.10.2
validations:
required: true
- type: input
id: web-version
attributes:
label: Web version
placeholder: 10.10.2
validations:
required: true
- type: input
id: build-version
attributes:
label: Build version
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
### Client
Information about the device you are seeing the issue on
- type: input
id: platform
attributes:
label: Platform
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
placeholder: e.g. Linux, Windows, iPhone, Tizen
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
placeholder: e.g. Firefox, Chrome, Safari
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false

View file

@ -1,22 +0,0 @@
---
name: Playback Issue
about: You have playback issues with some files
labels: playback
---
**Describe The Bug**
<!-- A clear and concise description of what the bug is. -->
**Media Information**
<!-- Please paste any ffprobe or MediaInfo logs. -->
**Screenshots**
<!-- Add screenshots from the Playback Data and Media Info. -->
**System (please complete the following information):**
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
- Browser: [e.g. Firefox, Chrome, Safari]
- Jellyfin Version: [e.g. 10.6.0]
**Additional Context**
<!-- Add any other context about the problem here. -->

View file

@ -0,0 +1,145 @@
name: Playback Issue
description: Create a bug report related to media playback
labels:
- bug
- playback
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug.
You can also attach screenshots or screen recordings here to help explain your issue.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: |
Steps to reproduce the behavior:
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
validations:
required: true
- type: textarea
id: behaviour
attributes:
label: Expected/Actual behaviour
description: |
Describe the behavior you were expecting versus what actually occurred.
placeholder: |
I expected the app to... However, the actual behavior was that...
validations:
required: true
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: markdown
attributes:
value: |
## Logs
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please paste your logs here if applicable.
placeholder: Paste logs…
- type: textarea
id: logs-ffmpeg
attributes:
label: FFmpeg logs
description: |
Please paste your FFmpeg logs here if available. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: markdown
attributes:
value: |
## Environment
- type: markdown
attributes:
value: |
### Server
You will find these values in your Admin Dashboard
- type: input
id: server-version
attributes:
label: Server version
placeholder: 10.10.2
validations:
required: true
- type: input
id: web-version
attributes:
label: Web version
placeholder: 10.10.2
validations:
required: true
- type: input
id: build-version
attributes:
label: Build version
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
### Client
Information about the device you are seeing the issue on
- type: input
id: platform
attributes:
label: Platform
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
placeholder: e.g. Linux, Windows, iPhone, Tizen
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
placeholder: e.g. Firefox, Chrome, Safari
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false

View file

@ -1,13 +0,0 @@
---
name: Technical Discussion
about: You want to discuss technical aspects of changes you intend to make
labels: enhancement
---
<!-- Explain the change and the motivations behind it.
For example, if you plan to rely on a new dependency, explain why and what
it brings to the project.
If you plan to make significant changes, go roughly over the steps you intend
to take and how you would divide the change in PRs of a manageable size. -->

View file

@ -1,9 +0,0 @@
---
name: Meta Issue
about: You want to track a number of other issues as part of a larger project
labels: meta
---
* [ ] Issue 1 [#123]
* [ ] Issue 2 [#456]
* [ ] ...

View file

@ -4,6 +4,9 @@
"github>jellyfin/.github//renovate-presets/nodejs",
":dependencyDashboard"
],
"lockFileMaintenance": {
"enabled": false
},
"packageRules": [
{
"matchPackageNames": [ "@jellyfin/sdk" ],

View file

@ -26,15 +26,15 @@ jobs:
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
category: '/language:${{matrix.language}}'

View file

@ -35,7 +35,7 @@ jobs:
path: dist
- name: Publish to Cloudflare Pages 📃
uses: cloudflare/wrangler-action@7a5f8bbdfeedcde38e6777a50fe685f89259d4ca # v3.13.1
uses: cloudflare/wrangler-action@392082e81ffbcb9ebdde27400634aa004b35ea37 # v3.14.0
id: cf
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View file

@ -19,7 +19,7 @@ jobs:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm
@ -39,7 +39,7 @@ jobs:
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: frontend
path: dist

View file

@ -48,7 +48,7 @@ jobs:
show-progress: false
- name: Setup node environment ⚙️
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm

View file

@ -85,7 +85,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm
@ -95,6 +95,6 @@ jobs:
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@9c12109c4943f26f0676b71c9c10e456748872cf # v4.1.7
uses: CatChen/eslint-suggestion-action@3ba53ce078667d5f60a73a8005627cf95ab57dce # v4.1.9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -10,7 +10,7 @@ permissions:
jobs:
issues:
name: Check issues
name: Check stale issues and PRs
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
@ -18,10 +18,9 @@ jobs:
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
# Issues receive a stale warning after 120 days and close after an additional 21 days
days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
stale-issue-label: stale
stale-issue-message: |-
@ -30,21 +29,10 @@ jobs:
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
prs-conflicts:
name: Check PRs with merge conflicts
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
# The merge conflict action will remove the label when updated
remove-stale-when-updated: false
days-before-stale: -1
days-before-close: 90
days-before-issue-close: -1
stale-pr-label: merge conflict
# PRs are closed after having unresolved merge conflicts for 90 days
days-before-pr-stale: 0
days-before-pr-close: 90
only-pr-labels: merge conflict
stale-pr-label: stale
close-pr-message: |-
This PR has been closed due to having unresolved merge conflicts.

View file

@ -1,4 +1,7 @@
{
"[json][typescript][typescriptreact][javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},

View file

@ -94,6 +94,8 @@
- [iFraan](https://github.com/iFraan)
- [Ali](https://github.com/bu3alwa)
- [K. Kyle Puchkov](https://github.com/kepper104)
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
- [Jxiced](https://github.com/Jxiced)
## Emby Contributors

385
eslint.config.mjs Normal file
View file

@ -0,0 +1,385 @@
// @ts-check
import eslint from '@eslint/js';
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import compat from 'eslint-plugin-compat';
import globals from 'globals';
// @ts-expect-error Missing type definition
import importPlugin from 'eslint-plugin-import';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import restrictedGlobals from 'confusing-browser-globals';
import sonarjs from 'eslint-plugin-sonarjs';
import stylistic from '@stylistic/eslint-plugin';
// eslint-disable-next-line import/no-unresolved
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
// @ts-expect-error Harmless type mismatch in dependency
comments.recommended,
compat.configs['flat/recommended'],
importPlugin.flatConfigs.errors,
sonarjs.configs.recommended,
reactPlugin.configs.flat.recommended,
{
settings: {
react: {
version: 'detect'
}
}
},
jsxA11y.flatConfigs.recommended,
// Global ignores
{
ignores: [
'node_modules',
'coverage',
'dist',
'.idea',
'.vscode'
]
},
// Global style rules
{
plugins: {
'@stylistic': stylistic
},
extends: [ importPlugin.flatConfigs.typescript ],
rules: {
'array-callback-return': ['error', { 'checkForEach': true }],
'curly': ['error', 'multi-line', 'consistent'],
'default-case-last': 'error',
'max-params': ['error', 7],
'new-cap': [
'error',
{
'capIsNewExceptions': ['jQuery.Deferred'],
'newIsCapExceptionPattern': '\\.default$'
}
],
'no-duplicate-imports': 'error',
'no-empty-function': 'error',
'no-extend-native': 'error',
'no-lonely-if': 'error',
'no-nested-ternary': 'error',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
'no-restricted-globals': ['error'].concat(restrictedGlobals),
'no-return-assign': 'error',
'no-return-await': 'error',
'no-sequences': ['error', { 'allowInParentheses': false }],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-throw-literal': 'error',
'no-undef-init': 'error',
'no-unneeded-ternary': 'error',
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
'no-unused-private-class-members': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'no-useless-rename': 'error',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-var': 'error',
'no-void': ['error', { 'allowAsStatement': true }],
'no-warning-comments': ['warn', { 'terms': ['hack', 'xxx'] }],
'one-var': ['error', 'never'],
'prefer-const': ['error', { 'destructuring': 'all' }],
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
'@typescript-eslint/prefer-for-of': 'error',
'radix': 'error',
'yoda': 'error',
'sonarjs/fixme-tag': 'warn',
'sonarjs/todo-tag': 'off',
'sonarjs/deprecation': 'warn',
'sonarjs/no-alphabetical-sort': 'warn',
'sonarjs/no-inverted-boolean-check': 'error',
'sonarjs/no-selector-parameter': 'off',
'sonarjs/pseudo-random': 'warn',
// TODO: Enable the following sonarjs rules and fix issues
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-functions': 'warn',
// TODO: Replace with stylistic.configs.customize()
'@stylistic/block-spacing': 'error',
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/comma-spacing': 'error',
'@stylistic/eol-last': 'error',
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/keyword-spacing': 'error',
'@stylistic/max-statements-per-line': 'error',
'@stylistic/no-floating-decimal': 'error',
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'@stylistic/padded-blocks': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'@stylistic/semi': 'error',
'@stylistic/space-before-blocks': 'error',
'@stylistic/space-infix-ops': 'error'
}
},
// Config files use node globals
{
ignores: [ 'src' ],
languageOptions: {
globals: {
...globals.node
}
}
},
// Config files are commonjs by default
{
files: [ '**/*.{cjs,js}' ],
ignores: [ 'src' ],
languageOptions: {
sourceType: 'commonjs'
},
rules: {
'@typescript-eslint/no-require-imports': 'off'
}
},
// App files
{
files: [
'src/**/*.{js,jsx,ts,tsx}'
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
},
globals: {
...globals.browser,
// Tizen globals
'tizen': false,
'webapis': false,
// WebOS globals
'webOS': false,
// Dependency globals
'$': false,
'jQuery': false,
// Jellyfin globals
'ApiClient': true,
'Events': true,
'chrome': true,
'Emby': false,
'Hls': true,
'LibraryMenu': true,
'Windows': false,
// Build time definitions
__COMMIT_SHA__: false,
__JF_BUILD_VERSION__: false,
__PACKAGE_JSON_NAME__: false,
__PACKAGE_JSON_VERSION__: false,
__USE_SYSTEM_FONTS__: false,
__WEBPACK_SERVE__: false
}
},
settings: {
'import/resolver': {
node: {
extensions: [
'.js',
'.ts',
'.jsx',
'.tsx'
],
moduleDirectory: [
'node_modules',
'src'
]
}
},
polyfills: [
'Promise',
// whatwg-fetch
'fetch',
// document-register-element
'document.registerElement',
// resize-observer-polyfill
'ResizeObserver',
// fast-text-encoding
'TextEncoder',
// intersection-observer
'IntersectionObserver',
// Core-js
'Object.assign',
'Object.is',
'Object.setPrototypeOf',
'Object.toString',
'Object.freeze',
'Object.seal',
'Object.preventExtensions',
'Object.isFrozen',
'Object.isSealed',
'Object.isExtensible',
'Object.getOwnPropertyDescriptor',
'Object.getPrototypeOf',
'Object.keys',
'Object.entries',
'Object.getOwnPropertyNames',
'Function.name',
'Function.hasInstance',
'Array.from',
'Array.arrayOf',
'Array.copyWithin',
'Array.fill',
'Array.find',
'Array.findIndex',
'Array.iterator',
'String.fromCodePoint',
'String.raw',
'String.iterator',
'String.codePointAt',
'String.endsWith',
'String.includes',
'String.repeat',
'String.startsWith',
'String.trim',
'String.anchor',
'String.big',
'String.blink',
'String.bold',
'String.fixed',
'String.fontcolor',
'String.fontsize',
'String.italics',
'String.link',
'String.small',
'String.strike',
'String.sub',
'String.sup',
'RegExp',
'Number',
'Math',
'Date',
'async',
'Symbol',
'Map',
'Set',
'WeakMap',
'WeakSet',
'ArrayBuffer',
'DataView',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'Reflect'
]
},
rules: {
// TODO: Add typescript recommended typed rules
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allow'
},
{
selector: 'variable',
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
{
selector: 'typeLike',
format: [ 'PascalCase' ]
},
{
selector: 'enumMember',
format: [ 'PascalCase', 'UPPER_CASE' ]
},
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
// and api_key parameter
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: null,
filter: {
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
match: true
}
}
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error'
}
},
// React files
{
files: [ 'src/**/*.{jsx,tsx}' ],
plugins: {
'react-hooks': reactHooks
},
rules: {
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
'react/jsx-no-bind': 'error',
'react/jsx-no-useless-fragment': 'error',
'react/no-array-index-key': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
},
// Service worker
{
files: [ 'src/serviceworker.js' ],
languageOptions: {
globals: {
...globals.serviceworker
}
}
},
// Legacy JS (less strict)
{
files: [ 'src/**/*.{js,jsx}' ],
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'sonarjs/public-static-readonly': 'off',
// TODO: Enable the following rules and fix issues
'sonarjs/cognitive-complexity': 'off',
'sonarjs/constructor-for-side-effects': 'off',
'sonarjs/function-return-type': 'off',
'sonarjs/no-async-constructor': 'off',
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-ignored-exceptions': 'off',
'sonarjs/no-invariant-returns': 'warn',
'sonarjs/no-nested-functions': 'off',
'sonarjs/void-use': 'off'
}
}
);

8229
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,14 @@
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "7.25.8",
"@babel/plugin-transform-modules-umd": "7.25.7",
"@babel/preset-env": "7.25.8",
"@babel/preset-react": "7.25.7",
"@babel/core": "7.26.9",
"@babel/plugin-transform-modules-umd": "7.25.9",
"@babel/preset-env": "7.26.9",
"@babel/preset-react": "7.26.3",
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
"@stylistic/eslint-plugin": "2.12.1",
"@stylistic/stylelint-plugin": "3.1.1",
"@eslint/js": "9.20.0",
"@stylistic/eslint-plugin": "3.1.0",
"@stylistic/stylelint-plugin": "3.1.2",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9",
@ -21,10 +22,9 @@
"@types/react-dom": "18.3.1",
"@types/react-lazy-load-image-component": "1.6.4",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "8.17.0",
"@typescript-eslint/parser": "8.17.0",
"@typescript-eslint/parser": "8.24.1",
"@uupaa/dynamic-import-polyfill": "1.0.2",
"@vitest/coverage-v8": "2.1.8",
"@vitest/coverage-v8": "3.0.5",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"clean-webpack-plugin": "4.0.0",
@ -34,40 +34,42 @@
"css-loader": "7.1.2",
"cssnano": "7.0.6",
"es-check": "7.2.1",
"eslint": "8.57.1",
"eslint-plugin-compat": "4.2.0",
"eslint": "9.20.1",
"eslint-plugin-compat": "6.0.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.3",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-sonarjs": "0.25.1",
"expose-loader": "5.0.0",
"eslint-plugin-react": "7.37.4",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-sonarjs": "3.0.2",
"expose-loader": "5.0.1",
"fork-ts-checker-webpack-plugin": "9.0.2",
"globals": "15.15.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.0",
"html-webpack-plugin": "5.6.3",
"jsdom": "25.0.1",
"mini-css-extract-plugin": "2.9.1",
"postcss": "8.4.49",
"mini-css-extract-plugin": "2.9.2",
"postcss": "8.5.2",
"postcss-loader": "8.1.1",
"postcss-preset-env": "10.1.3",
"postcss-preset-env": "10.1.4",
"postcss-scss": "4.0.9",
"sass": "1.83.1",
"sass-loader": "16.0.2",
"sass": "1.85.0",
"sass-loader": "16.0.5",
"source-map-loader": "5.0.0",
"speed-measure-webpack-plugin": "1.5.0",
"style-loader": "4.0.0",
"stylelint": "16.12.0",
"stylelint": "16.14.1",
"stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "1.3.0",
"stylelint-order": "6.0.4",
"stylelint-scss": "6.10.0",
"ts-loader": "9.5.1",
"typescript": "5.6.3",
"vitest": "2.1.8",
"webpack": "5.95.0",
"stylelint-scss": "6.11.0",
"ts-loader": "9.5.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"webpack": "5.98.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0",
"webpack-dev-server": "5.2.0",
"webpack-merge": "6.0.1",
"worker-loader": "3.0.8"
},
@ -81,27 +83,27 @@
"@fontsource/noto-sans-sc": "5.1.1",
"@fontsource/noto-sans-tc": "5.1.1",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202501180501",
"@mui/icons-material": "5.16.7",
"@mui/material": "5.16.7",
"@mui/x-date-pickers": "7.20.0",
"@jellyfin/sdk": "0.0.0-unstable.202502210501",
"@mui/icons-material": "5.16.14",
"@mui/material": "5.16.14",
"@mui/x-date-pickers": "7.26.0",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.62.16",
"@tanstack/react-query-devtools": "5.62.16",
"abortcontroller-polyfill": "1.7.5",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1",
"core-js": "3.38.1",
"date-fns": "2.30.0",
"dompurify": "2.5.7",
"dompurify": "2.5.8",
"epubjs": "0.3.93",
"escape-html": "1.0.3",
"fast-text-encoding": "1.0.6",
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.5.18",
"hls.js": "1.5.20",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@ -121,14 +123,14 @@
"react-router-dom": "6.27.0",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.3",
"swiper": "11.2.1",
"usehooks-ts": "3.1.0",
"sortablejs": "1.15.6",
"swiper": "11.2.3",
"usehooks-ts": "3.1.1",
"webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20"
},
"optionalDependencies": {
"sass-embedded": "1.83.1"
"sass-embedded": "1.85.0"
},
"browserslist": [
"last 2 Firefox versions",

View file

@ -1,4 +1,5 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { SxProps, Theme } from '@mui/material';
import IconButton from '@mui/material/IconButton/IconButton';
import React, { type FC } from 'react';
import { Link } from 'react-router-dom';
@ -7,14 +8,21 @@ import UserAvatar from 'components/UserAvatar';
interface UserAvatarButtonProps {
user?: UserDto
sx?: SxProps<Theme>
}
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
const UserAvatarButton: FC<UserAvatarButtonProps> = ({
user,
sx
}) => (
user?.Id ? (
<IconButton
size='large'
color='inherit'
sx={{ padding: 0 }}
sx={{
padding: 0,
...sx
}}
title={user.Name || undefined}
component={Link}
to={`/dashboard/users/profile?userId=${user.Id}`}

View file

@ -0,0 +1,17 @@
import format from 'date-fns/format';
import type { MRT_Cell, MRT_RowData } from 'material-react-table';
import { FC } from 'react';
import { useLocale } from 'hooks/useLocale';
interface CellProps {
cell: MRT_Cell<MRT_RowData>
}
const DateTimeCell: FC<CellProps> = ({ cell }) => {
const { dateFnsLocale } = useLocale();
return format(cell.getValue<Date>(), 'Pp', { locale: dateFnsLocale });
};
export default DateTimeCell;

View file

@ -0,0 +1,72 @@
import Box from '@mui/material/Box/Box';
import Stack from '@mui/material/Stack/Stack';
import Typography from '@mui/material/Typography/Typography';
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
import React from 'react';
import Page, { type PageProps } from 'components/Page';
interface TablePageProps<T extends MRT_RowData> extends PageProps {
title: string
subtitle?: string
table: MRT_TableInstance<T>
}
export const DEFAULT_TABLE_OPTIONS = {
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
}
};
const TablePage = <T extends MRT_RowData>({
title,
subtitle,
table,
children,
...pageProps
}: TablePageProps<T>) => {
return (
<Page
title={title}
{...pageProps}
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Stack
spacing={2}
sx={{
marginBottom: 1
}}
>
<Typography variant='h2'>
{title}
</Typography>
{subtitle && (
<Typography>
{subtitle}
</Typography>
)}
</Stack>
<MaterialReactTable table={table} />
</Box>
{children}
</Page>
);
};
export default TablePage;

View file

@ -23,10 +23,10 @@
<button is="emby-button" type="button" class="raised btnRefresh">
<span>${ButtonScanAllLibraries}</span>
</button>
<button is="emby-button" type="button" id="btnRestartServer" class="raised" onclick="DashboardPage.restart(this);">
<button is="emby-button" type="button" id="btnRestartServer" class="raised">
<span>${Restart}</span>
</button>
<button is="emby-button" type="button" id="btnShutdown" class="raised" onclick="DashboardPage.shutdown(this);">
<button is="emby-button" type="button" id="btnShutdown" class="raised">
<span>${ButtonShutdown}</span>
</button>
</div>

View file

@ -1,36 +1,36 @@
import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime';
import Events from '../../utils/events.ts';
import itemHelper from '../../components/itemHelper';
import serverNotifications from '../../scripts/serverNotifications';
import dom from '../../scripts/dom';
import globalize from '../../lib/globalize';
import datetime from 'scripts/datetime';
import Events from 'utils/events.ts';
import itemHelper from 'components/itemHelper';
import serverNotifications from 'scripts/serverNotifications';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import { formatDistanceToNow } from 'date-fns';
import { getLocaleWithSuffix } from '../../utils/dateFnsLocale.ts';
import loading from '../../components/loading/loading';
import playMethodHelper from '../../components/playback/playmethodhelper';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import ActivityLog from '../../components/activitylog';
import imageHelper from '../../utils/image';
import indicators from '../../components/indicators/indicators';
import taskButton from '../../scripts/taskbutton';
import Dashboard from '../../utils/dashboard';
import ServerConnections from '../../components/ServerConnections';
import alert from '../../components/alert';
import confirm from '../../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils';
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
import loading from 'components/loading/loading';
import playMethodHelper from 'components/playback/playmethodhelper';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import ActivityLog from 'components/activitylog';
import imageHelper from 'utils/image';
import indicators from 'components/indicators/indicators';
import taskButton from 'scripts/taskbutton';
import Dashboard from 'utils/dashboard';
import ServerConnections from 'components/ServerConnections';
import alert from 'components/alert';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient';
import '../../elements/emby-button/emby-button';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-button/emby-button';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import '../../components/listview/listview.scss';
import '../../styles/flexstyles.scss';
import 'components/listview/listview.scss';
import 'styles/flexstyles.scss';
import './dashboard.scss';
function showPlaybackInfo(btn, session) {
@ -72,7 +72,7 @@ function showPlaybackInfo(btn, session) {
}
function showSendMessageForm(btn, session) {
import('../../components/prompt/prompt').then(({ default: prompt }) => {
import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({
title: globalize.translate('HeaderSendMessage'),
label: globalize.translate('LabelMessageText'),
@ -89,7 +89,7 @@ function showSendMessageForm(btn, session) {
}
function showOptionsMenu(btn, session) {
import('../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
const menuItems = [];
if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) {
@ -394,7 +394,7 @@ function renderRunningTasks(view, tasks) {
view.querySelector('#divRunningTasks').innerHTML = html;
}
window.DashboardPage = {
const DashboardPage = {
startInterval: function (apiClient) {
apiClient.sendMessage('SessionsStart', '0,1500');
apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000');
@ -714,33 +714,38 @@ window.DashboardPage = {
pollForInfo(page, ApiClient);
});
},
restart: function (btn) {
restart: function (event) {
confirm({
title: globalize.translate('Restart'),
text: globalize.translate('MessageConfirmRestart'),
confirmText: globalize.translate('Restart'),
primary: 'delete'
}).then(function () {
const page = dom.parentWithClass(btn, 'page');
}).then(() => {
const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true;
ApiClient.restartServer();
}).catch(() => {
// Confirm dialog closed
});
},
shutdown: function (btn) {
shutdown: function (event) {
confirm({
title: globalize.translate('ButtonShutdown'),
text: globalize.translate('MessageConfirmShutdown'),
confirmText: globalize.translate('ButtonShutdown'),
primary: 'delete'
}).then(function () {
const page = dom.parentWithClass(btn, 'page');
}).then(() => {
const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true;
ApiClient.shutdownServer();
}).catch(() => {
// Confirm dialog closed
});
}
};
export default function (view) {
function onRestartRequired(evt, apiClient) {
console.debug('onRestartRequired not implemented', evt, apiClient);
@ -816,7 +821,11 @@ export default function (view) {
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
});
view.addEventListener('viewbeforehide', function () {
const apiClient = ApiClient;
const page = this;
@ -838,6 +847,9 @@ export default function (view) {
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
});
view.addEventListener('viewdestroy', function () {
const page = this;

View file

@ -1,9 +1,9 @@
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import dom from '../../scripts/dom';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import dom from 'scripts/dom';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, systemInfo) {
Array.prototype.forEach.call(page.querySelectorAll('.chkDecodeCodec'), function (c) {
@ -263,7 +263,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
setDecodingCodecsVisible(page, this.value);
});
$('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
@ -280,7 +280,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
});
});
$('#btnSelectFallbackFontPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
includeDirectories: true,

View file

@ -1,14 +1,14 @@
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import '../../elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-textarea/emby-textarea';
import '../../elements/emby-input/emby-input';
import '../../elements/emby-select/emby-select';
import '../../elements/emby-button/emby-button';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-textarea/emby-textarea';
import 'elements/emby-input/emby-input';
import 'elements/emby-select/emby-select';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, languageOptions, systemInfo) {
page.querySelector('#txtServerName').value = systemInfo.ServerName;
@ -53,7 +53,7 @@ function onSubmit() {
export default function (view) {
$('#btnSelectCachePath', view).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
@ -70,7 +70,7 @@ export default function (view) {
});
});
$('#btnSelectMetadataPath', view).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
path: view.querySelector('#txtMetadataPath').value,

View file

@ -1,17 +1,18 @@
import escapeHtml from 'escape-html';
import taskButton from '../../scripts/taskbutton';
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import dom from '../../scripts/dom';
import imageHelper from '../../utils/image';
import '../../components/cardbuilder/card.scss';
import '../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
import Dashboard, { pageClassOn, pageIdOn } from '../../utils/dashboard';
import confirm from '../../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils';
import taskButton from 'scripts/taskbutton';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import dom from 'scripts/dom';
import imageHelper from 'utils/image';
import 'components/cardbuilder/card.scss';
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
import Dashboard, { pageClassOn, pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function addVirtualFolder(page) {
import('../../components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
return !f.hidden;
@ -26,7 +27,7 @@ function addVirtualFolder(page) {
}
function editVirtualFolder(page, virtualFolder) {
import('../../components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
new MediaLibraryEditor({
refresh: shouldRefreshLibraryAfterChanges(page),
library: virtualFolder
@ -60,7 +61,7 @@ function deleteVirtualFolder(page, virtualFolder) {
}
function refreshVirtualFolder(page, virtualFolder) {
import('../../components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
new RefreshDialog({
itemIds: [virtualFolder.ItemId],
serverId: ApiClient.serverId(),
@ -70,7 +71,7 @@ function refreshVirtualFolder(page, virtualFolder) {
}
function renameVirtualFolder(page, virtualFolder) {
import('../../components/prompt/prompt').then(({ default: prompt }) => {
import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({
label: globalize.translate('LabelNewName'),
description: globalize.translate('MessageRenameMediaFolder'),
@ -117,7 +118,7 @@ function showCardMenu(page, elem, virtualFolders) {
icon: 'delete'
});
import('../../components/actionSheet/actionSheet').then((actionsheet) => {
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
positionTo: elem,
@ -206,7 +207,7 @@ function reloadVirtualFolders(page, virtualFolders) {
}
function editImages(page, virtualFolder) {
import('../../components/imageeditor/imageeditor').then((imageEditor) => {
import('components/imageeditor/imageeditor').then((imageEditor) => {
imageEditor.show({
itemId: virtualFolder.ItemId,
serverId: ApiClient.serverId()

View file

@ -1,7 +1,7 @@
import loading from '../../components/loading/loading';
import '../../elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-button/emby-button';
import Dashboard from '../../utils/dashboard';
import loading from 'components/loading/loading';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
export default function(view) {
function loadData() {

View file

@ -1,15 +1,15 @@
import loading from '../components/loading/loading';
import globalize from '../lib/globalize';
import Dashboard, { pageIdOn } from '../utils/dashboard';
import { getParameterByName } from '../utils/url.ts';
import Events from '../utils/events.ts';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard, { pageIdOn } from 'utils/dashboard';
import { getParameterByName } from 'utils/url';
import Events from 'utils/events';
function onListingsSubmitted() {
Dashboard.navigate('dashboard/livetv');
}
function init(page, type, providerId) {
import(`../components/tvproviders/${type}`).then(({ default: ProviderFactory }) => {
import(`components/tvproviders/${type}`).then(({ default: ProviderFactory }) => {
const instance = new ProviderFactory(page, providerId, {});
Events.on(instance, 'submitted', onListingsSubmitted);
instance.init();
@ -17,7 +17,7 @@ function init(page, type, providerId) {
}
function loadTemplate(page, type, providerId) {
import(`../components/tvproviders/${type}.template.html`).then(({ default: html }) => {
import(`components/tvproviders/${type}.template.html`).then(({ default: html }) => {
page.querySelector('.providerTemplate').innerHTML = globalize.translateHtml(html);
init(page, type, providerId);
});

View file

@ -1,9 +1,10 @@
import 'jquery';
import loading from '../components/loading/loading';
import globalize from '../lib/globalize';
import '../elements/emby-button/emby-button';
import Dashboard from '../utils/dashboard';
import alert from '../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config) {
page.querySelector('.liveTvSettingsForm').classList.remove('hide');
@ -64,7 +65,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
const page = this;
$('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
$('#btnSelectRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
@ -79,7 +80,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
});
});
$('#btnSelectMovieRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
@ -94,7 +95,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
});
});
$('#btnSelectSeriesRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
@ -109,7 +110,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
});
});
$('#btnSelectPostProcessorPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
includeFiles: true,

View file

@ -1,19 +1,20 @@
import 'jquery';
import globalize from '../lib/globalize';
import taskButton from '../scripts/taskbutton';
import dom from '../scripts/dom';
import layoutManager from '../components/layoutManager';
import loading from '../components/loading/loading';
import browser from '../scripts/browser';
import '../components/listview/listview.scss';
import '../styles/flexstyles.scss';
import '../elements/emby-itemscontainer/emby-itemscontainer';
import '../components/cardbuilder/card.scss';
import globalize from 'lib/globalize';
import taskButton from 'scripts/taskbutton';
import dom from 'scripts/dom';
import layoutManager from 'components/layoutManager';
import loading from 'components/loading/loading';
import browser from 'scripts/browser';
import 'components/listview/listview.scss';
import 'styles/flexstyles.scss';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'components/cardbuilder/card.scss';
import 'material-design-icons-iconfont';
import '../elements/emby-button/emby-button';
import Dashboard from '../utils/dashboard';
import confirm from '../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../components/cardbuilder/cardBuilderUtils';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
const enableFocusTransform = !browser.slow && !browser.edge;
@ -153,7 +154,7 @@ function showProviderOptions(page, providerId, button) {
id: 'map'
});
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button
@ -171,7 +172,7 @@ function showProviderOptions(page, providerId, button) {
}
function mapChannels(page, providerId) {
import('../components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
new ChannelMapper({
serverId: ApiClient.serverInfo().Id,
providerId: providerId
@ -243,7 +244,7 @@ function addProvider(button) {
id: 'xmltv'
});
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: menuItems,
positionTo: button,
@ -269,7 +270,7 @@ function showDeviceMenu(button, tunerDeviceId) {
id: 'edit'
});
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button

View file

@ -1,12 +1,12 @@
import globalize from '../lib/globalize';
import loading from '../components/loading/loading';
import dom from '../scripts/dom';
import '../elements/emby-input/emby-input';
import '../elements/emby-button/emby-button';
import '../elements/emby-checkbox/emby-checkbox';
import '../elements/emby-select/emby-select';
import Dashboard from '../utils/dashboard';
import { getParameterByName } from '../utils/url.ts';
import globalize from 'lib/globalize';
import loading from 'components/loading/loading';
import dom from 'scripts/dom';
import 'elements/emby-input/emby-input';
import 'elements/emby-button/emby-button';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
import Dashboard from 'utils/dashboard';
import { getParameterByName } from 'utils/url';
function isM3uVariant(type) {
return ['nextpvr'].indexOf(type || '') !== -1;
@ -112,7 +112,7 @@ function submitForm(page) {
}
function getDetectedDevice() {
return import('../components/tunerPicker').then(({ default: TunerPicker }) => {
return import('components/tunerPicker').then(({ default: TunerPicker }) => {
return new TunerPicker().show({
serverId: ApiClient.serverId()
});
@ -235,7 +235,7 @@ export default function (view, params) {
});
});
view.querySelector('.btnSelectPath').addEventListener('click', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
includeFiles: true,

View file

@ -2,11 +2,11 @@ import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import Dashboard from '../../utils/dashboard';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard from 'utils/dashboard';
import '../../components/listview/listview.scss';
import 'components/listview/listview.scss';
function populateImageResolutionOptions(select) {
let html = '';

View file

@ -1,9 +1,10 @@
import escapeHtml from 'escape-html';
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, users) {
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';

View file

@ -1,9 +1,9 @@
import loading from '../../components/loading/loading';
import globalize from '../../lib/globalize';
import '../../elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-select/emby-select';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function onSubmit(e) {
const form = this;
@ -159,7 +159,7 @@ export default function (view) {
}
});
view.querySelector('#btnSelectCertPath').addEventListener('click', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
includeFiles: true,

View file

@ -1,11 +1,11 @@
import loading from '../../../../components/loading/loading';
import dom from '../../../../scripts/dom';
import globalize from '../../../../lib/globalize';
import '../../../../components/cardbuilder/card.scss';
import '../../../../elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from '../../../../utils/dashboard';
import confirm from '../../../../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils';
import loading from 'components/loading/loading';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import 'components/cardbuilder/card.scss';
import 'elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function deletePlugin(page, uniqueid, version, name) {
const msg = globalize.translate('UninstallPluginConfirmation', name);
@ -187,7 +187,7 @@ function showPluginMenu(page, elem) {
});
}
import('../../../../components/actionSheet/actionSheet').then((actionsheet) => {
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
positionTo: elem,

View file

@ -1,14 +1,14 @@
import loading from '../../../../components/loading/loading';
import globalize from '../../../../lib/globalize';
import dialogHelper from '../../../../components/dialogHelper/dialogHelper';
import confirm from '../../../../components/confirm/confirm';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import dialogHelper from 'components/dialogHelper/dialogHelper';
import confirm from 'components/confirm/confirm';
import '../../../../elements/emby-button/emby-button';
import '../../../../elements/emby-checkbox/emby-checkbox';
import '../../../../elements/emby-select/emby-select';
import 'elements/emby-button/emby-button';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
import '../../../../components/formdialog.scss';
import '../../../../components/listview/listview.scss';
import 'components/formdialog.scss';
import 'components/listview/listview.scss';
let repositories = [];

View file

@ -1,12 +1,12 @@
import loading from '../../../components/loading/loading';
import datetime from '../../../scripts/datetime';
import dom from '../../../scripts/dom';
import globalize from '../../../lib/globalize';
import '../../../elements/emby-input/emby-input';
import '../../../elements/emby-button/emby-button';
import '../../../elements/emby-select/emby-select';
import confirm from '../../../components/confirm/confirm';
import { getParameterByName } from '../../../utils/url.ts';
import loading from 'components/loading/loading';
import datetime from 'scripts/datetime';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import 'elements/emby-input/emby-input';
import 'elements/emby-button/emby-button';
import 'elements/emby-select/emby-select';
import confirm from 'components/confirm/confirm';
import { getParameterByName } from 'utils/url.ts';
function fillTimeOfDay(select) {
const options = [];
@ -35,7 +35,7 @@ const ScheduledTaskPage = {
view.querySelector('.taskName').innerHTML = task.Name;
view.querySelector('#pTaskDescription').innerHTML = task.Description;
import('../../../components/listview/listview.scss').then(() => {
import('components/listview/listview.scss').then(() => {
ScheduledTaskPage.loadTaskTriggers(view, task);
});

View file

@ -0,0 +1,24 @@
import type { DevicesApiDeleteDeviceRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useDeleteDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiDeleteDeviceRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.deleteDevice(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,38 @@
import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'Devices';
const fetchDevices = async (
api?: Api,
requestParams?: DevicesApiGetDevicesRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchDevices] No API instance available');
return;
}
const response = await getDevicesApi(api).getDevices(requestParams, {
signal: options?.signal
});
return response.data;
};
export const useDevices = (
requestParams: DevicesApiGetDevicesRequest
) => {
const { api } = useApi();
return useQuery({
queryKey: [QUERY_KEY, requestParams],
queryFn: ({ signal }) =>
fetchDevices(api, requestParams, { signal }),
enabled: !!api
});
};

View file

@ -0,0 +1,24 @@
import type { DevicesApiUpdateDeviceOptionsRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useUpdateDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiUpdateDeviceOptionsRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.updateDeviceOptions(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,22 @@
import React, { FC } from 'react';
import { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell';
import { getDeviceIcon } from 'utils/image';
const DeviceNameCell: FC<DeviceInfoCell> = ({ row, renderedCellValue }) => (
<>
<img
alt={row.original.AppName || undefined}
src={getDeviceIcon(row.original)}
style={{
display: 'inline-block',
maxWidth: '1.5em',
maxHeight: '1.5em',
marginRight: '1rem'
}}
/>
{renderedCellValue}
</>
);
export default DeviceNameCell;

View file

@ -0,0 +1,7 @@
import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto';
import type { MRT_Row } from 'material-react-table';
export interface DeviceInfoCell {
renderedCellValue: React.ReactNode
row: MRT_Row<DeviceInfoDto>
}

View file

@ -0,0 +1,25 @@
import { Api } from '@jellyfin/sdk';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchServerLog = async (
api: Api,
name: string,
options?: AxiosRequestConfig
) => {
const response = await getSystemApi(api).getLogFile({ name }, options);
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
return response.data as never as string;
};
export const useServerLog = (name: string) => {
const { api } = useApi();
return useQuery({
queryKey: ['ServerLog', name],
queryFn: ({ signal }) => fetchServerLog(api!, name, { signal }),
enabled: !!api
});
};

View file

@ -2,28 +2,15 @@ import React, { FunctionComponent } from 'react';
import type { LogFile } from '@jellyfin/sdk/lib/generated-client/models/log-file';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useApi } from 'hooks/useApi';
import datetime from 'scripts/datetime';
import ListItemLink from 'components/ListItemLink';
type LogItemProps = {
logs: LogFile[];
};
const LogItemList: FunctionComponent<LogItemProps> = ({ logs }: LogItemProps) => {
const { api } = useApi();
const getLogFileUrl = (logFile: LogFile) => {
if (!api) return '';
return api.getUri('/System/Logs/Log', {
name: logFile.Name,
api_key: api.accessToken
});
};
const getDate = (logFile: LogFile) => {
const date = datetime.parseISO8601Date(logFile.DateModified, true);
return datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date);
@ -34,15 +21,14 @@ const LogItemList: FunctionComponent<LogItemProps> = ({ logs }: LogItemProps) =>
{logs.map(log => {
return (
<ListItem key={log.Name} disablePadding>
<ListItemButton href={getLogFileUrl(log)} target='_blank'>
<ListItemLink to={`/dashboard/logs/${log.Name}`}>
<ListItemText
primary={log.Name}
primaryTypographyProps={{ variant: 'h3' }}
secondary={getDate(log)}
secondaryTypographyProps={{ variant: 'body1' }}
/>
<OpenInNewIcon />
</ListItemButton>
</ListItemLink>
</ListItem>
);
})}

View file

@ -0,0 +1,23 @@
import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useTasks';
export const useStartTask = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: ScheduledTasksApiStartTaskRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getScheduledTasksApi(api!)
.startTask(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,23 @@
import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useTasks';
export const useStopTask = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: ScheduledTasksApiStartTaskRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getScheduledTasksApi(api!)
.stopTask(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,35 @@
import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'Tasks';
const fetchTasks = async (
api?: Api,
params?: ScheduledTasksApiGetTasksRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchTasks] No API instance available');
return;
}
const response = await getScheduledTasksApi(api).getTasks(params, options);
return response.data;
};
export const useTasks = (params?: ScheduledTasksApiGetTasksRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [QUERY_KEY],
queryFn: ({ signal }) =>
fetchTasks(api, params, { signal }),
enabled: !!api
});
};

View file

@ -0,0 +1,67 @@
import React, { FunctionComponent, useCallback } from 'react';
import ListItem from '@mui/material/ListItem';
import Avatar from '@mui/material/Avatar';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import Dashboard from 'utils/dashboard';
import { TaskProps } from '../types/taskProps';
import TaskProgress from './TaskProgress';
import TaskLastRan from './TaskLastRan';
import IconButton from '@mui/material/IconButton';
import PlayArrow from '@mui/icons-material/PlayArrow';
import Stop from '@mui/icons-material/Stop';
import { useStartTask } from '../api/useStartTask';
import { useStopTask } from '../api/useStopTask';
const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const startTask = useStartTask();
const stopTask = useStopTask();
const navigateTaskEdit = useCallback(() => {
Dashboard.navigate(`/dashboard/tasks/edit?id=${task.Id}`)
.catch(err => {
console.error('[Task] failed to navigate to task edit page', err);
});
}, [task]);
const handleStartTask = useCallback(() => {
if (task.Id) {
startTask.mutate({ taskId: task.Id });
}
}, [task, startTask]);
const handleStopTask = useCallback(() => {
if (task.Id) {
stopTask.mutate({ taskId: task.Id });
}
}, [task, stopTask]);
return (
<ListItem
disablePadding
secondaryAction={
<IconButton onClick={task.State == 'Running' ? handleStopTask : handleStartTask}>
{task.State == 'Running' ? <Stop /> : <PlayArrow />}
</IconButton>
}
>
<ListItemButton onClick={navigateTaskEdit}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<AccessTimeIcon sx={{ color: '#fff' }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={<Typography variant='h3'>{task.Name}</Typography>}
secondary={task.State == 'Running' ? <TaskProgress task={task} /> : <TaskLastRan task={task} />}
disableTypography
/>
</ListItemButton>
</ListItem>
);
};
export default Task;

View file

@ -0,0 +1,45 @@
import React, { FunctionComponent, useMemo } from 'react';
import { TaskProps } from '../types/taskProps';
import { useLocale } from 'hooks/useLocale';
import { formatDistance, formatDistanceToNow, parseISO } from 'date-fns';
import Typography from '@mui/material/Typography';
import globalize from 'lib/globalize';
const TaskLastRan: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const { dateFnsLocale } = useLocale();
const [ lastRan, timeTaken ] = useMemo(() => {
if (task.LastExecutionResult?.StartTimeUtc && task.LastExecutionResult?.EndTimeUtc) {
const endTime = parseISO(task.LastExecutionResult.EndTimeUtc);
const startTime = parseISO(task.LastExecutionResult.StartTimeUtc);
return [
formatDistanceToNow(endTime, { locale: dateFnsLocale, addSuffix: true }),
formatDistance(startTime, endTime, { locale: dateFnsLocale })
];
}
return [];
}, [task, dateFnsLocale]);
if (task.State == 'Idle') {
if (task.LastExecutionResult?.StartTimeUtc && task.LastExecutionResult?.EndTimeUtc) {
const lastResultStatus = task.LastExecutionResult.Status;
return (
<Typography sx={{ lineHeight: '1.2rem', color: 'text.secondary' }} variant='body1'>
{globalize.translate('LabelScheduledTaskLastRan', lastRan, timeTaken)}
{lastResultStatus == 'Failed' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelFailed')})`}</Typography>}
{lastResultStatus == 'Cancelled' && <Typography display='inline' color='blue'>{` (${globalize.translate('LabelCancelled')})`}</Typography>}
{lastResultStatus == 'Aborted' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelAbortedByServerShutdown')})`}</Typography>}
</Typography>
);
}
} else {
return (
<Typography sx={{ color: 'text.secondary' }}>{globalize.translate('LabelStopping')}</Typography>
);
}
};
export default TaskLastRan;

View file

@ -0,0 +1,32 @@
import React, { FunctionComponent } from 'react';
import { TaskProps } from '../types/taskProps';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
import Typography from '@mui/material/Typography';
const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const progress = task.CurrentProgressPercentage;
return (
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
{progress != null ? (
<>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant='determinate' value={progress} />
</Box>
<Box>
<Typography
variant='body1'
>{`${Math.round(progress)}%`}</Typography>
</Box>
</>
) : (
<Box sx={{ width: '100%' }}>
<LinearProgress />
</Box>
)}
</Box>
);
};
export default TaskProgress;

View file

@ -0,0 +1,29 @@
import React, { FunctionComponent } from 'react';
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
import List from '@mui/material/List';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Task from './Task';
type TasksProps = {
category: string;
tasks: TaskInfo[];
};
const Tasks: FunctionComponent<TasksProps> = ({ category, tasks }: TasksProps) => {
return (
<Stack spacing={2}>
<Typography variant='h2'>{category}</Typography>
<List sx={{ bgcolor: 'background.paper' }}>
{tasks.map(task => {
return <Task
key={task.Id}
task={task}
/>;
})}
</List>
</Stack>
);
};
export default Tasks;

View file

@ -0,0 +1,5 @@
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
export type TaskProps = {
task: TaskInfo;
};

View file

@ -0,0 +1,27 @@
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
export function getCategories(tasks: TaskInfo[] | undefined) {
if (!tasks) return [];
const categories: string[] = [];
for (const task of tasks) {
if (task.Category && !categories.includes(task.Category)) {
categories.push(task.Category);
}
}
return categories.sort((a, b) => a.localeCompare(b));
}
export function getTasksByCategory(tasks: TaskInfo[] | undefined, category: string) {
if (!tasks) return [];
return tasks.filter(task => task.Category == category).sort((a, b) => {
if (a.Name && b.Name) {
return a.Name?.localeCompare(b.Name);
} else {
return 0;
}
});
}

View file

@ -4,10 +4,15 @@ import { AppType } from 'constants/appType';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AppType.Dashboard },
{ path: 'branding', type: AppType.Dashboard },
{ path: 'devices', type: AppType.Dashboard },
{ path: 'keys', type: AppType.Dashboard },
{ path: 'logs', type: AppType.Dashboard },
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
{ path: 'playback/resume', type: AppType.Dashboard },
{ path: 'playback/streaming', type: AppType.Dashboard },
{ path: 'playback/trickplay', type: AppType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
{ path: 'tasks', type: AppType.Dashboard },
{ path: 'users', type: AppType.Dashboard },
{ path: 'users/access', type: AppType.Dashboard },
{ path: 'users/add', type: AppType.Dashboard },

View file

@ -6,92 +6,71 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
path: '/dashboard',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/dashboard',
view: 'dashboard/dashboard.html'
controller: 'dashboard',
view: 'dashboard.html'
}
}, {
path: 'settings',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/general',
view: 'dashboard/general.html'
controller: 'general',
view: 'general.html'
}
}, {
path: 'networking',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/networking',
view: 'dashboard/networking.html'
}
}, {
path: 'devices',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/devices/devices',
view: 'dashboard/devices/devices.html'
}
}, {
path: 'devices/edit',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
controller: 'networking',
view: 'networking.html'
}
}, {
path: 'libraries',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/library',
view: 'dashboard/library.html'
controller: 'library',
view: 'library.html'
}
}, {
path: 'libraries/display',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/librarydisplay',
view: 'dashboard/librarydisplay.html'
controller: 'librarydisplay',
view: 'librarydisplay.html'
}
}, {
path: 'playback/transcoding',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/encodingsettings',
view: 'dashboard/encodingsettings.html'
controller: 'encodingsettings',
view: 'encodingsettings.html'
}
}, {
path: 'libraries/metadata',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/metadataImages',
view: 'dashboard/metadataimages.html'
controller: 'metadataImages',
view: 'metadataimages.html'
}
}, {
path: 'libraries/nfo',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html'
}
}, {
path: 'playback/resume',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/playback',
view: 'dashboard/playback.html'
controller: 'metadatanfo',
view: 'metadatanfo.html'
}
}, {
path: 'plugins/catalog',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/plugins/available/index',
view: 'dashboard/plugins/available/index.html'
controller: 'plugins/available/index',
view: 'plugins/available/index.html'
}
}, {
path: 'plugins/repositories',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/plugins/repositories/index',
view: 'dashboard/plugins/repositories/index.html'
controller: 'plugins/repositories/index',
view: 'plugins/repositories/index.html'
}
}, {
path: 'livetv/guide',
@ -125,29 +104,15 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
path: 'plugins',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/plugins/installed/index',
view: 'dashboard/plugins/installed/index.html'
controller: 'plugins/installed/index',
view: 'plugins/installed/index.html'
}
}, {
path: 'tasks/edit',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/scheduledtasks/scheduledtask',
view: 'dashboard/scheduledtasks/scheduledtask.html'
}
}, {
path: 'tasks',
pageProps: {
appType: AppType.Dashboard,
controller: 'dashboard/scheduledtasks/scheduledtasks',
view: 'dashboard/scheduledtasks/scheduledtasks.html'
}
}, {
path: 'playback/streaming',
pageProps: {
appType: AppType.Dashboard,
view: 'dashboard/streaming.html',
controller: 'dashboard/streaming'
controller: 'scheduledtasks/scheduledtask',
view: 'scheduledtasks/scheduledtask.html'
}
}
];

View file

@ -1,28 +1,24 @@
import parseISO from 'date-fns/parseISO';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import Box from '@mui/material/Box';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton';
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
import Page from 'components/Page';
import { useUsers } from 'hooks/useUsers';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
import globalize from 'lib/globalize';
import { toBoolean } from 'utils/string';
type UsersRecords = Record<string, UserDto>;
const DEFAULT_PAGE_SIZE = 25;
const VIEW_PARAM = 'useractivity';
@ -44,7 +40,7 @@ const getUserCell = (users: UsersRecords) => function UserCell({ row }: Activity
);
};
const Activity = () => {
export const Component = () => {
const [ searchParams, setSearchParams ] = useSearchParams();
const [ activityView, setActivityView ] = useState(
@ -55,29 +51,7 @@ const Activity = () => {
pageSize: DEFAULT_PAGE_SIZE
});
const { data: usersData, isLoading: isUsersLoading } = useUsers();
const users: UsersRecords = useMemo(() => {
if (!usersData) return {};
return usersData.reduce<UsersRecords>((acc, user) => {
const userId = user.Id;
if (!userId) return acc;
return {
...acc,
[userId]: user
};
}, {});
}, [ usersData ]);
const userNames = useMemo(() => {
const names: string[] = [];
usersData?.forEach(user => {
if (user.Name) names.push(user.Name);
});
return names;
}, [ usersData ]);
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const UserCell = getUserCell(users);
@ -87,7 +61,13 @@ const Activity = () => {
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
}), [activityView, pagination.pageIndex, pagination.pageSize]);
const { data: logEntries, isLoading: isLogEntriesLoading } = useLogEntries(activityParams);
const { data, isLoading: isLogEntriesLoading } = useLogEntries(activityParams);
const logEntries = useMemo(() => (
data?.Items || []
), [ data ]);
const rowCount = useMemo(() => (
data?.TotalRecordCount || 0
), [ data ]);
const isLoading = isUsersLoading || isLogEntriesLoading;
@ -109,10 +89,10 @@ const Activity = () => {
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
{
id: 'Date',
accessorFn: row => parseISO8601Date(row.Date),
accessorFn: row => row.Date ? parseISO(row.Date) : undefined,
header: globalize.translate('LabelTime'),
size: 160,
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
Cell: DateTimeCell,
filterVariant: 'datetime-range'
},
{
@ -177,21 +157,10 @@ const Activity = () => {
}, [ activityView, searchParams, setSearchParams ]);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
columns,
data: logEntries?.Items || [],
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
data: logEntries,
// State
initialState: {
@ -205,7 +174,7 @@ const Activity = () => {
// Server pagination
manualPagination: true,
onPaginationChange: setPagination,
rowCount: logEntries?.TotalRecordCount || 0,
rowCount,
// Custom toolbar contents
renderTopToolbarCustomActions: () => (
@ -229,32 +198,13 @@ const Activity = () => {
});
return (
<Page
<TablePage
id='serverActivityPage'
title={globalize.translate('HeaderActivity')}
className='mainAnimatedPage type-interior'
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Box
sx={{
marginBottom: 1
}}
>
<Typography variant='h2'>
{globalize.translate('HeaderActivity')}
</Typography>
</Box>
<MaterialReactTable table={table} />
</Box>
</Page>
table={table}
/>
);
};
export default Activity;
Component.displayName = 'ActivityPage';

View file

@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import React, { useCallback, useEffect, useState } from 'react';
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
import React, { useCallback, useState } from 'react';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
import Loading from 'components/loading/LoadingComponent';
@ -60,8 +60,9 @@ export const loader = () => {
};
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const [ isSubmitting, setIsSubmitting ] = useState(false);
const isSubmitting = navigation.state === 'submitting';
const {
data: defaultBrandingOptions,
@ -69,14 +70,6 @@ export const Component = () => {
} = useBrandingOptions();
const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {});
useEffect(() => {
setIsSubmitting(false);
}, [ actionData ]);
const onSubmit = useCallback(() => {
setIsSubmitting(true);
}, []);
const setSplashscreenEnabled = useCallback((_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
setBrandingOptions({
...brandingOptions,
@ -98,13 +91,11 @@ export const Component = () => {
return (
<Page
id='brandingPage'
title={globalize.translate('HeaderBranding')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form
method='POST'
onSubmit={onSubmit}
>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('HeaderBranding')}

View file

@ -0,0 +1,265 @@
import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button';
import IconButton from '@mui/material/IconButton/IconButton';
import Tooltip from '@mui/material/Tooltip/Tooltip';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo, useState } from 'react';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton';
import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice';
import { useDevices } from 'apps/dashboard/features/devices/api/useDevices';
import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice';
import DeviceNameCell from 'apps/dashboard/features/devices/components/DeviceNameCell';
import type { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell';
import ConfirmDialog from 'components/ConfirmDialog';
import { useApi } from 'hooks/useApi';
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
import globalize from 'lib/globalize';
const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) {
return (
<>
<UserAvatarButton
user={row.original.LastUserId && users[row.original.LastUserId] || undefined}
sx={{ mr: '1rem' }}
/>
{renderedCellValue}
</>
);
};
export const Component = () => {
const { api } = useApi();
const { data, isLoading: isDevicesLoading, isRefetching } = useDevices({});
const devices = useMemo(() => (
data?.Items || []
), [ data ]);
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
const [ pendingDeleteDeviceId, setPendingDeleteDeviceId ] = useState<string>();
const deleteDevice = useDeleteDevice();
const updateDevice = useUpdateDevice();
const isLoading = isDevicesLoading || isUsersLoading;
const onDeleteDevice = useCallback((id: string | null | undefined) => () => {
if (id) {
setPendingDeleteDeviceId(id);
setIsDeleteConfirmOpen(true);
}
}, []);
const onCloseDeleteConfirmDialog = useCallback(() => {
setPendingDeleteDeviceId(undefined);
setIsDeleteConfirmOpen(false);
}, []);
const onConfirmDelete = useCallback(() => {
if (pendingDeleteDeviceId) {
deleteDevice.mutate({
id: pendingDeleteDeviceId
}, {
onSettled: onCloseDeleteConfirmDialog
});
}
}, [ deleteDevice, onCloseDeleteConfirmDialog, pendingDeleteDeviceId ]);
const onDeleteAll = useCallback(() => {
setIsDeleteAllConfirmOpen(true);
}, []);
const onCloseDeleteAllConfirmDialog = useCallback(() => {
setIsDeleteAllConfirmOpen(false);
}, []);
const onConfirmDeleteAll = useCallback(() => {
if (devices) {
Promise
.all(devices.map(item => {
if (api && item.Id && api.deviceInfo.id === item.Id) {
return deleteDevice.mutateAsync({ id: item.Id });
}
return Promise.resolve();
}))
.catch(err => {
console.error('[DevicesPage] failed deleting all devices', err);
})
.finally(() => {
onCloseDeleteAllConfirmDialog();
});
}
}, [ api, deleteDevice, devices, onCloseDeleteAllConfirmDialog ]);
const UserCell = getUserCell(users);
const columns = useMemo<MRT_ColumnDef<DeviceInfoDto>[]>(() => [
{
id: 'DateLastActivity',
accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined,
header: globalize.translate('LastActive'),
size: 160,
Cell: DateTimeCell,
filterVariant: 'datetime-range',
enableEditing: false
},
{
id: 'Name',
accessorFn: row => row.CustomName || row.Name,
header: globalize.translate('LabelDevice'),
size: 200,
Cell: DeviceNameCell
},
{
id: 'App',
accessorFn: row => [row.AppName, row.AppVersion]
.filter(v => !!v) // filter missing values
.join(' '),
header: globalize.translate('LabelAppName'),
size: 200,
enableEditing: false
},
{
accessorKey: 'LastUserName',
header: globalize.translate('LabelUser'),
size: 120,
enableEditing: false,
Cell: UserCell,
filterVariant: 'multi-select',
filterSelectOptions: userNames
}
], [ UserCell, userNames ]);
const mrTable = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
columns,
data: devices,
// State
initialState: {
density: 'compact',
pagination: {
pageIndex: 0,
pageSize: 25
}
},
state: {
isLoading
},
// Do not reset the page index when refetching data
autoResetPageIndex: !isRefetching,
// Editing device name
enableEditing: true,
onEditingRowSave: ({ table, row, values }) => {
const newName = values.Name?.trim();
const hasChanged = row.original.CustomName ?
newName !== row.original.CustomName :
newName !== row.original.Name;
// If the name has changed, save it as the custom name
if (row.original.Id && hasChanged) {
updateDevice.mutate({
id: row.original.Id,
deviceOptionsDto: {
CustomName: newName || undefined
}
});
}
table.setEditingRow(null); //exit editing mode
},
// Custom actions
enableRowActions: true,
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
header: ''
}
},
renderRowActions: ({ row, table }) => {
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
return (
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={globalize.translate('Edit')}>
<IconButton
// eslint-disable-next-line react/jsx-no-bind
onClick={() => table.setEditingRow(row)}
>
<Edit />
</IconButton>
</Tooltip>
{/* Don't include Tooltip when disabled */}
{isDeletable ? (
<IconButton
color='error'
disabled
>
<Delete />
</IconButton>
) : (
<Tooltip title={globalize.translate('Delete')}>
<IconButton
color='error'
onClick={onDeleteDevice(row.original.Id)}
>
<Delete />
</IconButton>
</Tooltip>
)}
</Box>
);
},
// Custom toolbar contents
renderTopToolbarCustomActions: () => (
<Button
color='error'
startIcon={<Delete />}
onClick={onDeleteAll}
>
{globalize.translate('DeleteAll')}
</Button>
)
});
return (
<TablePage
id='devicesPage'
title={globalize.translate('HeaderDevices')}
className='mainAnimatedPage type-interior'
table={mrTable}
>
<ConfirmDialog
open={isDeleteConfirmOpen}
title={globalize.translate('HeaderDeleteDevice')}
text={globalize.translate('DeleteDeviceConfirmation')}
onCancel={onCloseDeleteConfirmDialog}
onConfirm={onConfirmDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
<ConfirmDialog
open={isDeleteAllConfirmOpen}
title={globalize.translate('HeaderDeleteDevices')}
text={globalize.translate('DeleteDevicesConfirmation')}
onCancel={onCloseDeleteAllConfirmDialog}
onConfirm={onConfirmDeleteAll}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
</TablePage>
);
};
Component.displayName = 'DevicesPage';

View file

@ -1,26 +1,30 @@
import Page from 'components/Page';
import { useApi } from 'hooks/useApi';
import globalize from 'lib/globalize';
import React, { useCallback, useMemo } from 'react';
import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info';
import confirm from 'components/confirm/confirm';
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo } from 'react';
const ApiKeys = () => {
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
import confirm from 'components/confirm/confirm';
import prompt from 'components/prompt/prompt';
import { useApi } from 'hooks/useApi';
import globalize from 'lib/globalize';
export const Component = () => {
const { api } = useApi();
const { data: keys, isLoading } = useApiKeys();
const { data, isLoading } = useApiKeys();
const keys = useMemo(() => (
data?.Items || []
), [ data ]);
const revokeKey = useRevokeKey();
const createKey = useCreateKey();
@ -38,34 +42,23 @@ const ApiKeys = () => {
},
{
id: 'DateIssued',
accessorFn: item => parseISO8601Date(item.DateCreated),
Cell: ({ cell }) => toLocaleDateString(cell.getValue<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
accessorFn: item => item.DateCreated ? parseISO(item.DateCreated) : undefined,
Cell: DateTimeCell,
header: globalize.translate('HeaderDateIssued'),
filterVariant: 'datetime-range'
}
], []);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
columns,
data: keys?.Items || [],
data: keys,
state: {
isLoading
},
rowCount: keys?.TotalRecordCount || 0,
enableColumnPinning: true,
enableColumnResizing: true,
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
// Enable (delete) row actions
enableRowActions: true,
positionActionsColumn: 'last',
@ -77,8 +70,10 @@ const ApiKeys = () => {
},
renderTopToolbarCustomActions: () => (
<Button onClick={showNewKeyPopup}>
<AddIcon />
<Button
startIcon={<AddIcon />}
onClick={showNewKeyPopup}
>
{globalize.translate('HeaderNewApiKey')}
</Button>
),
@ -115,53 +110,28 @@ const ApiKeys = () => {
const showNewKeyPopup = useCallback(() => {
if (!api) return;
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
prompt({
title: globalize.translate('HeaderNewApiKey'),
label: globalize.translate('LabelAppName'),
description: globalize.translate('LabelAppNameExample')
}).then((value) => {
createKey.mutate({
app: value
});
}).catch(() => {
// popup closed
prompt({
title: globalize.translate('HeaderNewApiKey'),
label: globalize.translate('LabelAppName'),
description: globalize.translate('LabelAppNameExample')
}).then((value) => {
createKey.mutate({
app: value
});
}).catch(err => {
console.error('[apikeys] failed to load api key popup', err);
}).catch(() => {
// popup closed
});
}, [api, createKey]);
return (
<Page
<TablePage
id='apiKeysPage'
title={globalize.translate('HeaderApiKeys')}
subtitle={globalize.translate('HeaderApiKeysHelp')}
className='mainAnimatedPage type-interior'
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Box
sx={{
marginBottom: 1
}}
>
<Stack spacing={2}>
<Typography variant='h2'>
{globalize.translate('HeaderApiKeys')}
</Typography>
<Typography>{globalize.translate('HeaderApiKeysHelp')}</Typography>
</Stack>
</Box>
<MaterialReactTable table={table} />
</Box>
</Page>
table={table}
/>
);
};
export default ApiKeys;
Component.displayName = 'ApiKeysPage';

View file

@ -0,0 +1,109 @@
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import React, { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import Container from '@mui/material/Container';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { ContentCopy, FileDownload } from '@mui/icons-material';
import globalize from 'lib/globalize';
import toast from 'components/toast/toast';
import { copy } from 'scripts/clipboard';
export const Component = () => {
const { file: fileName } = useParams();
const {
isError: error,
isPending: loading,
data: log,
refetch
} = useServerLog(fileName ?? '');
const retry = useCallback(() => refetch(), [refetch]);
const copyToClipboard = useCallback(async () => {
if (log) {
await copy(log);
toast({ text: globalize.translate('CopyLogSuccess') });
}
}, [log]);
const downloadFile = useCallback(() => {
if ('URL' in globalThis && log && fileName) {
const blob = new Blob([log], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
}, [log, fileName]);
return (
<Page
id='logPage'
title={fileName}
className='mainAnimatedPage type-interior'
>
<Container className='content-primary'>
<Box>
<Typography variant='h1'>{fileName}</Typography>
{error && (
<Alert
key='error'
severity='error'
sx={{ mt: 2 }}
action={
<Button
color='inherit'
size='small'
onClick={retry}
>
{globalize.translate('Retry')}
</Button>
}
>
{globalize.translate('LogLoadFailure')}
</Alert>
)}
{loading && <Loading />}
{!error && !loading && (
<>
<ButtonGroup variant='contained' sx={{ mt: 2 }}>
<Button
startIcon={<ContentCopy />}
onClick={copyToClipboard}
>
{globalize.translate('Copy')}
</Button>
<Button
startIcon={<FileDownload />}
onClick={downloadFile}
>
{globalize.translate('Download')}
</Button>
</ButtonGroup>
<Paper sx={{ mt: 2 }}>
<code>
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
</code>
</Paper>
</>
)}
</Box>
</Container>
</Page>
);
};
Component.displayName = 'LogPage';

View file

@ -11,7 +11,7 @@ import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import ServerConnections from 'components/ServerConnections';
import { useServerLogs } from 'apps/dashboard/features/logs/api/useServerLogs';
import { useConfiguration } from 'hooks/useConfiguration';
@ -42,9 +42,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
};
const Logs = () => {
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const [ isSubmitting, setIsSubmitting ] = useState(false);
const isSubmitting = navigation.state === 'submitting';
const { isPending: isLogEntriesPending, data: logs } = useServerLogs();
const { isPending: isConfigurationPending, data: defaultConfiguration } = useConfiguration();
@ -72,10 +73,6 @@ const Logs = () => {
});
}, [configuration]);
const onSubmit = useCallback(() => {
setIsSubmitting(true);
}, []);
if (isLogEntriesPending || isConfigurationPending || loading || !logs) {
return <Loading />;
}
@ -87,13 +84,13 @@ const Logs = () => {
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form method='POST' onSubmit={onSubmit}>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('TabLogs')}
</Typography>
{isSubmitting && actionData?.isSaved && (
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
@ -113,7 +110,7 @@ const Logs = () => {
<TextField
fullWidth
type='number'
name={'SlowResponseTime'}
name='SlowResponseTime'
label={globalize.translate('LabelSlowResponseTime')}
value={configuration?.SlowResponseThresholdMs}
disabled={!configuration?.EnableSlowResponseWarning}
@ -136,4 +133,4 @@ const Logs = () => {
);
};
export default Logs;
Component.displayName = 'LogsPage';

View file

@ -0,0 +1,156 @@
import React from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { ActionData } from 'types/actionData';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Loading from 'components/loading/LoadingComponent';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
const formData = await request.formData();
const minResumePercentage = formData.get('MinResumePercentage')?.toString();
const maxResumePercentage = formData.get('MaxResumePercentage')?.toString();
const minAudiobookResume = formData.get('MinAudiobookResume')?.toString();
const maxAudiobookResume = formData.get('MaxAudiobookResume')?.toString();
const minResumeDuration = formData.get('MinResumeDuration')?.toString();
if (minResumePercentage) config.MinResumePct = parseInt(minResumePercentage, 10);
if (maxResumePercentage) config.MaxResumePct = parseInt(maxResumePercentage, 10);
if (minAudiobookResume) config.MinAudiobookResume = parseInt(minAudiobookResume, 10);
if (maxAudiobookResume) config.MaxAudiobookResume = parseInt(maxAudiobookResume, 10);
if (minResumeDuration) config.MinResumeDurationSeconds = parseInt(minResumeDuration, 10);
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
return {
isSaved: true
};
};
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const isSubmitting = navigation.state === 'submitting';
const { isPending: isConfigurationPending, data: config } = useConfiguration();
if (isConfigurationPending) {
return <Loading />;
}
return (
<Page
id='playbackConfigurationPage'
title={globalize.translate('ButtonResume')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h2'>
{globalize.translate('ButtonResume')}
</Typography>
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<TextField
label={globalize.translate('LabelMinResumePercentage')}
name='MinResumePercentage'
type='number'
defaultValue={config?.MinResumePct}
inputProps={{
min: 0,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMinResumePercentageHelp')}
/>
<TextField
label={globalize.translate('LabelMaxResumePercentage')}
name='MaxResumePercentage'
type='number'
defaultValue={config?.MaxResumePct}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMaxResumePercentageHelp')}
/>
<TextField
label={globalize.translate('LabelMinAudiobookResume')}
name='MinAudiobookResume'
type='number'
defaultValue={config?.MinAudiobookResume}
inputProps={{
min: 0,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMinAudiobookResumeHelp')}
/>
<TextField
label={globalize.translate('LabelMaxAudiobookResume')}
name='MaxAudiobookResume'
type='number'
defaultValue={config?.MaxAudiobookResume}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMaxAudiobookResumeHelp')}
/>
<TextField
label={globalize.translate('LabelMinResumeDuration')}
name='MinResumeDuration'
type='number'
defaultValue={config?.MinResumeDurationSeconds}
inputProps={{
min: 0,
required: true
}}
helperText={globalize.translate('LabelMinResumeDurationHelp')}
/>
<Button
type='submit'
size='large'
>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
</Box>
</Page>
);
};
Component.displayName = 'ResumePage';

View file

@ -0,0 +1,95 @@
import React from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Loading from 'components/loading/LoadingComponent';
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
const formData = await request.formData();
const bitrateLimit = formData.get('StreamingBitrateLimit')?.toString();
config.RemoteClientBitrateLimit = Math.trunc(1e6 * parseFloat(bitrateLimit || '0'));
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
return {
isSaved: true
};
};
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const isSubmitting = navigation.state === 'submitting';
const { isPending: isConfigurationPending, data: defaultConfiguration } = useConfiguration();
if (isConfigurationPending) {
return <Loading />;
}
return (
<Page
id='streamingSettingsPage'
title={globalize.translate('TabStreaming')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h2'>
{globalize.translate('TabStreaming')}
</Typography>
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<TextField
type='number'
inputMode='decimal'
inputProps={{
min: 0,
step: 0.25
}}
name='StreamingBitrateLimit'
label={globalize.translate('LabelRemoteClientBitrateLimit')}
helperText={globalize.translate('LabelRemoteClientBitrateLimitHelp')}
defaultValue={defaultConfiguration?.RemoteClientBitrateLimit ? defaultConfiguration?.RemoteClientBitrateLimit / 1e6 : ''}
/>
<Button
type='submit'
size='large'
>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
</Box>
</Page>
);
};
Component.displayName = 'StreamingPage';

View file

@ -1,325 +1,259 @@
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
import React from 'react';
import globalize from 'lib/globalize';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Page from 'components/Page';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormControl from '@mui/material/FormControl';
import Switch from '@mui/material/Switch';
import Loading from 'components/loading/LoadingComponent';
import FormHelperText from '@mui/material/FormHelperText';
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Alert from '@mui/material/Alert';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
import React, { type FC, useCallback, useEffect, useRef } from 'react';
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
import globalize from '../../../../lib/globalize';
import Page from '../../../../components/Page';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import InputElement from '../../../../elements/InputElement';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import ServerConnections from '../../../../components/ServerConnections';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
function onSaveComplete() {
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const formData = await request.formData();
const data = Object.fromEntries(formData);
const PlaybackTrickplay: FC = () => {
const element = useRef<HTMLDivElement>(null);
const { data: config } = await getConfigurationApi(api).getConfiguration();
const loadConfig = useCallback((config: ServerConfiguration) => {
const page = element.current;
const options = config.TrickplayOptions;
const options = config.TrickplayOptions;
if (!options) throw new Error('Unexpected null TrickplayOptions');
if (!page) {
console.error('Unexpected null reference');
return;
}
options.EnableHwAcceleration = data.HwAcceleration?.toString() === 'on';
options.EnableHwEncoding = data.HwEncoding?.toString() === 'on';
options.EnableKeyFrameOnlyExtraction = data.KeyFrameOnlyExtraction?.toString() === 'on';
options.ScanBehavior = data.ScanBehavior.toString() as TrickplayScanBehavior;
options.ProcessPriority = data.ProcessPriority.toString() as ProcessPriorityClass;
options.Interval = parseInt(data.ImageInterval.toString() || '10000', 10);
options.WidthResolutions = data.WidthResolutions.toString().replace(' ', '').split(',').map(Number);
options.TileWidth = parseInt(data.TileWidth.toString() || '10', 10);
options.TileHeight = parseInt(data.TileHeight.toString() || '10', 10);
options.Qscale = parseInt(data.Qscale.toString() || '4', 10);
options.JpegQuality = parseInt(data.JpegQuality.toString() || '90', 10);
options.ProcessThreads = parseInt(data.TrickplayThreads.toString() || '1', 10);
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
loading.hide();
}, []);
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
const loadData = useCallback(() => {
loading.show();
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
loadConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
}, [loadConfig]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
const saveConfig = (config: ServerConfiguration) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
if (!config.TrickplayOptions) {
throw new Error('Unexpected null TrickplayOptions');
}
const options = config.TrickplayOptions;
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
apiClient.updateServerConfiguration(config).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[PlaybackTrickplay] failed to update config', err);
});
};
const onSubmit = (e: Event) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
loading.show();
apiClient.getServerConfiguration().then(function (config) {
saveConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
e.preventDefault();
e.stopPropagation();
return false;
};
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
loadData();
}, [loadData]);
const optionScanBehavior = () => {
let content = '';
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
return content;
return {
isSaved: true
};
};
const optionProcessPriority = () => {
let content = '';
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
return content;
};
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const { data: defaultConfig, isPending } = useConfiguration();
const isSubmitting = navigation.state === 'submitting';
if (!defaultConfig || isPending) {
return <Loading />;
}
return (
<Page
id='trickplayConfigurationPage'
className='mainAnimatedPage type-interior playbackConfigurationPage'
className='mainAnimatedPage type-interior'
title={globalize.translate('Trickplay')}
>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={globalize.translate('Trickplay')}
/>
</div>
<Box className='content-primary'>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('Trickplay')}
</Typography>
<form className='trickplayConfigurationForm'>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwAcceleration'
title='LabelTrickplayAccel'
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<FormControl>
<FormControlLabel
control={
<Switch
name='HwAcceleration'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwAcceleration}
/>
}
label={globalize.translate('LabelTrickplayAccel')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Switch
name='HwEncoding'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwEncoding}
/>
}
label={globalize.translate('LabelTrickplayAccelEncoding')}
/>
<FormHelperText>{globalize.translate('LabelTrickplayAccelEncodingHelp')}</FormHelperText>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Switch
name='KeyFrameOnlyExtraction'
defaultChecked={defaultConfig.TrickplayOptions?.EnableKeyFrameOnlyExtraction}
/>
}
label={globalize.translate('LabelTrickplayKeyFrameOnlyExtraction')}
/>
<FormHelperText>{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}</FormHelperText>
</FormControl>
<TextField
name='ScanBehavior'
select
defaultValue={defaultConfig.TrickplayOptions?.ScanBehavior}
label={globalize.translate('LabelScanBehavior')}
helperText={globalize.translate('LabelScanBehaviorHelp')}
>
<MenuItem value={TrickplayScanBehavior.NonBlocking}>{globalize.translate('NonBlockingScan')}</MenuItem>
<MenuItem value={TrickplayScanBehavior.Blocking}>{globalize.translate('BlockingScan')}</MenuItem>
</TextField>
<TextField
name='ProcessPriority'
select
defaultValue={defaultConfig.TrickplayOptions?.ProcessPriority}
label={globalize.translate('LabelProcessPriority')}
helperText={globalize.translate('LabelProcessPriorityHelp')}
>
<MenuItem value={ProcessPriorityClass.High}>{globalize.translate('PriorityHigh')}</MenuItem>
<MenuItem value={ProcessPriorityClass.AboveNormal}>{globalize.translate('PriorityAboveNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.Normal}>{globalize.translate('PriorityNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.BelowNormal}>{globalize.translate('PriorityBelowNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.Idle}>{globalize.translate('PriorityIdle')}</MenuItem>
</TextField>
<TextField
label={globalize.translate('LabelImageInterval')}
name='ImageInterval'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Interval}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelImageIntervalHelp')}
/>
</div>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwEncoding'
title='LabelTrickplayAccelEncoding'
<TextField
label={globalize.translate('LabelWidthResolutions')}
name='WidthResolutions'
defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
inputProps={{
required: true,
pattern: '[0-9,]*'
}}
helperText={globalize.translate('LabelWidthResolutionsHelp')}
/>
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayAccelEncodingHelp')}
</div>
</div>
</div>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableKeyFrameOnlyExtraction'
title='LabelTrickplayKeyFrameOnlyExtraction'
<TextField
label={globalize.translate('LabelTileWidth')}
name='TileWidth'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileWidthHelp')}
/>
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectScanBehavior'>
<SelectElement
id='selectScanBehavior'
label='LabelScanBehavior'
>
{optionScanBehavior()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelScanBehaviorHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelTileHeight')}
name='TileHeight'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileHeightHelp')}
/>
<div className='verticalSection'>
<div className='selectContainer fldSelectProcessPriority'>
<SelectElement
id='selectProcessPriority'
label='LabelProcessPriority'
>
{optionProcessPriority()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelProcessPriorityHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelJpegQuality')}
name='JpegQuality'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelJpegQualityHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtInterval'
label='LabelImageInterval'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelImageIntervalHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelQscale')}
name='Qscale'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Qscale}
inputProps={{
min: 2,
max: 31,
required: true
}}
helperText={globalize.translate('LabelQscaleHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='text'
id='txtWidthResolutions'
label='LabelWidthResolutions'
options={'required pattern="[0-9,]*"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelWidthResolutionsHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelTrickplayThreads')}
name='TrickplayThreads'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
inputProps={{
min: 0,
required: true
}}
helperText={globalize.translate('LabelTrickplayThreadsHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileWidth'
label='LabelTileWidth'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileWidthHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileHeight'
label='LabelTileHeight'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileHeightHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtJpegQuality'
label='LabelJpegQuality'
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelJpegQualityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtQscale'
label='LabelQscale'
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelQscaleHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtProcessThreads'
label='LabelTrickplayThreads'
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayThreadsHelp')}
</div>
</div>
</div>
<div>
<ButtonElement
<Button
type='submit'
className='raised button-submit block'
title='Save'
/>
</div>
</form>
</div>
size='large'
>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
</Box>
</Page>
);
};
export default PlaybackTrickplay;
Component.displayName = 'TrickplayPage';

View file

@ -0,0 +1,75 @@
import React, { useEffect } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import { QUERY_KEY, useTasks } from '../../features/scheduledtasks/api/useTasks';
import { getCategories, getTasksByCategory } from '../../features/scheduledtasks/utils/tasks';
import Loading from 'components/loading/LoadingComponent';
import Tasks from '../../features/scheduledtasks/components/Tasks';
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
import serverNotifications from 'scripts/serverNotifications';
import Events, { Event } from 'utils/events';
import { ApiClient } from 'jellyfin-apiclient';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
export const Component = () => {
const { __legacyApiClient__ } = useApi();
const { data: tasks, isPending } = useTasks({ isHidden: false });
// TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK.
useEffect(() => {
const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => {
queryClient.setQueryData([ QUERY_KEY ], info);
};
const fallbackInterval = setInterval(() => {
if (!__legacyApiClient__?.isMessageChannelOpen()) {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
}, 1e4);
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000');
Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
return () => {
clearInterval(fallbackInterval);
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null);
Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
};
}, [__legacyApiClient__]);
if (isPending || !tasks) {
return <Loading />;
}
const categories = getCategories(tasks);
return (
<Page
id='scheduledTasksPage'
title={globalize.translate('TabScheduledTasks')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Box className='readOnlyContent'>
<Stack spacing={3} mt={2}>
{categories.map(category => {
return <Tasks
key={category}
category={category}
tasks={getTasksByCategory(tasks, category)}
/>;
})}
</Stack>
</Box>
</Box>
</Page>
);
};
Component.displayName = 'TasksPage';

View file

@ -111,8 +111,9 @@ const UserNew = () => {
const saveUser = () => {
const userInput: UserInput = {};
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
window.ApiClient.createUser(userInput).then(function (user) {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');

View file

@ -199,7 +199,7 @@ const UserEdit = () => {
throw new Error('Unexpected null user id or policy');
}
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value.trim();
user.Policy.IsAdministrator = (page.querySelector('.chkIsAdmin') as HTMLInputElement).checked;
user.Policy.IsHidden = (page.querySelector('.chkIsHidden') as HTMLInputElement).checked;
user.Policy.IsDisabled = (page.querySelector('.chkDisabled') as HTMLInputElement).checked;

View file

@ -17,6 +17,7 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
parentId,
collectionType,
itemType
// eslint-disable-next-line sonarjs/function-return-type
}) => {
const { isLoading, data: genresResult } = useGetGenres(itemType, parentId);

View file

@ -6,6 +6,7 @@ import SectionContainer from 'components/common/SectionContainer';
import { CardShape } from 'utils/card';
import type { LibraryViewProps } from 'types/library';
// eslint-disable-next-line sonarjs/function-return-type
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
const { isLoading, data: groupsUpcomingEpisodes } =
useGetGroupsUpcomingEpisodes(parentId);

View file

@ -46,6 +46,7 @@ const VideoPage: FC = () => {
<AppToolbar
isDrawerAvailable={false}
isDrawerOpen={false}
isFullscreen
isUserMenuAvailable={false}
buttons={
<>

View file

@ -17,6 +17,7 @@ export interface MovedItem {
playlistItemId: string
}
// eslint-disable-next-line sonarjs/redundant-type-aliases
export type PlayerErrorCode = string;
export interface PlayerStopInfo {

View file

@ -0,0 +1,102 @@
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { describe, expect, it } from 'vitest';
import type { ItemDto } from 'types/base/models/item-dto';
import { getItemTextLines } from './itemText';
describe('getItemTextLines', () => {
it('Should return undefined if item is invalid', () => {
let lines = getItemTextLines({});
expect(lines).toBeUndefined();
lines = getItemTextLines(null);
expect(lines).toBeUndefined();
lines = getItemTextLines(undefined);
expect(lines).toBeUndefined();
});
it('Should return the name and index number', () => {
const item: ItemDto = {
Name: 'Item Name'
};
let lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(1);
expect(lines?.[0]).toBe(item.Name);
item.MediaType = MediaType.Video;
item.IndexNumber = 5;
lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(1);
expect(lines?.[0]).toBe(`${item.IndexNumber} - ${item.Name}`);
item.ParentIndexNumber = 2;
lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(1);
expect(lines?.[0]).toBe(`${item.ParentIndexNumber}.${item.IndexNumber} - ${item.Name}`);
});
it('Should add artist names', () => {
let item: ItemDto = {
Name: 'Item Name',
ArtistItems: [
{ Name: 'Artist 1' },
{ Name: 'Artist 2' }
]
};
let lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(2);
expect(lines?.[0]).toBe(item.Name);
expect(lines?.[1]).toBe('Artist 1, Artist 2');
item = {
Name: 'Item Name',
Artists: [
'Artist 1',
'Artist 2'
]
};
lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(2);
expect(lines?.[0]).toBe(item.Name);
expect(lines?.[1]).toBe('Artist 1, Artist 2');
});
it('Should add album or series name', () => {
let item: ItemDto = {
Name: 'Item Name',
SeriesName: 'Series'
};
let lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(2);
expect(lines?.[0]).toBe(item.SeriesName);
expect(lines?.[1]).toBe(item.Name);
item = {
Name: 'Item Name',
Album: 'Album'
};
lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(2);
expect(lines?.[0]).toBe(item.Album);
expect(lines?.[1]).toBe(item.Name);
});
it('Should add production year', () => {
const item = {
Name: 'Item Name',
ProductionYear: 2025
};
const lines = getItemTextLines(item);
expect(lines).toBeDefined();
expect(lines).toHaveLength(2);
expect(lines?.[0]).toBe(item.Name);
expect(lines?.[1]).toBe(String(item.ProductionYear));
});
});

View file

@ -0,0 +1,44 @@
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import type { ItemDto } from 'types/base/models/item-dto';
/**
* Gets lines of text used to describe an item for display.
* @param nowPlayingItem The item to describe
* @param isYearIncluded Should the production year be included
* @returns The list of strings describing the item for display
*/
export function getItemTextLines(
nowPlayingItem: ItemDto | null | undefined,
isYearIncluded = true
) {
let line1 = nowPlayingItem?.Name;
if (nowPlayingItem?.MediaType === MediaType.Video) {
if (nowPlayingItem.IndexNumber != null) {
line1 = nowPlayingItem.IndexNumber + ' - ' + line1;
}
if (nowPlayingItem.ParentIndexNumber != null) {
line1 = nowPlayingItem.ParentIndexNumber + '.' + line1;
}
}
let line2: string | null | undefined;
if (nowPlayingItem?.ArtistItems?.length) {
line2 = nowPlayingItem.ArtistItems.map(a => a.Name).join(', ');
} else if (nowPlayingItem?.Artists?.length) {
line2 = nowPlayingItem.Artists.join(', ');
} else if (nowPlayingItem?.SeriesName || nowPlayingItem?.Album) {
line2 = line1;
line1 = nowPlayingItem.SeriesName || nowPlayingItem.Album;
} else if (nowPlayingItem?.ProductionYear && isYearIncluded) {
line2 = String(nowPlayingItem.ProductionYear);
}
if (!line1) return;
const lines = [ line1 ];
if (line2) lines.push(line2);
return lines;
}

View file

@ -1,8 +1,8 @@
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { getImageUrl } from 'apps/stable/features/playback/utils/image';
import { getItemTextLines } from 'apps/stable/features/playback/utils/itemText';
import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber';
import { getNowPlayingNames } from 'components/playback/nowplayinghelper';
import type { PlaybackManager } from 'components/playback/playbackmanager';
import { MILLISECONDS_PER_SECOND, TICKS_PER_MILLISECOND } from 'constants/time';
import browser from 'scripts/browser';
@ -110,11 +110,11 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
}
const album = item.Album || undefined;
const [ line1, line2 ] = getNowPlayingNames(item, false) || [];
const [ line1, line2 ] = getItemTextLines(item, false) || [];
// The artist will be the second line if present or the first line otherwise
const artist = (line2 || line1)?.text;
const artist = line2 || line1;
// The title will be the first line if there are two lines
const title = (line2 && line1)?.text;
const title = line2 && line1;
if (hasNavigatorSession) {
if (

View file

@ -16,6 +16,7 @@ const MarkdownBox: FC<MarkdownBoxProps> = ({
<Box
dangerouslySetInnerHTML={
markdown ?
// eslint-disable-next-line sonarjs/disabled-auto-escaping
{ __html: DOMPurify.sanitize(markdownIt({ html: true }).render(markdown)) } :
undefined
}

View file

@ -2,7 +2,7 @@ import React, { type FC, type PropsWithChildren, type HTMLAttributes, useEffect,
import viewManager from './viewManager/viewManager';
type PageProps = {
type CustomPageProps = {
id: string, // id is required for libraryMenu
title?: string,
isBackButtonEnabled?: boolean,
@ -12,11 +12,13 @@ type PageProps = {
backDropType?: string,
};
export type PageProps = CustomPageProps & HTMLAttributes<HTMLDivElement>;
/**
* Page component that handles hiding active non-react views, triggering the required events for
* navigation and appRouter state updates, and setting the correct classes and data attributes.
*/
const Page: FC<PropsWithChildren<PageProps & HTMLAttributes<HTMLDivElement>>> = ({
const Page: FC<PropsWithChildren<PageProps>> = ({
children,
id,
className = '',

View file

@ -1,4 +1,4 @@
@import '../../styles/mixins';
@use '../../styles/mixins' as *;
.alphaPicker {
text-align: center;

View file

@ -9,6 +9,7 @@ interface CardTextProps {
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
const { title, titleAction } = textLine;
// eslint-disable-next-line sonarjs/function-return-type
const renderCardText = () => {
if (titleAction) {
return (

View file

@ -323,7 +323,7 @@ function shouldShowMediaTitle(
}
function shouldShowExtraType(itemExtraType: NullableString) {
return itemExtraType && itemExtraType !== 'Unknown';
return !!(itemExtraType && itemExtraType !== 'Unknown');
}
function shouldShowSeriesYearOrYear(
@ -351,7 +351,7 @@ function shouldShowPersonRoleOrType(
showPersonRoleOrType: boolean | undefined,
item: ItemDto
) {
return showPersonRoleOrType && (item as BaseItemPerson).Role;
return !!(showPersonRoleOrType && (item as BaseItemPerson).Role);
}
function shouldShowParentTitle(

View file

@ -195,6 +195,7 @@ function buildCardsHtmlInternal(items, options) {
if (isVertical) {
html += '</div>';
}
// eslint-disable-next-line sonarjs/no-dead-store
hasOpenSection = false;
}
@ -215,6 +216,7 @@ function buildCardsHtmlInternal(items, options) {
if (options.rows && itemsInRow === 0) {
if (hasOpenRow) {
html += '</div>';
// eslint-disable-next-line sonarjs/no-dead-store
hasOpenRow = false;
}
@ -704,7 +706,8 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
if (item.Role) {
if ([ PersonKind.Actor, PersonKind.GuestStar ].includes(item.Type)) {
// List actor roles formatted like "as Character Name"
lines.push(globalize.translate('PersonRole', escapeHtml(item.Role)));
const roleText = globalize.translate('PersonRole', escapeHtml(item.Role));
lines.push(`<span title="${roleText}">${roleText}</span>`);
} else if (item.Role.toLowerCase() === item.Type.toLowerCase()) {
// Role and Type are the same so use the localized Type
lines.push(escapeHtml(globalize.translate(item.Type)));

View file

@ -4,7 +4,7 @@ import globalize from '../../../lib/globalize';
import IconButtonElement from '../../../elements/IconButtonElement';
type AccessScheduleListProps = {
index: number;
index?: number;
DayOfWeek?: string;
StartHour?: number ;
EndHour?: number;

Some files were not shown because too many files have changed in this diff Show more