├── .devcontainer ├── Dockerfile ├── compose.yaml └── devcontainer.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── cliff.toml ├── docs ├── dev.md ├── editor-overview.png ├── editor-tvshow.png └── player-editor.png ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon.ico ├── icon.png ├── icons │ ├── favicon-128x128.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon-96x96.png └── splashscreen.html ├── quasar.config.js ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── gen │ └── schemas │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ ├── linux-schema.json │ │ └── windows-schema.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── app-icon.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── commands.rs │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── src ├── App.vue ├── boot │ ├── i18n.ts │ └── notify-defaults.js ├── components │ ├── FilterView.vue │ ├── btn │ │ └── PlusNewSegment.vue │ ├── image │ │ ├── BlurhashImage.vue │ │ └── ItemImage.vue │ ├── player │ │ ├── Player.vue │ │ ├── PlayerEditor.vue │ │ └── PlayerScrubber.vue │ ├── plugins │ │ └── EdlDialog.vue │ ├── segment │ │ ├── EditorSeasons.vue │ │ ├── SegmentAdd.vue │ │ ├── SegmentEdit.vue │ │ ├── SegmentSlider.vue │ │ ├── SegmentVisual.vue │ │ └── SegmentsBar.vue │ ├── settings │ │ ├── AppSettings.vue │ │ ├── AuthSettings.vue │ │ ├── PluginSettings.vue │ │ └── SettingsDialog.vue │ ├── utils │ │ └── KeyboardKeys.vue │ └── views │ │ ├── AlbumView.vue │ │ ├── ArtistView.vue │ │ └── SeriesView.vue ├── composables │ ├── BlurhashWorker.ts │ ├── api.ts │ ├── constants.ts │ ├── dialog.ts │ ├── fetch.ts │ ├── locales.ts │ ├── mediaCapabilities.ts │ ├── pluginEdlApi.ts │ ├── pluginMediaSegmentsApi.ts │ ├── segmentApi.ts │ ├── tauri.ts │ ├── utils.ts │ └── videoApi.ts ├── css │ ├── app.scss │ └── quasar.variables.scss ├── globals │ ├── components.d.ts │ ├── env.d.ts │ ├── quasar.d.ts │ └── shims-vue.d.ts ├── i18n │ └── locale │ │ ├── de.yaml │ │ ├── en-US.yaml │ │ └── fr.yaml ├── interfaces │ └── index.ts ├── layouts │ └── MainLayout.vue ├── pages │ ├── AlbumPage.vue │ ├── ArtistPage.vue │ ├── ErrorNotFound.vue │ ├── IndexPage.vue │ ├── PlayerPage.vue │ └── SeriesPage.vue ├── router │ ├── index.ts │ └── routes.ts └── stores │ ├── api.ts │ ├── app.ts │ ├── index.ts │ ├── items.ts │ ├── modal.ts │ ├── plugin.ts │ ├── segments.ts │ ├── session.ts │ └── store-flag.d.ts └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/typescript-node:22 2 | 3 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 4 | && apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf gstreamer1.0-plugins-ugly gstreamer1.0-plugins-bad -------------------------------------------------------------------------------- /.devcontainer/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | command: sleep infinity 7 | volumes: 8 | - ../..:/workspaces:cached 9 | # extra_hosts: 10 | # - "host.docker.internal:host-gateway" 11 | jellyfin: 12 | image: lscr.io/linuxserver/jellyfin:10.10.3 13 | container_name: jellyfin 14 | environment: 15 | - PUID=1000 16 | - PGID=1000 17 | # - TZ=Etc/UTC 18 | # - JELLYFIN_PublishedServerUrl=http://192.168.0.5 #optional 19 | volumes: 20 | - ~/containerc/jellyfin:/config 21 | # - /path/to/tvseries:/data/tvshows 22 | # - /path/to/movies:/data/movies 23 | ports: 24 | - 8096:8096 25 | # - 8920:8920 #optional 26 | # - 7359:7359/udp #optional 27 | # - 1900:1900/udp #optional 28 | restart: unless-stopped 29 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jellyfin SegmentEditor", 3 | "dockerComposeFile": "compose.yaml", 4 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 5 | "service": "app", 6 | "features": { 7 | "rust": { 8 | "version": "latest", 9 | "profile": "default" 10 | } 11 | }, 12 | "postCreateCommand": { 13 | "npm": "npm i && npm i -g @quasar/cli" 14 | }, 15 | "forwardPorts": [ 16 | 3111 17 | ], 18 | "customizations": { 19 | "vscode": { 20 | "extensions": [ 21 | "dbaeumer.vscode-eslint", 22 | "esbenp.prettier-vscode", 23 | "editorconfig.editorconfig", 24 | "vue.volar", 25 | "wayou.vscode-todo-highlight", 26 | "lokalise.i18n-ally", 27 | "pflannery.vscode-versionlens" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /src-capacitor 3 | /src-cordova 4 | /.quasar 5 | /node_modules 6 | .eslintrc.js 7 | /src-ssr 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy 3 | // This option interrupts the configuration hierarchy at this file 4 | // Remove this if you have an higher level ESLint config file (it usually happens into a monorepos) 5 | root: true, 6 | 7 | // https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser 8 | // Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working 9 | // `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted 10 | parserOptions: { 11 | parser: require.resolve('@typescript-eslint/parser'), 12 | extraFileExtensions: [ '.vue' ] 13 | }, 14 | 15 | env: { 16 | browser: true, 17 | es2021: true, 18 | node: true, 19 | 'vue/setup-compiler-macros': true 20 | }, 21 | 22 | // Rules order is important, please avoid shuffling them 23 | extends: [ 24 | // Base ESLint recommended rules 25 | // 'eslint:recommended', 26 | 27 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage 28 | // ESLint typescript rules 29 | 'plugin:@typescript-eslint/recommended', 30 | 31 | // Uncomment any of the lines below to choose desired strictness, 32 | // but leave only one uncommented! 33 | // See https://eslint.vuejs.org/rules/#available-rules 34 | 'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention) 35 | // 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability) 36 | // 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead) 37 | 38 | // https://github.com/prettier/eslint-config-prettier#installation 39 | // usage with Prettier, provided by 'eslint-config-prettier'. 40 | 'prettier' 41 | ], 42 | 43 | plugins: [ 44 | // required to apply rules which need type information 45 | '@typescript-eslint', 46 | 47 | // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files 48 | // required to lint *.vue files 49 | 'vue' 50 | 51 | // https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674 52 | // Prettier has not been included as plugin to avoid performance impact 53 | // add it as an extension for your IDE 54 | 55 | ], 56 | 57 | globals: { 58 | ga: 'readonly', // Google Analytics 59 | cordova: 'readonly', 60 | __statics: 'readonly', 61 | __QUASAR_SSR__: 'readonly', 62 | __QUASAR_SSR_SERVER__: 'readonly', 63 | __QUASAR_SSR_CLIENT__: 'readonly', 64 | __QUASAR_SSR_PWA__: 'readonly', 65 | process: 'readonly', 66 | Capacitor: 'readonly', 67 | chrome: 'readonly' 68 | }, 69 | 70 | // add your custom rules here 71 | rules: { 72 | 73 | 'prefer-promise-reject-errors': 'off', 74 | 75 | quotes: ['warn', 'single', { avoidEscape: true }], 76 | 77 | // this rule, if on, would require explicit return type on the `render` function 78 | '@typescript-eslint/explicit-function-return-type': 'off', 79 | 80 | // in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled 81 | '@typescript-eslint/no-var-requires': 'off', 82 | 83 | // The core 'no-unused-vars' rules (in the eslint:recommended ruleset) 84 | // does not work with type definitions 85 | 'no-unused-vars': 'off', 86 | 87 | // allow debugger during development only 88 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | on: [pull_request, push] 3 | 4 | jobs: 5 | test-tauri: 6 | permissions: 7 | contents: write 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 13 | args: '--target aarch64-apple-darwin' 14 | - platform: 'macos-latest' # for Intel based macs. 15 | args: '--target x86_64-apple-darwin' 16 | - platform: 'ubuntu-22.04' 17 | args: '' 18 | - platform: 'windows-latest' 19 | args: '' 20 | 21 | runs-on: ${{ matrix.platform }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: lts/* 29 | 30 | - name: install Rust stable 31 | uses: dtolnay/rust-toolchain@stable 32 | with: 33 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 34 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 35 | 36 | - name: install dependencies (ubuntu only) 37 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 38 | run: | 39 | sudo apt-get update 40 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf gstreamer1.0-plugins-ugly gstreamer1.0-plugins-bad 41 | 42 | - name: install frontend dependencies 43 | run: npm install # change this to npm, pnpm or bun depending on which one you use. 44 | 45 | - uses: tauri-apps/tauri-action@v0 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | generate-changelog: 10 | name: Generate changelog 11 | runs-on: ubuntu-22.04 12 | outputs: 13 | release_body: ${{ steps.git-cliff.outputs.content }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Generate a changelog 20 | uses: orhun/git-cliff-action@main 21 | id: git-cliff 22 | with: 23 | config: cliff.toml 24 | args: -vv --latest --no-exec --github-repo ${{ github.repository }} 25 | 26 | publish-tauri: 27 | permissions: 28 | contents: write 29 | needs: generate-changelog 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | include: 34 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 35 | args: '--target aarch64-apple-darwin' 36 | - platform: 'macos-latest' # for Intel based macs. 37 | args: '--target x86_64-apple-darwin' 38 | - platform: 'ubuntu-22.04' 39 | args: '' 40 | - platform: 'windows-latest' 41 | args: '' 42 | 43 | runs-on: ${{ matrix.platform }} 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: setup node 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: lts/* 51 | 52 | - name: install Rust stable 53 | uses: dtolnay/rust-toolchain@stable 54 | with: 55 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 56 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 57 | 58 | - name: install dependencies (ubuntu only) 59 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 60 | run: | 61 | sudo apt-get update 62 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf gstreamer1.0-plugins-ugly gstreamer1.0-plugins-bad 63 | 64 | - name: install frontend dependencies 65 | run: npm install # change this to npm, pnpm or bun depending on which one you use. 66 | 67 | - uses: tauri-apps/tauri-action@v0 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | tagName: ${{ github.ref_name }} # the action automatically replaces \_\_VERSION\_\_ with the app version. 72 | releaseName: 'Jellyfin Segment Editor __VERSION__' 73 | releaseBody: ${{ needs.generate-changelog.outputs.release_body }} 74 | releaseDraft: true 75 | prerelease: false 76 | args: ${{ matrix.args }} 77 | 78 | docker: 79 | runs-on: ubuntu-latest 80 | permissions: 81 | contents: read 82 | packages: write 83 | steps: 84 | - name: Checkout repository 85 | uses: actions/checkout@v4 86 | 87 | - name: Login Registry 88 | uses: docker/login-action@v1 89 | with: 90 | registry: ghcr.io 91 | username: ${{github.actor}} 92 | password: ${{secrets.GITHUB_TOKEN}} 93 | 94 | - name: Build Image 95 | run: | 96 | docker build . --tag ghcr.io/endrl/jellyfin-se:latest 97 | docker push ghcr.io/endrl/jellyfin-se:latest 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules 4 | 5 | # Quasar core related directories 6 | .quasar 7 | /dist 8 | 9 | # Cordova related directories and files 10 | /src-cordova/node_modules 11 | /src-cordova/platforms 12 | /src-cordova/plugins 13 | /src-cordova/www 14 | 15 | # Capacitor related directories and files 16 | /src-capacitor/www 17 | /src-capacitor/node_modules 18 | 19 | # BEX related directories and files 20 | /src-bex/www 21 | /src-bex/js/core 22 | 23 | # Log files 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Editor directories and files 29 | .idea 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # pnpm-related options 2 | shamefully-hoist=true 3 | strict-peer-dependencies=false 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "editorconfig.editorconfig", 6 | "vue.volar", 7 | "wayou.vscode-todo-highlight", 8 | "lokalise.i18n-ally", 9 | "pflannery.vscode-versionlens" 10 | ], 11 | "unwantedRecommendations": [ 12 | "octref.vetur", 13 | "hookyqr.beautify", 14 | "dbaeumer.jshint", 15 | "ms-vscode.vscode-typescript-tslint-plugin" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.bracketPairColorization.enabled": true, 3 | "editor.guides.bracketPairs": true, 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": [ 6 | "source.fixAll.eslint" 7 | ], 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "typescript", 12 | "vue" 13 | ], 14 | "typescript.tsdk": "node_modules/typescript/lib", 15 | "i18n-ally.localesPaths": [ 16 | "src/i18n/locale" 17 | ], 18 | "i18n-ally.keystyle": "nested" 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.4.4](https://github.com/endrl/segment-editor/compare/0.4.3..0.4.4) - 2024-11-25 3 | 4 | ### 🐛 Bug Fixes 5 | 6 | - Reliable sort order - ([4a6a115](https://github.com/endrl/segment-editor/commit/4a6a11518087d0832840b0b6662d792bb1c2e26a)) 7 | - Remove api key placeholder - ([0551699](https://github.com/endrl/segment-editor/commit/0551699c70f84fd241500b1ac441113c4b5054ff)) 8 | - Show window after load - ([64bfcf8](https://github.com/endrl/segment-editor/commit/64bfcf8066d3447d024feba5c8fef3063aeaef97)) 9 | 10 | ### ⚙️ Miscellaneous Tasks 11 | 12 | - Add dev container - ([09ae3db](https://github.com/endrl/segment-editor/commit/09ae3dbc98827463cbcb13440b577c3806bd5985)) 13 | - Update tauri - ([7ab9381](https://github.com/endrl/segment-editor/commit/7ab93811052524b19e142320ef02bb1b9c56dc43)) 14 | - Introduce changelog gen tool - ([0e623dd](https://github.com/endrl/segment-editor/commit/0e623dd61e185abb7b5e933d6ebec33f512030b4)) 15 | 16 | 17 | ## [0.4.3](https://github.com/endrl/segment-editor/compare/0.4.2..0.4.3) - 2024-09-17 18 | 19 | ### 🐛 Bug Fixes 20 | 21 | - Enable Video playback in AppImage - ([c0d14cf](https://github.com/endrl/segment-editor/commit/c0d14cf27df4c5b3d1855f98b6c5ccfe1974a0d5)) 22 | 23 | ## New Contributors ❤️ 24 | 25 | * @mihawk90 made their first contribution 26 | 27 | ## [0.4.2](https://github.com/endrl/segment-editor/compare/0.4.1..0.4.2) - 2024-09-01 28 | 29 | ### 🐛 Bug Fixes 30 | 31 | - Tauri migration fault - ([f8e9e85](https://github.com/endrl/segment-editor/commit/f8e9e8546d909df667c1fdf0344519cb00a3a5df)) 32 | - Remove splashscreen - ([2bea8ea](https://github.com/endrl/segment-editor/commit/2bea8eab875f063f9b841aca9d561a2b52496423)) 33 | 34 | 35 | ## [0.4.1](https://github.com/endrl/segment-editor/compare/0.4.0..0.4.1) - 2024-08-31 36 | 37 | ### 🐛 Bug Fixes 38 | 39 | - Docker build - ([839a14e](https://github.com/endrl/segment-editor/commit/839a14ebc4a9fca7dc79b359b3ab8efb4e73e6d8)) 40 | 41 | ### Core 42 | 43 | - Update gh actions ci - ([fc31760](https://github.com/endrl/segment-editor/commit/fc31760ac984916c09b74331d59db99d3a9ced8e)) 44 | 45 | 46 | ## [0.4.0](https://github.com/endrl/segment-editor/compare/0.3.1..0.4.0) - 2024-08-27 47 | 48 | ### ⛰️ Features 49 | 50 | - ApiKey option can handle white spaces & hidden app reset - ([43b33a5](https://github.com/endrl/segment-editor/commit/43b33a557e61efabacf7bb766089da2ce9f7ca91)) 51 | - Handle segment type 'unknown' - ([473433a](https://github.com/endrl/segment-editor/commit/473433a5c9c58437e50d2c4010a375d56af175df)) 52 | - Add docker build - ([bbf679a](https://github.com/endrl/segment-editor/commit/bbf679a2ad03a511ac46e3c2e9ac3b394e7c895f)) 53 | 54 | ### 🐛 Bug Fixes 55 | 56 | - Fetch issues on initial start - ([f38d2be](https://github.com/endrl/segment-editor/commit/f38d2be24645566081c5ab3c60f1060fe4ede018)) 57 | - Player audio slider - ([62a98de](https://github.com/endrl/segment-editor/commit/62a98de6635ae21e92ef82a687f558e4ae07423b)) 58 | 59 | 60 | ## [0.3.1](https://github.com/endrl/segment-editor/compare/0.3..0.3.1) - 2023-11-03 61 | 62 | ### 🐛 Bug Fixes 63 | 64 | - I18n - ([a62817e](https://github.com/endrl/segment-editor/commit/a62817e1b69e92e43ac8f5bf928348238418a55f)) 65 | 66 | ### ⚙️ Miscellaneous Tasks 67 | 68 | - Release 0.3.1 - ([abf2047](https://github.com/endrl/segment-editor/commit/abf2047a2bf43b50d43da6b85be32d83eebd2261)) 69 | 70 | ### Core 71 | 72 | - *(ci)* Add CI - ([af6f8c3](https://github.com/endrl/segment-editor/commit/af6f8c3c201115ef9527586ba013f66729b317d1)) 73 | - Relax node - ([cccd8f0](https://github.com/endrl/segment-editor/commit/cccd8f07496553e987a5de3805ca81ac9ab83b6a)) 74 | - Update Readme - ([385160b](https://github.com/endrl/segment-editor/commit/385160b46857152e9c4569ae2e24d6c368c73b4a)) 75 | 76 | ## New Contributors ❤️ 77 | 78 | * @Gauvino made their first contribution 79 | 80 | ## [0.3](https://github.com/endrl/segment-editor/compare/0.2..0.3) - 2023-11-03 81 | 82 | ### ⛰️ Features 83 | 84 | - Remove creatorId - ([f14a9ab](https://github.com/endrl/segment-editor/commit/f14a9ab0c7d387183957761b7044518f9aec79c0)) 85 | 86 | ### ⚙️ Miscellaneous Tasks 87 | 88 | - Release 0.3 - ([8b2e82f](https://github.com/endrl/segment-editor/commit/8b2e82f867b2e180ef1b6682d909a0efd8364d62)) 89 | 90 | 91 | ## [0.2](https://github.com/endrl/segment-editor/compare/0.1..0.2) - 2023-10-19 92 | 93 | ### 🐛 Bug Fixes 94 | 95 | - Initial start - ([0309abd](https://github.com/endrl/segment-editor/commit/0309abd85d16adf33e9c48f971639030ecdd9bb1)) 96 | 97 | ### ⚙️ Miscellaneous Tasks 98 | 99 | - Release 0.2.0 - ([1c8ad0e](https://github.com/endrl/segment-editor/commit/1c8ad0e74384fda21ec1d191348ce0f8d317841a)) 100 | - Update Tauri deps - ([a75457e](https://github.com/endrl/segment-editor/commit/a75457e9f5fae9e0fec89fb8e9d21d139ea304fd)) 101 | - Update deps - ([b7c6172](https://github.com/endrl/segment-editor/commit/b7c61729d38c539eff95d6e379db61d477743d1e)) 102 | 103 | ### Core 104 | 105 | - Update to Quasar Framework - ([3c0e996](https://github.com/endrl/segment-editor/commit/3c0e9966296cc50850ddb7317bd60017b21f12de)) 106 | 107 | 108 | ## [0.1] - 2023-06-22 109 | 110 | ### 🐛 Bug Fixes 111 | 112 | - Ci build - ([c9f5c9e](https://github.com/endrl/segment-editor/commit/c9f5c9e882dc461d9476b2a9ac4a4e0ce4b3dcd3)) 113 | 114 | ## New Contributors ❤️ 115 | 116 | * @endrl made their first contribution 117 | 118 | 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build 2 | FROM node:20.9-alpine as build-step 3 | 4 | LABEL org.opencontainers.image.description="https://github.com/endrl/segment-editor" 5 | 6 | RUN mkdir -p /app 7 | RUN npm cache clear --force 8 | WORKDIR /app 9 | COPY package.json /app 10 | RUN npm install 11 | COPY . /app 12 | RUN npm run build 13 | 14 | # deploy 15 | FROM nginx:alpine 16 | COPY --from=build-step /app/dist/spa /usr/share/nginx/html 17 | 18 | EXPOSE 80 19 | 20 | STOPSIGNAL SIGTERM 21 | 22 | CMD ["nginx", "-g", "daemon off;"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 endrl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jellyfin Segment Editor 2 | 3 | Manage Jellyfin Media Segment positions the simple way. This tool is in early stages of development. 4 | 5 | - Create/Edit/Delete all kind of Segments (Intro, Outro, ...) 6 | - Player to copy timestamps while you watch 7 | 8 | ## Requirements 9 | 10 | - Jellyfin 10.10 11 | - Jellyfin Plugin [MediaSegments API](https://github.com/endrl/jellyfin-plugin-ms-api) 12 | - Jellyfin Server API Key (created by you) 13 | 14 | ## Installation 15 | 16 | - Download for your platform from [Releases](https://github.com/endrl/segment-editor/releases/latest) 17 | 18 | ## Related projects 19 | 20 | - Jellyfin Plugin: [.EDL Creator](https://github.com/endrl/jellyfin-plugin-edl) 21 | - Jellyfin Plugin: [MediaSegments API](https://github.com/endrl/jellyfin-plugin-ms-api) 22 | 23 | ## Work in progress 24 | 25 | - List all versions of a movie 26 | - [x] Player view 27 | - Server side search query 28 | - [x] Copy/Paste segments 29 | - Add audio support 30 | - More filter 31 | 32 | ## Pictures 33 | 34 | ![Overview](docs/editor-overview.png) 35 | ![TV Shows](docs/editor-tvshow.png) 36 | ![Player](docs/player-editor.png) 37 | 38 | ## Development setup 39 | 40 | Install node LTS, clone this repo and run 41 | 42 | ```bash 43 | npm i && npm i -g @quasar/cli 44 | ``` 45 | 46 | ### Start the app in development mode (hot-code reloading, error reporting, etc.) 47 | 48 | ```bash 49 | quasar dev 50 | ``` 51 | 52 | ### Lint the files 53 | 54 | ```bash 55 | yarn lint 56 | # or 57 | npm run lint 58 | ``` 59 | 60 | ### Format the files 61 | 62 | ```bash 63 | yarn format 64 | # or 65 | npm run format 66 | ``` 67 | 68 | ### Build the app for production 69 | 70 | ```bash 71 | quasar build 72 | ``` 73 | 74 | ## Tauri App building 75 | 76 | Install [Rust](https://www.rust-lang.org/learn/get-started) 77 | 78 | ### Tauri dev 79 | 80 | ```bash 81 | npm run tauri dev 82 | ``` 83 | 84 | ### Tauri build for production 85 | 86 | ```bash 87 | npm run tauri build 88 | ``` 89 | 90 | ## Additional Tooling 91 | 92 | - Changelog Management [git-cliff](https://github.com/orhun/git-cliff) 93 | - See the next version bump `git cliff --bumped-version` 94 | - Set the version in package.json 95 | - Create Changelog `git cliff --bump --output CHANGELOG.md` 96 | - Create and push the tag with version `git tag -a 1.4.0 -m "release 1.4"` and `git push origin tag 1.4.0` 97 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | # ! When used in other repo: Replace the github repo info and postprocessor url (L76) 9 | [remote.github] 10 | owner = "endrl" 11 | repo = "segment-editor" 12 | 13 | [changelog] 14 | # template for the changelog header 15 | header = "" 16 | # template for the changelog body 17 | # https://keats.github.io/tera/docs/#introduction 18 | body = """ 19 | {%- macro remote_url() -%} 20 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 21 | {%- endmacro -%} 22 | 23 | {% macro print_commit(commit) -%} 24 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 25 | {% if commit.breaking %}[**breaking**] {% endif %}\ 26 | {{ commit.message | upper_first }} - \ 27 | ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ 28 | {% endmacro -%} 29 | 30 | {% if version %}\ 31 | {% if previous.version %}\ 32 | ## [{{ version | trim_start_matches(pat="v") }}]\ 33 | ({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} 34 | {% else %}\ 35 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 36 | {% endif %}\ 37 | {% else %}\ 38 | ## [unreleased] 39 | {% endif %}\ 40 | 41 | {% for group, commits in commits | group_by(attribute="group") %} 42 | ### {{ group | striptags | trim | upper_first }} 43 | {% for commit in commits 44 | | filter(attribute="scope") 45 | | sort(attribute="scope") %} 46 | {{ self::print_commit(commit=commit) }} 47 | {%- endfor %} 48 | {% for commit in commits %} 49 | {%- if not commit.scope -%} 50 | {{ self::print_commit(commit=commit) }} 51 | {% endif -%} 52 | {% endfor -%} 53 | {% endfor -%} 54 | {%- if github -%} 55 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 56 | ## New Contributors ❤️ 57 | {% endif %}\ 58 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 59 | * @{{ contributor.username }} made their first contribution 60 | {%- if contributor.pr_number %} in \ 61 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 62 | {%- endif %} 63 | {%- endfor -%} 64 | {%- endif %} 65 | 66 | 67 | """ 68 | # template for the changelog footer 69 | footer = """ 70 | 71 | """ 72 | # remove the leading and trailing whitespace from the templates 73 | trim = true 74 | # postprocessors 75 | postprocessors = [ 76 | { pattern = '', replace = "https://github.com/endrl/segment-editor" }, # replace repository URL 77 | ] 78 | 79 | [git] 80 | # parse the commits based on https://www.conventionalcommits.org 81 | conventional_commits = true 82 | # filter out the commits that are not conventional 83 | filter_unconventional = true 84 | # process each line of a commit as an individual commit 85 | split_commits = false 86 | # regex for preprocessing the commit messages 87 | commit_preprocessors = [ 88 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, 89 | # Check spelling of the commit with https://github.com/crate-ci/typos 90 | # If the spelling is incorrect, it will be automatically fixed. 91 | # { pattern = '.*', replace_command = 'typos --write-changes -' }, 92 | ] 93 | # regex for parsing and grouping commits 94 | commit_parsers = [ 95 | { message = "^feat", group = "⛰️ Features" }, 96 | { message = "^fix", group = "🐛 Bug Fixes" }, 97 | { message = "^doc", group = "📚 Documentation" }, 98 | { message = "^perf", group = "⚡ Performance" }, 99 | { message = "^refactor\\(clippy\\)", skip = true }, 100 | { message = "^refactor", group = "🚜 Refactor" }, 101 | { message = "^style", group = "🎨 Styling" }, 102 | { message = "^test", group = "🧪 Testing" }, 103 | { message = "^release", skip = true }, 104 | { message = "^chore\\(release\\): prepare for", skip = true }, 105 | { message = "^chore\\(deps.*\\)", skip = true }, 106 | { message = "^chore\\(pr\\)", skip = true }, 107 | { message = "^chore\\(pull\\)", skip = true }, 108 | { message = "^chore\\(npm\\).*yarn\\.lock", skip = true }, 109 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 110 | { body = ".*security", group = "🛡️ Security" }, 111 | { message = "^revert", group = "◀️ Revert" }, 112 | ] 113 | # protect breaking changes from being skipped due to matching a skipping commit_parser 114 | protect_breaking_commits = false 115 | # filter out the commits that are not matched by commit parsers 116 | filter_commits = false 117 | # regex for matching git tags 118 | tag_pattern = "[0-9].*" 119 | # regex for skipping tags 120 | skip_tags = "beta|alpha" 121 | # regex for ignoring tags 122 | # ignore_tags = "rc|v2.1.0|v2.1.1" 123 | # sort the tags topologically 124 | topo_order = false 125 | # sort the commits inside sections by oldest/newest order 126 | sort_commits = "newest" 127 | -------------------------------------------------------------------------------- /docs/dev.md: -------------------------------------------------------------------------------- 1 | 2 | ## vuetify3 to Quasar conversion cheatsheet 3 | 4 | * Flex classes. Quasar tool: [Flex Playground](https://quasar.dev/layout/grid/flex-playground) 5 | * `d-flex` || `flex-row` -> `row` 6 | * `flex-column` -> `column` 7 | * `d-inline-flex` -> `row inline` || `column inline` 8 | * `flex-row-reverse` -> `row reverse` 9 | * `flex-column-reverse` -> `column reverse` 10 | * Grow 11 | * `flex-grow-1` -> `col-grow` 12 | * `flex-shrink-1` -> `col-shrink` 13 | * Wrapping 14 | * `flex-wrap` -> `wrap` (set by default!) 15 | * `flex-nowrap` -> `no-wrap` 16 | * `flex-wrap-reverse` -> `reverse-wrap` 17 | * Justify x-axis 18 | * [x] `justify-start *end *center` 19 | * `justify-space-between` -> `justify-between` 20 | * `justify-space-around` -> `justify-around` 21 | * `justify-space-evenly` -> `justify-evenly` 22 | * Sizes `col` evenly. `col-[1-12]` with breakpoint `col-[xs-sm-md-lg-xl]-[1-12]` or with offset `offset-md-4` 23 | * `col-*` 24 | * `col-auto` use only space that is required. `col` uses all space available (but will shrink if required) 25 | * `flex-grow-1` -> `col-grow` 26 | * `flex-shrink-1` -> `col-shrink` 27 | * flex-grow-0 and flex-shrink-0 are not availble. Also no breakpoints 28 | * Align y-axis 29 | * `align-start` -> `items-start` 30 | * `align-end` -> `items-end` 31 | * `align-center` -> `items-center` 32 | * `align-baseline` -> `items-baseline` 33 | * `align-stretch` -> `items-stretch` 34 | * Position 35 | * `position-absolute`->`absolute` 36 | * `position-relative`->`relative-position` 37 | * Spacing 38 | * `ma-2` -> `q-ma-sm` (1,2,3,4,5,6 -> xs, sm, md, lg xl (6 missing)) 39 | * Width and height 40 | * `w-100` && `h-100` -> `fit` 41 | * `w-100` -> `full-width` 42 | * `h-100` -> `full-height` 43 | * `h-screen` -> `window-height` 44 | * Rounded borders 45 | * `rounded`->`rounded-borders` 46 | * COMPONENTS 47 | * `v-text-field` -> `q-input` 48 | * `error-messages` -> `error-message` also add `bottom-slots`. HINT: Better use `rules` prop 49 | * `@input` -> `:rules="[fn]"` Rule returns string (indicates error) or boolean true. You can use plain js functions and add `reactive-rules` if vars of the rule changes independent of the current input element (if it depends on other inputs) 50 | * `v-select` -> `q-select` 51 | * emit-value 52 | * map-options 53 | * `item-title="Name"` -> `option-label="Name"` Default is property 'label' 54 | * `item-value="ItemId"` ->`option-value="ItemId"` Default is property 'value' 55 | * `items` -> `options` 56 | * `v-btn` -> `q-btn` 57 | * `variant="tonal"` No replacement, use `flat` which has a tonal hover effect 58 | * `variant="elevated"` -> Removed, button has a shadow by default. Use `unelevated` to remove shadow 59 | * `variant="flat"` -> `flat` 60 | * `variant="outlined"` _> `outline` 61 | * `variant="text"` -> Use `flat` and `unelevated` 62 | * `variant="plain"` -> No replacement 63 | * `rounded="XX"` -> `round` 64 | * `size` -> `size="XX"` 'x-small', 'small', 'large', 'x-large' ->'xs', 'sm', 'md', 'lg', 'xl' or '20px' 65 | * `v-menu` -> `q-menu` 66 | * q-menu is wrappend by q-btn (or any other). There are more options available 67 | * Position: [Quasar tool](https://quasar.dev/vue-components/menu#positioning) 68 | * `location="top"` -> `anchor="top left" self="bottom left"` 69 | * Layout -> `q-list` -> `q-item` -> `q-item-section` 70 | * Set `clickable` on `q-item` if element is clickable 71 | * Set `auto-close` on `q-menu` if click on an element should close the menu 72 | * `v-slider` -> `q-slider` 73 | * `direction="vertical"` -> `vertical` 74 | * `v-list` -> `q-list`. Docs: [Quasar list](https://quasar.dev/vue-components/list-and-list-items#basic) 75 | * `v-list-item` -> `q-item` If it should be clickable add also `clickable` as prop 76 | * `v-list-item-title` -> `q-item-label` Be aware: Use `q-item-section` inside a `q-item` instead of a `q-item-label` 77 | * `v-card` -> `q-card` 78 | * `v-card-title` -> `q-card-section` add a div with class `text-h6` as title and optional a div as subtitle with class `text-subtitle2` or `text-subtitle1` 79 | * `v-card-text` -> `q-card-section` 80 | * `v-card-actions` -> `q-card-actions` 81 | * `v-expansion-panels` -> `q-expansion-item` 82 | * [Expansion Item](https://quasar.dev/vue-components/expansion-item) 83 | * `v-chip` -> `q-chip` 84 | * `variant="outlined"` -> `outline` 85 | * If chip is clickable add `clickable` 86 | * Many components: `density="compact"` -> `dense` 87 | -------------------------------------------------------------------------------- /docs/editor-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/docs/editor-overview.png -------------------------------------------------------------------------------- /docs/editor-tvshow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/docs/editor-tvshow.png -------------------------------------------------------------------------------- /docs/player-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/docs/player-editor.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= productName %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jellyfin-segment-editor", 3 | "version": "0.4.4", 4 | "description": "Manage Jellyfin Media Segments", 5 | "productName": "Jellyfin Segment Editor", 6 | "author": "endrl <119058008+endrl@users.noreply.github.com>", 7 | "private": true, 8 | "scripts": { 9 | "lint": "eslint --ext .js,.ts,.vue ./", 10 | "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore", 11 | "test": "echo \"No test specified\" && exit 0", 12 | "dev": "quasar dev", 13 | "build": "quasar build", 14 | "tauri": "tauri" 15 | }, 16 | "dependencies": { 17 | "@quasar/extras": "^1.16.12", 18 | "@tauri-apps/api": "2.1.1", 19 | "@vueuse/core": "^11.0.3", 20 | "blurhash": "^2.0.5", 21 | "comlink": "^4.4.1", 22 | "hls.js": "^1.5.15", 23 | "pinia": "^2.2.2", 24 | "pinia-plugin-persistedstate": "^3.2.1", 25 | "quasar": "^2.16.9", 26 | "vue": "^3.4.38", 27 | "vue-i18n": "^9.14.0", 28 | "vue-router": "^4.4.3" 29 | }, 30 | "devDependencies": { 31 | "@iconify-json/mdi": "^1.1.54", 32 | "@intlify/vite-plugin-vue-i18n": "^3.4.0", 33 | "@quasar/app-vite": "^1.9.5", 34 | "@quasar/cli": "^2.4.1", 35 | "@tauri-apps/cli": "^2.0.0-rc.7", 36 | "@types/node": "22.5.0", 37 | "@typescript-eslint/eslint-plugin": "7.18.0", 38 | "@typescript-eslint/parser": "7.18.0", 39 | "autoprefixer": "^10.4.16", 40 | "eslint": "8.57.0", 41 | "eslint-config-prettier": "^9.0.0", 42 | "eslint-plugin-vue": "^9.17.0", 43 | "prettier": "^3.0.3", 44 | "typescript": "^5.2.2", 45 | "unplugin-icons": "0.17.4", 46 | "unplugin-vue-components": "^0.27.4", 47 | "vite-plugin-yaml2": "^1.1.5" 48 | }, 49 | "engines": { 50 | "node": ">= 18", 51 | "npm": ">= 6.13.4", 52 | "yarn": "Not supported, use npm" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // https://github.com/michael-ciniawsky/postcss-load-config 3 | 4 | module.exports = { 5 | plugins: [ 6 | // https://github.com/postcss/autoprefixer 7 | require('autoprefixer')({ 8 | overrideBrowserslist: [ 9 | 'last 4 Chrome versions', 10 | 'last 4 Firefox versions', 11 | 'last 4 Edge versions', 12 | 'last 4 Safari versions', 13 | 'last 4 Android versions', 14 | 'last 4 ChromeAndroid versions', 15 | 'last 4 FirefoxAndroid versions', 16 | 'last 4 iOS versions' 17 | ] 18 | }) 19 | 20 | // https://github.com/elchininet/postcss-rtlcss 21 | // If you want to support RTL css, then 22 | // 1. yarn/npm install postcss-rtlcss 23 | // 2. optionally set quasar.config.js > framework > lang to an RTL language 24 | // 3. uncomment the following line: 25 | // require('postcss-rtlcss') 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/icon.png -------------------------------------------------------------------------------- /public/icons/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/icons/favicon-128x128.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/splashscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Jellyfin 10 | 11 | 12 | 13 |
14 | 15 |
16 | 17 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /quasar.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | /* 4 | * This file runs in a Node context (it's NOT transpiled by Babel), so use only 5 | * the ES6 features that are supported by your Node version. https://node.green/ 6 | */ 7 | 8 | // Configuration for your app 9 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js 10 | 11 | 12 | const { configure } = require('quasar/wrappers'); 13 | const path = require('path'); 14 | 15 | const IconsResolver = require('unplugin-icons/resolver') 16 | const { QuasarResolver } = require('unplugin-vue-components/resolvers') 17 | 18 | module.exports = configure(function (/* ctx */) { 19 | return { 20 | eslint: { 21 | // fix: true, 22 | // include: [], 23 | // exclude: [], 24 | // rawOptions: {}, 25 | warnings: false, 26 | errors: false 27 | }, 28 | 29 | // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature 30 | // preFetch: true, 31 | 32 | // app boot file (/src/boot) 33 | // --> boot files are part of "main.js" 34 | // https://v2.quasar.dev/quasar-cli-vite/boot-files 35 | boot: [ 36 | 'i18n', 37 | 'notify-defaults' 38 | ], 39 | 40 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css 41 | css: [ 42 | 'app.scss' 43 | ], 44 | 45 | // https://github.com/quasarframework/quasar/tree/dev/extras 46 | extras: [ 47 | 'roboto-font', 48 | 'material-icons', 49 | ], 50 | 51 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build 52 | build: { 53 | target: { 54 | browser: [ 'es2022', 'edge94', 'firefox93', 'chrome94', 'safari16.4' ], 55 | node: 'node18' 56 | }, 57 | 58 | vueRouterMode: 'hash', // available values: 'hash', 'history' 59 | // vueRouterBase, 60 | // vueDevtools, 61 | // vueOptionsAPI: false, 62 | 63 | // rebuildCache: true, // rebuilds Vite/linter/etc cache on startup 64 | 65 | // publicPath: '/', 66 | // analyze: true, 67 | // env: {}, 68 | rawDefine: { 69 | APP_COMMIT: JSON.stringify(process.env.COMMIT_HASH || '') 70 | }, 71 | // ignorePublicFolder: true, 72 | // minify: false, 73 | // polyfillModulePreload: true, 74 | // distDir 75 | 76 | // extendViteConf (viteConf) { }, 77 | // viteVuePluginOptions: {}, 78 | alias : { 79 | '@': path.join(__dirname, 'src'), 80 | composables: path.join(__dirname, 'src/composables'), 81 | }, 82 | vitePlugins: [ 83 | ['@intlify/vite-plugin-vue-i18n', { 84 | runtimeOnly: false, 85 | }], 86 | ['vite-plugin-yaml2',{}], 87 | ['unplugin-vue-components/vite',{ 88 | resolvers: [ 89 | IconsResolver(), 90 | QuasarResolver(), 91 | ], 92 | dts: path.join(__dirname, './src/globals/components.d.ts') 93 | }], 94 | ['unplugin-icons/vite',{}], 95 | ] 96 | }, 97 | 98 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer 99 | devServer: { 100 | // https: true 101 | port: 3111, 102 | open: false // opens browser window automatically 103 | }, 104 | 105 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework 106 | framework: { 107 | config: { 108 | dark:true 109 | }, 110 | 111 | // iconSet: 'material-icons', // Quasar icon set 112 | lang: 'en-US', // Quasar language pack 113 | 114 | // For special cases outside of where the auto-import strategy can have an impact 115 | // (like functional components as one of the examples), 116 | // you can manually specify Quasar components/directives to be available everywhere: 117 | // 118 | // components: [], 119 | // directives: [], 120 | 121 | // Quasar plugins 122 | plugins: ['Dialog','Notify'] 123 | }, 124 | 125 | // animations: 'all', // --- includes all animations 126 | // https://v2.quasar.dev/options/animations 127 | animations: [], 128 | 129 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#sourcefiles 130 | // sourceFiles: { 131 | // rootComponent: 'src/App.vue', 132 | // router: 'src/router/index', 133 | // store: 'stores/index', 134 | // registerServiceWorker: 'src-pwa/register-service-worker', 135 | // serviceWorker: 'src-pwa/custom-service-worker', 136 | // pwaManifestFile: 'src-pwa/manifest.json', 137 | // electronMain: 'src-electron/electron-main', 138 | // electronPreload: 'src-electron/electron-preload' 139 | // }, 140 | } 141 | }); 142 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "Segment Editor for Jellyfin" 5 | authors = ["Endrl"] 6 | license = "Apache" 7 | repository = "" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.60" 11 | 12 | [lib] 13 | name = "app_lib" 14 | crate-type = ["staticlib", "cdylib", "rlib"] 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [build-dependencies] 19 | tauri-build = { version = "2.0.0-rc", features = [] } 20 | 21 | [dependencies] 22 | serde_json = "^1.0" 23 | serde = { version = "1.0", features = ["derive"] } 24 | tauri = { version = "2.1.1", features = [] } 25 | tauri-plugin-window-state = { version = "^2" } 26 | 27 | [features] 28 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. 29 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. 30 | # DO NOT REMOVE!! 31 | custom-protocol = ["tauri/custom-protocol"] 32 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "default", 3 | "description": "default permissions", 4 | "local": true, 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "window-state:default" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 1 | {"default":{"identifier":"default","description":"default permissions","local":true,"windows":["main"],"permissions":["core:default","window-state:default"]}} -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/app-icon.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use tauri::Manager; 2 | 3 | #[tauri::command] 4 | pub async fn show_main_window(window: tauri::Window) { 5 | window.get_webview_window("main").unwrap().show().unwrap(); 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | use tauri_plugin_window_state::StateFlags; 2 | 3 | mod commands; 4 | 5 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 6 | pub fn run() { 7 | let context = tauri::generate_context!(); 8 | let builder = tauri::Builder::default(); 9 | 10 | builder 11 | .plugin( 12 | tauri_plugin_window_state::Builder::new() 13 | .with_state_flags( 14 | StateFlags::DECORATIONS 15 | | StateFlags::FULLSCREEN 16 | | StateFlags::MAXIMIZED 17 | | StateFlags::POSITION 18 | | StateFlags::SIZE, 19 | ) 20 | .build(), 21 | ) 22 | .invoke_handler(tauri::generate_handler![commands::show_main_window]) 23 | .run(context) 24 | .expect("error while running tauri application"); 25 | } 26 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "npm run build", 5 | "beforeDevCommand": "npm run dev", 6 | "frontendDist": "../dist/spa", 7 | "devUrl": "http://localhost:3111" 8 | }, 9 | "bundle": { 10 | "active": true, 11 | "category": "Productivity", 12 | "copyright": "MIT", 13 | "targets": "all", 14 | "externalBin": [], 15 | "icon": [ 16 | "icons/32x32.png", 17 | "icons/128x128.png", 18 | "icons/128x128@2x.png", 19 | "icons/icon.icns", 20 | "icons/icon.ico" 21 | ], 22 | "windows": { 23 | "certificateThumbprint": null, 24 | "digestAlgorithm": "sha256", 25 | "timestampUrl": "" 26 | }, 27 | "longDescription": "Manage Intro, Outro and other media segments for your Jellyfin Server", 28 | "macOS": { 29 | "entitlements": null, 30 | "exceptionDomain": "", 31 | "frameworks": [], 32 | "providerShortName": null, 33 | "signingIdentity": null 34 | }, 35 | "resources": [], 36 | "shortDescription": "Manage Jellyfin Media Segments", 37 | "linux": { 38 | "deb": { 39 | "depends": [] 40 | }, 41 | "appimage": { 42 | "bundleMediaFramework": true 43 | } 44 | } 45 | }, 46 | "productName": "Jellyfin Segment Editor", 47 | "version": "../package.json", 48 | "identifier": "com.endrl.segment.editor", 49 | "plugins": {}, 50 | "app": { 51 | "windows": [ 52 | { 53 | "label": "main", 54 | "fullscreen": false, 55 | "height": 900, 56 | "resizable": true, 57 | "center": true, 58 | "title": "Jellyfin Segment Editor", 59 | "width": 1200, 60 | "visible": false 61 | } 62 | ], 63 | "security": { 64 | "csp": null 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | -------------------------------------------------------------------------------- /src/boot/i18n.ts: -------------------------------------------------------------------------------- 1 | import { boot } from 'quasar/wrappers'; 2 | import { createI18n } from 'vue-i18n'; 3 | 4 | import en from 'src/i18n/locale/en-US.yaml'; 5 | 6 | export type MessageLanguages = keyof typeof en; 7 | // Type-define 'en-US' as the master schema for the resource 8 | export type MessageSchema = typeof en['en-US']; 9 | 10 | // See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition 11 | /* eslint-disable @typescript-eslint/no-empty-interface */ 12 | declare module 'vue-i18n' { 13 | // define the locale messages schema 14 | export interface DefineLocaleMessage extends MessageSchema { } 15 | 16 | // define the datetime format schema 17 | export interface DefineDateTimeFormat { } 18 | 19 | // define the number format schema 20 | export interface DefineNumberFormat { } 21 | } 22 | /* eslint-enable @typescript-eslint/no-empty-interface */ 23 | 24 | export default boot(({ app }) => { 25 | const i18n = createI18n({ 26 | locale: 'en-US', 27 | fallbackLocale: 'en-US', 28 | legacy: false, 29 | messages: { 30 | 'en-US': en, 31 | }, 32 | }); 33 | // Set i18n instance on app 34 | app.use(i18n); 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /src/boot/notify-defaults.js: -------------------------------------------------------------------------------- 1 | import { Notify } from 'quasar' 2 | 3 | Notify.setDefaults({ 4 | position: 'top', 5 | }) 6 | -------------------------------------------------------------------------------- /src/components/FilterView.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 61 | 62 | 82 | -------------------------------------------------------------------------------- /src/components/btn/PlusNewSegment.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | -------------------------------------------------------------------------------- /src/components/image/BlurhashImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 20 | 61 | -------------------------------------------------------------------------------- /src/components/image/ItemImage.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 51 | 52 | 63 | -------------------------------------------------------------------------------- /src/components/player/Player.vue: -------------------------------------------------------------------------------- 1 | 124 | 125 | 403 | 404 | 409 | -------------------------------------------------------------------------------- /src/components/player/PlayerEditor.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 114 | -------------------------------------------------------------------------------- /src/components/player/PlayerScrubber.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 44 | 45 | 54 | -------------------------------------------------------------------------------- /src/components/plugins/EdlDialog.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 64 | -------------------------------------------------------------------------------- /src/components/segment/EditorSeasons.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 46 | 47 | 53 | -------------------------------------------------------------------------------- /src/components/segment/SegmentAdd.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 93 | -------------------------------------------------------------------------------- /src/components/segment/SegmentEdit.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 111 | -------------------------------------------------------------------------------- /src/components/segment/SegmentSlider.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 130 | -------------------------------------------------------------------------------- /src/components/segment/SegmentVisual.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | -------------------------------------------------------------------------------- /src/components/segment/SegmentsBar.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 49 | 50 | 148 | 149 | 162 | -------------------------------------------------------------------------------- /src/components/settings/AppSettings.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 39 | -------------------------------------------------------------------------------- /src/components/settings/AuthSettings.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 50 | -------------------------------------------------------------------------------- /src/components/settings/PluginSettings.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 44 | -------------------------------------------------------------------------------- /src/components/settings/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 35 | -------------------------------------------------------------------------------- /src/components/utils/KeyboardKeys.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /src/components/views/AlbumView.vue: -------------------------------------------------------------------------------- 1 | 20 | 45 | 46 | 58 | -------------------------------------------------------------------------------- /src/components/views/ArtistView.vue: -------------------------------------------------------------------------------- 1 | 21 | 45 | 46 | 58 | -------------------------------------------------------------------------------- /src/components/views/SeriesView.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/composables/BlurhashWorker.ts: -------------------------------------------------------------------------------- 1 | import { decode } from 'blurhash'; 2 | import { expose } from 'comlink'; 3 | 4 | const cache: { [key: string]: Uint8ClampedArray } = {}; 5 | 6 | /** 7 | * Decodes blurhash outside the main thread, in a web worker 8 | * 9 | * @param hash - Hash to decode. 10 | * @param width - Width of the decoded pixel array 11 | * @param height - Height of the decoded pixel array. 12 | * @param punch - Contrast of the decoded pixels 13 | * @returns - Returns the decoded pixels in the proxied response by Comlink 14 | */ 15 | export default function getPixels( 16 | hash: string, 17 | width: number, 18 | height: number, 19 | punch: number 20 | ): Uint8ClampedArray { 21 | try { 22 | const params = [hash, width, height, punch].toString(); 23 | let canvas = cache[params]; 24 | 25 | if (!canvas) { 26 | canvas = decode(hash, width, height, punch); 27 | cache[params] = canvas; 28 | } 29 | 30 | return canvas; 31 | } catch { 32 | throw new TypeError(`Blurhash ${hash} is not valid`); 33 | } 34 | } 35 | 36 | expose(getPixels); 37 | -------------------------------------------------------------------------------- /src/composables/api.ts: -------------------------------------------------------------------------------- 1 | import { useApiStore } from 'stores/api' 2 | 3 | import { ImageType, VirtualFolderDto } from 'src/interfaces'; 4 | 5 | export function useApi() { 6 | const { fetchWithAuthJson, fetchWithAuth } = useApiStore() 7 | /* 8 | type RequestBody = { 9 | userId: number 10 | title: string 11 | body: string 12 | } 13 | 14 | type ResponseBody = RequestBody & { 15 | id: string 16 | } 17 | 18 | const newPost = { 19 | userId: 1, 20 | title: 'my post', 21 | body: 'some content', 22 | } 23 | 24 | const response = await fetch.post( 25 | 'https://jsonplaceholder.typicode.com/posts', 26 | newPost, 27 | ) 28 | */ 29 | 30 | // Get items for collection 31 | async function getItems(collectionId: string, index?: number) { 32 | const query: Map = new Map(); 33 | query.set('parentId', collectionId); 34 | query.set('fields', 'MediaStreams') 35 | query.set('sortBy','AiredEpisodeOrder,SortName') 36 | if (index != undefined) { 37 | // TODO all broken?!?! 38 | //query.set('startIndex', index) 39 | //query.set('parentIndexNumber', index) 40 | //query.set('searchTerm', 'Ava') 41 | //query.set('limit', '5') 42 | } 43 | 44 | const items = await fetchWithAuthJson('Items', query) 45 | return items; 46 | } 47 | 48 | // Get all collections 49 | async function getCollections() { 50 | const collections: VirtualFolderDto[] = await fetchWithAuthJson('Library/VirtualFolders') 51 | return collections 52 | } 53 | 54 | // Get Image for item 55 | async function getImage(itemId: string, width = 133, height = 200, type: ImageType = ImageType.Primary) { 56 | const query: Map = new Map(); 57 | 58 | query.set('tag', `segmenteditor_${itemId}_${type}`); 59 | query.set('width', width); 60 | query.set('height', height); 61 | 62 | const image = await fetchWithAuth(`Items/${itemId}/Images/${type}`) 63 | return image 64 | } 65 | 66 | return { getItems, getImage, getCollections } 67 | } 68 | -------------------------------------------------------------------------------- /src/composables/constants.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/endrl/segment-editor/35994aa101ea35a9d0b77a629ce7ae619e067e3b/src/composables/constants.ts -------------------------------------------------------------------------------- /src/composables/dialog.ts: -------------------------------------------------------------------------------- 1 | import useModalStore from "stores/modal"; 2 | import YesNoModal from "src/components/dialog/YesNoModal.vue"; 3 | import { useI18n } from "vue-i18n"; 4 | 5 | export function useDialog() { 6 | const { t } = useI18n(); 7 | const modal = useModalStore(); 8 | 9 | const yesNoDialog = (title: string, message: string, lcallback: (arg0: boolean) => void) => { 10 | const customDialog = YesNoModal; 11 | const customDialogProps = { 12 | title: title, 13 | message: message 14 | }; 15 | const actions = [ 16 | { 17 | label: t('yes'), 18 | callback: () => { 19 | lcallback(true) 20 | modal.close(); 21 | }, 22 | }, 23 | { 24 | label: t('no'), 25 | callback: () => { 26 | lcallback(false) 27 | modal.close(); 28 | }, 29 | }, 30 | ]; 31 | 32 | const dialog = { 33 | init: () => modal.init(customDialog, actions, customDialogProps), 34 | open: () => modal.open() 35 | } 36 | return dialog; 37 | } 38 | 39 | return { yesNoDialog } 40 | } 41 | -------------------------------------------------------------------------------- /src/composables/fetch.ts: -------------------------------------------------------------------------------- 1 | async function http(path: string, config: RequestInit): Promise { 2 | const request = new Request(path, config) 3 | const response = await fetch(request) 4 | 5 | if (!response.ok) { 6 | throw new Error({ name: response.status, message: response.statusText }) 7 | } 8 | 9 | // may error if there is no body, return empty array 10 | return response.json().catch(() => ({})) 11 | } 12 | 13 | export async function get(path: string, config?: RequestInit): Promise { 14 | const init = { method: 'get', ...config } 15 | return await http(path, init) 16 | } 17 | 18 | export async function post(path: string, body: T, config?: RequestInit): Promise { 19 | const init = { method: 'post', body: JSON.stringify(body), ...config } 20 | return await http(path, init) 21 | } 22 | 23 | export async function put(path: string, body: T, config?: RequestInit): Promise { 24 | const init = { method: 'put', body: JSON.stringify(body), ...config } 25 | return await http(path, init) 26 | } 27 | -------------------------------------------------------------------------------- /src/composables/locales.ts: -------------------------------------------------------------------------------- 1 | import { useQuasar } from 'quasar' 2 | import { useI18n } from 'vue-i18n' 3 | 4 | // generate imports 5 | const qLangList = import.meta.glob('../../node_modules/quasar/lang/*.js') 6 | const i18nLangList = import.meta.glob('../i18n/locale/*.yaml') 7 | const SUPPORTED_LOCALES = ['auto', 'en-US', 'fr', 'de'] 8 | 9 | export function useLocales() { 10 | const $q = useQuasar() 11 | const { locale, setLocaleMessage } = useI18n() 12 | 13 | /** 14 | * transform browser iso code to quasar locale isoCode ex. (de-DE to de) 15 | * @param isoCode code 16 | * @returns 17 | */ 18 | const transformLocale = (isoCode: string) => { 19 | if (isoCode.includes('de')) { 20 | return 'de' 21 | } 22 | else if (isoCode.includes('fr')) { 23 | return 'fr' 24 | } 25 | return isoCode 26 | } 27 | 28 | /** 29 | * Set locale for Quasar and vue-i18n, will import translation file 30 | * @param lang auto or isoCode that matches quasar and src/locales file 31 | * @returns 32 | */ 33 | const handleLocaleChange = (lang: string): string => { 34 | let langIso = lang == 'auto' ? $q.lang.getLocale() ?? 'en-US' : lang 35 | langIso = transformLocale(langIso) 36 | try { 37 | // quasar 38 | qLangList[`../../node_modules/quasar/lang/${langIso}.js`]().then(lang => { 39 | $q.lang.set(lang.default) 40 | }) 41 | // vue-i18n 42 | // TODO we can track the imports to prevent import twice 43 | i18nLangList[`../i18n/locale/${langIso}.yaml`]().then(lang => { 44 | setLocaleMessage(langIso, lang.default) 45 | }) 46 | } catch (error) { 47 | console.error(`Loading lang: ${langIso}`) 48 | langIso = 'en-US' 49 | } 50 | locale.value = langIso 51 | return langIso 52 | } 53 | 54 | return { handleLocaleChange, SUPPORTED_LOCALES } 55 | } 56 | -------------------------------------------------------------------------------- /src/composables/mediaCapabilities.ts: -------------------------------------------------------------------------------- 1 | import { BaseMediaStream } from 'src/interfaces'; 2 | 3 | export function useMediaCapabilities() { 4 | 5 | /** 6 | * Convert profile to browser codec digits 7 | * FFmpeg Profile: https://trac.ffmpeg.org/wiki/Encode/H.264#Profile 8 | * MDN: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#iso_base_media_file_format_mp4_quicktime_and_3gp 9 | * @param prof AVC Profile 10 | * @return PPCC digits 11 | */ 12 | const getAvcProfile = (prof: string): string | undefined => { 13 | switch (prof) { 14 | case 'Constrained Baseline': 15 | return '4240' 16 | case 'Baseline': 17 | return '4200' 18 | case 'Extended': 19 | return '5800' 20 | case 'Main': 21 | return '4D00' 22 | case 'High': 23 | return '6400' 24 | case 'Progressive High': 25 | return '6408' 26 | case 'Constrained High': 27 | return '640C' 28 | case 'High 10': 29 | return '6E00' 30 | case 'High 4:2:2': 31 | return '7A00' 32 | case 'High 4:4:4 Predictive': 33 | return 'F400' 34 | case 'High 10 Intra': 35 | return '6E10' 36 | case 'High 4:2:2 Intra': 37 | return '7A10' 38 | case 'High 4:4:4 Intra': 39 | return 'F410' 40 | case 'CAVLC 4:4:4 Intra': 41 | return '4400' 42 | case 'Scalable Baseline ': 43 | return '5300' 44 | case 'Scalable Constrained Baseline': 45 | return '5304' 46 | case 'Scalable High': 47 | return '5600' 48 | case 'Scalable Constrained High': 49 | return '5604' 50 | case 'Scalable High Intra': 51 | return '5620' 52 | case 'Stereo High': 53 | return '8000' 54 | case 'Multiview High': 55 | return '7600' 56 | case 'Multiview Depth High': 57 | return '8A00' 58 | default: 59 | console.error('Unhandled AVC Profile:', prof) 60 | break; 61 | } 62 | } 63 | 64 | 65 | /** 66 | * Convert profile to browser codec digits 67 | * FFmpeg Profile: https://trac.ffmpeg.org/wiki/Encode/H.264#Profile 68 | * MDN: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#iso_base_media_file_format_mp4_quicktime_and_3gp 69 | * Copied from: https://tools.axinom.com/capabilities/media 70 | * TODO: Find proper docs to create this list seperated instead of array 71 | * @param prof Profile 72 | * @return digits 73 | */ 74 | const getHevcProfile = (prof: string): string[] => { 75 | switch (prof) { 76 | case 'Main': 77 | return ['1.6', 'B0'] 78 | case 'Main 10': 79 | return ['2.4', 'B0'] 80 | case 'Main 12': 81 | return ['4.16', 'B9.88'] 82 | case 'Main Intra': 83 | return ['4.16', 'BF.A8'] 84 | case 'Main 4:4:4': 85 | return ['4.16', 'BE.A8'] 86 | case 'Main 4:4:4 Intra': 87 | return ['4.16', 'BE.28'] 88 | case 'Main 10 Intra': 89 | return ['4.16', 'BD.A8'] 90 | case 'Main 4:2:2 10': 91 | return ['4.16', 'BD.28'] 92 | case 'Main 4:2:2 Intra': 93 | return ['4.16', 'BD.08'] 94 | case 'Main 4:4:4 10': 95 | return ['4.16', 'BC.08'] 96 | case 'Main 4:4:4 10 Intra': 97 | return ['4.16', 'BC.28'] 98 | case 'Main 12 Intra': 99 | return ['4.16', 'B9.A8'] 100 | case 'Main 4:2:2 12': 101 | return ['4.16', 'B9.28'] 102 | case 'Main 4:2:2 12 Intra': 103 | return ['4.16', 'B9.08'] 104 | case 'Main 4:4:4 12': 105 | return ['4.16', 'B8.08'] 106 | case 'Main 4:4:4 12 Intra': 107 | return ['4.16', 'BC.28'] 108 | case 'Main 4:4:4 16': 109 | return ['4.16', 'B8.20'] 110 | case 'Monochrome': 111 | return ['4.16', 'BF.C8'] 112 | case 'Monochrome 10': 113 | return ['4.16', 'BD.C8'] 114 | case 'Monochrome 12': 115 | return ['4.16', 'B9.C8'] 116 | case 'Monochrome 16': 117 | return ['4.16', 'B1.C8'] 118 | case 'Screen-Extended': 119 | return ['9.512', 'BF.8C'] 120 | case 'Screen-Extended Main 4:4:4': 121 | return ['9.512', 'BE.0C'] 122 | case 'Screen-Extended Main 10': 123 | return ['9.512', 'BD.8C'] 124 | case 'Screen-Extended Main 4:4:4 10': 125 | return ['9.512', 'BC.0C'] 126 | case 'Screen-Extended High Throughput 4:4:4': 127 | return ['9.512', 'BE.0C'] 128 | case 'Screen-Extended High Throughput 4:4:4 10': 129 | return ['9.512', 'BC.0C'] 130 | case 'Screen-Extended High Throughput 4:4:4 14': 131 | return ['9.512', 'B0.0C'] 132 | case 'High Throughput 4:4:4': 133 | return ['5.32', 'BE.0C'] 134 | case 'High Throughput 4:4:4 10': 135 | return ['5.32', 'BC.0C'] 136 | case 'High Throughput 4:4:4 14': 137 | return ['5.32', 'B0.0C'] 138 | case 'High Throughput 4:4:4 16 Intro': 139 | return ['5.32', 'B0.24'] 140 | default: 141 | console.error('Unhandled HEVC Profile:', prof) 142 | return ['1.6', 'B0'] 143 | } 144 | } 145 | 146 | /** 147 | * Convert profile to browser codec digits 148 | * FFmpeg Profile: https://trac.ffmpeg.org/wiki/Encode/AV1 149 | * MDN: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#av1 150 | * Spec: https://aomediacodec.github.io/av1-spec/#profiles 151 | * @param prof AVC Profile 152 | * @return P digit 153 | */ 154 | const getAv1Profile = (prof: string): string | undefined => { 155 | switch (prof.toLowerCase()) { 156 | case 'main': 157 | return '0' 158 | case 'high': 159 | return '1' 160 | case 'professional': 161 | return '2' 162 | default: 163 | console.error('Unhandled AV1 Profile:', prof) 164 | break; 165 | } 166 | } 167 | 168 | /** 169 | * Get av1 codec specific sample digits 170 | * @param sampl pixel format 171 | */ 172 | const getAv1Subsampling = (sampl: string) => { 173 | if (sampl.includes('yuv444')) { 174 | return '000' 175 | } else if (sampl.includes('yuv422')) { 176 | return '100' 177 | } else if (sampl.includes('yuv420')) { 178 | return '110' 179 | } else if (sampl.includes('yuv400')) { 180 | return '111' 181 | } 182 | } 183 | 184 | /** 185 | * Get webm specific sample digits 186 | * @param sampl pixel format 187 | */ 188 | const getWebmSubsampling = (sampl: string) => { 189 | if (sampl.includes('yuv420')) { 190 | return '00' 191 | } else if (sampl.includes('yuv422')) { 192 | return '02' 193 | } else if (sampl.includes('yuv444')) { 194 | return '03' 195 | } 196 | } 197 | 198 | /** 199 | * Transform h264 to browser codec 200 | * FFmpeg Profile: https://trac.ffmpeg.org/wiki/Encode/H.264#Profile 201 | * @param stream 202 | */ 203 | const getCodecH264 = (stream: BaseMediaStream) => { 204 | // codec schema: avc1[.PPCCLL] 205 | const PPCC = getAvcProfile(stream.Profile) 206 | const LL = stream.Level.toString(16) 207 | return `avc1.${PPCC}${LL}` 208 | } 209 | 210 | /** 211 | * Transform av1 to browser codec 212 | * FFmpeg: https://trac.ffmpeg.org/wiki/Encode/AV1 213 | * MDN: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#av1 214 | * Spec: https://aomediacodec.github.io/av1-spec/#annex-a-profiles-and-levels 215 | * @param stream 216 | */ 217 | const getCodecAV1 = (stream: BaseMediaStream) => { 218 | // codec schema: cccc.P.LLT.DD[.M.CCC.cp.tc.mc.F] 219 | // const xlvl = 2 + (stream.Level >> 2) 220 | // const ylvl = (stream.Level & 3) 221 | 222 | const cccc = stream.CodecTag 223 | const P = getAv1Profile(stream.Profile) 224 | const LL = stream.Level < 10 ? `0${stream.Level}` : stream.Level; 225 | const T = 'M' // seq_tier derived from ?!, available from xlvl >= 4 which can be set to 'H' for high tier 226 | const DD = stream.BitDepth < 10 ? `0${stream.BitDepth}` : stream.BitDepth; 227 | const CCC = getAv1Subsampling(stream.PixelFormat) 228 | const M = CCC ? (CCC == '111' ? 1 : 0) : undefined; 229 | 230 | let codec = `${cccc}.${P}.${LL}${T}.${DD}` 231 | // optional info 232 | if (M != undefined) { 233 | codec += `.${M}.${CCC}` 234 | } 235 | return codec 236 | } 237 | 238 | /** 239 | * Get hevc browser codec. TODO: Missing docs for codec schema! 240 | * FFmpeg Profile: https://trac.ffmpeg.org/wiki/Encode/H.265#Profile 241 | * Help: https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding#mediacapabilities 242 | * Spec: https://x265.readthedocs.io/en/master/cli.html#profile-level-tier 243 | * @param stream 244 | */ 245 | const getCodecHevc = (stream: BaseMediaStream) => { 246 | // codec schema UNKNOWN: ccc.P.LLT.DD[.M.CCC.cp.tc.mc.F] 247 | // fallback if CodecTag is not available 248 | const cccc = stream.CodecTag ?? 'hev1'; 249 | const X = getHevcProfile(stream.Profile) 250 | const T = 'L' // Main Tier = L, High Tier = H (high requires profile >= 4.0) stream.Level >= 120 (decimal) 251 | const L = stream.Level 252 | 253 | return `${cccc}.${X[0]}.${T}${L}.${X[1]}` 254 | } 255 | 256 | /** Get codec (VP8,VP9) string for WebM container format 257 | * MDN: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#webm 258 | * Spec: https://www.webmproject.org/vp9/mp4/ 259 | * @param stream 260 | */ 261 | const getCodecWebM = (stream: BaseMediaStream) => { 262 | // codec schema cccc.PP.LL.DD[.CC.cp.tc.mc.FF] 263 | const cccc = stream.Codec === 'vp9' ? 'vp09' : 'vp08' 264 | // vp9 test file had "Profile 0" as profile string, ffmpeg probe test confirmed the bug 265 | const cleanProfile = stream.Profile.includes('Profile') ? stream.Profile.substring(stream.Profile.length - 1) : stream.Profile; 266 | const PP = cleanProfile.length == 1 ? '0' + cleanProfile : cleanProfile; 267 | const LL = stream.Level < 10 ? 10 : stream.Level; // vp8,vp9 test file got -99, so set to 10 as fallback 268 | const DD = stream.BitDepth < 10 ? `0${stream.BitDepth}` : stream.BitDepth; 269 | const CC = getWebmSubsampling(stream.PixelFormat); 270 | let codec = `${cccc}.${PP}.${LL}.${DD}` 271 | 272 | if (CC) { 273 | codec += `.${CC}` 274 | } 275 | return codec 276 | } 277 | 278 | /** 279 | * transform from ffmpeg codec to browser codec definition 280 | * @param stream the stream 281 | * @returns audio codec 282 | */ 283 | const getAudioCodec = (stream: BaseMediaStream) => { 284 | switch (stream.Codec) { 285 | case 'aac': 286 | return 'mp4a.40.2' // .2 -> AAC-LC, .5 -> AAC-HE, .29 -> AAC-HE v2 287 | case 'mp3': 288 | return 'mp4a.6B' // 6B -> MPEG-1 Part 3, 69 -> MPEG-2 Part 3 289 | case 'ac3': // Dolby Digital 290 | return 'ac-3' 291 | case 'eac3': // Dolby Digital Plus 292 | return 'ec-3' 293 | default: 294 | return `${stream.Codec}` 295 | } 296 | } 297 | 298 | 299 | /** 300 | * transform from ffmpeg codec to browser codec definition 301 | * @param stream the stream 302 | * @returns audio codec 303 | */ 304 | const getVideoCodec = (stream: BaseMediaStream) => { 305 | switch (stream.Codec) { 306 | case 'h264': 307 | return getCodecH264(stream) 308 | case 'hevc': // h265 309 | return getCodecHevc(stream) 310 | case 'av1': 311 | return getCodecAV1(stream) 312 | case 'vp8': 313 | case 'vp9': 314 | return getCodecWebM(stream) 315 | default: 316 | return stream.Codec 317 | } 318 | } 319 | 320 | /** 321 | * Get possible container for browser by codec 322 | * @param stream the stream 323 | * @returns possible containers 324 | */ 325 | const getMediaContainer = (stream: BaseMediaStream) => { 326 | switch (stream.Codec) { 327 | case 'h264': 328 | return ['mp4', 'mp2ts'] // or mp2ts 329 | case 'hevc': // h265 330 | return ['mp4'] // mp2ts not supported by hls.js. See https://github.com/video-dev/hls.js/issues/4943#issuecomment-1577457737 331 | case 'av1': 332 | return ['mp4'] // webm or mp2ts. (mp2ts is not supported by chrome), (vp9 with mp4 partly hangs in chrome for me) https://en.wikipedia.org/wiki/AV1#Supported_container_formats 333 | // case "vp8": 334 | // case "vp9": 335 | // case "vorbis": 336 | // case "opus": 337 | // return ["webm"] 338 | default: 339 | return undefined 340 | } 341 | } 342 | 343 | /** 344 | * Build a configuration for video 345 | * @param stream the stream 346 | * @param withAudio Add audio 347 | * @returns new configuration 348 | */ 349 | const getVideoConfiguration = (stream: BaseMediaStream): MediaDecodingConfiguration | undefined => { 350 | const videoCodec = getVideoCodec(stream) 351 | const container = getMediaContainer(stream) 352 | 353 | if (container) { 354 | return { 355 | type: 'media-source', 356 | video: { 357 | contentType: `video/${container[0]};codecs="${videoCodec}"`, 358 | width: stream.Width, 359 | height: stream.Height, 360 | bitrate: stream.BitRate, 361 | framerate: stream.AverageFrameRate, 362 | } 363 | }; 364 | } 365 | } 366 | 367 | /** 368 | * Build a configuration for Audio 369 | * @param stream the stream 370 | * @returns new configuration 371 | */ 372 | const getAudioConfiguration = (stream: BaseMediaStream): MediaDecodingConfiguration | undefined => { 373 | const audioCodec = getAudioCodec(stream) 374 | const container = getMediaContainer(stream) 375 | 376 | if (container) { 377 | return { 378 | type: 'media-source', 379 | audio: { 380 | contentType: `audio/${container[0]};codecs="${audioCodec}"`, 381 | bitrate: stream.BitRate, 382 | channels: stream.ChannelLayout, 383 | samplerate: stream.SampleRate, 384 | } 385 | }; 386 | } 387 | } 388 | 389 | /** 390 | * Test a media stream for compatibility 391 | * @param stream 392 | * @returns 393 | */ 394 | const testMediaStream = async (stream: BaseMediaStream) => { 395 | let config; 396 | if (stream.Type === 'Video') { 397 | config = getVideoConfiguration(stream) 398 | } else if (stream.Type === 'Audio') { 399 | config = getAudioConfiguration(stream) 400 | } 401 | 402 | if (config) { 403 | // console.log('Config: ',stream.Type,config) 404 | const res = await navigator.mediaCapabilities.decodingInfo(config); 405 | return res 406 | } 407 | } 408 | 409 | /** 410 | * Test a video stream together with audio stream for compatibility. Does not work with avc1 + aac. Just the MDN examples with webm container? 411 | * @param video stream 412 | * @param audio stream 413 | * @returns 414 | */ 415 | const testVideoAudioStream = async (video: BaseMediaStream, audio: BaseMediaStream) => { 416 | const videoCodec = getVideoCodec(video) 417 | const audioCodec = getAudioCodec(audio) 418 | const container = getMediaContainer(video) 419 | let config: MediaDecodingConfiguration | undefined = undefined; 420 | 421 | if (container) { 422 | config = { 423 | type: 'media-source', 424 | video: { // avc1.XXXXX dot notation of video part breaks parsing 425 | contentType: `video/${container[0]};codecs="${videoCodec},mp4a.40.2"`, // 426 | width: video.Width, 427 | height: video.Height, 428 | bitrate: video.BitRate, 429 | framerate: video.AverageFrameRate, 430 | } 431 | }; 432 | } 433 | 434 | if (config) { 435 | // console.log('Config: ',stream.Type,config) 436 | const res = await navigator.mediaCapabilities.decodingInfo(config); 437 | return res 438 | } 439 | } 440 | 441 | /** 442 | * transform browser string to jellyfin container string 443 | * @param cont container browser string 444 | * @returns 445 | */ 446 | const toJellyfinContainer = (cont?: string) => { 447 | switch (cont) { 448 | case 'mp4': 449 | return 'mp4' 450 | case 'mp2ts': 451 | return 'ts' 452 | default: 453 | return undefined 454 | } 455 | } 456 | 457 | return { testMediaStream, getMediaContainer, toJellyfinContainer } 458 | } 459 | -------------------------------------------------------------------------------- /src/composables/pluginEdlApi.ts: -------------------------------------------------------------------------------- 1 | import { ItemDto } from 'src/interfaces' 2 | import { useApiStore } from 'stores/api' 3 | 4 | export function usePluginEdlApi() { 5 | const { fetchWithAuthJson, postJson } = useApiStore() 6 | 7 | async function getEdlById(id: ItemDto) { 8 | 9 | // const response = await fetchWithAuthJson(`PluginEdl/Edl/${id}`) 10 | const response = await fetchWithAuthJson(`PluginEdl/Edl/${id}`) 11 | return response 12 | } 13 | 14 | async function createEdlById(id: string[]) { 15 | const response = await postJson('PluginEdl/Edl', id) 16 | return response 17 | } 18 | 19 | async function getEdlPluginMeta() { 20 | 21 | const response = await fetchWithAuthJson('PluginEdl') 22 | return response 23 | } 24 | 25 | return { getEdlById, createEdlById, getEdlPluginMeta } 26 | } 27 | -------------------------------------------------------------------------------- /src/composables/pluginMediaSegmentsApi.ts: -------------------------------------------------------------------------------- 1 | import { useApiStore } from 'stores/api' 2 | 3 | export function usePluginMediaSegmentsApi() { 4 | const { fetchWithAuthJson } = useApiStore() 5 | async function getMediaSegmentsApiPluginMeta() { 6 | 7 | const response = await fetchWithAuthJson('MediaSegmentsApi') 8 | return response 9 | } 10 | 11 | return { getMediaSegmentsApiPluginMeta } 12 | } 13 | -------------------------------------------------------------------------------- /src/composables/segmentApi.ts: -------------------------------------------------------------------------------- 1 | import { useApiStore } from 'stores/api' 2 | import { ItemDto, MediaSegment } from 'src/interfaces'; 3 | import { useUtils } from './utils'; 4 | 5 | export function useSegmentApi() { 6 | const { fetchWithAuthJson, postJson, deleteJson } = useApiStore() 7 | const { secondsToTicks, ticksToMs } = useUtils() 8 | 9 | // Get segments. Convert ticks to seconds 10 | async function getSegmentsById(itemId: ItemDto['Id']) { 11 | const query: Map = new Map(); 12 | query.set('itemId', itemId) 13 | 14 | const items = await fetchWithAuthJson(`MediaSegments/${itemId}`, query) 15 | 16 | items.Items.forEach((seg: MediaSegment) => { 17 | seg.StartTicks = ticksToMs(seg.StartTicks) / 1000 18 | seg.EndTicks = ticksToMs(seg.EndTicks) / 1000 19 | }); 20 | 21 | return items; 22 | } 23 | 24 | /** 25 | * Create a media segment on server. Convert seconds to ticks 26 | * @param segment segment 27 | * @return The created segments 28 | */ 29 | async function createSegment(segment: MediaSegment) { 30 | const query: Map = new Map(); 31 | query.set('providerId', 'SegmentEditor') 32 | 33 | segment.StartTicks = secondsToTicks(segment.StartTicks) 34 | segment.EndTicks = secondsToTicks(segment.EndTicks) 35 | 36 | return postJson(`MediaSegmentsApi/${segment.ItemId}`, segment, query) 37 | } 38 | 39 | /** 40 | * Delte a media segment on server 41 | * @param segment segment 42 | */ 43 | async function deleteSegment(segment: MediaSegment) { 44 | deleteJson(`MediaSegmentsApi/${segment.Id}`) 45 | } 46 | 47 | /** 48 | * Delete all media segments for providerId on server 49 | * @param segment segment 50 | */ 51 | async function deleteSegmentsByProviderId(itemId: ItemDto['Id']) { 52 | itemId 53 | console.error('deleteSegmentsByProviderId not possible') 54 | } 55 | 56 | return { getSegmentsById, createSegment, deleteSegment, deleteSegmentsByProviderId } 57 | } 58 | -------------------------------------------------------------------------------- /src/composables/tauri.ts: -------------------------------------------------------------------------------- 1 | export function useTauri() { 2 | 3 | /** 4 | * Hide Tauri splashscreen 5 | */ 6 | const show_main_window = async () => { 7 | if (window.isTauri) { 8 | const { invoke } = await import('@tauri-apps/api/core') 9 | invoke('show_main_window') 10 | } 11 | } 12 | 13 | return { show_main_window } 14 | } 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/composables/utils.ts: -------------------------------------------------------------------------------- 1 | import { ImageType, ItemDto, MediaSegment } from 'src/interfaces'; 2 | import { useApi } from 'src/composables/api' 3 | 4 | export function useUtils() { 5 | const { getImage } = useApi() 6 | 7 | /** 8 | * Get image url from item 9 | * @param item item 10 | * @param imgType image type 11 | */ 12 | async function getItemImageUrl(item: ItemDto, width: number, height: number, imgType: ImageType) { 13 | let imgTag; 14 | let itemId: string = item.Id; 15 | 16 | const preferBackdrop = imgType == ImageType.Backdrop 17 | 18 | if ( 19 | preferBackdrop && 20 | item.BackdropImageTags && 21 | item.BackdropImageTags.length > 0 22 | ) { 23 | imgType = ImageType.Backdrop; 24 | imgTag = item.BackdropImageTags[0]; 25 | } else if ( 26 | preferBackdrop && 27 | item.ParentBackdropImageTags?.[0] && 28 | item.ParentBackdropItemId 29 | ) { 30 | imgType = ImageType.Backdrop; 31 | imgTag = item.ParentBackdropImageTags[0]; 32 | itemId = item.ParentBackdropItemId; 33 | } /* else if ( 34 | item.ImageTags && 35 | item.ImageTags.Primary && 36 | (item.Type !== BaseItemKind.Episode || item.ChildCount !== 0) 37 | ) { 38 | imgType = ImageType.Primary; 39 | imgTag = item.ImageTags.Primary; 40 | height = 41 | width && item.PrimaryImageAspectRatio 42 | ? Math.round(width / item.PrimaryImageAspectRatio) 43 | : undefined; 44 | } else if (item.SeriesPrimaryImageTag) { 45 | imgType = ImageType.Primary; 46 | imgTag = item.SeriesPrimaryImageTag; 47 | itemId = item.SeriesId; 48 | } else if (item.ParentPrimaryImageTag) { 49 | imgType = ImageType.Primary; 50 | imgTag = item.ParentPrimaryImageTag; 51 | itemId = item.ParentPrimaryImageItemId; 52 | } else if (item.AlbumId && item.AlbumPrimaryImageTag) { 53 | imgType = ImageType.Primary; 54 | imgTag = item.AlbumPrimaryImageTag; 55 | itemId = item.AlbumId; 56 | height = 57 | width && item.PrimaryImageAspectRatio 58 | ? Math.round(width / item.PrimaryImageAspectRatio) 59 | : undefined; 60 | }*/ 61 | 62 | const res = await getImage(itemId, width, height, imgType) 63 | const url = await getImageOfStream(res) 64 | return url 65 | } 66 | 67 | // Get image of a Request body stream 68 | async function getImageOfStream(response: Response) { 69 | const reader = response?.body?.getReader(); 70 | const readableStream = new ReadableStream({ 71 | start(controller) { 72 | return pump(); 73 | function pump() { 74 | return reader?.read().then(({ done, value }) => { 75 | // When no more data needs to be consumed, close the stream 76 | if (done) { 77 | controller.close(); 78 | return; 79 | } 80 | // Enqueue the next data chunk into our target stream 81 | controller.enqueue(value); 82 | return pump(); 83 | }); 84 | } 85 | }, 86 | }); 87 | 88 | // Create a new response out of the stream 89 | const newres = new Response(readableStream) 90 | // Create an object URL for the response 91 | const blob = await newres.blob() 92 | return URL.createObjectURL(blob) 93 | } 94 | 95 | function getColorByType(type: MediaSegment['Type']) { 96 | switch (type) { 97 | case 'Intro': 98 | return 'green-5' 99 | case 'Outro': 100 | return 'purple-4' 101 | case 'Preview': 102 | return 'light-green' 103 | case 'Recap': 104 | return 'lime' 105 | case 'Commercial': 106 | return 'red' 107 | case 'Unknown': 108 | return 'grey-6' 109 | default: 110 | return 'white' 111 | } 112 | } 113 | 114 | /** 115 | * Converts .NET ticks to milliseconds 116 | * 117 | * @param ticks - Number of .NET ticks to convert 118 | * @returns The converted value in milliseconds 119 | */ 120 | function ticksToMs(ticks: number | null | undefined): number { 121 | if (!ticks) { 122 | ticks = 0; 123 | } 124 | 125 | return Math.round(ticks / 10_000); 126 | } 127 | 128 | /** 129 | * Converts seconds to Ticks 130 | * 131 | * @param ticks - Number of .NET ticks to convert 132 | * @returns The converted value in ticks 133 | */ 134 | function secondsToTicks(seconds: number | null | undefined): number { 135 | if (!seconds) { 136 | seconds = 0; 137 | } 138 | 139 | return Math.round(seconds * 10_000_000); 140 | } 141 | 142 | /** 143 | * Parse a number string to 3 digits float 144 | * @param numb number to parse 145 | * @returns 146 | */ 147 | function stringToNumber(numb: string) { 148 | return Math.round((Number.parseFloat(numb) + Number.EPSILON) * 1000) / 1000 149 | } 150 | 151 | /** 152 | * Parse a number to 3 digits float 153 | * @param numb number to parse 154 | * @returns 155 | */ 156 | function numberToNumber(numb: number) { 157 | return Math.round((numb + Number.EPSILON) * 1000) / 1000 158 | } 159 | 160 | 161 | /** 162 | * Array.sort() function to sort by Segment.Start 163 | * @param sA segmnt a 164 | * @param sB segment b 165 | * @returns 166 | */ 167 | function sortSegmentsStart(sA: MediaSegment, sB: MediaSegment) { 168 | if (sA.StartTicks < sB.StartTicks) { 169 | return -1; 170 | } 171 | if (sA.StartTicks > sB.StartTicks) { 172 | return 1; 173 | } 174 | // or equal 175 | return 0; 176 | } 177 | 178 | /** 179 | * Convert seconds into a time string like so '1h 10m 20s' 180 | * @param timeInsSeconds The seconds to convert 181 | * @returns time tring 182 | */ 183 | function getReadableTimeFromSeconds(timeInsSeconds: number) { 184 | const minsTemp = timeInsSeconds / 60; 185 | let hours = Math.floor(minsTemp / 60); 186 | const mins = Math.floor(minsTemp % 60); 187 | const secs = Math.floor(timeInsSeconds % 60); 188 | if (hours !== 0 && mins !== 0) { 189 | if (mins > 59) { 190 | hours += 1; 191 | return +`${hours}h ${secs}s`; 192 | } else { 193 | return `${hours}h ${mins.toFixed(0)}m ${secs}s`; 194 | } 195 | } else if (hours === 0 && mins === 0) { 196 | return `${secs}s`; 197 | } else if (hours === 0 && mins !== 0) { 198 | return `${mins.toFixed(0)}m ${secs}s`; 199 | } else if (hours !== 0 && mins === 0) { 200 | return `${hours}h ${secs}s`; 201 | } 202 | } 203 | 204 | /** 205 | * Convert seconds into a time string like so '1:10:20' 206 | * @param timeInsSeconds The seconds to convert 207 | * @returns time tring 208 | */ 209 | function getTimefromSeconds(timeInsSeconds: number) { 210 | 211 | const minsTemp = timeInsSeconds / 60; 212 | let hours = Math.floor(minsTemp / 60); 213 | 214 | const minTemp1 = Math.floor(minsTemp % 60) 215 | const mins = minTemp1 < 10 ? `0${minTemp1}` : `${minTemp1}`; 216 | 217 | const secsTemp1 = Math.floor(timeInsSeconds % 60); 218 | const secs = secsTemp1 < 10 ? `0${secsTemp1}` : `${secsTemp1}` 219 | 220 | const ms = (timeInsSeconds % 1).toFixed(3).substring(2); 221 | 222 | if (hours !== 0 && minTemp1 !== 0) { 223 | if (minTemp1 > 59) { 224 | hours += 1; 225 | return +`${hours}:${mins}:${secs}:${ms}`; 226 | } else { 227 | return `${hours}:${mins}:${secs}:${ms}`; 228 | } 229 | } else if (hours === 0) { 230 | return `${mins}:${secs}:${ms}`; 231 | } else if (hours !== 0) { 232 | return `${hours}:${mins}:${secs}:${ms}`; 233 | } 234 | 235 | } 236 | 237 | /** 238 | * Convert a time string to seconds. 1:20:15 or 1 20 15 239 | * @param time time string to parse 240 | * @returns time in seconds 241 | */ 242 | function getSecondsFromTime(time: string | number | undefined) { 243 | if (time == undefined) 244 | return 0 245 | const ntime = typeof time === 'string' ? time.trim() : String(time); 246 | 247 | let splitted = new Array(); 248 | // prepare format 249 | if ([':', ' '].filter((el) => ntime.includes(el))) 250 | splitted = ntime.split(':') 251 | if (splitted.length == 1) 252 | splitted = ntime.split(' ') 253 | // last segment is seconds 254 | const sec = splitted.pop()?.trim() 255 | const min = splitted.pop()?.trim() 256 | const h = splitted.pop()?.trim() 257 | 258 | let totalSecs = 0 259 | 260 | if (sec != undefined) 261 | totalSecs += Number.parseFloat(sec) 262 | if (min != undefined) 263 | totalSecs += Number.parseFloat(min) * 60 264 | if (h != undefined) 265 | totalSecs += Number.parseFloat(h) * 60 * 60 266 | 267 | return totalSecs 268 | } 269 | 270 | 271 | /** 272 | * Create uuid 273 | * @returns uuid 274 | */ 275 | function generateUUID() { 276 | let d = new Date().getTime(); 277 | let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0; 278 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 279 | let r = Math.random() * 16; 280 | if (d > 0) { 281 | r = (d + r) % 16 | 0; 282 | d = Math.floor(d / 16); 283 | } else { 284 | r = (d2 + r) % 16 | 0; 285 | d2 = Math.floor(d2 / 16); 286 | } 287 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); 288 | }); 289 | } 290 | 291 | return { getImageOfStream, getColorByType, getReadableTimeFromSeconds, getTimefromSeconds, getSecondsFromTime, ticksToMs, secondsToTicks, stringToNumber, numberToNumber, sortSegmentsStart, getItemImageUrl, generateUUID } 292 | } 293 | -------------------------------------------------------------------------------- /src/composables/videoApi.ts: -------------------------------------------------------------------------------- 1 | import { useApiStore } from 'stores/api' 2 | import { ItemDto } from 'src/interfaces'; 3 | 4 | export function useVideoApi() { 5 | const { buildUrl, postJson } = useApiStore() 6 | 7 | /** 8 | * Get a media stream url by id 9 | * Endpoint: Videos/itemId/master.m3u8 is HLS. HLS supports audio: aac, mp3 ac3, eac3. video container: fMP4, mp2ts 10 | * Doc: https://datatracker.ietf.org/doc/html/draft-pantos-http-live-streaming-20#section-3.1 -3.4 11 | * @param itemId itemId of media 12 | * @param forceVideoReason force transcode of video 13 | * @param forceAudioReason force transcode of audio 14 | * @param container ts, mp4, ... 15 | */ 16 | function getVideoStream(itemId: ItemDto['Id'], forceVideoReason?: string, forceAudioReason?: string, container?: string) { 17 | const query: Map = new Map(); 18 | const transcodeReasons: string[] = []; 19 | query.set('MediaSourceId', itemId) 20 | 21 | // if there is no transcode, enable direct stream 22 | if (!forceAudioReason && !forceVideoReason) { 23 | query.set('static', true) 24 | } 25 | if (forceAudioReason) { 26 | transcodeReasons.push(`Audio not supported (${forceAudioReason})`) 27 | query.set('AudioCodec', 'aac') 28 | query.set('TranscodingMaxAudioChannels', 2) 29 | } 30 | if (forceVideoReason) { 31 | transcodeReasons.push(`Video not supported (${forceVideoReason}`) 32 | query.set('VideoCodec', 'h264') 33 | } 34 | if (transcodeReasons.length) { 35 | query.set('transcodeReasons', transcodeReasons.join(',')) 36 | } 37 | if (container) { 38 | query.set('segmentContainer', container) 39 | } 40 | // skip session, we can't stop it without userId... 41 | // query.set('PlaySessionId',CREATOR_UUID) 42 | 43 | const baseUrl = `Videos/${itemId}/master.m3u8`; 44 | return buildUrl(baseUrl, query) 45 | } 46 | 47 | /** 48 | * Report end of playback. Requires userId from httpContext server side... 49 | * @param itemId 50 | */ 51 | function reportPlayingStop(itemId: ItemDto['Id']) { 52 | const payload = { 53 | ItemId: itemId, 54 | MediaSourceId: itemId, 55 | PlaySessionId: 'xxx' 56 | } 57 | postJson('Sessions/Playing/Stopped', payload) 58 | } 59 | 60 | return { getVideoStream, reportPlayingStop } 61 | } 62 | -------------------------------------------------------------------------------- /src/css/app.scss: -------------------------------------------------------------------------------- 1 | // app global css in SCSS form 2 | @use "sass:list"; 3 | 4 | // no browser color/decoration for links 5 | a, 6 | a:hover, 7 | a:visited, 8 | a:active { 9 | color: inherit; 10 | text-decoration: none; 11 | } 12 | 13 | // Shadow fixes: https: //github.com/quasarframework/quasar/issues/15144#issuecomment-1494673280 14 | $shadow-color: #000 !default; 15 | $dark-shadow-color: #fff !default; 16 | 17 | :root { 18 | --q-shadow-color: #{to-rgb($shadow-color)}; 19 | --q-dark-shadow-color: #{to-rgb($dark-shadow-color)}; 20 | --q-dark-shadow-opacity: 1; 21 | } 22 | 23 | $shadow-offsets: [ (1px, 3px, 0, 1px, 0, 2px, 1px, -1px), 24 | (1px, 5px, 0, 2px, 0, 3px, 1px, -2px), (1px, 8px, 0, 4px, 0, 3px, 3px, -2px), 25 | (2px, 4px, -1px, 5px, 0, 1px, 10px, 0), (3px, 5px, -1px, 8px, 0, 1px, 14px, 0), 26 | (3px, 5px, -1px, 10px, 0, 1px, 18px, 0), 27 | (4px, 5px, -2px, 10px, 1px, 2px, 16px, 1px), 28 | (5px, 5px, -3px, 10px, 1px, 3px, 14px, 2px), 29 | (5px, 6px, -3px, 12px, 1px, 3px, 16px, 2px), 30 | (6px, 6px, -3px, 14px, 1px, 4px, 18px, 3px), 31 | (6px, 7px, -4px, 15px, 1px, 4px, 20px, 3px), 32 | (7px, 8px, -4px, 17px, 2px, 5px, 22px, 4px), 33 | (7px, 8px, -4px, 19px, 2px, 5px, 24px, 4px), 34 | (7px, 9px, -4px, 21px, 2px, 5px, 26px, 4px), 35 | (8px, 9px, -5px, 22px, 2px, 6px, 28px, 5px), 36 | (8px, 10px, -5px, 24px, 2px, 6px, 30px, 5px), 37 | (8px, 11px, -5px, 26px, 2px, 6px, 32px, 5px), 38 | (9px, 11px, -5px, 28px, 2px, 7px, 34px, 6px), 39 | (9px, 12px, -6px, 29px, 2px, 7px, 36px, 6px), 40 | (10px, 13px, -6px, 31px, 3px, 8px, 38px, 7px), 41 | (10px, 13px, -6px, 33px, 3px, 8px, 40px, 7px), 42 | (10px, 14px, -6px, 35px, 3px, 8px, 42px, 7px), 43 | (11px, 14px, -7px, 36px, 3px, 9px, 44px, 8px), 44 | (11px, 15px, -7px, 38px, 3px, 9px, 46px, 8px)]; 45 | 46 | @for $i from 1 through length($shadow-offsets) { 47 | .shadow-#{$i} { 48 | $offsets: list.nth($shadow-offsets, $i); 49 | box-shadow: 0 list.nth($offsets, 1) list.nth($offsets, 2) list.nth($offsets, 3) rgba(var(--q-shadow-color), 0.2), 50 | 0 #{$i}px list.nth($offsets, 4) list.nth($offsets, 5) rgba(var(--q-shadow-color), 0.14), 51 | 0 list.nth($offsets, 6) list.nth($offsets, 7) list.nth($offsets, 8) rgba(var(--q-shadow-color), 0.12); 52 | } 53 | 54 | .shadow-up-#{$i } { 55 | $offsets: list.nth($shadow-offsets, $i); 56 | box-shadow: 0 #{0 - 57 | list.nth($offsets, 1) 58 | } 59 | 60 | list.nth($offsets, 2) list.nth($offsets, 3) rgba(var(--q-shadow-color), 0.2), 61 | 0 #{0 - 62 | $i 63 | } 64 | 65 | px list.nth($offsets, 4) list.nth($offsets, 5) rgba(var(--q-shadow-color), 0.14), 66 | 0 list.nth($offsets, 6) list.nth($offsets, 7) list.nth($offsets, 8) rgba(var(--q-shadow-color), 0.12); 67 | } 68 | 69 | body.body--dark { 70 | .shadow-#{$i } { 71 | $offsets: list.nth($shadow-offsets, $i); 72 | box-shadow: 0 list.nth($offsets, 1) list.nth($offsets, 2) list.nth($offsets, 3) rgba(var(--q-dark-shadow-color), 73 | calc(0.2 * var(--q-dark-shadow-opacity))), 74 | 0 #{$i}px list.nth($offsets, 4) list.nth($offsets, 5) rgba(var(--q-dark-shadow-color), 75 | calc(0.14 * var(--q-dark-shadow-opacity))), 76 | 0 list.nth($offsets, 6) list.nth($offsets, 7) list.nth($offsets, 8) rgba(var(--q-dark-shadow-color), 77 | calc(0.12 * var(--q-dark-shadow-opacity))); 78 | } 79 | 80 | .shadow-up-#{$i } { 81 | $offsets: list.nth($shadow-offsets, $i); 82 | box-shadow: 0 #{0 - 83 | list.nth($offsets, 1) 84 | } 85 | 86 | list.nth($offsets, 2) list.nth($offsets, 3) rgba(var(--q-dark-shadow-color), 87 | calc(0.2 * var(--q-dark-shadow-opacity))), 88 | 0 #{0 - 89 | $i 90 | } 91 | 92 | px list.nth($offsets, 4) list.nth($offsets, 5) rgba(var(--q-dark-shadow-color), 93 | calc(0.14 * var(--q-dark-shadow-opacity))), 94 | 0 list.nth($offsets, 6) list.nth($offsets, 7) list.nth($offsets, 8) rgba(var(--q-dark-shadow-color), 95 | calc(0.12 * var(--q-dark-shadow-opacity))); 96 | } 97 | } 98 | } 99 | 100 | .q-layout__shadow::after { 101 | box-shadow: 0 0 10px 2px rgba(var(--q-shadow-color), 0.2), 102 | 0 0px 10px rgba(var(--q-shadow-color), 0.24); 103 | } 104 | 105 | .inset-shadow { 106 | box-shadow: 0 7px 9px -7px rgba(var(--q-shadow-color), 0.7) inset; 107 | } 108 | 109 | .inset-shadow-down { 110 | box-shadow: 0 -7px 9px -7px rgba(var(--q-shadow-color), 0.7) inset; 111 | } 112 | 113 | .q-layout__shadow::after { 114 | box-shadow: 0 0 10px 2px rgba(var(--q-shadow-color), 0.2), 115 | 0 0px 10px rgba(var(--q-shadow-color), 0.24); 116 | } 117 | 118 | body.body--dark { 119 | .inset-shadow { 120 | box-shadow: 0 7px 9px -7px rgba(var(--q-dark-shadow-color), 121 | calc(0.7 * var(--q-dark-shadow-opacity))) inset; 122 | } 123 | 124 | .inset-shadow-down { 125 | box-shadow: 0 -7px 9px -7px rgba(var(--q-dark-shadow-color), calc(0.7 * var(--q-dark-shadow-opacity))) inset; 126 | } 127 | 128 | .q-layout__shadow::after { 129 | box-shadow: 0 0 10px 2px rgba(var(--q-dark-shadow-color), 130 | calc(0.2 * var(--q-dark-shadow-opacity))), 131 | 0 0px 10px rgba(var(--q-dark-shadow-color), 132 | calc(0.24 * var(--q-dark-shadow-opacity))); 133 | } 134 | } 135 | 136 | .q-card--dark, 137 | .q-date--dark, 138 | .q-time--dark, 139 | .q-menu--dark, 140 | .q-color-picker--dark, 141 | .q-table__card--dark, 142 | .q-table--dark, 143 | .q-uploader--dark { 144 | box-shadow: 0 1px 5px rgba(var(--q-dark-shadow-color), calc(0.2 * var(--q-dark-shadow-opacity))), 145 | 0 2px 2px rgba(var(--q-dark-shadow-color), 146 | calc(0.14 * var(--q-dark-shadow-opacity))), 147 | 0 3px 1px -2px rgba(var(--q-dark-shadow-color), calc(0.12 * var(--q-dark-shadow-opacity))); 148 | } 149 | 150 | /* ignore from here, leave this comment */ 151 | // demo specific styles 152 | .shadow-box { 153 | width: 90px; 154 | height: 90px; 155 | margin: 25px; 156 | border-radius: 50%; 157 | font-size: 12px; 158 | background-color: #fafafa; 159 | } 160 | 161 | .doc-inset-shadow { 162 | width: 120px; 163 | height: 120px; 164 | padding: 4px; 165 | } 166 | 167 | body.body--dark { 168 | .shadow-box { 169 | background-color: #141414; 170 | } 171 | 172 | .doc-inset-shadow { 173 | background-color: #101010; 174 | } 175 | } 176 | 177 | // now you can override color using CSS variables (if not defined in quasar config) 178 | //:root { 179 | //--q-shadow-color: 0, 0, 255; 180 | //--q-dark-shadow-color: 255, 0, 0; 181 | //} 182 | 183 | 184 | // CUSTOM 185 | 186 | .text-ellipsis { 187 | text-overflow: ellipsis; 188 | overflow: hidden; 189 | white-space: nowrap; 190 | } 191 | -------------------------------------------------------------------------------- /src/css/quasar.variables.scss: -------------------------------------------------------------------------------- 1 | // Quasar SCSS (& Sass) Variables 2 | // -------------------------------------------------- 3 | // To customize the look and feel of this app, you can override 4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files. 5 | 6 | // Check documentation for full list of Quasar variables 7 | 8 | // Your own variables (that are declared here) and Quasar's own 9 | // ones will be available out of the box in your .vue/.scss/.sass files 10 | 11 | // It's highly recommended to change the default colors 12 | // to match your app's branding. 13 | // Tip: Use the "Theme Builder" on Quasar's documentation website. 14 | 15 | $primary : #1976D2; 16 | $secondary : #26A69A; 17 | $accent : #9C27B0; 18 | 19 | $dark : #1D1D1D; 20 | $dark-page : #121212; 21 | 22 | $positive : #21BA45; 23 | $negative : #f44336; 24 | $info : #31CCEC; 25 | $warning : #F2C037; -------------------------------------------------------------------------------- /src/globals/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | export {} 6 | 7 | /* prettier-ignore */ 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | AlbumView: typeof import('./../components/views/AlbumView.vue')['default'] 11 | AppSettings: typeof import('./../components/settings/AppSettings.vue')['default'] 12 | ArtistView: typeof import('./../components/views/ArtistView.vue')['default'] 13 | AuthSettings: typeof import('./../components/settings/AuthSettings.vue')['default'] 14 | BlurhashImage: typeof import('./../components/image/BlurhashImage.vue')['default'] 15 | EditorSeasons: typeof import('./../components/segment/EditorSeasons.vue')['default'] 16 | EdlDialog: typeof import('./../components/plugins/EdlDialog.vue')['default'] 17 | FilterView: typeof import('./../components/FilterView.vue')['default'] 18 | IMdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default'] 19 | IMdiClose: typeof import('~icons/mdi/close')['default'] 20 | IMdiCog: typeof import('~icons/mdi/cog')['default'] 21 | IMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] 22 | IMdiContentPaste: typeof import('~icons/mdi/content-paste')['default'] 23 | IMdiDelete: typeof import('~icons/mdi/delete')['default'] 24 | IMdiFilmstrip: typeof import('~icons/mdi/filmstrip')['default'] 25 | IMdiInformation: typeof import('~icons/mdi/information')['default'] 26 | IMdiKeyboardArrowUp: typeof import('~icons/mdi/keyboard-arrow-up')['default'] 27 | IMdiMovie: typeof import('~icons/mdi/movie')['default'] 28 | IMdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default'] 29 | IMdiPause: typeof import('~icons/mdi/pause')['default'] 30 | IMdiPlay: typeof import('~icons/mdi/play')['default'] 31 | IMdiPlayPause: typeof import('~icons/mdi/play-pause')['default'] 32 | IMdiPlus: typeof import('~icons/mdi/plus')['default'] 33 | IMdiRayEndArrow: typeof import('~icons/mdi/ray-end-arrow')['default'] 34 | IMdiRayStartArrow: typeof import('~icons/mdi/ray-start-arrow')['default'] 35 | IMdiReload: typeof import('~icons/mdi/reload')['default'] 36 | IMdiSkipForward: typeof import('~icons/mdi/skip-forward')['default'] 37 | IMdiTrashCan: typeof import('~icons/mdi/trash-can')['default'] 38 | IMdiVolume: typeof import('~icons/mdi/volume')['default'] 39 | IMdiVolumeMute: typeof import('~icons/mdi/volume-mute')['default'] 40 | ItemImage: typeof import('./../components/image/ItemImage.vue')['default'] 41 | KeyboardKeys: typeof import('./../components/utils/KeyboardKeys.vue')['default'] 42 | Player: typeof import('./../components/player/Player.vue')['default'] 43 | PlayerEditor: typeof import('./../components/player/PlayerEditor.vue')['default'] 44 | PlayerScrubber: typeof import('./../components/player/PlayerScrubber.vue')['default'] 45 | PluginSettings: typeof import('./../components/settings/PluginSettings.vue')['default'] 46 | PlusNewSegment: typeof import('./../components/btn/PlusNewSegment.vue')['default'] 47 | RouterLink: typeof import('vue-router')['RouterLink'] 48 | RouterView: typeof import('vue-router')['RouterView'] 49 | SegmentAdd: typeof import('./../components/segment/SegmentAdd.vue')['default'] 50 | SegmentEdit: typeof import('./../components/segment/SegmentEdit.vue')['default'] 51 | SegmentsBar: typeof import('./../components/segment/SegmentsBar.vue')['default'] 52 | SegmentSlider: typeof import('./../components/segment/SegmentSlider.vue')['default'] 53 | SegmentVisual: typeof import('./../components/segment/SegmentVisual.vue')['default'] 54 | SeriesView: typeof import('./../components/views/SeriesView.vue')['default'] 55 | SettingsDialog: typeof import('./../components/settings/SettingsDialog.vue')['default'] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/globals/env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Declare yaml files 4 | declare module '*.yaml' { 5 | const value: any; 6 | export default value; 7 | } 8 | 9 | 10 | declare namespace NodeJS { 11 | interface ProcessEnv { 12 | NODE_ENV: string; 13 | VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined; 14 | VUE_ROUTER_BASE: string | undefined; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/globals/quasar.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Forces TS to apply `@quasar/app-vite` augmentations of `quasar` package 4 | // Removing this would break `quasar/wrappers` imports as those typings are declared 5 | // into `@quasar/app-vite` 6 | // As a side effect, since `@quasar/app-vite` reference `quasar` to augment it, 7 | // this declaration also apply `quasar` own 8 | // augmentations (eg. adds `$q` into Vue component context) 9 | /// 10 | -------------------------------------------------------------------------------- /src/globals/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /// 4 | 5 | // Mocks all files ending in `.vue` showing them as plain Vue instances 6 | declare module '*.vue' { 7 | import type { DefineComponent } from 'vue'; 8 | const component: DefineComponent<{}, {}, any>; 9 | export default component; 10 | } 11 | -------------------------------------------------------------------------------- /src/i18n/locale/de.yaml: -------------------------------------------------------------------------------- 1 | login: 2 | api_key: Jellyfin API Key 3 | server_address: Jellyfin Server Adresse 4 | test_conn: Verbindung testen 5 | validation: 6 | api_key_invalid: Ungültiger API Key 7 | url_invalid: URL Fehler. Sollte mit http:// oder https:// beginnen 8 | connect_fail: Verbindung fehlgeschlagen! 9 | auth_fail: Authentifizierung fehlgeschlagen! 10 | app: 11 | theme: 12 | title: Stil 13 | system: System 14 | dark: Dunkel 15 | light: Hell 16 | locale: 17 | auto: Auto 18 | en-US: Englisch 19 | de: Deutsch 20 | fr: Französisch 21 | title: Sprache 22 | plugins: 23 | title: Installierte Server Plugins 24 | title: Segment Editor 25 | showVideoPlayer: Videoplayer anzeigen 26 | items: 27 | select_collection: Sammlung 28 | filter: 29 | name: Suchen 30 | collection: Sammlung 31 | editor: 32 | saveSegment: Speichern 33 | deleteSure: Möchtest du dieses '{Type}' Segment wirklich löschen? 34 | deleteSureTitle: Segment entfernen | Segmente entfernen | {count} Segmente entfernen 35 | time: Zeit 36 | deleteSegments: Möchtest du wirklich alle Segmente für '{item}' entfernen? 37 | newSegment: Neues Segment 38 | slider: 39 | title: Slider Editor 40 | noSegments: Keine Segmente gefunden, erstelle eines mit '+' 41 | noSegmentInClipboard: Kein Segment in der Zwischenablage 42 | segmentCopiedToClipboard: Segment in die Zwischenablage kopiert 43 | 'yes': Ja 44 | 'no': Nein 45 | segment: 46 | start: Start 47 | end: Ende 48 | type: Segment Typ 49 | edit: Segment bearbeiten 50 | duration: Dauer 51 | validation: 52 | StartEnd: Start liegt immer vor dem Ende 53 | close: Schließen 54 | back: Zurück 55 | plugin: 56 | edl: 57 | enable: EDL Unterstützung 58 | created: Edl Datei wurde erstellt 59 | -------------------------------------------------------------------------------- /src/i18n/locale/en-US.yaml: -------------------------------------------------------------------------------- 1 | login: 2 | api_key: Jellyfin API key 3 | server_address: Jellyfin Server Address 4 | test_conn: Test Connection 5 | validation: 6 | api_key_invalid: API Key Invalid 7 | url_invalid: URL invalid. Should start with http:// or https:// 8 | connect_fail: Connection failed! 9 | auth_fail: Authentication failed! 10 | app: 11 | theme: 12 | title: Theme 13 | system: System 14 | dark: Dark 15 | light: Light 16 | locale: 17 | auto: Auto 18 | en-US: English 19 | de: German 20 | fr: French 21 | title: Locale 22 | plugins: 23 | title: Installed Server Plugins 24 | title: Segment Editor 25 | showVideoPlayer: Show Video Player 26 | items: 27 | select_collection: Collection 28 | filter: 29 | name: Search 30 | collection: Collection 31 | editor: 32 | saveSegment: Save 33 | deleteSure: Are you sure to delete segment '{Type}'? 34 | deleteSureTitle: Delete Segment | Delete Segments | Delete {count} Segments 35 | time: Time 36 | deleteSegments: Are you sure to delete all segments for '{item}'? 37 | newSegment: New Segment 38 | slider: 39 | title: Slider Editor 40 | noSegments: No Segments available, create one with '+' 41 | segmentCopiedToClipboard: Segment copied to clipboard 42 | 'yes': 'Yes' 43 | 'no': 'No' 44 | segment: 45 | start: Start 46 | end: End 47 | type: Segment Type 48 | edit: Edit Segment 49 | duration: Duration 50 | validation: 51 | StartEnd: Start is always before End 52 | close: Close 53 | back: Back 54 | plugin: 55 | edl: 56 | enable: EDL support 57 | created: Edl file created 58 | -------------------------------------------------------------------------------- /src/i18n/locale/fr.yaml: -------------------------------------------------------------------------------- 1 | login: 2 | api_key: Clé d'API Jellyfin 3 | server_address: Adresse du serveur Jellyfin 4 | test_conn: Test de connexion 5 | validation: 6 | api_key_invalid: Clé d'API invalide 7 | url_invalid: URL invalide. Doit commencer par http:// ou https:// 8 | connect_fail: Connection échoué ! 9 | auth_fail: Échec d'authentification ! 10 | app: 11 | theme: 12 | title: Thème 13 | system: Système 14 | dark: Sombre 15 | light: Clair 16 | locale: 17 | auto: Auto 18 | en-US: Anglais 19 | fr: Français 20 | de: Allemand 21 | title: Langues 22 | plugins: 23 | title: Plugins de serveur installés 24 | title: Éditeur de segment 25 | showVideoPlayer: Afficher le lecteur vidéo 26 | items: 27 | select_collection: Collection 28 | filter: 29 | name: Recherche 30 | collection: Collection 31 | editor: 32 | saveSegment: Sauvegarder 33 | deleteSure: Êtes-vous sûr de supprimer le segment '{Type}'? 34 | deleteSureTitle: Supprimer un segment | Supprimer des segments | Supprimer {count} segments 35 | time: Temps 36 | deleteSegments: Êtes-vous sûr de supprimer tous les segments pour '{item}'? 37 | newSegment: Nouveau segment 38 | slider: 39 | title: Éditeur de Slider 40 | noSegments: Aucun segment disponible, créez-en un avec '+' 41 | noSegmentInClipboard: Clipboard has no segment to copy 42 | segmentCopiedToClipboard: Segment copié dans le presse-papiers 43 | 'yes': Oui 44 | 'no': Non 45 | segment: 46 | start: Début 47 | end: Fin 48 | type: Type de segment 49 | edit: Éditez le segment 50 | duration: Durée 51 | validation: 52 | StartEnd: Le début est toujours avant la fin 53 | close: Fermé 54 | back: Retour 55 | plugin: 56 | edl: 57 | enable: EDL Soutien 58 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export enum MediaSegmentType { 2 | UNKNOWN = 'Unknown', 3 | INTRO = 'Intro', 4 | OUTRO = 'Outro', 5 | PREVIEW = 'Preview', 6 | RECAP = 'Recap', 7 | COMMERCIAL = 'Commercial' 8 | } 9 | 10 | export class MediaSegment { 11 | Id = 'notlegit'; 12 | StartTicks = 0; 13 | EndTicks = 1; 14 | Type: MediaSegmentType = MediaSegmentType.INTRO; 15 | ItemId = 'notlegit'; 16 | } 17 | 18 | export enum ItemType { 19 | Movie = 'Movie', 20 | Series = 'Series', 21 | Season = 'Season', 22 | Episode = 'Episode', 23 | MusicArtist = 'MusicArtist', 24 | MusicAlbum = 'MusicAlbum', 25 | } 26 | 27 | export interface UserData { 28 | UnplayedItemCount: number, 29 | PlaybackPositionTicks: number, 30 | PlayCount: number, 31 | IsFavorite: boolean, 32 | Played: boolean, 33 | Key: number 34 | } 35 | 36 | export interface BaseMediaStream { 37 | Codec: string, // dts, h264, hevc or vp8 38 | CodecTag?: string // hev1, hvc1, av01 39 | Language: string, // eng or ger 40 | TimeBase: string, // 1/1000 41 | DisplayTitle: string, // 1080p H264 SDR or Ger - DTS - 5.1 - Default 42 | IsInterlaced: boolean, // false 43 | IsAVC: boolean, // false 44 | BitRate: number, // 1536000 45 | IsDefault: boolean, // true 46 | IsForced: boolean, // false 47 | IsHearingImpaired: boolean, // false 48 | Profile: string, // High for movie codec or DTS for audio 49 | Type: string, // Video or Audio or Data 50 | Index: number, // 0 (array index position in MediaStreams) 51 | IsExternal: boolean, // false 52 | IsTextSubtitleStream: boolean, // false 53 | SupportsExternalStream: boolean, // false 54 | Level: number // For video: 41 or 0 for audio 55 | // audio 56 | ChannelLayout: string, // 5.1 57 | Channels: number, // 6 58 | SampleRate: number, // 48000 59 | // video 60 | VideoRange: string, // SDR 61 | VideoRangeType: string, //SDR 62 | ColorPrimaries?: string, 63 | ColorSpace?: string, // h264 -> "bt709" 64 | ColorTransfer?: string, // h264 -> "bt709" 65 | NalLengthSize: number, // 4 66 | BitDepth: number, // 8 67 | RefFrames: number, // 1 68 | Height: number, // 1080 69 | Width: number, // 1920 70 | AverageFrameRate: number, // 25 71 | RealFrameRate: number, // 25 72 | AspectRatio: string, // 16:9 73 | PixelFormat: string, // yuv420p 74 | } 75 | 76 | export interface ImageBlurHashesDto { 77 | Backdrop: { 78 | id: string 79 | } 80 | Primary: { 81 | id: string 82 | } 83 | Logo: { 84 | id: string 85 | } 86 | } 87 | 88 | export interface ItemDto { 89 | Name: string, // Episode 1, Season 1, Movie name 90 | serverId: string, 91 | Id: string, 92 | PremiereDate: Date, 93 | OfficialRating: string, 94 | ChannelId: null | string, 95 | CommunityRating: number, 96 | RunTimeTicks: number, 97 | ProductionYear: number, 98 | MediaStreams: BaseMediaStream[], 99 | Container: string, // matroska,webm 100 | IsFolder: boolean, 101 | Type: ItemType, 102 | UserData: UserData, 103 | Status: string, 104 | ImageBlurHashes: ImageBlurHashesDto, 105 | LocationType: string, // Filesystem 106 | // Season Dto 107 | IndexNumber?: number, 108 | ParentLogoItemId?: string, 109 | ParentBackdropItemId?: string, 110 | ParentBackdropImageTags?: [ 111 | string 112 | ], 113 | SeriesName?: string, 114 | SeriesId?: string, 115 | SeriesPrimaryImageTag?: string, 116 | ImageTags?: { 117 | Primary?: string 118 | }, 119 | BackdropImageTags?: [string], 120 | ParentLogoImageTag?: string 121 | // EpisodeDto 122 | ParentIndexNumber?: number, 123 | SeasonId?: string, 124 | SeasonName?: string, // Staffel 1 125 | VideoType?: string, // VideoFile 126 | MediaType?: string // Video 127 | // MusicAlbum 128 | AlbumArtist?: string, 129 | AlbumArtists?: object[], 130 | ArtistItems?: object[] 131 | 132 | } 133 | 134 | export interface VirtualFolderDto { 135 | Name: string, // Name of folder 136 | Locations: [ 137 | string 138 | ], 139 | CollectionType: string, // movies, tvshows 140 | LibraryOptions: { 141 | EnablePhotos: boolean, 142 | EnableRealtimeMonitor: boolean, 143 | EnableChapterImageExtraction: boolean, 144 | ExtractChapterImagesDuringLibraryScan: boolean, 145 | PathInfos: [ 146 | { 147 | Path: string 148 | } 149 | ], 150 | SaveLocalMetadata: boolean, 151 | EnableInternetProviders: boolean, 152 | EnableAutomaticSeriesGrouping: boolean, 153 | EnableEmbeddedTitles: boolean, 154 | EnableEmbeddedExtrasTitles: boolean, 155 | EnableEmbeddedEpisodeInfos: boolean, 156 | AutomaticRefreshIntervalDays: number, 157 | PreferredMetadataLanguage: string, // de 158 | MetadataCountryCode: string, // DE 159 | SeasonZeroDisplayName: string, // Specials 160 | MetadataSavers: [string], 161 | DisabledLocalMetadataReaders: [string], 162 | LocalMetadataReaderOrder: [ // nfo 163 | string 164 | ], 165 | DisabledSubtitleFetchers: [string], 166 | SubtitleFetcherOrder: [string], 167 | SkipSubtitlesIfEmbeddedSubtitlesPresent: boolean, 168 | SkipSubtitlesIfAudioTrackMatches: boolean, 169 | SubtitleDownloadLanguages: [string], 170 | RequirePerfectSubtitleMatch: boolean, 171 | SaveSubtitlesWithMedia: boolean, 172 | AutomaticallyAddToCollection: boolean, 173 | AllowEmbeddedSubtitles: string, // AllowAll 174 | TypeOptions: [ 175 | { 176 | Type: string, // Movies, Episode, Season, Series 177 | MetadataFetchers: [ // TheMovieDb, The Open Movie Database 178 | string 179 | ], 180 | MetadataFetcherOrder: [ 181 | string 182 | ], 183 | ImageFetchers: [ 184 | string 185 | ], 186 | ImageFetcherOrder: [ 187 | string 188 | ], 189 | ImageOptions: [] 190 | } 191 | ] 192 | }, 193 | ItemId: string, // the itemID 194 | PrimaryImageItemId: string, 195 | RefreshStatus: string // Idle 196 | } 197 | 198 | /** 199 | * Enum ImageType. 200 | * @export 201 | * @enum {string} 202 | */ 203 | export enum ImageType { 204 | Primary = 'Primary', 205 | Art = 'Art', 206 | Backdrop = 'Backdrop', 207 | Banner = 'Banner', 208 | Logo = 'Logo', 209 | Thumb = 'Thumb', 210 | Disc = 'Disc', 211 | Box = 'Box', 212 | Screenshot = 'Screenshot', 213 | Menu = 'Menu', 214 | Chapter = 'Chapter', 215 | BoxRear = 'BoxRear', 216 | Profile = 'Profile', 217 | } 218 | -------------------------------------------------------------------------------- /src/layouts/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 32 | -------------------------------------------------------------------------------- /src/pages/AlbumPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /src/pages/ArtistPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /src/pages/ErrorNotFound.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 28 | -------------------------------------------------------------------------------- /src/pages/IndexPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /src/pages/PlayerPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /src/pages/SeriesPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { route } from 'quasar/wrappers'; 2 | import { 3 | createMemoryHistory, 4 | createRouter, 5 | createWebHashHistory, 6 | createWebHistory, 7 | } from 'vue-router'; 8 | 9 | import routes from './routes'; 10 | 11 | /* 12 | * If not building with SSR mode, you can 13 | * directly export the Router instantiation; 14 | * 15 | * The function below can be async too; either use 16 | * async/await or return a Promise which resolves 17 | * with the Router instance. 18 | */ 19 | 20 | export default route(function (/* { store, ssrContext } */) { 21 | const createHistory = process.env.SERVER 22 | ? createMemoryHistory 23 | : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory); 24 | 25 | const Router = createRouter({ 26 | scrollBehavior(to, from, savedPosition) { 27 | if (savedPosition) { 28 | return savedPosition 29 | } else { 30 | return { top: 0 } 31 | } 32 | }, 33 | routes, 34 | 35 | // Leave this as is and make changes in quasar.conf.js instead! 36 | // quasar.conf.js -> build -> vueRouterMode 37 | // quasar.conf.js -> build -> publicPath 38 | history: createHistory(process.env.VUE_ROUTER_BASE), 39 | }); 40 | 41 | return Router; 42 | }); 43 | -------------------------------------------------------------------------------- /src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw } from 'vue-router'; 2 | 3 | const routes: RouteRecordRaw[] = [ 4 | { 5 | path: '/', 6 | component: () => import('layouts/MainLayout.vue'), 7 | children: [ 8 | { path: '', component: () => import('pages/IndexPage.vue') }, 9 | { path: 'player/:itemId', component: () => import('pages/PlayerPage.vue') }, 10 | { path: 'series/:itemId', component: () => import('pages/SeriesPage.vue') }, 11 | { path: 'artist/:itemId', component: () => import('pages/ArtistPage.vue') }, 12 | { path: 'album/:itemId', component: () => import('pages/AlbumPage.vue') } 13 | 14 | 15 | ], 16 | }, 17 | 18 | // Always leave this as last one, 19 | // but you can also remove it 20 | { 21 | path: '/:catchAll(.*)*', 22 | component: () => import('pages/ErrorNotFound.vue'), 23 | }, 24 | ]; 25 | 26 | export default routes; 27 | -------------------------------------------------------------------------------- /src/stores/api.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref } from 'vue' 3 | 4 | export const useApiStore = defineStore('api', () => { 5 | const apiKey = ref(undefined) 6 | const serverAddress = ref('http://localhost:8096') 7 | const validConnection = ref(false) 8 | const validAuth = ref(false) 9 | 10 | let pluginAuthHeader: HeadersInit | undefined = undefined 11 | 12 | // When we run as plugin we inject address and auth 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | // @ts-ignore 15 | if ((window.ApiClient as any)) { 16 | console.log('Running as Jellyfin Plugin') 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | serverAddress.value = ApiClient.serverAddress() 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 21 | // @ts-ignore 22 | pluginAuthHeader = { 'MediaBrowser Token': ApiClient.accessToken() } 23 | } 24 | 25 | const fetchWithAuth = async (endpoint: string, query?: Map) => { 26 | const reqInit: RequestInit = { 27 | method: 'GET', 28 | headers: pluginAuthHeader, 29 | }; 30 | 31 | const response = await fetch(buildUrl(endpoint, query), reqInit); 32 | return response; 33 | } 34 | 35 | /** 36 | * Builds a final url from endpoint and query with server address and auth 37 | * @param endpoint endpoint to reach 38 | * @param query optoinal query 39 | * @returns new url 40 | */ 41 | const buildUrl = (endpoint: string, query?: Map): RequestInfo => { 42 | let queryString = '' 43 | if (!pluginAuthHeader) 44 | queryString = `&ApiKey=${apiKey.value}`; 45 | 46 | query?.forEach((value, key) => queryString += `&${key}=${value}`) 47 | if (queryString.length > 1) 48 | queryString = '?' + queryString.slice(1); 49 | 50 | return `${serverAddress.value}/${endpoint}${queryString}` 51 | } 52 | 53 | /** 54 | * Send post message to server 55 | * @param body the body to post 56 | * @param query the query 57 | */ 58 | const postJson = async (endpoint: string, body?: string | any, query?: Map) => { 59 | let headers: HeadersInit = { 60 | 'Content-Type': 'application/json' 61 | } 62 | if (body) { 63 | body = JSON.stringify(body) 64 | } 65 | 66 | if (pluginAuthHeader) { 67 | headers = Object.assign(headers, pluginAuthHeader) 68 | } 69 | 70 | const reqInit: RequestInit = { 71 | method: 'POST', 72 | headers: headers, 73 | body: body, 74 | }; 75 | const response = await fetch(buildUrl(endpoint, query), reqInit); 76 | 77 | // filter for broken access 78 | if (response.status == 401) { 79 | validAuth.value = false 80 | } 81 | 82 | // filter for broken request 83 | if (response.status == 400) { 84 | console.error('post response', response) 85 | return 86 | } 87 | 88 | let jsonData 89 | try { 90 | jsonData = await response.json(); 91 | } catch (error) { 92 | 93 | } 94 | return jsonData; 95 | } 96 | 97 | /** 98 | * Send delete message to server 99 | * @param body the body to post 100 | */ 101 | const deleteJson = async (endpoint: string, body?: string, query?: Map) => { 102 | if (body != undefined) { 103 | body = JSON.stringify(body) 104 | } 105 | const reqInit: RequestInit = { 106 | method: 'DELETE', 107 | body: body, 108 | headers: pluginAuthHeader, 109 | }; 110 | const response = await fetch(buildUrl(endpoint, query), reqInit); 111 | 112 | // filter for broken access 113 | if (response.status == 401) { 114 | validAuth.value = false 115 | } 116 | 117 | validConnection.value = response.ok 118 | } 119 | 120 | // Test if connection and auth is valid 121 | const testConnection = async () => { 122 | let response; 123 | try { 124 | response = await fetchWithAuth('System/Info'); 125 | } catch (error) { 126 | console.error('testConnection Error', error) 127 | validConnection.value = false 128 | validAuth.value = false 129 | return false 130 | } 131 | 132 | validAuth.value = response.status != 401 133 | validConnection.value = response.ok 134 | 135 | return response.ok && validAuth.value 136 | } 137 | 138 | const fetchWithAuthJson = async (endpoint: string, query?: Map) => { 139 | const response = await fetchWithAuth(endpoint, query); 140 | // filter for broken access 141 | if (response.status == 401) { 142 | validAuth.value = false 143 | return []; 144 | } 145 | // plugin endpoint tests 146 | if (response.status == 404) { 147 | return false; 148 | } 149 | 150 | const jsonData = await response.json(); 151 | return jsonData; 152 | } 153 | 154 | return { apiKey, serverAddress, validConnection, validAuth, fetchWithAuthJson, fetchWithAuth, testConnection, postJson, deleteJson, buildUrl } 155 | }) 156 | -------------------------------------------------------------------------------- /src/stores/app.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { defineStore } from 'pinia' 3 | import { ref, watch } from 'vue' 4 | import { useQuasar } from 'quasar' 5 | import { useLocales } from 'src/composables/locales' 6 | 7 | export const useAppStore = defineStore('app', () => { 8 | const $q = useQuasar() 9 | const { handleLocaleChange, SUPPORTED_LOCALES } = useLocales() 10 | 11 | const selectedLocale = ref('auto') 12 | const themeIndex = ref(1) 13 | const showVideoPlayer = ref(true) 14 | const enableEdl = ref(true) 15 | 16 | const setTheme = () => { 17 | if (!themeIndex.value) { 18 | $q.dark.set('auto') 19 | } else { 20 | $q.dark.set(themeIndex.value == 1) 21 | } 22 | } 23 | 24 | // watch user theme changes 25 | watch(themeIndex, setTheme) 26 | 27 | // locale handling 28 | const setLocale = () => { 29 | handleLocaleChange(selectedLocale.value) 30 | } 31 | // watch user lang changes 32 | watch(selectedLocale, setLocale) 33 | 34 | return { selectedLang: selectedLocale, themeIndex, showVideoPlayer, enableEdl, setTheme, setLocale, SUPPORTED_LOCALES } 35 | }) 36 | -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { store } from 'quasar/wrappers' 2 | import { createPinia } from 'pinia' 3 | import { Router } from 'vue-router'; 4 | import { createPersistedState } from 'pinia-plugin-persistedstate' 5 | 6 | const persistedState = createPersistedState({ 7 | auto: true, 8 | }) 9 | 10 | /* 11 | * When adding new properties to stores, you should also 12 | * extend the `PiniaCustomProperties` interface. 13 | * @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties 14 | */ 15 | declare module 'pinia' { 16 | export interface PiniaCustomProperties { 17 | readonly router: Router; 18 | } 19 | } 20 | 21 | /* 22 | * If not building with SSR mode, you can 23 | * directly export the Store instantiation; 24 | * 25 | * The function below can be async too; either use 26 | * async/await or return a Promise which resolves 27 | * with the Store instance. 28 | */ 29 | 30 | export default store((/* { ssrContext } */) => { 31 | const pinia = createPinia() 32 | 33 | // You can add Pinia plugins here 34 | pinia.use(persistedState) 35 | 36 | return pinia 37 | }) 38 | -------------------------------------------------------------------------------- /src/stores/items.ts: -------------------------------------------------------------------------------- 1 | import { defineStore, storeToRefs } from 'pinia' 2 | import { ref, watch, computed } from 'vue' 3 | import { useApi } from 'src/composables/api' 4 | import { useApiStore } from './api' 5 | import { ItemDto, ItemType, VirtualFolderDto } from 'src/interfaces' 6 | 7 | export const useItemsStore = defineStore('items', () => { 8 | const { getItems, getCollections } = useApi() 9 | const apiStore = useApiStore() 10 | const { validConnection, validAuth } = storeToRefs(apiStore) 11 | 12 | const collections = ref>([]) 13 | const localItems = ref>([]) 14 | 15 | const selectedCol = ref('') 16 | const TotalRecordCount = ref(0) 17 | const StartIndex = ref(0) 18 | const filterName = ref('') 19 | 20 | const getCollectionss = async () => { 21 | const coll = await getCollections() 22 | collections.value = coll; 23 | 24 | if (coll.length) 25 | selectedCol.value = coll[0].ItemId 26 | } 27 | 28 | // reset localItems and get new one 29 | const getNewItems = async () => { 30 | const items = await getItems(selectedCol.value, 0) 31 | 32 | TotalRecordCount.value = items.TotalRecordCount 33 | StartIndex.value = items.StartIndex 34 | localItems.value.splice(0) 35 | localItems.value.push(...items.Items) 36 | } 37 | 38 | // push more items to localItems 39 | const pushMoreItems = (items: ItemDto[]) => { 40 | for (const item of items) { 41 | if (localItems.value.findIndex((el) => item.Id == el.Id) == -1) { 42 | localItems.value.push(item) 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * Get more items if possible 49 | */ 50 | const getMoreItems = async () => { 51 | if (TotalRecordCount.value > localItems.value.length) { 52 | const items = await getItems(selectedCol.value, StartIndex.value) 53 | StartIndex.value += 1 54 | localItems.value.push(...items.Items) 55 | } 56 | } 57 | // Transform Collection Type to Item Type 58 | const collectionToType = computed(() => { 59 | const col = collections.value.find((col: VirtualFolderDto) => col.ItemId == selectedCol.value) 60 | 61 | switch (col?.CollectionType) { 62 | case 'movies': 63 | return ItemType.Movie 64 | case 'tvshows': 65 | return ItemType.Series 66 | case 'music': 67 | return ItemType.MusicArtist 68 | default: 69 | return '' 70 | } 71 | 72 | }) 73 | 74 | // apply items filter 75 | const filteredItems = computed(() => localItems.value.filter((item) => collectionToType.value == item.Type && item.Name.toLowerCase().includes(filterName.value.toLowerCase()))) 76 | 77 | // Get and reset items whenever connection is io or collection changed 78 | watch([selectedCol, validConnection, validAuth], ([selcol, connection, auth]) => { 79 | 80 | if (connection && auth) { 81 | // on intial startup collections are empty. This is already in App.vue 82 | if (selcol == '') 83 | getCollectionss(); 84 | 85 | getNewItems() 86 | } 87 | }) 88 | 89 | return { localItems, collections, selectedCol, filteredItems, filterName, getCollectionss, getMoreItems, pushMoreItems } 90 | }) 91 | -------------------------------------------------------------------------------- /src/stores/modal.ts: -------------------------------------------------------------------------------- 1 | // stores/modal.ts 2 | 3 | import { markRaw } from 'vue'; 4 | import { defineStore } from 'pinia'; 5 | 6 | export type Modal = { 7 | isOpen: boolean, 8 | view: object, 9 | actions?: ModalAction[], 10 | viewProps?: object, 11 | }; 12 | 13 | export type ModalAction = { 14 | label: string, 15 | callback: (props?: any) => void, 16 | }; 17 | 18 | export const useModalStore = defineStore('modal', { 19 | state: (): Modal => ({ 20 | isOpen: false, 21 | view: {}, 22 | actions: [], 23 | viewProps: {} 24 | }), 25 | actions: { 26 | init(view: object, actions?: ModalAction[], props?: object) { 27 | this.actions = actions; 28 | // using markRaw to avoid over performance as reactive is not required 29 | this.view = markRaw(view); 30 | this.viewProps = props; 31 | }, 32 | open() { 33 | this.isOpen = true; 34 | }, 35 | close() { 36 | this.isOpen = false; 37 | this.view = {}; 38 | this.actions = []; 39 | this, this.viewProps = {}; 40 | }, 41 | }, 42 | persist: false, 43 | }); 44 | 45 | export default useModalStore; 46 | -------------------------------------------------------------------------------- /src/stores/plugin.ts: -------------------------------------------------------------------------------- 1 | import { defineStore, storeToRefs } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { useApiStore } from './api'; 4 | import { usePluginEdlApi } from 'src/composables/pluginEdlApi'; 5 | import { usePluginMediaSegmentsApi } from 'src/composables/pluginMediaSegmentsApi'; 6 | import { useAppStore } from './app'; 7 | 8 | 9 | export const usePluginStore = defineStore('plugin', () => { 10 | const apiStore = useApiStore() 11 | const appStore = useAppStore() 12 | const { getEdlPluginMeta } = usePluginEdlApi() 13 | const { getMediaSegmentsApiPluginMeta } = usePluginMediaSegmentsApi() 14 | const { validAuth } = storeToRefs(apiStore) 15 | const { enableEdl } = storeToRefs(appStore) 16 | 17 | 18 | const pluginSegmentsApiInstalled = ref(false) 19 | const pluginSegmentsApiVersion = ref('0.0.0') 20 | 21 | const pluginEdlInstalled = ref(false) 22 | const pluginEdlVersion = ref('0.0.0') 23 | 24 | // Test for installed server Plugins 25 | const testServerPlugins = async () => { 26 | testMediaSegmentsApi() 27 | testEdl() 28 | } 29 | 30 | const testMediaSegmentsApi = async () => { 31 | let response; 32 | try { 33 | response = await getMediaSegmentsApiPluginMeta(); 34 | 35 | } catch (error) { 36 | console.error('testPluginSegmentsApi Error', error) 37 | return false 38 | } 39 | if (response && response.version) { 40 | pluginSegmentsApiInstalled.value = true 41 | pluginSegmentsApiVersion.value = response.version 42 | return 43 | } 44 | pluginSegmentsApiInstalled.value = false 45 | pluginSegmentsApiVersion.value = '0.0.0' 46 | } 47 | 48 | const testEdl = async () => { 49 | let response; 50 | try { 51 | response = await getEdlPluginMeta(); 52 | 53 | } catch (error) { 54 | console.error('testEdl Error', error) 55 | return false 56 | } 57 | if (response && response.version) { 58 | pluginEdlInstalled.value = true 59 | pluginEdlVersion.value = response.version 60 | return 61 | } 62 | pluginEdlInstalled.value = false 63 | pluginEdlVersion.value = '0.0.0' 64 | } 65 | 66 | // check if we should show edl handling 67 | const showEdlBtn = () => { 68 | return pluginEdlInstalled.value && enableEdl.value 69 | } 70 | 71 | // When auth changed check for server plugins state 72 | watch([validAuth], ([auth]) => { 73 | 74 | if (auth) { 75 | testServerPlugins() 76 | } 77 | }) 78 | 79 | 80 | 81 | return { pluginSegmentsApiInstalled, pluginSegmentsApiVersion, pluginEdlInstalled, pluginEdlVersion, showEdlBtn, testServerPlugins } 82 | }, { 83 | persist: { 84 | storage: sessionStorage, 85 | }, 86 | }) 87 | -------------------------------------------------------------------------------- /src/stores/segments.ts: -------------------------------------------------------------------------------- 1 | import { useSegmentApi } from 'src/composables/segmentApi' 2 | import { ItemDto, MediaSegment } from 'src/interfaces' 3 | import { defineStore } from 'pinia' 4 | import { ref } from 'vue' 5 | 6 | export const useSegmentsStore = defineStore('segments', () => { 7 | const sapi = useSegmentApi() 8 | const localSegments = ref>([]) 9 | 10 | /** 11 | * Gather new segments by id from server, deletes local cached ones 12 | */ 13 | const getNewSegmentsById = async (itemId: ItemDto['Id']) => { 14 | const segments = await sapi.getSegmentsById(itemId) 15 | // simply filter and replace array 16 | localSegments.value = localSegments.value.filter((seg: MediaSegment) => seg.ItemId != itemId) 17 | // push new segments 18 | localSegments.value.push(...segments.Items) 19 | } 20 | 21 | /** 22 | * Save/update segments local and server 23 | * @param segment MediaSegment to save 24 | */ 25 | const saveSegment = async (segment: MediaSegment) => { 26 | // update local and server 27 | localSegments.value.push(segment) 28 | // stringify and parse (seconds to ticks creates reactivity) 29 | const offseg = JSON.parse(JSON.stringify(segment)) 30 | // remove id 31 | //offseg.Id = undefined; 32 | sapi.createSegment(offseg) 33 | 34 | // FIXME: Returned elements have 0000000000000 as Id. When you fetch it's correct. EF Core doesn't seem to return the id after save?! 35 | // Solution: When a segment is created we generate a new uuid, the database accepts them as long they are unique 36 | } 37 | 38 | /** 39 | * Update segment 40 | * @param segment Modified segment 41 | */ 42 | const saveUpdatedSegment = (segment: MediaSegment) => { 43 | // check if segment is already available, if so remove it 44 | const found = localSegments.value.findIndex((seg: MediaSegment) => seg.Id) 45 | if (found > -1) { 46 | localSegments.value.splice(found, 1) 47 | } 48 | saveSegment(segment) 49 | } 50 | 51 | /** 52 | * New segment to save 53 | * @param segment New segment 54 | */ 55 | const saveNewSegment = (segment: MediaSegment) => { 56 | saveSegment(segment) 57 | } 58 | 59 | /** 60 | * New segments to save 61 | * @param segments New segments 62 | */ 63 | const saveNewSegments = (segments: MediaSegment[]) => { 64 | for (const seg of segments) { 65 | saveNewSegment(seg) 66 | } 67 | } 68 | 69 | /** 70 | * Delete segment 71 | * @param segment segment to delete 72 | */ 73 | const deleteSegment = (segment: MediaSegment) => { 74 | // check if segment is available, if so remove it 75 | const found = localSegments.value.findIndex((seg: MediaSegment) => seg.Id == segment.Id) 76 | if (found > -1) { 77 | localSegments.value.splice(found, 1) 78 | } 79 | sapi.deleteSegment(segment) 80 | } 81 | 82 | /** 83 | * Delete all segments with itemid 84 | * @param itemId itemid of segments 85 | */ 86 | const deleteSegments = (itemId: ItemDto['Id']) => { 87 | const found = localSegments.value.filter((seg: MediaSegment) => seg.ItemId == itemId) 88 | // simply filter and replace array 89 | localSegments.value = localSegments.value.filter((seg: MediaSegment) => seg.ItemId != itemId) 90 | 91 | found.forEach(el => { 92 | sapi.deleteSegment(el) 93 | }); 94 | 95 | 96 | } 97 | 98 | 99 | return { saveUpdatedSegment, saveNewSegment, saveNewSegments, deleteSegment, deleteSegments, getNewSegmentsById, localSegments } 100 | }) 101 | -------------------------------------------------------------------------------- /src/stores/session.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { MediaSegment } from 'src/interfaces' 3 | import { ref } from 'vue' 4 | 5 | export const useSessionStore = defineStore('session', () => { 6 | const users = ref(undefined) 7 | const openOptions = ref(false) 8 | const seriesAccordionInd = ref(0) 9 | const segmentClipboard = ref() 10 | 11 | const saveSegmentToClipboard = (seg: MediaSegment) => { 12 | segmentClipboard.value = JSON.parse(JSON.stringify(seg)) 13 | } 14 | 15 | const getFromSegmentClipboard = () => { 16 | if (segmentClipboard.value) 17 | return JSON.parse(JSON.stringify(segmentClipboard.value)) 18 | return undefined 19 | } 20 | 21 | return { users, openOptions, seriesAccordionInd, saveSegmentToClipboard, getFromSegmentClipboard } 22 | }, 23 | { 24 | persist: false, 25 | }) 26 | -------------------------------------------------------------------------------- /src/stores/store-flag.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED, 3 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING 4 | import "quasar/dist/types/feature-flag"; 5 | 6 | declare module "quasar/dist/types/feature-flag" { 7 | interface QuasarFeatureFlags { 8 | store: true; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@quasar/app-vite/tsconfig-preset", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | } 6 | } 7 | --------------------------------------------------------------------------------