├── .devcontainer ├── devcontainer.json └── postunpack.sh ├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.yaml │ └── config.yml ├── labeler.yml ├── renovate.json └── workflows │ ├── TODO.md │ ├── __automation.yml │ ├── __codeql.yml │ ├── __deploy.yml │ ├── __job_messages.yml │ ├── __package.yml │ ├── __quality_checks.yml │ ├── pull_request.yml │ ├── push_release.yml │ └── schedule.yml ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── frontend ├── eslint.config.js ├── index.html ├── locales │ ├── ar.json │ ├── ca.json │ ├── cs.json │ ├── da.json │ ├── de.json │ ├── el.json │ ├── en.json │ ├── eo.json │ ├── es.json │ ├── et.json │ ├── fi.json │ ├── fil.json │ ├── fr.json │ ├── he.json │ ├── hu.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── kk.json │ ├── ko.json │ ├── lt.json │ ├── ml.json │ ├── mn.json │ ├── nb-NO.json │ ├── nl.json │ ├── nn.json │ ├── pa.json │ ├── pl.json │ ├── pt-BR.json │ ├── pt.json │ ├── ro.json │ ├── ru.json │ ├── sk.json │ ├── sl.json │ ├── sr-Latn.json │ ├── sv.json │ ├── ta.json │ ├── th.json │ ├── tr.json │ ├── uk.json │ ├── ur.json │ ├── vi.json │ ├── zh-CN.json │ └── zh-TW.json ├── package.json ├── public │ ├── config.json │ ├── favicon.ico │ ├── icon.svg │ └── robots.txt ├── scripts │ ├── paths.ts │ ├── plugins │ │ ├── analysis.ts │ │ └── chunking.ts │ └── virtual-modules.ts ├── src │ ├── App.vue │ ├── assets │ │ └── styles │ │ │ ├── global.css │ │ │ ├── index.css │ │ │ └── splashscreen.css │ ├── components │ │ ├── Buttons │ │ │ ├── FilterButton.vue │ │ │ ├── LikeButton.vue │ │ │ ├── MarkPlayedButton.vue │ │ │ ├── Playback │ │ │ │ ├── NextTrackButton.vue │ │ │ │ ├── PlayButton.vue │ │ │ │ ├── PlayPauseButton.vue │ │ │ │ ├── PlaybackSettingsButton.vue │ │ │ │ ├── PreviousTrackButton.vue │ │ │ │ ├── RepeatButton.vue │ │ │ │ └── ShuffleButton.vue │ │ │ ├── QueueButton.vue │ │ │ ├── ScrollToTopButton.vue │ │ │ ├── SortButton.vue │ │ │ ├── SubtitleSelectionButton.vue │ │ │ └── TypeButton.vue │ │ ├── Dialogs │ │ │ ├── ConfirmDialog.vue │ │ │ └── GenericDialog.vue │ │ ├── Forms │ │ │ ├── AddServerForm.vue │ │ │ └── LoginForm.vue │ │ ├── Item │ │ │ ├── Card │ │ │ │ ├── GenericItemCard.vue │ │ │ │ ├── ItemCard.vue │ │ │ │ └── ServerCard.vue │ │ │ ├── CollectionTabs.vue │ │ │ ├── Identify │ │ │ │ ├── IdentifyDialog.vue │ │ │ │ └── IdentifyResults.vue │ │ │ ├── ItemGrid.vue │ │ │ ├── ItemMenu.vue │ │ │ ├── MediaDetail │ │ │ │ ├── MediaDetailAttr.vue │ │ │ │ ├── MediaDetailContent.vue │ │ │ │ └── MediaDetailDialog.vue │ │ │ ├── MediaInfo.vue │ │ │ ├── MediaSourceSelector.vue │ │ │ ├── MediaStreamSelector.vue │ │ │ ├── Metadata │ │ │ │ ├── DateInput.vue │ │ │ │ ├── ImageEditor.vue │ │ │ │ ├── ImageSearch.vue │ │ │ │ ├── MetadataEditor.vue │ │ │ │ ├── MetadataEditorDialog.vue │ │ │ │ ├── PersonEditor.vue │ │ │ │ └── RefreshMetadataDialog.vue │ │ │ ├── PeopleList.vue │ │ │ ├── RelatedItems.vue │ │ │ ├── SeasonTabs.vue │ │ │ └── WatchedIndicator.vue │ │ ├── Layout │ │ │ ├── AppBar │ │ │ │ ├── AppBar.vue │ │ │ │ ├── AppBarButtonLayout.vue │ │ │ │ ├── Buttons │ │ │ │ │ ├── CastButton.vue │ │ │ │ │ ├── TaskManagerButton.vue │ │ │ │ │ └── UserButton.vue │ │ │ │ └── SearchField.vue │ │ │ ├── Artist │ │ │ │ └── ArtistTab.vue │ │ │ ├── AudioControls.vue │ │ │ ├── Backdrop.vue │ │ │ ├── Carousel │ │ │ │ ├── Carousel.vue │ │ │ │ ├── CarouselProgressBar.vue │ │ │ │ └── Item │ │ │ │ │ ├── ItemsCarousel.vue │ │ │ │ │ └── ItemsCarouselTitle.vue │ │ │ ├── Images │ │ │ │ ├── Blurhash │ │ │ │ │ ├── BlurhashCanvas.vue │ │ │ │ │ ├── BlurhashImage.vue │ │ │ │ │ └── BlurhashImageIcon.vue │ │ │ │ └── UserImage.vue │ │ │ ├── ItemCols.vue │ │ │ ├── Navigation │ │ │ │ ├── CommitLink.vue │ │ │ │ └── NavigationDrawer.vue │ │ │ ├── SettingsPage.vue │ │ │ ├── SwiperSection.vue │ │ │ ├── TimeSlider.vue │ │ │ └── VolumeSlider.vue │ │ ├── Playback │ │ │ ├── DraggableQueue.vue │ │ │ ├── MiniVideoPlayer.vue │ │ │ ├── MusicVisualizer.vue │ │ │ ├── PlayerElement.vue │ │ │ ├── SubtitleTrack.vue │ │ │ ├── TrackList.vue │ │ │ └── UpNext.vue │ │ ├── Selectors │ │ │ └── FontSelector.vue │ │ ├── Skeletons │ │ │ ├── SkeletonCard.vue │ │ │ └── SkeletonItemGrid.vue │ │ ├── System │ │ │ ├── AboutLinks.vue │ │ │ ├── AddApiKey.vue │ │ │ ├── LoadingIndicator.vue │ │ │ ├── LocaleSwitcher.vue │ │ │ └── Snackbar.vue │ │ ├── Users │ │ │ └── UserCard.vue │ │ ├── Wizard │ │ │ ├── WizardAdminAccount.vue │ │ │ ├── WizardLanguage.vue │ │ │ ├── WizardMetadata.vue │ │ │ └── WizardRemoteAccess.vue │ │ └── lib │ │ │ ├── JApp.vue │ │ │ ├── JHover.vue │ │ │ ├── JImg.vue │ │ │ ├── JSafeHtml.vue │ │ │ ├── JSlot.vue │ │ │ ├── JSplashscreen.vue │ │ │ ├── JTransition.vue │ │ │ ├── JView.vue │ │ │ └── JVirtual │ │ │ ├── JVirtual.vue │ │ │ ├── j-virtual.worker.ts │ │ │ └── pipeline.ts │ ├── composables │ │ ├── apis.ts │ │ ├── backdrop.ts │ │ ├── page-title.ts │ │ ├── use-confirm-dialog.ts │ │ ├── use-datefns.ts │ │ ├── use-loading.ts │ │ ├── use-pausable-effect.ts │ │ ├── use-playback.ts │ │ ├── use-responsive-classes.ts │ │ └── use-snackbar.ts │ ├── layouts │ │ ├── default.vue │ │ ├── fullpage.vue │ │ └── server.vue │ ├── main.ts │ ├── pages │ │ ├── artist │ │ │ └── [itemId].vue │ │ ├── genre │ │ │ └── [itemId].vue │ │ ├── index.vue │ │ ├── item │ │ │ └── [itemId].vue │ │ ├── library │ │ │ └── [itemId].vue │ │ ├── metadata.vue │ │ ├── musicalbum │ │ │ └── [itemId].vue │ │ ├── person │ │ │ └── [itemId].vue │ │ ├── playback │ │ │ ├── music.vue │ │ │ └── video.vue │ │ ├── search.vue │ │ ├── series │ │ │ └── [itemId].vue │ │ ├── server │ │ │ ├── add.vue │ │ │ ├── login.vue │ │ │ └── select.vue │ │ ├── settings │ │ │ ├── apikeys.vue │ │ │ ├── devices.vue │ │ │ ├── index.vue │ │ │ ├── logs-and-activity.vue │ │ │ ├── subtitles.vue │ │ │ └── users │ │ │ │ ├── [id].vue │ │ │ │ ├── index.vue │ │ │ │ └── new.vue │ │ └── wizard.vue │ ├── plugins │ │ ├── directives.ts │ │ ├── i18n.ts │ │ ├── remote │ │ │ ├── auth.ts │ │ │ ├── axios.ts │ │ │ ├── index.ts │ │ │ ├── sdk │ │ │ │ ├── index.ts │ │ │ │ └── sdk-utils.ts │ │ │ ├── socket.ts │ │ │ └── types.d.ts │ │ ├── router │ │ │ ├── index.ts │ │ │ └── middlewares │ │ │ │ ├── admin-pages.ts │ │ │ │ ├── login.ts │ │ │ │ ├── meta.ts │ │ │ │ ├── playback.ts │ │ │ │ └── validate.ts │ │ ├── vuetify.ts │ │ └── workers │ │ │ ├── blurhash-decoder.worker.ts │ │ │ ├── canvas-drawer.worker.ts │ │ │ ├── generic.worker.ts │ │ │ ├── generic │ │ │ └── subtitles.ts │ │ │ └── index.ts │ ├── splashscreen.ts │ ├── store │ │ ├── api.ts │ │ ├── client-settings │ │ │ ├── index.ts │ │ │ └── subtitle-settings.ts │ │ ├── index.ts │ │ ├── playback-manager.ts │ │ ├── player-element.ts │ │ ├── super │ │ │ ├── common-store.ts │ │ │ └── synced-store.ts │ │ └── task-manager.ts │ └── utils │ │ ├── browser-detection.ts │ │ ├── data-manipulation.ts │ │ ├── external-config.ts │ │ ├── forms.ts │ │ ├── html.ts │ │ ├── i18n.ts │ │ ├── images.ts │ │ ├── items.ts │ │ ├── playback-profiles │ │ ├── directplay-profile.ts │ │ ├── helpers │ │ │ ├── audio-formats.ts │ │ │ ├── codec-profiles.ts │ │ │ ├── fmp4-audio-formats.ts │ │ │ ├── fmp4-video-formats.ts │ │ │ ├── hls-formats.ts │ │ │ ├── mp4-audio-formats.ts │ │ │ ├── mp4-video-formats.ts │ │ │ ├── transcoding-formats.ts │ │ │ ├── ts-audio-formats.ts │ │ │ ├── ts-video-formats.ts │ │ │ ├── webm-audio-formats.ts │ │ │ └── webm-video-formats.ts │ │ ├── index.ts │ │ ├── subtitle-profile.ts │ │ └── transcoding-profile.ts │ │ ├── time.ts │ │ └── validation.ts ├── tsconfig.json ├── types │ ├── global │ │ ├── attributes.d.ts │ │ ├── components.d.ts │ │ ├── plugins.d.ts │ │ ├── routes.d.ts │ │ └── util.d.ts │ └── modules │ │ └── virtual.d.ts ├── uno.config.ts └── vite.config.ts ├── package-lock.json ├── package.json └── packaging ├── deb ├── README.md ├── debian │ ├── changelog │ ├── compat │ ├── conffiles │ ├── control │ ├── copyright │ ├── gbp.conf │ ├── install │ ├── po │ │ ├── POTFILES.in │ │ └── templates.pot │ ├── rules │ └── source │ │ ├── format │ │ └── options └── root ├── docker ├── Dockerfile ├── README.md └── contents │ ├── docker-entrypoint.sh │ ├── nginx.conf │ ├── postunpack.sh │ └── setup.sh └── tauri ├── Cargo.lock ├── Cargo.toml ├── README.md ├── apt_packages ├── package.json ├── rustfmt.toml ├── src ├── build.rs └── main.rs └── tauri.conf.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jellyfin-vue Codespace (with support for Tauri and Docker development)", 3 | "image": "node:20-slim", 4 | "features": { 5 | "ghcr.io/devcontainers/features/rust:1": { 6 | "profile": "default" 7 | }, 8 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 9 | "installDockerComposeSwitch": false 10 | }, 11 | "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {}, 12 | "ghcr.io/devcontainers/features/github-cli:1": {} 13 | }, 14 | "forwardPorts": [3000], 15 | "portsAttributes": { 16 | "3000": { 17 | "label": "Vite server", 18 | "onAutoForward": "notify" 19 | } 20 | }, 21 | "postCreateCommand": { 22 | "npm": "npm ci --no-audit", 23 | "no-yarn": "unset YARN_VERSION && rm -rf /opt/yarn*", 24 | "use-bash": "rm -rf /bin/sh && ln -s /bin/bash /bin/sh", 25 | "git-config": "git config --global core.editor 'code --wait'", 26 | "postunpack": ".devcontainer/postunpack.sh" 27 | }, 28 | "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", 29 | "hostRequirements": { "cpus": 4, "memory": "8gb" } 30 | } 31 | -------------------------------------------------------------------------------- /.devcontainer/postunpack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## Install Tauri dependencies 4 | apt update 5 | apt install -y --no-install-recommends $(cat packaging/tauri/apt_packages) 6 | rm -rf /var/lib/apt/lists /var/cache/apt/archives 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: You have noticed a general issue or regression, and would like to report it 3 | labels: [bug] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to fill out this bug report! 10 | Please note that contributor time is limited, and we might not get back to you immediately. 11 | Also make sure to check if [an existing issue](https://github.com/jellyfin/jellyfin-vue/issues) matches yours. 12 | - type: textarea 13 | attributes: 14 | label: Description of the bug 15 | description: Provide a clear and concise description of the bug. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Steps to reproduce 21 | description: Explain how a maintainer can reliably reproduce the bug. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Expected behavior 27 | description: Provide a clear and concise description of what you expected to happen. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Logs 33 | description: If relevant, provide **browser** logs indicating an error. 34 | render: text 35 | - type: textarea 36 | attributes: 37 | label: Screenshots 38 | description: If applicable, add screenshots to help explain your problem. 39 | - type: dropdown 40 | attributes: 41 | label: Platform 42 | options: 43 | - Linux 44 | - Windows 45 | - macOS 46 | - Android 47 | - iOS 48 | validations: 49 | required: true 50 | - type: dropdown 51 | attributes: 52 | label: Browser 53 | options: 54 | - Firefox 55 | - Chrome 56 | - Safari 57 | - Edge 58 | - Other (Please specify in the "additional context" section) 59 | validations: 60 | required: true 61 | - type: input 62 | attributes: 63 | label: Jellyfin server version 64 | placeholder: 10.7.6 65 | validations: 66 | required: true 67 | - type: textarea 68 | attributes: 69 | label: Additional context 70 | description: If necessary, provide any further context or information. 71 | render: text 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request 4 | url: https://features.jellyfin.org/ 5 | about: Please head over to our feature request hub to vote on or submit a feature. 6 | - name: Help Or Question 7 | url: https://matrix.to/#/#jellyfin-troubleshooting:matrix.org 8 | about: Please join the troubleshooting Matrix channel to get some help. 9 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | tests: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - '**/*.spec.*' 5 | 6 | ci: 7 | - changed-files: 8 | - any-glob-to-any-file: 9 | - '.github/workflows/**/**' 10 | 11 | 'frontend:store': 12 | - changed-files: 13 | - any-glob-to-any-file: 14 | - 'frontend/store/**/*.ts' 15 | 16 | 'frontend:plugins': 17 | - changed-files: 18 | - any-glob-to-any-file: 19 | - 'frontend/plugins/**/*.ts' 20 | 21 | vue: 22 | - changed-files: 23 | - any-glob-to-any-file: 24 | - '**/*.vue' 25 | 26 | packaging: 27 | - changed-files: 28 | - any-glob-to-any-file: 29 | - 'packaging/**/**' 30 | 31 | ui: 32 | - changed-files: 33 | - any-glob-to-any-file: 34 | - 'frontend/components/**/*.vue' 35 | - 'frontend/layouts/**/*.vue' 36 | - 'frontend/pages/**/*.vue' 37 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "timezone": "Europe/Madrid", 3 | "packageRules": [ 4 | { 5 | "matchManagers": [ 6 | "npm" 7 | ], 8 | "matchDepTypes": [ 9 | "devDependencies" 10 | ], 11 | "groupName": "npm (development)" 12 | }, 13 | { 14 | "matchManagers": [ 15 | "npm" 16 | ], 17 | "matchDepTypes": [ 18 | "dependencies" 19 | ], 20 | "groupName": "npm (runtime)" 21 | }, 22 | { 23 | "matchManagers": [ 24 | "github-actions" 25 | ], 26 | "separateMajorMinor": false, 27 | "groupName": "ci" 28 | }, 29 | { 30 | "matchFileNames": [ 31 | "packaging/tauri/**" 32 | ], 33 | "separateMajorMinor": false, 34 | "groupName": "tauri" 35 | }, 36 | { 37 | "matchUpdateTypes": [ 38 | "lockFileMaintenance" 39 | ], 40 | "groupName": "lockfiles", 41 | "dependencyDashboardApproval": true 42 | } 43 | ], 44 | "dependencyDashboard": true, 45 | "prConcurrentLimit": 0, 46 | "prHourlyLimit": 0, 47 | "osvVulnerabilityAlerts": true, 48 | "vulnerabilityAlerts": { 49 | "enabled": true, 50 | "groupName": "vulnerable", 51 | "labels": [ 52 | "security", 53 | "dependencies" 54 | ] 55 | }, 56 | "ignoreDeps": [ 57 | "npm", 58 | "node", 59 | "vuetify" 60 | ], 61 | "enabledManagers": [ 62 | "npm", 63 | "github-actions", 64 | "cargo" 65 | ], 66 | "labels": [ 67 | "dependencies" 68 | ], 69 | "rebaseWhen": "behind-base-branch", 70 | "rangeStrategy": "pin", 71 | "lockFileMaintenance": { 72 | "enabled": true, 73 | "groupName": "lockfiles", 74 | "schedule": [ 75 | "every month" 76 | ] 77 | }, 78 | "assignees": [ 79 | "ferferga", 80 | "ThibaultNocchi" 81 | ], 82 | "reviewers": [ 83 | "ferferga", 84 | "ThibaultNocchi" 85 | ], 86 | "extends": [ 87 | "mergeConfidence:age-confidence-badges" 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/TODO.md: -------------------------------------------------------------------------------- 1 | * Reusable workflows should be under a `reusable` folder. Track https://github.com/orgs/community/discussions/10773. Right now, they're prefixed with `__`. 2 | -------------------------------------------------------------------------------- /.github/workflows/__automation.yml: -------------------------------------------------------------------------------- 1 | name: Automation 🎛️ 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | label: 8 | name: Label conflicted PRs 🏷️⛔ 9 | if: ${{ always() && !cancelled() }} 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check all PRs for merge conflicts and label them ⛔ 14 | uses: eps1lon/actions-label-merge-conflict@v3.0.2 15 | with: 16 | dirtyLabel: "merge conflict" 17 | repoToken: ${{ secrets.JF_BOT_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/__codeql.yml: -------------------------------------------------------------------------------- 1 | name: GitHub CodeQL 🔬 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | commit: 7 | required: true 8 | type: string 9 | jobs: 10 | analyze: 11 | name: Analyze ${{ matrix.language }} 🔬 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | language: 18 | - javascript-typescript 19 | 20 | steps: 21 | - name: Checkout repository ⬇️ 22 | uses: actions/checkout@v4.1.7 23 | with: 24 | ref: ${{ inputs.commit }} 25 | show-progress: false 26 | 27 | - name: Initialize CodeQL 🛠️ 28 | uses: github/codeql-action/init@v3.26.6 29 | with: 30 | queries: security-and-quality 31 | languages: ${{ matrix.language }} 32 | 33 | - name: Autobuild 📦 34 | uses: github/codeql-action/autobuild@v3.26.6 35 | 36 | - name: Perform CodeQL Analysis 🧪 37 | uses: github/codeql-action/analyze@v3.26.6 38 | with: 39 | category: '/language:${{matrix.language}}' 40 | -------------------------------------------------------------------------------- /.github/workflows/__deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 🏗️ 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | branch: 7 | required: true 8 | type: string 9 | commit: 10 | required: false 11 | type: string 12 | comment: 13 | required: false 14 | type: boolean 15 | artifact_name: 16 | required: false 17 | type: string 18 | default: frontend 19 | 20 | jobs: 21 | cf-pages: 22 | name: CloudFlare Pages 📃 23 | runs-on: ubuntu-latest 24 | environment: 25 | name: ${{ inputs.branch == 'master' && 'Production' || 'Preview' }} 26 | url: ${{ steps.cf.outputs.deployment-url }} 27 | outputs: 28 | url: ${{ steps.cf.outputs.deployment-url }} 29 | 30 | steps: 31 | - name: Download workflow artifact ⬇️ 32 | uses: actions/download-artifact@v4.1.8 33 | with: 34 | name: ${{ inputs.artifact_name }} 35 | path: dist 36 | 37 | - name: Publish to Cloudflare Pages 📃 38 | uses: cloudflare/wrangler-action@v3.7.0 39 | id: cf 40 | with: 41 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 42 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 43 | command: pages deploy dist --project-name=jf-vue --branch=${{ inputs.branch }} 44 | 45 | compose-comment: 46 | name: Compose and push comment 📝 47 | # Always run so the comment is composed for the workflow summary 48 | if: ${{ always() }} 49 | uses: ./.github/workflows/__job_messages.yml 50 | secrets: inherit 51 | needs: 52 | - cf-pages 53 | 54 | with: 55 | branch: ${{ inputs.branch }} 56 | commit: ${{ inputs.commit }} 57 | preview_url: ${{ needs.cf-pages.outputs.url }} 58 | in_progress: false 59 | comment: ${{ inputs.comment }} 60 | -------------------------------------------------------------------------------- /.github/workflows/__quality_checks.yml: -------------------------------------------------------------------------------- 1 | name: Quality checks 👌🧪 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | commit: 7 | required: true 8 | type: string 9 | workflow_dispatch: 10 | 11 | jobs: 12 | dependency-review: 13 | name: Vulnerable dependencies 🔎 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4.1.7 18 | with: 19 | ref: ${{ inputs.commit }} 20 | show-progress: false 21 | 22 | - name: Scan 23 | uses: actions/dependency-review-action@v4.3.4 24 | with: 25 | ## Workaround from https://github.com/actions/dependency-review-action/issues/456 26 | ## TODO: Remove when necessary 27 | base-ref: ${{ github.event.pull_request.base.sha || 'master' }} 28 | head-ref: ${{ github.event.pull_request.head.sha || github.ref }} 29 | 30 | quality: 31 | name: Run ${{ matrix.command }} 🕵️‍♂️ 32 | runs-on: ubuntu-latest 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | command: 37 | - lint 38 | - check:types 39 | - analyze:cycles 40 | defaults: 41 | run: 42 | working-directory: frontend 43 | 44 | steps: 45 | - name: Checkout ⬇️ 46 | uses: actions/checkout@v4.1.7 47 | with: 48 | ref: ${{ inputs.commit }} 49 | show-progress: false 50 | 51 | - name: Setup node environment ⚙️ 52 | uses: actions/setup-node@v4.0.3 53 | with: 54 | node-version: 20 55 | check-latest: true 56 | cache: 'npm' 57 | 58 | - name: Install dependencies 📦 59 | run: npm ci --no-audit 60 | 61 | - name: Run ${{ matrix.command }} ⚙️ 62 | run: npm run ${{ matrix.command }} 63 | 64 | commits_checks: 65 | name: Commit linting 💬✅ 66 | runs-on: ubuntu-latest 67 | 68 | steps: 69 | - name: Checkout ⬇️ 70 | uses: actions/checkout@v4.1.7 71 | with: 72 | ref: ${{ inputs.commit }} 73 | show-progress: false 74 | 75 | - name: Check if all commits comply with the specification 76 | uses: webiny/action-conventional-commits@v1.3.0 77 | 78 | - name: Check for merge commits 79 | run: | 80 | git log --merges --oneline | grep -q "^" && exit 1 || exit 0 81 | 82 | -------------------------------------------------------------------------------- /.github/workflows/push_release.yml: -------------------------------------------------------------------------------- 1 | name: Push & Release 🌍 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | release: 9 | types: 10 | - released 11 | - prereleased 12 | push: 13 | branches: 14 | - master 15 | paths-ignore: 16 | - '**/*.md' 17 | 18 | jobs: 19 | automation: 20 | name: Automation 🎛️ 21 | if: ${{ github.repository == 'jellyfin/jellyfin-vue' }} 22 | uses: ./.github/workflows/__automation.yml 23 | secrets: inherit 24 | 25 | main: 26 | name: ${{ github.event_name == 'push' && 'Unstable 🚀⚠️' || 'Stable 🏷️✅' }} 27 | uses: ./.github/workflows/__package.yml 28 | secrets: inherit 29 | # Needed for attestation publication 30 | permissions: 31 | id-token: write 32 | attestations: write 33 | with: 34 | commit: ${{ github.event_name == 'push' && github.sha }} 35 | is_prerelease: ${{ github.event_name == 'release' && github.event.action == 'prereleased' }} 36 | tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name }} 37 | push: ${{ github.repository == 'jellyfin/jellyfin-vue' }} 38 | 39 | codeql: 40 | name: GitHub CodeQL 🔬 41 | uses: ./.github/workflows/__codeql.yml 42 | permissions: 43 | actions: read 44 | contents: read 45 | security-events: write 46 | with: 47 | commit: ${{ github.sha }} 48 | 49 | deploy: 50 | name: Deploy 🚀 51 | if: ${{ github.repository == 'jellyfin/jellyfin-vue' }} 52 | uses: ./.github/workflows/__deploy.yml 53 | needs: 54 | - main 55 | permissions: 56 | contents: read 57 | deployments: write 58 | secrets: inherit 59 | with: 60 | branch: ${{ github.ref_name }} 61 | comment: false 62 | -------------------------------------------------------------------------------- /.github/workflows/schedule.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled tasks 🕒 2 | 3 | on: 4 | schedule: 5 | - cron: 30 7 * * 6 6 | workflow_dispatch: 7 | 8 | jobs: 9 | codeql: 10 | name: GitHub CodeQL 🔬 11 | uses: ./.github/workflows/__codeql.yml 12 | permissions: 13 | actions: read 14 | contents: read 15 | security-events: write 16 | with: 17 | commit: ${{ github.sha }} 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | lockfile-version=3 2 | save-exact=true 3 | engine-strict=true 4 | 5 | ## Fix timeout errors in CI 6 | fetch-retries=15 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "antfu.iconify", 4 | "aaron-bond.better-comments", 5 | "eamodio.gitlens", 6 | "vue.volar", 7 | "dbaeumer.vscode-eslint", 8 | "lokalise.i18n-ally", 9 | "ryanluker.vscode-coverage-gutters", 10 | "yoavbls.pretty-ts-errors", 11 | "SonarSource.sonarlint-vscode" 12 | ], 13 | "unwantedRecommendations": [ 14 | "octref.vetur", 15 | "esbenp.prettier-vscode", 16 | "vue.vscode-typescript-vue-plugin" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always", 5 | "source.organizeImports": "never" 6 | }, 7 | "[javascript]": { 8 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 9 | }, 10 | "[typescript]": { 11 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 12 | }, 13 | "[vue]": { 14 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 15 | }, 16 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 17 | "i18n-ally.keystyle": "flat", 18 | "i18n-ally.sortKeys": true, 19 | "i18n-ally.sourceLanguage": "en", 20 | "i18n-ally.localesPaths": [ 21 | "frontend/locales" 22 | ], 23 | "i18n-ally.enabledFrameworks": [ 24 | "vue" 25 | ], 26 | "eslint.validate": [ 27 | "vue", 28 | "javascript", 29 | "typescript" 30 | ], 31 | "eslint.format.enable": true, 32 | "eslint.useFlatConfig": true, 33 | "eslint.workingDirectories": [ 34 | { 35 | "mode": "auto" 36 | } 37 | ], 38 | "vue.server.hybridMode": true, 39 | "vue.autoInsert.dotValue": true, 40 | "vue.inlayHints.missingProps": true, 41 | "vue.complete.casing.props": "kebab", 42 | "vue.complete.casing.tags": "pascal", 43 | "vue.format.script.initialIndent": true, 44 | "vue.format.style.initialIndent": true, 45 | "vue.inlayHints.inlineHandlerLeading": true, 46 | "vue.inlayHints.vBindShorthand": true, 47 | "vue.inlayHints.optionsWrapper": true, 48 | "vue.inlayHints.destructuredProps": true, 49 | "sonarlint.output.showAnalyzerLogs": true, 50 | "sonarlint.output.showVerboseLogs": true, 51 | "sonarlint.connectedMode.project": { 52 | "connectionId": "jellyfin-vue", 53 | "projectKey": "jellyfin_jellyfin-vue" 54 | }, 55 | "typescript.tsserver.experimental.enableProjectDiagnostics": true 56 | } 57 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | Jellyfin Vue 13 | 14 | 15 | 16 | 17 | Jellyfin Logo 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/locales/fil.json: -------------------------------------------------------------------------------- 1 | { 2 | "NoMediaSourcesAvailable": "Walang available na media source", 3 | "actor": "Aktor", 4 | "actors": "Mga Aktor", 5 | "addNewPerson": "Magdagdag ng bagong tao", 6 | "airPlayDevices": "Mga AirPlay Device", 7 | "albums": "Mga Album", 8 | "allLanguages": "Lahat ng mga wika", 9 | "artist": "Artista", 10 | "artists": "Mga artista", 11 | "audio": "Audio", 12 | "badRequest": "Bad request. Subukan muli", 13 | "books": "Mga libro", 14 | "byArtist": "Ayon sa {artist}", 15 | "cancel": "Kanselahin", 16 | "castAndCrew": "Cast at crew", 17 | "collections": "Mga koleksyon", 18 | "communityRating": "Rating ng komunidad", 19 | "confirm": "Kumpirmahin", 20 | "connect": "Kumonekta", 21 | "continueWatching": "Ituloy ang panonood", 22 | "criticRating": "Rating ng kritiko", 23 | "customRating": "Custom na rating", 24 | "dateAdded": "Idinagdag ng petsa", 25 | "details": "Mga Detalye", 26 | "directing": "Nagdidirekta", 27 | "director": "Direktor", 28 | "disabled": "Naka-disable", 29 | "discNumber": "Disc {discNumber}", 30 | "editMetadata": "I-edit ang metadata", 31 | "editPerson": "I-edit ang tao", 32 | "endsAt": "Matatapos sa {time}", 33 | "favorite": "Paborito", 34 | "features": "Mga Feature", 35 | "filter": "I-filter", 36 | "filtersNotFound": "Hindi ma-load ang mga filter", 37 | "fullScreen": "I-fullscreen", 38 | "general": "General", 39 | "genericJellyfinPlaceholderDevice": "Generic na Jellyfin device", 40 | "genres": "Mga genre", 41 | "home": "Home", 42 | "images": "Mga larawan", 43 | "incorrectUsernameOrPassword": "Maling username o password", 44 | "jellyfinLogo": "Logo ng jellyfin", 45 | "latestLibrary": "Pinakabagong {libraryName}", 46 | "libraries": "Mga Library", 47 | "libraryEmpty": "Walang laman ang library na ito", 48 | "libraryEmptyFilters": "Walang tugma. Alisin ang ilang mga filter upang magpakita ng higit pang mga item", 49 | "liked": "Nagustuhan" 50 | } 51 | -------------------------------------------------------------------------------- /frontend/locales/lt.json: -------------------------------------------------------------------------------- 1 | { 2 | "accept": "Priimti", 3 | "actor": "Aktorius/ė", 4 | "actors": "Aktoriai", 5 | "addNewPerson": "Pridėti naują asmenį", 6 | "administrator": "Administratorius/ė", 7 | "airPlayDevices": "AirPlay įrenginiai", 8 | "albums": "Albumai", 9 | "allLanguages": "Visos kalbos", 10 | "audio": "Garsas", 11 | "badRequest": "Bloga užklausa. Bandykite iš naujo", 12 | "continueWatching": "Tęsti žiūrėjimą", 13 | "endsAt": "Baigiasi {time}", 14 | "home": "Pradžia", 15 | "incorrectUsernameOrPassword": "Neteisingas vartotojo vardas arba slaptažodis", 16 | "libraryEmpty": "Tuščia mediateka", 17 | "logout": "Atsijungti", 18 | "password": "Slaptažodis", 19 | "play": "Leisti", 20 | "present": "Dabar", 21 | "rating": "Įvertinimas", 22 | "releaseDate": "Išleidimo data", 23 | "signIn": "Prisijungti", 24 | "unexpectedError": "Netikėta klaida", 25 | "username": "Vartotojo vardas" 26 | } 27 | -------------------------------------------------------------------------------- /frontend/locales/mn.json: -------------------------------------------------------------------------------- 1 | { 2 | "artist": "Жүжигчин", 3 | "cancel": "Цуцлах", 4 | "collections": "Багц", 5 | "criticRating": "Зэрэглэл", 6 | "dateAdded": "Өдөр нэмэх", 7 | "disabled": "Идэвхгүй", 8 | "favorite": "Дуртай", 9 | "filter": "Шүүлтүүр", 10 | "general": "Ерөнхий", 11 | "genres": "Төрөл зүйл", 12 | "movies": "Кино", 13 | "name": "Нэр", 14 | "nextUp": "Дараах", 15 | "people": "Хүмүүс", 16 | "play": "Тоглуулах", 17 | "rating": "Зэрэглэл", 18 | "series": "Цуврал", 19 | "songs": "Дуунууд", 20 | "trailer": "Трейлер", 21 | "version": "Хувилбар", 22 | "video": "Видео" 23 | } 24 | -------------------------------------------------------------------------------- /frontend/locales/nn.json: -------------------------------------------------------------------------------- 1 | { 2 | "NoMediaSourcesAvailable": "Ingen mediekjelder tilgjengelege", 3 | "actor": "Skodespelar", 4 | "actors": "Skodespelarar", 5 | "addNewPerson": "Legg til ny person", 6 | "albums": "Album", 7 | "allLanguages": "Alle språk", 8 | "artist": "Artist", 9 | "artists": "Artistar", 10 | "audio": "Lyd", 11 | "badRequest": "Feil i førespurnaden. Prøv igjen", 12 | "byArtist": "Av {artist}", 13 | "cancel": "Avbryt", 14 | "castAndCrew": "Rollebesetning og mannskap", 15 | "collections": "Samlingar", 16 | "communityRating": "Vurderinga til fellesskapet", 17 | "confirm": "Bekreft", 18 | "connect": "Kople til", 19 | "continueWatching": "Fortset å sjå", 20 | "criticRating": "Kritikarvurdering", 21 | "customRating": "Eigendefinert vurdering", 22 | "dateAdded": "Dato lagt til", 23 | "details": "Detaljar", 24 | "directing": "Regi", 25 | "director": "Regissør", 26 | "discNumber": "Plate {discNumber}", 27 | "editMetadata": "Rediger metadata", 28 | "editPerson": "Rediger person", 29 | "endsAt": "Sluttar {time}", 30 | "favorite": "Favoritt", 31 | "filter": "Filter", 32 | "filtersNotFound": "Kan ikkje lasta filter", 33 | "fullScreen": "Fullskjerm", 34 | "general": "Generelt", 35 | "genres": "Sjangrar", 36 | "guestStar": "Gjesterolle", 37 | "home": "Heim", 38 | "images": "Bilete", 39 | "incorrectUsernameOrPassword": "Feil brukarnamn eller passord", 40 | "jellyfinLogo": "Jellyfin-logo", 41 | "latestLibrary": "Nyaste i {libraryName}", 42 | "libraries": "Bibliotek", 43 | "libraryEmpty": "Dette biblioteket er tomt", 44 | "libraryEmptyFilters": "Ingen treff. Fjern nokre filter for å visa fleire element", 45 | "liked": "Likt", 46 | "logout": "Logg ut", 47 | "manualLogin": "Manuell innlogging", 48 | "menu": "Meny", 49 | "metadataEditor": "Metadataredigerer", 50 | "moreLikeArtist": "Meir liknande {artist}", 51 | "movies": "Filmar", 52 | "name": "Namn", 53 | "networks": "Nettverk", 54 | "nextUp": "Neste", 55 | "noImagesFound": "Ingen bilete funne", 56 | "noResultsFound": "Her var det tomt", 57 | "numberTracks": "{number} spor", 58 | "originalTitle": "Original tittel", 59 | "overview": "Oversyn", 60 | "parentalRating": "Aldersgrense", 61 | "parentalRatings": "Aldersgrense", 62 | "password": "Passord", 63 | "people": "Personar", 64 | "person": "Person", 65 | "play": "Spel av", 66 | "playFromBeginning": "Spel av frå starten" 67 | } 68 | -------------------------------------------------------------------------------- /frontend/locales/pa.json: -------------------------------------------------------------------------------- 1 | { 2 | "albums": "ਐਲਬਮਾਂ", 3 | "artists": "ਕਲਾਕਾਰ", 4 | "collections": "ਸੰਗ੍ਰਹਿ", 5 | "genres": "ਸ਼ੈਲੀਆਂ", 6 | "movies": "ਫਿਲਮਾਂ", 7 | "shows": "ਸ਼ੋਅਜ਼", 8 | "undefined": "ਪਰਿਭਾਸ਼ਤ" 9 | } 10 | -------------------------------------------------------------------------------- /frontend/locales/sr-Latn.json: -------------------------------------------------------------------------------- 1 | { 2 | "NoMediaSourcesAvailable": "Nema dosutpnih izvora za medije", 3 | "actor": "Glumac", 4 | "actors": "Glumci", 5 | "addNewPerson": "Dodaj novu osobu", 6 | "airPlayDevices": "AirPlay uređaji", 7 | "albums": "Albumi", 8 | "allLanguages": "Svi jezici", 9 | "artist": "Umetnik", 10 | "artists": "Umetnici", 11 | "audio": "Zvuk", 12 | "badRequest": "Loš zahtev. Probajte ponovo", 13 | "books": "Knjige", 14 | "byArtist": "Od strane {artist}", 15 | "cancel": "Otkaži", 16 | "castAndCrew": "Glumci i ekipa", 17 | "collections": "Kolekcije", 18 | "communityRating": "Ocena zajednice", 19 | "confirm": "Potvrdi", 20 | "connect": "Poveži se", 21 | "continueWatching": "Nastavi sa gledanjem", 22 | "criticRating": "Ocena kritičara", 23 | "customRating": "Prilagođena ocena", 24 | "dateAdded": "Dodat na datum", 25 | "details": "Detalji", 26 | "directing": "Režija", 27 | "director": "Režiser", 28 | "disabled": "Onemogućeno", 29 | "discNumber": "Disk {discNumber}", 30 | "editMetadata": "Uredi metapodatke", 31 | "editPerson": "Uredi osobu", 32 | "endsAt": "Završava se u {time}", 33 | "favorite": "Omiljeno", 34 | "features": "Karakteristike", 35 | "filter": "Filter", 36 | "filtersNotFound": "Nije moguće učitati filtere", 37 | "fullScreen": "ceo ekran", 38 | "general": "Generalno", 39 | "genericJellyfinPlaceholderDevice": "Opšti Jellyfin uređaj", 40 | "genres": "Žanrovi", 41 | "googleCastPlaceholderDevice": "Google Cast uređaj", 42 | "guestStar": "Gostujuća zvezda", 43 | "home": "Početna strana", 44 | "images": "Slike", 45 | "incorrectUsernameOrPassword": "Pogrešno korisničko ime ili šifra", 46 | "jellyfinLogo": "logo Jellyfin-a", 47 | "latestLibrary": "Najnovije {libraryName}", 48 | "libraries": "Biblioteke", 49 | "libraryEmpty": "Ova biblioteka je prazna", 50 | "libraryEmptyFilters": "Nema podudaranja. Izbrišite neke filtere da bi se videlo više stavki", 51 | "liked": "Sviđano", 52 | "logout": "Odjavi se", 53 | "manualLogin": "Ručno prijavljivanje", 54 | "menu": "Meni", 55 | "metadataEditor": "Uređivač metapodataka", 56 | "moreLikeArtist": "Više kao {artist}", 57 | "movies": "Filmovi", 58 | "name": "Ime", 59 | "networks": "Mreže", 60 | "nextUp": "Sledeće", 61 | "noImagesFound": "Slike nisu pronađene", 62 | "noInformationAvailable": "Nema dostupnih informacija", 63 | "noResultsFound": "Ovde nema ničega", 64 | "numberTracks": "{number} traka", 65 | "originalTitle": "Originalni naslov", 66 | "overview": "Pregled", 67 | "parentalRating": "Roditeljska ocena", 68 | "parentalRatings": "Roditeljske ocene", 69 | "password": "Šifra", 70 | "people": "Ljudi", 71 | "person": "Osoba", 72 | "play": "Pusti", 73 | "playFromBeginning": "Pusti od početka" 74 | } 75 | -------------------------------------------------------------------------------- /frontend/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultServerURLs": [], 3 | "allowServerSelection": true, 4 | "routerMode": "hash" 5 | } 6 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickm53/jellyfin-vue/57607f1adc5b0b22780e53cadd55cce115b97a54/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 13 | 14 | 16 | image/svg+xml 17 | 19 | icon-solid-vue 20 | 21 | 22 | 23 | 25 | 32 | 36 | 40 | 41 | 42 | icon-solid-vue 44 | 46 | 50 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /frontend/scripts/paths.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | 3 | export const localeFilesFolder = resolve('locales/**'); 4 | export const srcRoot = `${resolve('src')}/`; 5 | export const entrypoints = { 6 | index: resolve('index.html'), 7 | main: `${srcRoot}/main.ts`, 8 | splashscreen: `${srcRoot}/splashscreen.ts` 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/scripts/plugins/analysis.ts: -------------------------------------------------------------------------------- 1 | import { visualizer } from 'rollup-plugin-visualizer'; 2 | import type { RollupLog } from 'rollup'; 3 | import type { Plugin } from 'vite'; 4 | 5 | /** 6 | * This plugin extracts the logic for the analyze commands, so the main Vite config is cleaner. 7 | */ 8 | export function JellyfinVueAnalysis(): Plugin { 9 | const warnings: RollupLog[] = []; 10 | 11 | return { 12 | name: 'Jellyfin_Vue:analysis', 13 | enforce: 'post', 14 | config: (_, env) => { 15 | if (env.mode === 'analyze:bundle') { 16 | return { 17 | build: { 18 | rollupOptions: { 19 | plugins: [ 20 | visualizer({ 21 | open: true, 22 | filename: 'dist/stats.html' 23 | }) 24 | ] 25 | } 26 | } 27 | }; 28 | } else if (env.mode === 'analyze:cycles') { 29 | return { 30 | build: { 31 | rollupOptions: { 32 | onwarn: (warning) => { 33 | if (warning.code === 'CIRCULAR_DEPENDENCY') { 34 | warnings.push(warning); 35 | } 36 | } 37 | } 38 | } 39 | }; 40 | } 41 | }, 42 | closeBundle: () => { 43 | if (warnings.length > 0) { 44 | for (const warning of warnings) { 45 | console.warn(warning); 46 | } 47 | 48 | throw new Error('There are circular dependencies'); 49 | } 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/scripts/plugins/chunking.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite'; 2 | 3 | /** 4 | * Creates the Rollup's chunking strategy of the application (for code-splitting) 5 | */ 6 | export function JellyfinVueChunking(): Plugin { 7 | return { 8 | name: 'Jellyfin_Vue:chunking', 9 | config: () => ({ 10 | build: { 11 | rollupOptions: { 12 | output: { 13 | /** 14 | * This is the first thing that should be debugged when there are issues 15 | * withe the bundle. Check these issues: 16 | * - https://github.com/vitejs/vite/issues/5142 17 | * - https://github.com/evanw/esbuild/issues/399 18 | * - https://github.com/rollup/rollup/issues/3888 19 | */ 20 | manualChunks(id) { 21 | const match = /node_modules\/([^/]+)/.exec(id)?.[1]; 22 | 23 | if (id.includes('virtual:locales') || ((id.includes('vuetify') || id.includes('date-fns')) && id.includes('locale'))) { 24 | return 'localization/meta'; 25 | } 26 | 27 | if (id.includes('@intlify/unplugin-vue-i18n/messages') 28 | ) { 29 | return 'localization/messages'; 30 | } 31 | 32 | /** 33 | * Split each vendor in its own chunk 34 | */ 35 | if (match) { 36 | return `vendor/${match.replace('@', '')}`; 37 | } 38 | } 39 | } 40 | } 41 | } 42 | }) 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/scripts/virtual-modules.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * We need to match our locales to the date-fns and Vuetify ones for proper localization. 3 | * In order to reduce bundle size, we calculate here (at build time) only the locales that we 4 | * have defined in the "locales" folder, to include only those, instead of importing all of them. 5 | * 6 | * We expose them later as 'virtual:date-fns/locales' and 'virtual:vuetify/locales' using @rollup/plugin-virtual 7 | */ 8 | import { readdirSync } from 'node:fs'; 9 | import { localeFilesFolder } from './paths'; 10 | 11 | const localeFiles = readdirSync(localeFilesFolder.replace('**', '')); 12 | const localeNames = localeFiles.map(l => l.replace('.json', '')); 13 | 14 | /** 15 | * Normalizes the locale names from the JSON files to ESM-compatible exports 16 | */ 17 | function localeTransform(keys: string[], l: string): string | undefined { 18 | const testStrings = l.split('-'); 19 | const lang = testStrings.join(''); 20 | 21 | /** 22 | * - If the i18n locale exactly matches the one from the module 23 | * - Removes the potential dash to match for instance "en-US" from i18n to "enUS" for the module. 24 | * We also need to remove all the hyphens, as using named exports with them is not valid JS syntax 25 | */ 26 | if (keys.includes(l) || keys.includes(lang)) { 27 | return lang; 28 | /** 29 | * Takes the part before the potential hyphen to try, for instance "fr-FR" in i18n to "fr" 30 | */ 31 | } else if (keys.includes(testStrings[0])) { 32 | return `${testStrings[0]} as ${lang}`; 33 | } 34 | } 35 | 36 | /** 37 | * Date-fns locale parsing 38 | */ 39 | const dfnskeys = Object.keys(await import('date-fns/locale')); 40 | /** 41 | * We need this due to the differences between the vue i18n and date-fns locales. 42 | */ 43 | const dfnsExports = localeNames 44 | .map(l => localeTransform(dfnskeys, l)) 45 | .filter((l): l is string => typeof l === 'string'); 46 | 47 | /** 48 | * Vuetify locale parsing 49 | */ 50 | const vuetify = await import('vuetify/locale'); 51 | const vuetifyKeys = Object.keys(vuetify); 52 | const vuetifyExports = localeNames 53 | .map(l => localeTransform(vuetifyKeys, l)) 54 | .filter((l): l is string => typeof l === 'string'); 55 | 56 | /** 57 | * Get commit hash 58 | */ 59 | const commit_available = !Number(process.env.IS_STABLE) && Boolean(process.env.COMMIT_HASH); 60 | const commit_hash = commit_available && `'${process.env.COMMIT_HASH}'` || undefined; 61 | 62 | /** 63 | * Date-fns exports all english locales with variants, so we need to add the match manually 64 | */ 65 | dfnsExports.unshift('enUS as en'); 66 | 67 | export default { 68 | 'virtual:locales/date-fns': `export { ${dfnsExports.join( 69 | ', ' 70 | )} } from 'date-fns/locale'`, 71 | 'virtual:locales/vuetify': `export { ${vuetifyExports.join( 72 | ', ' 73 | )} } from 'vuetify/locale'`, 74 | 'virtual:commit': `export const commit_hash = ${commit_hash}` 75 | }; 76 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 77 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: var(--j-font-family), sans-serif, system-ui !important; 3 | } 4 | 5 | html { 6 | overscroll-behavior: none; 7 | color-scheme: light dark; 8 | } 9 | 10 | html.no-forced-scrollbar { 11 | overflow-y: unset !important; 12 | } 13 | 14 | /* Custom utility classes */ 15 | 16 | .absolute-cover { 17 | height: 100%; 18 | width: 100%; 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | } 25 | 26 | .link { 27 | cursor: pointer; 28 | color: inherit !important; 29 | text-decoration: none; 30 | } 31 | 32 | .link:hover { 33 | text-decoration: underline; 34 | } 35 | 36 | .pa-s { 37 | padding: env(safe-area-inset-top) env(safe-area-inset-right) 38 | env(safe-area-inset-bottom) env(safe-area-inset-left); 39 | } 40 | 41 | .pt-s { 42 | padding-top: env(safe-area-inset-top); 43 | } 44 | 45 | .pl-s { 46 | padding-left: env(safe-area-inset-left); 47 | } 48 | 49 | .pr-s { 50 | padding-right: env(safe-area-inset-right); 51 | } 52 | 53 | .pb-s { 54 | padding-bottom: env(safe-area-inset-bottom); 55 | } 56 | 57 | .text-capitalize-first-letter::first-letter { 58 | text-transform: uppercase; 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/index.css: -------------------------------------------------------------------------------- 1 | @import url('@fontsource-variable/figtree'); 2 | @import url('@unocss/reset/tailwind-compat.css'); 3 | @import url('./global.css'); 4 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/splashscreen.css: -------------------------------------------------------------------------------- 1 | .j-splash { 2 | height: 100dvh; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | background-color: var(--j-color-background); 8 | } 9 | 10 | .j-splash > img { 11 | min-width: 30%; 12 | min-height: 30%; 13 | width: 30%; 14 | height: 30%; 15 | object-fit: contain; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/LikeButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 39 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/MarkPlayedButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 44 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/Playback/NextTrackButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/Playback/PlayPauseButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/Playback/PreviousTrackButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/Playback/RepeatButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/Playback/ShuffleButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/ScrollToTopButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/SortButton.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 66 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/SubtitleSelectionButton.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 63 | -------------------------------------------------------------------------------- /frontend/src/components/Buttons/TypeButton.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 70 | -------------------------------------------------------------------------------- /frontend/src/components/Dialogs/GenericDialog.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 68 | -------------------------------------------------------------------------------- /frontend/src/components/Forms/AddServerForm.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 82 | -------------------------------------------------------------------------------- /frontend/src/components/Item/Card/ServerCard.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 74 | -------------------------------------------------------------------------------- /frontend/src/components/Item/CollectionTabs.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/Item/Identify/IdentifyResults.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 66 | -------------------------------------------------------------------------------- /frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 33 | -------------------------------------------------------------------------------- /frontend/src/components/Item/MediaInfo.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 46 | 47 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/Item/MediaSourceSelector.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | -------------------------------------------------------------------------------- /frontend/src/components/Item/Metadata/DateInput.vue: -------------------------------------------------------------------------------- 1 | 16 | 37 | -------------------------------------------------------------------------------- /frontend/src/components/Item/Metadata/MetadataEditorDialog.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | 27 | 32 | -------------------------------------------------------------------------------- /frontend/src/components/Item/PeopleList.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 40 | -------------------------------------------------------------------------------- /frontend/src/components/Item/RelatedItems.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 49 | 50 | 66 | -------------------------------------------------------------------------------- /frontend/src/components/Item/WatchedIndicator.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 60 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/AppBar/SearchField.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 46 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Artist/ArtistTab.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Backdrop.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 53 | 54 | 58 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Carousel/Item/ItemsCarouselTitle.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 84 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Images/Blurhash/BlurhashCanvas.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 52 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Images/Blurhash/BlurhashImage.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Images/Blurhash/BlurhashImageIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Images/UserImage.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/ItemCols.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Navigation/CommitLink.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Navigation/NavigationDrawer.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 73 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/SettingsPage.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 35 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/TimeSlider.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 48 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/VolumeSlider.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 59 | 60 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/Playback/MiniVideoPlayer.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 76 | 77 | 89 | -------------------------------------------------------------------------------- /frontend/src/components/Playback/MusicVisualizer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 45 | -------------------------------------------------------------------------------- /frontend/src/components/Skeletons/SkeletonCard.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | 24 | 52 | -------------------------------------------------------------------------------- /frontend/src/components/Skeletons/SkeletonItemGrid.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 27 | 28 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/System/AboutLinks.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/System/AddApiKey.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 94 | 95 | 100 | -------------------------------------------------------------------------------- /frontend/src/components/System/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/src/components/System/LocaleSwitcher.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 43 | 44 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/System/Snackbar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | 34 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/Users/UserCard.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 33 | -------------------------------------------------------------------------------- /frontend/src/components/Wizard/WizardRemoteAccess.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 72 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JApp.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JHover.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JSafeHtml.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JSlot.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JSplashscreen.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JView.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /frontend/src/components/lib/JVirtual/j-virtual.worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from 'comlink'; 2 | import { getItemOffsetByIndex, type ResizeMeasurement, type BufferMeta, type InternalItem } from './pipeline'; 3 | import { sealed } from '@/utils/validation'; 4 | 5 | @sealed 6 | class JVirtualWorker { 7 | /** 8 | * Gets the items that must be visible in the grid based on the buffer measurements 9 | */ 10 | public readonly getVisibleIndexes = ( 11 | bufferMeta: BufferMeta, 12 | resizeMeasurement: ResizeMeasurement, 13 | collectionLength: number 14 | ): InternalItem[] => { 15 | const { bufferedOffset, bufferedLength } = bufferMeta; 16 | 17 | /** 18 | * When approaching the end of the VirtualGrid, we want to always be sure that 19 | * bufferedLength = the amount of visible items, 20 | * so no DOM nodes are destroyed (which would be a waste of resources if the user reverses the scroll), 21 | * so we need to change the slice values depending on the current offset. 22 | * 23 | * OffsetPlusLength is the length ahead that we have DOM nodes available for rendering. 24 | * 25 | * We initialize 'first' to 0 and 'last' to collectionLength to take into account those cases 26 | * where the available buffer is bigger than the real amount of items we need to display, 27 | * the if statement is where we really take into account a real virtual scrolling scenario 28 | */ 29 | const offsetPlusLength = bufferedOffset + bufferedLength; 30 | let first = 0; 31 | let last = collectionLength; 32 | 33 | if (collectionLength > bufferedLength) { 34 | first 35 | = collectionLength < offsetPlusLength 36 | ? collectionLength - bufferedLength 37 | : bufferedOffset; 38 | last 39 | = collectionLength < offsetPlusLength ? collectionLength : offsetPlusLength; 40 | } 41 | 42 | const res = []; 43 | 44 | for (let index = first; index < last; index++) { 45 | const { x, y } = getItemOffsetByIndex(index, resizeMeasurement); 46 | const translateX = `translateX(${x}px)`; 47 | const translateY = `translateY(${y}px)`; 48 | 49 | res.push({ 50 | index, 51 | style: { 52 | transform: `${translateX} ${translateY}` 53 | } 54 | }); 55 | } 56 | 57 | return res; 58 | }; 59 | } 60 | 61 | const instance = new JVirtualWorker(); 62 | export default instance; 63 | export type IJVirtualWorker = typeof instance; 64 | 65 | expose(instance); 66 | -------------------------------------------------------------------------------- /frontend/src/composables/backdrop.ts: -------------------------------------------------------------------------------- 1 | import { ImageType, type BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; 2 | import { toValue, type MaybeRefOrGetter } from 'vue'; 3 | import { useBackdrop } from '@/components/Layout/Backdrop.vue'; 4 | import { getBlurhash } from '@/utils/images'; 5 | 6 | /** 7 | * Same as useBackdrop, but is a shorthand for items only. 8 | */ 9 | export function useItemBackdrop(item: MaybeRefOrGetter>, ...args: Tail>) { 10 | useBackdrop(() => getBlurhash(toValue(item) ?? {}, ImageType.Primary), ...args); 11 | } 12 | 13 | /** 14 | * == COMPONENT COMPOSABLE == 15 | * 16 | * The definition of the composable are in the relevant component, 17 | * so the code that tracks the state of the component are alongside the component itself. 18 | * 19 | * We could re-define it here, but we would lose access to the 20 | * JSDoc of the original: that's why we just re-export it again. 21 | */ 22 | export { useBackdrop } from '@/components/Layout/Backdrop.vue'; 23 | -------------------------------------------------------------------------------- /frontend/src/composables/page-title.ts: -------------------------------------------------------------------------------- 1 | import { computed, onBeforeUnmount, onMounted, shallowRef, toRef, toValue, type MaybeRefOrGetter } from 'vue'; 2 | import { useTitle as _useTitle, watchImmediate } from '@vueuse/core'; 3 | import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; 4 | import { isNil } from '@/utils/validation'; 5 | 6 | /** 7 | * This composable handles the page title of the application. 8 | */ 9 | const DEFAULT_PAGE_TITLE = 'Jellyfin Vue'; 10 | const _title = shallowRef(); 11 | const _fullTitle = computed(() => _title.value ? `${_title.value.trim()} | ${DEFAULT_PAGE_TITLE}` : DEFAULT_PAGE_TITLE); 12 | 13 | _useTitle(_fullTitle); 14 | 15 | /** 16 | * Reactively sets the page title with the following template: **`title` | Jellyfin Vue**. Can be used in 2 ways: 17 | * 18 | * 1. Providing raw/reactive/ref/getter argument that will be tracked for changes by the composable. Raw values 19 | * can't be tracked directly, so they need to be wrapped in a ref or getter. 20 | * 2. Accessing the returned ref and setting it manually. 21 | * 22 | * Value will be set to default (undefined) when the component consuming this composable is unmounted. 23 | */ 24 | export function usePageTitle(title?: MaybeRefOrGetter>) { 25 | onMounted(() => { 26 | if (!isNil(title)) { 27 | watchImmediate(toRef(title), val => _title.value = val ?? undefined); 28 | } 29 | }); 30 | 31 | onBeforeUnmount(() => _title.value = undefined); 32 | 33 | return { title: _title }; 34 | }; 35 | 36 | /** 37 | * Same as useTitle, but is a shorthand for items only. 38 | */ 39 | export function useItemPageTitle(item: MaybeRefOrGetter>) { 40 | usePageTitle(() => toValue(item)?.Name); 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/composables/use-confirm-dialog.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * == COMPONENT COMPOSABLE == 3 | * 4 | * The definition of the composable are in the relevant component, 5 | * so the code that tracks the state of the component are alongside the component itself. 6 | * 7 | * We could re-define it here, but we would lose access to the 8 | * JSDoc of the original: that's why we just re-export it again. 9 | */ 10 | export { useConfirmDialog } from '@/components/Dialogs/ConfirmDialog.vue'; 11 | -------------------------------------------------------------------------------- /frontend/src/composables/use-datefns.ts: -------------------------------------------------------------------------------- 1 | import * as datefnslocales from 'virtual:locales/date-fns'; 2 | import { i18n } from '@/plugins/i18n'; 3 | import { isObj } from '@/utils/validation'; 4 | 5 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ 6 | 7 | /** 8 | * Use any date fns function with proper localization, based on the current locale. 9 | * Pass the date-fns function to invoke as the first parameter, 10 | * and it's parameters as the second parameter 11 | * 12 | * THIS FUNCTION MUST BE CALLED INSIDE A COMPUTED PROPERTY OR A TEMPLATE FOR CHANGES TO THE CURRENT LOCALE TO BE REFLECTED 13 | * 14 | * @param func - date-fns function to invoke 15 | * @param params - Parameters to pass to the date-fns function 16 | */ 17 | export function useDateFns any>( 18 | func: T, 19 | ...params: Parameters 20 | ): ReturnType { 21 | /** 22 | * We need to remove the hyphen of our locale codes, as using named exports with them is not valid JS syntax 23 | */ 24 | const importCode = i18n.locale.value.replace( 25 | '-', 26 | '' 27 | ) as keyof typeof datefnslocales; 28 | 29 | if (isObj(params.at(-1)) && !(params.at(-1) instanceof Date)) { 30 | params.at(-1).locale = datefnslocales[importCode]; 31 | } else { 32 | params.push({ locale: datefnslocales[importCode] }); 33 | } 34 | 35 | return func(...params); 36 | } 37 | 38 | /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ 39 | -------------------------------------------------------------------------------- /frontend/src/composables/use-loading.ts: -------------------------------------------------------------------------------- 1 | import { computed, shallowRef, type ComputedRef } from 'vue'; 2 | 3 | const requests = shallowRef(0); 4 | const isLoading = computed(() => requests.value > 0); 5 | 6 | /** 7 | * Composable for triggering the linear progress that appears at the top of the page 8 | * That progress bar is always indeterminate, so you can just start or stop requests 9 | * 10 | * For long running tasks (library refresh, config sync), use taskManager instead. 11 | * This is only meant for data fetch/push 12 | */ 13 | export function useLoading(): { 14 | start: () => number; 15 | finish: () => void; 16 | isLoading: ComputedRef; 17 | } { 18 | const start = (): number => requests.value++; 19 | const finish = (): void => { 20 | if (requests.value > 0) { 21 | requests.value--; 22 | } 23 | }; 24 | 25 | return { start, finish, isLoading }; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/composables/use-pausable-effect.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentScope, toRef, watch, type MaybeRefOrGetter } from 'vue'; 2 | 3 | /** 4 | * When the passed argument is truthy, the effect scope of the current component will be paused 5 | * until the argument becomes falsy again. 6 | * 7 | * This is useful for components that need to pause the DOM patching 8 | */ 9 | export function usePausableEffect(signal: MaybeRefOrGetter) { 10 | const scope = getCurrentScope(); 11 | 12 | if (scope) { 13 | watch(toRef(signal), val => val ? scope.pause() : scope.resume(), { immediate: true, flush: 'sync' }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/composables/use-playback.ts: -------------------------------------------------------------------------------- 1 | import { useFullscreen, useMagicKeys, whenever } from '@vueuse/core'; 2 | import { watch } from 'vue'; 3 | import { router } from '@/plugins/router'; 4 | import { mediaElementRef } from '@/store'; 5 | import { playbackManager } from '@/store/playback-manager'; 6 | 7 | interface PlaybackComposableReturn { 8 | fullscreen: ReturnType; 9 | } 10 | 11 | /** 12 | * Watchers and handlers that are common to music and video playback 13 | */ 14 | export function usePlayback(): PlaybackComposableReturn { 15 | watch(() => playbackManager.currentItem, () => { 16 | if (!playbackManager.currentItem) { 17 | router.back(); 18 | } 19 | }); 20 | 21 | /** 22 | * - iOS's Safari fullscreen API is only available for the video element 23 | */ 24 | const fullscreen = useFullscreen().isSupported.value 25 | ? useFullscreen(undefined, { autoExit: true }) 26 | : useFullscreen(mediaElementRef, { autoExit: true }); 27 | 28 | const keys = useMagicKeys(); 29 | 30 | whenever(keys.space, playbackManager.playPause); 31 | whenever(keys.k, playbackManager.playPause); 32 | whenever(keys.right, playbackManager.skipForward); 33 | whenever(keys.l, playbackManager.skipForward); 34 | whenever(keys.left, playbackManager.skipBackward); 35 | whenever(keys.j, playbackManager.skipBackward); 36 | whenever(keys.f, fullscreen.toggle); 37 | whenever(keys.m, playbackManager.toggleMute); 38 | 39 | whenever(keys.MediaPause, playbackManager.pause); 40 | whenever(keys.Pause, playbackManager.pause); 41 | whenever(keys.MediaPlay, playbackManager.unpause); 42 | whenever(keys.MediaPlayPause, playbackManager.playPause); 43 | whenever(keys.MediaStop, playbackManager.stop); 44 | whenever(keys.Exit, playbackManager.stop); 45 | whenever(keys.MediaTrackNext, playbackManager.setNextItem); 46 | whenever(keys.MediaTrackPrevious, playbackManager.setPreviousItem); 47 | whenever(keys.MediaFastForward, playbackManager.skipForward); 48 | whenever(keys.MediaRewind, playbackManager.skipBackward); 49 | whenever(keys.AudioVolumeMute, playbackManager.toggleMute); 50 | whenever(keys.AudioVolumeUp, playbackManager.volumeUp); 51 | whenever(keys.AudioVolumeDown, playbackManager.volumeDown); 52 | 53 | return { fullscreen }; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/composables/use-responsive-classes.ts: -------------------------------------------------------------------------------- 1 | import { vuetify } from '@/plugins/vuetify'; 2 | 3 | const display = vuetify.display; 4 | 5 | /** 6 | * Returns an additional class based on current Vuetify breakpoint. 7 | * Possibilities: 8 | * * lg, lg-and-down, lg-and-up 9 | * * md, md-and-down, md-and-up 10 | * * sm, sm-and-down, sm-and-up 11 | * * xl, xl-and-down, xl-and-up 12 | * * xs 13 | * * xxl 14 | * * mobile 15 | * 16 | * Additionally to all the classes stated above, a mobile class is also added 17 | * when the mobile breakpoint is active. 18 | */ 19 | export function useResponsiveClasses(classes: string): string { 20 | let out = classes; 21 | 22 | if (display.lg.value) { 23 | out += ' lg'; 24 | } 25 | 26 | if (display.lgAndDown.value) { 27 | out += ' lg-and-down'; 28 | } 29 | 30 | if (display.lgAndUp.value) { 31 | out += ' lg-and-up'; 32 | } 33 | 34 | if (display.md.value) { 35 | out += ' md'; 36 | } 37 | 38 | if (display.mdAndDown.value) { 39 | out += ' md-and-down'; 40 | } 41 | 42 | if (display.mdAndUp.value) { 43 | out += ' md-and-up'; 44 | } 45 | 46 | if (display.sm.value) { 47 | out += ' sm'; 48 | } 49 | 50 | if (display.smAndDown.value) { 51 | out += ' sm-and-down'; 52 | } 53 | 54 | if (display.smAndUp.value) { 55 | out += ' sm-and-up'; 56 | } 57 | 58 | if (display.xl.value) { 59 | out += ' xl'; 60 | } 61 | 62 | if (display.xlAndDown.value) { 63 | out += ' xl-and-down'; 64 | } 65 | 66 | if (display.xlAndUp.value) { 67 | out += ' xl-and-up'; 68 | } 69 | 70 | if (display.xs.value) { 71 | out += ' xs'; 72 | } 73 | 74 | if (display.xxl.value) { 75 | out += ' xxl'; 76 | } 77 | 78 | if (display.mobile.value) { 79 | out += ' mobile'; 80 | } 81 | 82 | return out; 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/composables/use-snackbar.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * == COMPONENT COMPOSABLE == 3 | * 4 | * The definition of the composable are in the relevant component, 5 | * so the code that tracks the sate of the component are alongside the component itself. 6 | * 7 | * We could re-define it here, but we would lose access to the 8 | * JSDoc of the original: that's why we just re-export it again. 9 | */ 10 | export { useSnackbar } from '@/components/System/Snackbar.vue'; 11 | -------------------------------------------------------------------------------- /frontend/src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 53 | -------------------------------------------------------------------------------- /frontend/src/layouts/fullpage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /frontend/src/layouts/server.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 17 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Top-level await requires ES2022 (at least) as target and module 3 | * for TypeScript compiler (check tsconfig.json) 4 | * https://caniuse.com/mdn-javascript_operators_await_top_level 5 | */ 6 | import { createApp } from 'vue'; 7 | import { routes } from 'vue-router/auto-routes'; 8 | import { getFontFaces } from '@/utils/data-manipulation'; 9 | import Root from '@/App.vue'; 10 | import { hideDirective } from '@/plugins/directives'; 11 | import { vuePlugin as i18n } from '@/plugins/i18n'; 12 | import { createPlugin as createRemote } from '@/plugins/remote'; 13 | import { router } from '@/plugins/router'; 14 | import { vuetify } from '@/plugins/vuetify'; 15 | /** 16 | * - GLOBAL STYLES - 17 | */ 18 | import 'uno.css'; 19 | import 'virtual:unocss-devtools'; 20 | import '@/assets/styles/index.css'; 21 | 22 | /** 23 | * - VUE PLUGINS, STORE AND DIRECTIVE - 24 | * The order of statements IS IMPORTANT 25 | */ 26 | const remote = createRemote(); 27 | const app = createApp(Root); 28 | 29 | /** 30 | * We add routes at this point instead of in the router plugin to avoid circular references 31 | * in components. At this stage, we're sure plugins are instantiated. 32 | */ 33 | for (const route of routes) { 34 | router.addRoute(route); 35 | } 36 | 37 | app.use(remote); 38 | app.use(i18n); 39 | app.use(router); 40 | app.use(vuetify); 41 | app.directive('hide', hideDirective); 42 | 43 | /** 44 | * Ensure everything is fully loaded before mounting the app 45 | */ 46 | await Promise.all([ 47 | router.isReady(), 48 | ...getFontFaces().map(font => font.load()) 49 | ]); 50 | await document.fonts.ready; 51 | 52 | /** 53 | * MOUNTING POINT 54 | */ 55 | app.mount(document.body); 56 | -------------------------------------------------------------------------------- /frontend/src/pages/metadata.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 97 | 112 | -------------------------------------------------------------------------------- /frontend/src/pages/server/add.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | meta: 21 | layout: 22 | name: server 23 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /frontend/src/pages/server/select.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | meta: 36 | layout: 37 | name: server 38 | 39 | 40 | 49 | -------------------------------------------------------------------------------- /frontend/src/pages/settings/subtitles.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 56 | -------------------------------------------------------------------------------- /frontend/src/pages/settings/users/index.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 59 | meta: 60 | admin: true 61 | 62 | 63 | 74 | 75 | 85 | -------------------------------------------------------------------------------- /frontend/src/plugins/directives.ts: -------------------------------------------------------------------------------- 1 | import type { DirectiveBinding } from 'vue'; 2 | 3 | /** 4 | * Toggles the CSS 'visibility' property of an element. 5 | */ 6 | export function hideDirective( 7 | element: HTMLElement, 8 | binding: DirectiveBinding 9 | ): void { 10 | if (element) { 11 | element.style.visibility = binding.value ? 'hidden' : 'visible'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | import messages from '@intlify/unplugin-vue-i18n/messages'; 3 | 4 | /** 5 | * See @/store/clientSettings to check where the current user language is initialised 6 | */ 7 | 8 | const DEFAULT_LANGUAGE = 'en'; 9 | 10 | export const vuePlugin = createI18n({ 11 | fallbackLocale: DEFAULT_LANGUAGE, 12 | globalInjection: true, 13 | legacy: false, 14 | messages: messages 15 | }); 16 | 17 | export const i18n = vuePlugin.global; 18 | -------------------------------------------------------------------------------- /frontend/src/plugins/remote/axios.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Instantiates the Axios instance used for the SDK and requests 3 | */ 4 | import axios, { 5 | type AxiosError 6 | } from 'axios'; 7 | import auth from './auth'; 8 | import { useSnackbar } from '@/composables/use-snackbar'; 9 | import { i18n } from '@/plugins/i18n'; 10 | import { sealed } from '@/utils/validation'; 11 | 12 | @sealed 13 | class RemotePluginAxios { 14 | public readonly instance = axios.create(); 15 | private readonly _defaults = this.instance.defaults; 16 | 17 | public resetDefaults(): void { 18 | this.instance.defaults = this._defaults; 19 | } 20 | 21 | /** 22 | * Intercepts 401 (Unathorized) error code and logs out the user inmmediately, 23 | * as the session probably has been revoked remotely. 24 | */ 25 | public logoutInterceptor = async (error: AxiosError): Promise => { 26 | if ( 27 | error.response?.status === 401 28 | && auth.currentUser 29 | && !error.config?.url?.includes('/Sessions/Logout') 30 | && !error.config?.url?.includes('/Users/Me') 31 | ) { 32 | try { 33 | await auth.refreshCurrentUserInfo(); 34 | } catch { 35 | await auth.logoutCurrentUser(true); 36 | useSnackbar(i18n.t('kickedOut'), 'error'); 37 | } 38 | } 39 | 40 | /** 41 | * Pass the error so it's handled in try/catch blocks afterwards 42 | */ 43 | throw error; 44 | }; 45 | 46 | public constructor() { 47 | this.instance.interceptors.response.use( 48 | undefined, 49 | this.logoutInterceptor 50 | ); 51 | } 52 | } 53 | 54 | const RemotePluginAxiosInstance = new RemotePluginAxios(); 55 | 56 | export default RemotePluginAxiosInstance; 57 | -------------------------------------------------------------------------------- /frontend/src/plugins/remote/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The 'remote' plugin includes the tools needed to interact with a Jellyfin 3 | * server: 4 | * - Authentication store ($remote.auth) 5 | * - Axios as the request performer ($remote.axios) 6 | * - Jellyfin SDK ($remote.sdk) 7 | * - WebSocket ($remote.socket) 8 | */ 9 | import type { App } from 'vue'; 10 | import RemotePluginAuthInstance from './auth'; 11 | import RemotePluginSDKInstance from './sdk'; 12 | import RemotePluginSocketInstance from './socket'; 13 | import { isNil, sealed } from '@/utils/validation'; 14 | import { getJSONConfig } from '@/utils/external-config'; 15 | 16 | @sealed 17 | class RemotePlugin { 18 | public readonly auth = RemotePluginAuthInstance; 19 | public readonly sdk = RemotePluginSDKInstance; 20 | public readonly socket = RemotePluginSocketInstance; 21 | } 22 | 23 | export const remote = new RemotePlugin(); 24 | 25 | /** 26 | * Installs the remote plugin into the Vue instance to enable the usage of 27 | * $remote to access all the tools for handling a Jellyfin server connection. 28 | */ 29 | export function createPlugin(): { 30 | install: (app: App) => Promise; 31 | } { 32 | return { 33 | install: async (app: App): Promise => { 34 | /** 35 | * `remote` is readonly but this is the one place it should actually be set 36 | */ 37 | (app.config.globalProperties.$remote as typeof remote) 38 | = remote; 39 | 40 | const auth = remote.auth; 41 | const config = await getJSONConfig(); 42 | const defaultServers = config.defaultServerURLs; 43 | /** 44 | * We reverse the list so the first server is the last to be connected, 45 | * and thus is the chosen one by default 46 | */ 47 | const missingServers = defaultServers 48 | .filter((serverUrl) => { 49 | const server = auth.servers.find( 50 | lsServer => lsServer.PublicAddress === serverUrl 51 | ); 52 | 53 | return isNil(server); 54 | }).reverse(); 55 | 56 | for (const serverUrl of missingServers) { 57 | await auth.connectServer(serverUrl, true); 58 | } 59 | } 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/plugins/remote/sdk/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This plugin instantiates the Jellyfin SDK. 3 | * It also sets the header and base URL for our axios instance 4 | */ 5 | import type { Api } from '@jellyfin/sdk'; 6 | import { watchSyncEffect } from 'vue'; 7 | import RemotePluginAuthInstance from '../auth'; 8 | import RemotePluginAxiosInstance from '../axios'; 9 | import SDK, { useOneTimeAPI } from './sdk-utils'; 10 | import { isNil, sealed } from '@/utils/validation'; 11 | 12 | @sealed 13 | class RemotePluginSDK { 14 | private readonly sdk = SDK; 15 | public readonly clientInfo = this.sdk.clientInfo; 16 | public readonly deviceInfo = this.sdk.deviceInfo; 17 | public readonly discovery = this.sdk.discovery; 18 | public api: Api | undefined; 19 | 20 | public constructor(auth: typeof RemotePluginAuthInstance) { 21 | /** 22 | * Configure app's axios instance to perform requests to the given Jellyfin server. 23 | */ 24 | watchSyncEffect(() => { 25 | const server = auth.currentServer; 26 | const accessToken = auth.currentUserToken; 27 | 28 | if (isNil(server)) { 29 | RemotePluginAxiosInstance.resetDefaults(); 30 | this.api = undefined; 31 | } else { 32 | this.api = this.sdk.createApi( 33 | server.PublicAddress, 34 | accessToken, 35 | RemotePluginAxiosInstance.instance 36 | ); 37 | RemotePluginAxiosInstance.instance.defaults.baseURL 38 | = server.PublicAddress; 39 | } 40 | }); 41 | } 42 | 43 | public oneTimeSetup = useOneTimeAPI; 44 | /** 45 | * Generates a Jellyfin API type with the current API instance. 46 | * 47 | * USE WITH CAUTION. Make sure this is only used in places where an user is logged in 48 | */ 49 | public newUserApi(apiSec: (api: Api) => T): T { 50 | // We want to explicitly assume the user is already logged in here 51 | return apiSec(this.api!); 52 | } 53 | } 54 | 55 | const RemotePluginSDKInstance = new RemotePluginSDK(RemotePluginAuthInstance); 56 | 57 | export default RemotePluginSDKInstance; 58 | -------------------------------------------------------------------------------- /frontend/src/plugins/remote/sdk/sdk-utils.ts: -------------------------------------------------------------------------------- 1 | import { type Api, Jellyfin } from '@jellyfin/sdk'; 2 | import { v4 } from 'uuid'; 3 | import { 4 | isAndroid, 5 | isApple, 6 | isChrome, 7 | isChromiumBased, 8 | isEdge, 9 | isFirefox, 10 | isMobile, 11 | isTizen, 12 | isWebOS 13 | } from '@/utils/browser-detection'; 14 | import { version } from '@/../package.json'; 15 | 16 | /** 17 | * Returns the device ID, creating it in case it does not exist 18 | */ 19 | function ensureDeviceId(): string { 20 | const storageKey = 'deviceId'; 21 | const val = window.localStorage.getItem(storageKey); 22 | 23 | if (!val) { 24 | const id = v4(); 25 | 26 | window.localStorage.setItem(storageKey, id); 27 | 28 | return id; 29 | } 30 | 31 | return val; 32 | } 33 | 34 | const SDK = new Jellyfin({ 35 | clientInfo: { 36 | name: 'Jellyfin Web (Vue)', 37 | version: version 38 | }, 39 | deviceInfo: { 40 | name: getDeviceName(), 41 | id: ensureDeviceId() 42 | } 43 | }); 44 | 45 | /** 46 | * Gets the device's name based on the browser's user agent. 47 | */ 48 | function getDeviceName(): string { 49 | let deviceName = 'Unknown'; 50 | 51 | if (isChrome()) { 52 | deviceName = 'Chrome'; 53 | } else if (isEdge() && !isChromiumBased()) { 54 | deviceName = 'Edge (EdgeHTML)'; 55 | } else if (isEdge()) { 56 | deviceName = 'Edge (Chromium)'; 57 | } else if (isFirefox()) { 58 | deviceName = 'Firefox'; 59 | } else if (isApple() && !isMobile()) { 60 | deviceName = 'Safari'; 61 | } else if (isWebOS()) { 62 | deviceName = 'LG Smart TV'; 63 | } else if (isTizen()) { 64 | deviceName = 'Samsung Smart TV'; 65 | } else if (isApple() && isMobile()) { 66 | deviceName = 'iPhone'; 67 | } else if (isAndroid()) { 68 | deviceName = 'Android'; 69 | } 70 | 71 | return deviceName; 72 | } 73 | 74 | /** 75 | * Connects to the given server with the given credentials without 76 | * altering the app's API/SDK or axios instance. 77 | */ 78 | export function useOneTimeAPI( 79 | ...arguments_: Parameters 80 | ): Api { 81 | return SDK.createApi(...arguments_); 82 | } 83 | 84 | export default SDK; 85 | -------------------------------------------------------------------------------- /frontend/src/plugins/remote/types.d.ts: -------------------------------------------------------------------------------- 1 | import type RemotePluginAuthInstance from './auth'; 2 | import type RemotePluginSDKInstance from './sdk'; 3 | import type RemotePluginSocketInstance from './socket'; 4 | 5 | export interface RemotePlugin { 6 | sdk: typeof RemotePluginSDKInstance; 7 | auth: typeof RemotePluginAuthInstance; 8 | socket: typeof RemotePluginSocketInstance; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/middlewares/admin-pages.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router'; 2 | import { useSnackbar } from '@/composables/use-snackbar'; 3 | import { i18n } from '@/plugins/i18n'; 4 | import { remote } from '@/plugins/remote'; 5 | 6 | /** 7 | * Redirect the user to index page when attempting to access 8 | * an admin page in settings. 9 | */ 10 | export function adminGuard( 11 | to: RouteLocationNormalized 12 | ): boolean | RouteLocationRaw { 13 | if (to.meta.admin && !remote.auth.currentUser?.Policy?.IsAdministrator) { 14 | useSnackbar(i18n.t('unauthorized'), 'error'); 15 | 16 | return { path: '/', replace: true }; 17 | } 18 | 19 | return true; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/middlewares/login.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RouteLocationNormalized, 3 | RouteLocationPathRaw, 4 | RouteLocationRaw 5 | } from 'vue-router'; 6 | import { until } from '@vueuse/core'; 7 | import { remote } from '@/plugins/remote'; 8 | import { isNil } from '@/utils/validation'; 9 | import { getJSONConfig } from '@/utils/external-config'; 10 | 11 | const serverAddUrl = '/server/add'; 12 | const serverSelectUrl = '/server/select'; 13 | const serverLoginUrl = '/server/login'; 14 | const serverRoutes = new Set([serverAddUrl, serverSelectUrl]); 15 | const routes = new Set([...serverRoutes, serverLoginUrl]); 16 | 17 | /** 18 | * Performs the login guard redirection ensuring no redirection loops happen 19 | */ 20 | function doRedir(dest: RouteLocationPathRaw, to: RouteLocationNormalized) { 21 | return to.path === dest.path 22 | ? true 23 | : dest; 24 | } 25 | 26 | /** 27 | * Redirects to login page if there's no user logged in. 28 | */ 29 | export async function loginGuard( 30 | to: RouteLocationNormalized 31 | ): Promise { 32 | const jsonConfig = await getJSONConfig(); 33 | 34 | if (jsonConfig.defaultServerURLs.length && isNil(remote.auth.currentServer)) { 35 | await until(() => remote.auth.currentServer).toBeTruthy({ flush: 'pre' }); 36 | } 37 | 38 | if ( 39 | ( 40 | !jsonConfig.allowServerSelection 41 | && serverRoutes.has(to.path) 42 | ) 43 | || ( 44 | !isNil(remote.auth.currentServer) 45 | && !isNil(remote.auth.currentUser) 46 | && !isNil(remote.auth.currentUserToken) 47 | && routes.has(to.path) 48 | ) 49 | ) { 50 | return doRedir({ path: '/', replace: true }, to); 51 | } 52 | 53 | if (!remote.auth.servers.length) { 54 | return doRedir({ path: serverAddUrl, replace: true }, to); 55 | } else if (isNil(remote.auth.currentServer)) { 56 | return doRedir({ path: serverSelectUrl, replace: true }, to); 57 | } else if (isNil(remote.auth.currentUser)) { 58 | return doRedir({ path: serverLoginUrl, replace: true }, to); 59 | } 60 | 61 | return true; 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/middlewares/meta.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu'; 2 | import { ref, toRaw } from 'vue'; 3 | import type { 4 | RouteLocationNormalized, 5 | RouteLocationRaw, 6 | RouteMeta 7 | } from 'vue-router'; 8 | 9 | const defaultMeta: RouteMeta = { 10 | layout: { 11 | transition: {} 12 | } 13 | }; 14 | 15 | const reactiveMeta = ref(structuredClone(defaultMeta)); 16 | 17 | /** 18 | * This middleware handles the meta property between routes 19 | * 20 | * The layout of the destination page needs to exist before the route 21 | * is accessed. This is why we need the following block in pages: 22 | * 23 | * 24 | * meta: 25 | * layout: 26 | * name: server 27 | * 28 | * 29 | * That block is also needed when a property needs to be resolved before 30 | * the component is instantiated (i.e, the admin property, so the correct redirection is done) 31 | * 32 | * That populates the meta property through Vite at build time, so the router 33 | * instantiation looks like this: https://router.vuejs.org/guide/advanced/meta.html#route-meta-fields 34 | * 35 | * If we want to change the meta at runtime (for page title, backdrop, etc), we need to merge 36 | * whatever default value is defined in the route block with our custom properties. In order 37 | * to ensure consistency, we pass an object with defaults that matches the *RouteMeta* type 38 | * present at the plugins.d.ts file 39 | */ 40 | export function metaGuard( 41 | to: RouteLocationNormalized, 42 | from: RouteLocationNormalized 43 | ): boolean | RouteLocationRaw { 44 | reactiveMeta.value = defu(to.meta, structuredClone(defaultMeta)); 45 | /** 46 | * This is needed to ensure all the meta matches the expected data 47 | */ 48 | from.meta = defu(toRaw(from.meta), structuredClone(defaultMeta)); 49 | to.meta = reactiveMeta.value; 50 | 51 | if (from.meta.layout.transition.leave) { 52 | to.meta.layout.transition.enter = from.meta.layout.transition.leave; 53 | } 54 | 55 | return true; 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/middlewares/playback.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationRaw } from 'vue-router'; 2 | import { playbackManager } from '@/store/playback-manager'; 3 | import { isNil } from '@/utils/validation'; 4 | 5 | /** 6 | * Validates that no playback is happening when accesing a route 7 | */ 8 | export function playbackGuard(): RouteLocationRaw | boolean { 9 | if (isNil(playbackManager.currentItem)) { 10 | return { path: '/', replace: true }; 11 | } 12 | 13 | return true; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/middlewares/validate.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router'; 2 | import { useSnackbar } from '@/composables/use-snackbar'; 3 | import { i18n } from '@/plugins/i18n'; 4 | import { isStr } from '@/utils/validation'; 5 | 6 | /** 7 | * Validates that the route has a correct itemId parameter by checking that the parameter is a valid 8 | * MD5 hash. 9 | */ 10 | export function validateGuard( 11 | to: RouteLocationNormalized 12 | ): boolean | RouteLocationRaw { 13 | if (('itemId' in to.params) && isStr(to.params.itemId)) { 14 | const check = /[\da-f]{32}/i.test(to.params.itemId); 15 | 16 | if (!check) { 17 | useSnackbar(i18n.t('routeValidationError'), 'error'); 18 | 19 | return { path: '/', replace: true }; 20 | } 21 | } 22 | 23 | return true; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import * as vuetifyLocales from 'virtual:locales/vuetify'; 2 | import { createVuetify, type ThemeDefinition } from 'vuetify'; 3 | import { md3 } from 'vuetify/blueprints'; 4 | import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'; 5 | import 'vuetify/styles'; 6 | 7 | const dark: ThemeDefinition = { 8 | colors: { 9 | accent: '#FF4081', 10 | background: '#111827', 11 | card: '#1c2331', 12 | chip: '#4b5563', 13 | divider: '#374151', 14 | error: '#FF5252', 15 | info: '#0099CC', 16 | menu: '#374151', 17 | surface: '#1f2937', 18 | primary: '#9d37c2', 19 | secondary: '#2f3951', 20 | success: '#4CAF50', 21 | thumb: '#252e41', 22 | warning: '#FB8C00' 23 | }, 24 | dark: true 25 | }; 26 | 27 | const light: ThemeDefinition = { 28 | colors: { 29 | accent: '#FF4081', 30 | background: '#f2f2f2', 31 | card: '#FFFFFF', 32 | error: '#FF5252', 33 | info: '#33b5e5', 34 | primary: '#9d37c2', 35 | secondary: '#424242', 36 | success: '#4CAF50', 37 | thumb: '#000000', 38 | warning: '#FB8C00', 39 | chip: '#e4e4e4', 40 | surface: '#f5f5f5' 41 | }, 42 | dark: false 43 | }; 44 | 45 | /** 46 | * If we don't define custom theme, Vuetify is going to take the 47 | * client's preferred color schema. 48 | */ 49 | export const vuetify = createVuetify({ 50 | blueprint: md3, 51 | defaults: { 52 | VSelect: { 53 | variant: 'outlined' 54 | }, 55 | VTextField: { 56 | variant: 'outlined', 57 | color: 'primary' 58 | }, 59 | VCheckbox: { 60 | color: 'primary' 61 | }, 62 | VProgressLinear: { 63 | color: 'primary' 64 | }, 65 | VBtn: { 66 | color: undefined, 67 | variant: 'text' 68 | }, 69 | VTooltip: { 70 | activator: 'parent' 71 | }, 72 | VMenu: { 73 | activator: 'parent' 74 | }, 75 | VChip: { 76 | rounded: true 77 | } 78 | }, 79 | locale: { 80 | fallback: 'en', 81 | messages: vuetifyLocales 82 | }, 83 | theme: { 84 | themes: { 85 | dark, 86 | light 87 | } 88 | }, 89 | icons: { 90 | defaultSet: 'mdi', 91 | aliases, 92 | sets: { 93 | mdi 94 | } 95 | } 96 | }); 97 | -------------------------------------------------------------------------------- /frontend/src/plugins/workers/blurhash-decoder.worker.ts: -------------------------------------------------------------------------------- 1 | import { decode } from 'blurhash'; 2 | import { expose } from 'comlink'; 3 | import { sealed } from '@/utils/validation'; 4 | 5 | /** 6 | * Decodes blurhash strings into pixels 7 | */ 8 | @sealed 9 | class BlurhashDecoder { 10 | private readonly _pixelsCache = new Map(); 11 | 12 | /** 13 | * Decodes blurhash outside the main thread, in a web worker. 14 | * 15 | * @param hash - Hash to decode. 16 | * @param width - Width of the decoded pixel array 17 | * @param height - Height of the decoded pixel array. 18 | * @param punch - Contrast of the decoded pixels 19 | * @returns - Returns the decoded pixels in the proxied response by Comlink 20 | */ 21 | public readonly getPixels = ( 22 | hash: string, 23 | width: number, 24 | height: number, 25 | punch: number 26 | ): Uint8ClampedArray => { 27 | try { 28 | const params = String([hash, width, height, punch]); 29 | let pixels = this._pixelsCache.get(params); 30 | 31 | if (!pixels) { 32 | pixels = decode(hash, width, height, punch); 33 | this._pixelsCache.set(params, pixels); 34 | } 35 | 36 | return pixels; 37 | } catch { 38 | throw new TypeError(`Blurhash ${hash} is not valid`); 39 | } 40 | }; 41 | 42 | /** 43 | * Clear the blurhashes cache 44 | */ 45 | public readonly clearCache = (): void => { 46 | this._pixelsCache.clear(); 47 | }; 48 | } 49 | 50 | const instance = new BlurhashDecoder(); 51 | export default instance; 52 | export type IBlurhashDecoder = typeof instance; 53 | 54 | expose(instance); 55 | -------------------------------------------------------------------------------- /frontend/src/plugins/workers/canvas-drawer.worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from 'comlink'; 2 | import { sealed } from '@/utils/validation'; 3 | 4 | /** 5 | * Draws canvases offscreen 6 | */ 7 | @sealed 8 | class CanvasDrawer { 9 | /** 10 | * Draws a transferred canvas from the main thread 11 | * 12 | * @param canvas - Canvas to draw the decoded pixels. Must come from main thread's canvas.transferControlToOffscreen() 13 | * @param width - Width of the target imageData 14 | * @param height - Height of the target imageData 15 | */ 16 | public readonly drawBlurhash = ({ 17 | canvas, pixels, width, height 18 | }: { canvas: OffscreenCanvas; pixels: Uint8ClampedArray; width: number; height: number }) => { 19 | const ctx = canvas.getContext('2d'); 20 | const imageData = ctx!.createImageData(width, height); 21 | 22 | imageData.data.set(pixels); 23 | ctx!.putImageData(imageData, 0, 0); 24 | }; 25 | } 26 | 27 | const instance = new CanvasDrawer(); 28 | export default instance; 29 | export type ICanvasDrawer = typeof instance; 30 | 31 | expose(instance); 32 | -------------------------------------------------------------------------------- /frontend/src/plugins/workers/generic.worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from 'comlink'; 2 | import { parseSsaFile, parseVttFile } from './generic/subtitles'; 3 | import { sealed } from '@/utils/validation'; 4 | 5 | /** 6 | * All functions that could take some time to complete and block the main thread 7 | * must be offloaded to this worker 8 | */ 9 | @sealed 10 | class GenericWorker { 11 | /** 12 | * Shuffles an array using the Durstenfeld shuffle algorithm, an 13 | * optimized version of Fisher-Yates shuffle. 14 | */ 15 | public shuffle(array: T[]) { 16 | for (let i = array.length - 1; i > 0; i--) { 17 | const j = Math.floor(Math.random() * (i + 1)); 18 | 19 | [array[i], array[j]] = [array[j], array[i]]; 20 | } 21 | 22 | return array; 23 | }; 24 | 25 | /** 26 | * Functions for parsing subtitles 27 | */ 28 | public parseVttFile = parseVttFile; 29 | public parseSsaFile = parseSsaFile; 30 | } 31 | 32 | const instance = new GenericWorker(); 33 | export default instance; 34 | export type IGenericWorker = typeof instance; 35 | 36 | expose(instance); 37 | -------------------------------------------------------------------------------- /frontend/src/plugins/workers/index.ts: -------------------------------------------------------------------------------- 1 | import { wrap } from 'comlink'; 2 | import type { IBlurhashDecoder } from './blurhash-decoder.worker'; 3 | import BlurhashDecoder from './blurhash-decoder.worker?worker'; 4 | import type { ICanvasDrawer } from './canvas-drawer.worker'; 5 | import CanvasDrawer from './canvas-drawer.worker?worker'; 6 | import type { IGenericWorker } from './generic.worker'; 7 | import GenericWorker from './generic.worker?worker'; 8 | 9 | /** 10 | * A worker for decoding blurhash strings into pixels 11 | */ 12 | export const blurhashDecoder = wrap(new BlurhashDecoder()); 13 | 14 | /** 15 | * A worker for drawing canvas offscreen. The canvas must be transferred like this: 16 | * ```ts 17 | * import { transfer } from 'comlink'; 18 | * 19 | * await canvasDrawer.drawBlurhash(transfer( 20 | * { canvas: offscreen, 21 | * pixels, 22 | * width, 23 | * height 24 | * }, [offscreen])); 25 | * ``` 26 | */ 27 | export const canvasDrawer = wrap(new CanvasDrawer()); 28 | 29 | /** 30 | * A worker for running any non-specific function that could be expensive and take some time to complete, 31 | * blocking the main thread 32 | */ 33 | export const genericWorker = wrap(new GenericWorker()); 34 | -------------------------------------------------------------------------------- /frontend/src/splashscreen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This modules handles the splashscreen color scheme based on user-agent preferences or stored settings 3 | * before Vue is loaded. 4 | * 5 | * When Vue is loaded, it gets replaced by the main application and the JSplashscreen (used in App.vue) 6 | * is displayed instead. 7 | */ 8 | import { destr } from 'destr'; 9 | import type { ClientSettingsState } from '@/store/client-settings'; 10 | import { isBool } from '@/utils/validation'; 11 | import '@/assets/styles/splashscreen.css'; 12 | 13 | const store = localStorage.getItem('clientSettings') ?? '{}'; 14 | const parsedStore = destr(store); 15 | const matchedDarkColorScheme = window.matchMedia( 16 | '(prefers-color-scheme: dark)' 17 | ).matches; 18 | const darkColor = '#111827'; 19 | const lightColor = '#f2f2f2'; 20 | let colorToApply: typeof darkColor | typeof lightColor = matchedDarkColorScheme ? darkColor : lightColor; 21 | 22 | if ('darkMode' in parsedStore) { 23 | const storeDarkMode = parsedStore.darkMode; 24 | 25 | if (isBool(storeDarkMode)) { 26 | colorToApply = parsedStore.darkMode === true ? darkColor : lightColor; 27 | } 28 | } 29 | 30 | document.body.style.setProperty('--j-color-background', colorToApply); 31 | -------------------------------------------------------------------------------- /frontend/src/store/client-settings/subtitle-settings.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue'; 2 | import { remote } from '@/plugins/remote'; 3 | import { sealed } from '@/utils/validation'; 4 | import { SyncedStore } from '@/store/super/synced-store'; 5 | import type { TypographyChoices } from '@/store'; 6 | 7 | /** 8 | * == INTERFACES AND TYPES == 9 | */ 10 | 11 | export interface SubtitleSettingsState { 12 | /** 13 | * Whether the customization of the subtitles is enabled or not 14 | * @default: false 15 | */ 16 | enabled: boolean; 17 | /** 18 | * default: Default application typography. 19 | * 20 | * system: System typography 21 | * 22 | * auto: Selects the current selected typography for the application 23 | * @default: auto 24 | */ 25 | fontFamily: 'auto' | TypographyChoices; 26 | fontSize: number; 27 | positionFromBottom: number; 28 | backdrop: boolean; 29 | stroke: boolean; 30 | } 31 | 32 | @sealed 33 | class SubtitleSettingsStore extends SyncedStore { 34 | public state = this._state; 35 | 36 | public constructor() { 37 | super('subtitleSettings', { 38 | enabled: false, 39 | fontFamily: 'auto', 40 | fontSize: 1.5, 41 | positionFromBottom: 10, 42 | backdrop: true, 43 | stroke: false 44 | }, 'localStorage', [ 45 | 'enabled', 46 | 'fontSize', 47 | 'positionFromBottom', 48 | 'backdrop', 49 | 'stroke' 50 | ]); 51 | 52 | /** 53 | * == WATCHERS == 54 | */ 55 | watch( 56 | () => remote.auth.currentUser, 57 | () => { 58 | if (!remote.auth.currentUser) { 59 | this._reset(); 60 | } 61 | }, { flush: 'post' } 62 | ); 63 | } 64 | } 65 | 66 | export const subtitleSettings = new SubtitleSettingsStore(); 67 | -------------------------------------------------------------------------------- /frontend/src/store/super/common-store.ts: -------------------------------------------------------------------------------- 1 | import { useStorage, type RemovableRef } from '@vueuse/core'; 2 | import { reactive, toValue } from 'vue'; 3 | import { mergeExcludingUnknown } from '@/utils/data-manipulation'; 4 | import { isNil } from '@/utils/validation'; 5 | 6 | export type Persistence = 'localStorage' | 'sessionStorage'; 7 | 8 | export abstract class CommonStore { 9 | protected readonly _storeKey: string; 10 | private readonly _defaultState: T; 11 | private readonly _internalState: T | RemovableRef; 12 | 13 | protected get _state(): T { 14 | return toValue(this._internalState); 15 | } 16 | 17 | protected readonly _reset = (): void => { 18 | Object.assign(this._state, this._defaultState); 19 | }; 20 | 21 | protected constructor(storeKey: string, defaultState: T, persistence?: Persistence) { 22 | this._storeKey = storeKey; 23 | this._defaultState = defaultState; 24 | 25 | let storage; 26 | 27 | if (persistence === 'localStorage') { 28 | storage = window.localStorage; 29 | } else if (persistence === 'sessionStorage') { 30 | storage = window.sessionStorage; 31 | } 32 | 33 | this._internalState = isNil(storage) 34 | ? reactive(structuredClone(defaultState)) as T 35 | : useStorage(storeKey, structuredClone(defaultState), storage, { 36 | mergeDefaults: (storageValue, defaults) => 37 | mergeExcludingUnknown(storageValue, defaults) 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/utils/data-manipulation.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu'; 2 | import { toRaw } from 'vue'; 3 | import { genericWorker } from '@/plugins/workers'; 4 | 5 | /** 6 | * Merge 2 objects, excluding the keys from the destination that are not present in source 7 | * 8 | * @param object - Target object. This one contains keys that might not be present in defaults 9 | * @param defaultObject - Sample/default representation of the object that should be used to detect which keys 10 | * should/shouldn't exist in the target. 11 | */ 12 | export function mergeExcludingUnknown( 13 | object: T, 14 | defaultObject: T 15 | ): T { 16 | const defaultKeys = new Set(Object.keys(defaultObject) as (keyof T)[]); 17 | const missingKeys = (Object.keys(object) as (keyof T)[]).filter( 18 | key => !defaultKeys.has(key) 19 | ); 20 | 21 | object = defu(object, defaultObject); 22 | 23 | for (const key of missingKeys) { 24 | delete object[key]; 25 | } 26 | 27 | return object; 28 | } 29 | 30 | /** 31 | * Uppercase the first letter of a string 32 | */ 33 | export function upperFirst(str: T): Capitalize { 34 | return (str[0].toUpperCase() + str.slice(1)) as Capitalize; 35 | } 36 | 37 | /** 38 | * Get the font faces present in the document. 39 | * 40 | * Instead of using a normal iterable (like `...[...document.fonts.keys()]`), 41 | * we need this for Firefox compatibility. 42 | * 43 | * See https://github.com/jellyfin/jellyfin-vue/issues/2432 44 | */ 45 | export function getFontFaces() { 46 | const iterable = document.fonts.keys(); 47 | const results = []; 48 | let iterator = iterable.next(); 49 | 50 | while (iterator.done === false) { 51 | results.push(iterator.value); 52 | 53 | iterator = iterable.next(); 54 | } 55 | 56 | return results; 57 | } 58 | 59 | /** 60 | * Picks certain keys from an object and returns a new object with only those keys. 61 | */ 62 | export function pick(object: T, keys: (keyof T)[]): Partial { 63 | const res = {} as Partial; 64 | 65 | for (const key of keys) { 66 | if (key in object) { 67 | res[key] = object[key]; 68 | } 69 | } 70 | 71 | return res; 72 | } 73 | 74 | /** 75 | * Shuffles an array in a WebWorker using the Durstenfeld shuffle algorithm, an 76 | * optimized version of Fisher-Yates shuffle. 77 | * 78 | * It's also prepared for the case when the array is reactive thorugh Vue's `ref` or `reactive`. 79 | */ 80 | export async function shuffle(array: T[]): Promise { 81 | return await genericWorker.shuffle(toRaw(array)) as T[]; 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/utils/external-config.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isBool, isNil, isObj, isStr } from '@/utils/validation'; 2 | 3 | interface ExternalJSONConfig { 4 | defaultServerURLs: string[]; 5 | allowServerSelection: boolean; 6 | routerMode: 'hash' | 'history'; 7 | } 8 | 9 | let externalConfig: ExternalJSONConfig | undefined; 10 | 11 | /** 12 | * Asserts that the config parameter is a valid configuration shape 13 | */ 14 | function validateJsonConfig( 15 | config: unknown 16 | ): asserts config is ExternalJSONConfig { 17 | if (!isObj(config)) { 18 | throw new Error('Expected not null or defined config'); 19 | } 20 | 21 | if ( 22 | !('defaultServerURLs' in config) 23 | || !isArray(config.defaultServerURLs) 24 | ) { 25 | throw new Error('Expected defaultServerURLS array'); 26 | } 27 | 28 | if ( 29 | config.defaultServerURLs.some( 30 | defaultServerURL => !isStr(defaultServerURL) 31 | ) 32 | ) { 33 | throw new Error('Expected defaultServerURLs to be a list of strings'); 34 | } 35 | 36 | if (!('allowServerSelection' in config) || !isBool(config.allowServerSelection)) { 37 | throw new Error('Expected allowServerSelection to be boolean'); 38 | } 39 | 40 | if ( 41 | !('routerMode' in config) 42 | || !isStr(config.routerMode) 43 | || !['hash', 'history'].includes(config.routerMode) 44 | ) { 45 | throw new Error('Expected router mode to be either hash or history'); 46 | } 47 | } 48 | 49 | /** 50 | * Fetch configuration at runtime from the config.json file 51 | * We use destr for serialization as it has better support for JS primitives. 52 | */ 53 | export async function getJSONConfig(): Promise { 54 | if (isNil(externalConfig)) { 55 | const loadedConfig: unknown = await ( 56 | await fetch('config.json', { cache: 'no-store' }) 57 | ).json(); 58 | 59 | validateJsonConfig(loadedConfig); 60 | 61 | externalConfig = loadedConfig; 62 | } 63 | 64 | return externalConfig; 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/utils/forms.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper for form related functions 3 | * 4 | */ 5 | 6 | export interface VSelectItem { 7 | value: T; 8 | } 9 | 10 | /** 11 | * Returns a suitable list for use with the 'item' prop for v-select 12 | * 13 | * @param values - list of values to use for the v-select 14 | * @returns list ready to be used in the :item property of a v-select 15 | */ 16 | export function getItemizedSelect(values: T[]): VSelectItem[] { 17 | return values.map((value) => { 18 | return { value }; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/utils/html.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper for HTML manipulation and sanitization 3 | */ 4 | import DOMPurify from 'dompurify'; 5 | import { parse } from 'marked'; 6 | 7 | /** 8 | * Sanitizes a string containing HTML tags and replaces newlines with the proper HTML tag. 9 | * 10 | * @param input - string to sanitize 11 | * @returns a cleaned up string 12 | */ 13 | export function sanitizeHtml(input: string, isMarkdown = false): string { 14 | // Some providers have newlines, replace them with the proper tag. 15 | const cleanString = input.replaceAll(/\r\n|\r|\n/g, '
'); 16 | const inputString = isMarkdown ? parse(cleanString, { async: false }) : cleanString; 17 | 18 | return DOMPurify.sanitize( 19 | inputString, 20 | { 21 | USE_PROFILES: { html: true } 22 | } 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { upperFirst } from '@/utils/data-manipulation'; 2 | import { isStr } from '@/utils/validation'; 3 | 4 | /** 5 | * Given a locale code, return the language name of another locale 6 | */ 7 | export function getLocaleName( 8 | fromCode: string, 9 | toCode = 'en' 10 | ): string | undefined { 11 | const r = new Intl.DisplayNames([toCode], { type: 'language' }).of(fromCode); 12 | 13 | return isStr(r) ? upperFirst(r) : r; 14 | } 15 | 16 | /** 17 | * Given a locale code, return the language name in that locale 18 | */ 19 | export function getLocaleNativeName(code: string): string | undefined { 20 | return getLocaleName(code, code); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/audio-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { isApple, isTizen, isTv, isWebOS } from '@/utils/browser-detection'; 6 | 7 | /** 8 | * Determines if audio codec is supported 9 | */ 10 | export function getSupportedAudioCodecs(format: string): boolean { 11 | let typeString; 12 | 13 | if (format === 'flac' && isTv()) { 14 | return true; 15 | } else if (format === 'wma' && isTizen()) { 16 | return true; 17 | } else if (format === 'asf' && isTv()) { 18 | return true; 19 | } else if (format === 'opus') { 20 | if (!isWebOS()) { 21 | typeString = 'audio/ogg; codecs="opus"'; 22 | 23 | return !!document 24 | .createElement('audio') 25 | .canPlayType(typeString) 26 | .replace(/no/, ''); 27 | } 28 | 29 | return false; 30 | } else if (format === 'alac' && isApple()) { 31 | return true; 32 | } else if (format === 'webma') { 33 | typeString = 'audio/webm'; 34 | } else if (format === 'mp2') { 35 | typeString = 'audio/mpeg'; 36 | } else { 37 | typeString = 'audio/' + format; 38 | } 39 | 40 | return !!document 41 | .createElement('audio') 42 | .canPlayType(typeString) 43 | .replace(/no/, ''); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/fmp4-audio-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { getSupportedAudioCodecs } from './audio-formats'; 6 | import { 7 | hasAacSupport, 8 | hasAc3InHlsSupport, 9 | hasAc3Support, 10 | hasEac3Support, 11 | hasMp3AudioSupport 12 | } from './mp4-audio-formats'; 13 | import { isEdge } from '@/utils/browser-detection'; 14 | 15 | /** 16 | * Gets an array with the supported fmp4 codecs 17 | * 18 | * @param videoTestElement - A HTML video element for testing codecs 19 | * @returns List of supported FMP4 audio codecs 20 | */ 21 | export function getSupportedFmp4AudioCodecs( 22 | videoTestElement: HTMLVideoElement 23 | ): string[] { 24 | const codecs = []; 25 | 26 | if (hasAacSupport(videoTestElement)) { 27 | codecs.push('aac'); 28 | } 29 | 30 | if (hasMp3AudioSupport(videoTestElement)) { 31 | codecs.push('mp3'); 32 | } 33 | 34 | if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { 35 | codecs.push('ac3'); 36 | 37 | if (hasEac3Support(videoTestElement)) { 38 | codecs.push('eac3'); 39 | } 40 | } 41 | 42 | if (getSupportedAudioCodecs('flac') && !isEdge()) { 43 | codecs.push('flac'); 44 | } 45 | 46 | if (getSupportedAudioCodecs('alac')) { 47 | codecs.push('alac'); 48 | } 49 | 50 | return codecs; 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/fmp4-video-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { hasH264Support, hasHevcSupport } from './mp4-video-formats'; 6 | import { 7 | isApple, 8 | isChrome, 9 | isEdge, 10 | isFirefox, 11 | isTizen, 12 | isWebOS 13 | } from '@/utils/browser-detection'; 14 | 15 | /** 16 | * Gets an array of supported fmp4 video codecs 17 | * 18 | * @param videoTestElement - A HTML video element for testing codecs 19 | * @returns List of supported fmp4 video codecs 20 | */ 21 | export function getSupportedFmp4VideoCodecs( 22 | videoTestElement: HTMLVideoElement 23 | ): string[] { 24 | const codecs = []; 25 | 26 | if ( 27 | (isApple() || isEdge() || isTizen() || isWebOS()) 28 | && hasHevcSupport(videoTestElement) 29 | ) { 30 | codecs.push('hevc'); 31 | } 32 | 33 | if ( 34 | hasH264Support(videoTestElement) 35 | && (isChrome() 36 | || isFirefox() 37 | || isApple() 38 | || isEdge() 39 | || isTizen() 40 | || isWebOS()) 41 | ) { 42 | codecs.push('h264'); 43 | } 44 | 45 | return codecs; 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/hls-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { hasH264Support, hasH265Support } from './mp4-video-formats'; 6 | import { hasEac3Support, hasAacSupport } from './mp4-audio-formats'; 7 | import { getSupportedAudioCodecs } from './audio-formats'; 8 | import { isTv } from '@/utils/browser-detection'; 9 | 10 | /** 11 | * Check if client supports AC3 in HLS stream 12 | * 13 | * @param videoTestElement - A HTML video element for testing codecs 14 | * @returns Determines if the browser has AC3 in HLS support 15 | */ 16 | function supportsAc3InHls( 17 | videoTestElement: HTMLVideoElement 18 | ): boolean | string { 19 | if (isTv()) { 20 | return true; 21 | } 22 | 23 | if (videoTestElement.canPlayType) { 24 | return ( 25 | videoTestElement 26 | .canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"') 27 | .replace(/no/, '') 28 | || videoTestElement 29 | .canPlayType( 30 | 'application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"' 31 | ) 32 | .replace(/no/, '') 33 | ); 34 | } 35 | 36 | return false; 37 | } 38 | 39 | /** 40 | * Gets the supported HLS video codecs 41 | * 42 | * @param videoTestElement - A HTML video element for testing codecs 43 | * @returns Array of video codecs supported in HLS 44 | */ 45 | export function getHlsVideoCodecs( 46 | videoTestElement: HTMLVideoElement 47 | ): string[] { 48 | const hlsVideoCodecs = []; 49 | 50 | if (hasH264Support(videoTestElement)) { 51 | hlsVideoCodecs.push('h264'); 52 | } 53 | 54 | if (hasH265Support(videoTestElement) || isTv()) { 55 | hlsVideoCodecs.push('h265', 'hevc'); 56 | } 57 | 58 | return hlsVideoCodecs; 59 | } 60 | 61 | /** 62 | * Gets the supported HLS audio codecs 63 | * 64 | * @param videoTestElement - A HTML video element for testing codecs 65 | * @returns Array of audio codecs supported in HLS 66 | */ 67 | export function getHlsAudioCodecs( 68 | videoTestElement: HTMLVideoElement 69 | ): string[] { 70 | const hlsVideoAudioCodecs = []; 71 | 72 | if (supportsAc3InHls(videoTestElement)) { 73 | hlsVideoAudioCodecs.push('ac3'); 74 | 75 | if (hasEac3Support(videoTestElement)) { 76 | hlsVideoAudioCodecs.push('eac3'); 77 | } 78 | } 79 | 80 | if (hasAacSupport(videoTestElement)) { 81 | hlsVideoAudioCodecs.push('aac'); 82 | } 83 | 84 | if (getSupportedAudioCodecs('opus')) { 85 | hlsVideoAudioCodecs.push('opus'); 86 | } 87 | 88 | return hlsVideoAudioCodecs; 89 | } 90 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/transcoding-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { 6 | isEdge, 7 | isTizen, 8 | isTv, 9 | supportsMediaSource 10 | } from '@/utils/browser-detection'; 11 | 12 | /** 13 | * Checks if the client can play native HLS 14 | * 15 | * @param videoTestElement - A HTML video element for testing codecs 16 | * @returns Determines if the browser can play native Hls 17 | */ 18 | export function canPlayNativeHls(videoTestElement: HTMLVideoElement): boolean { 19 | if (isTizen()) { 20 | return true; 21 | } 22 | 23 | return !!( 24 | videoTestElement.canPlayType('application/x-mpegURL').replace(/no/, '') 25 | || videoTestElement 26 | .canPlayType('application/vnd.apple.mpegURL') 27 | .replace(/no/, '') 28 | ); 29 | } 30 | 31 | /** 32 | * Determines if the browser can play Hls with Media Source Extensions 33 | */ 34 | export function canPlayHlsWithMSE(): boolean { 35 | return supportsMediaSource(); 36 | } 37 | 38 | /** 39 | * Determines if the browser can play Mkvs 40 | */ 41 | export function hasMkvSupport(videoTestElement: HTMLVideoElement): boolean { 42 | if (isTv()) { 43 | return true; 44 | } 45 | 46 | if ( 47 | videoTestElement.canPlayType('video/x-matroska').replace(/no/, '') 48 | || videoTestElement.canPlayType('video/mkv').replace(/no/, '') 49 | ) { 50 | return true; 51 | } 52 | 53 | return !!isEdge(); 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/ts-audio-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { 6 | hasAacSupport, 7 | hasAc3InHlsSupport, 8 | hasAc3Support, 9 | hasEac3Support, 10 | hasMp3AudioSupport 11 | } from './mp4-audio-formats'; 12 | 13 | /** 14 | * List of supported Ts audio codecs 15 | */ 16 | export function getSupportedTsAudioCodecs( 17 | videoTestElement: HTMLVideoElement 18 | ): string[] { 19 | const codecs = []; 20 | 21 | if (hasAacSupport(videoTestElement)) { 22 | codecs.push('aac'); 23 | } 24 | 25 | if (hasMp3AudioSupport(videoTestElement)) { 26 | codecs.push('mp3'); 27 | } 28 | 29 | if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { 30 | codecs.push('ac3'); 31 | 32 | if (hasEac3Support(videoTestElement)) { 33 | codecs.push('eac3'); 34 | } 35 | } 36 | 37 | return codecs; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/ts-video-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { hasH264Support } from './mp4-video-formats'; 6 | 7 | /** 8 | * List of supported ts video codecs 9 | */ 10 | export function getSupportedTsVideoCodecs( 11 | videoTestElement: HTMLVideoElement 12 | ): string[] { 13 | const codecs = []; 14 | 15 | if (hasH264Support(videoTestElement)) { 16 | codecs.push('h264'); 17 | } 18 | 19 | return codecs; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/webm-audio-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { isWebOS } from '@/utils/browser-detection'; 6 | 7 | /** 8 | * Get an array of supported codecs 9 | */ 10 | export function getSupportedWebMAudioCodecs( 11 | videoTestElement: HTMLVideoElement 12 | ): string[] { 13 | const codecs = []; 14 | 15 | codecs.push('vorbis'); 16 | 17 | if ( 18 | !isWebOS() 19 | && videoTestElement.canPlayType('audio/ogg; codecs="opus"').replace(/no/, '') 20 | ) { 21 | codecs.push('opus'); 22 | } 23 | 24 | return codecs; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/helpers/webm-video-formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { 6 | hasAv1Support, 7 | hasVp8Support, 8 | hasVp9Support 9 | } from './mp4-video-formats'; 10 | 11 | /** 12 | * Get an array of supported codecs WebM video codecs 13 | */ 14 | export function getSupportedWebMVideoCodecs( 15 | videoTestElement: HTMLVideoElement 16 | ): string[] { 17 | const codecs = []; 18 | 19 | if (hasVp8Support(videoTestElement)) { 20 | codecs.push('vp8'); 21 | } 22 | 23 | if (hasVp9Support(videoTestElement)) { 24 | codecs.push('vp9'); 25 | } 26 | 27 | if (hasAv1Support(videoTestElement)) { 28 | codecs.push('av1'); 29 | } 30 | 31 | return codecs; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/utils/playback-profiles/subtitle-profile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated - Check @/utils/playback-profiles/index 3 | */ 4 | 5 | import { 6 | SubtitleDeliveryMethod, 7 | type SubtitleProfile 8 | } from '@jellyfin/sdk/lib/generated-client'; 9 | 10 | /** 11 | * Returns a valid SubtitleProfile for the current platform. 12 | * 13 | * @returns An array of subtitle profiles for the current platform. 14 | */ 15 | export function getSubtitleProfiles(): SubtitleProfile[] { 16 | const SubtitleProfiles: SubtitleProfile[] = []; 17 | 18 | SubtitleProfiles.push( 19 | { 20 | Format: 'vtt', 21 | Method: SubtitleDeliveryMethod.External 22 | }, 23 | { 24 | Format: 'ass', 25 | Method: SubtitleDeliveryMethod.External 26 | }, 27 | { 28 | Format: 'ssa', 29 | Method: SubtitleDeliveryMethod.External 30 | }, 31 | { 32 | Format: 'pgssub', 33 | Method: SubtitleDeliveryMethod.External 34 | } 35 | ); 36 | 37 | return SubtitleProfiles; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from 'axios'; 2 | 3 | /** 4 | * Validator to which enforces that a select component has at least one value selected 5 | */ 6 | export const SomeItemSelectedRule = [ 7 | (v: unknown[]): boolean | string => v.length !== 0 8 | ]; 9 | 10 | /** 11 | * Check if the value is a number. 12 | */ 13 | export function isNumber(value: unknown): value is number { 14 | return typeof value === 'number'; 15 | } 16 | 17 | /** 18 | * Check if the value is a boolean. 19 | */ 20 | export function isBool(value: unknown): value is boolean { 21 | return typeof value === 'boolean'; 22 | } 23 | 24 | /** 25 | * Check if the value is a string. 26 | */ 27 | export function isStr(value: unknown): value is string { 28 | return typeof value === 'string'; 29 | } 30 | 31 | /** 32 | * Check if the given value is a funcion 33 | */ 34 | export function isFunc(value: unknown): value is (...args: unknown[]) => unknown { 35 | return typeof value === 'function'; 36 | } 37 | 38 | /** 39 | * Check if the value is undefined 40 | */ 41 | export function isUndef(value: unknown): value is undefined { 42 | return value === undefined; 43 | } 44 | 45 | /** 46 | * Check if the value is null 47 | */ 48 | export function isNull(value: unknown): value is null { 49 | return value === null; 50 | } 51 | 52 | /** 53 | * Check if the value is null or undefined 54 | */ 55 | export function isNil(value: unknown): value is null | undefined { 56 | return isUndef(value) || isNull(value); 57 | } 58 | 59 | /** 60 | * Check if the value is an object. 61 | */ 62 | export function isObj(value: unknown): value is object { 63 | return typeof value === 'object' && !isNull(value); 64 | } 65 | 66 | /** 67 | * TypeScript type guard for AxiosError 68 | */ 69 | export function isAxiosError(object: unknown): object is AxiosError { 70 | return isObj(object) && 'isAxiosError' in object; 71 | } 72 | 73 | /** 74 | * Check if the value is an array 75 | */ 76 | export function isArray(object: unknown): object is unknown[] { 77 | return Array.isArray(object); 78 | } 79 | 80 | /** 81 | * Seals a class 82 | * 83 | * @type TypeScript Decorator 84 | */ 85 | export function sealed(constructor: new (...args: never[]) => object): void { 86 | Object.seal(constructor); 87 | Object.seal(constructor.prototype); 88 | } 89 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "Preserve", 5 | "moduleResolution": "Bundler", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "noEmit": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "useDefineForClassFields": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "noErrorTruncation": true, 18 | "experimentalDecorators": true, 19 | "removeComments": true, 20 | "verbatimModuleSyntax": true, 21 | "incremental": true, 22 | "tsBuildInfoFile": "../node_modules/.cache/tsconfig.tsbuildinfo", 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["src/*"] 26 | }, 27 | "types": [ 28 | "axios", 29 | "@intlify/unplugin-vue-i18n/messages", 30 | "unplugin-icons/types/vue", 31 | "unplugin-vue-router/client", 32 | "vite/client", 33 | "vuetify", 34 | "vue" 35 | ] 36 | }, 37 | "vueCompilerOptions": { 38 | "strictTemplates": true, 39 | "htmlAttributes": ["aria-*", "data-*"], 40 | "fallthroughAttributes": true 41 | }, 42 | "include": [ 43 | "src/**/*.ts", 44 | "src/**/*.d.ts", 45 | "src/**/*.vue", 46 | "**/**/*.json", 47 | "types/**/*.d.ts", 48 | "*.config.*" 49 | ], 50 | "exclude": ["node_modules"] 51 | } 52 | -------------------------------------------------------------------------------- /frontend/types/global/attributes.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-indexed-object-style */ 2 | 3 | declare module 'vue' { 4 | export interface AllowedComponentProps { 5 | [key: `data${string}`]: string; 6 | } 7 | 8 | export interface ComponentCustomProps { 9 | // Allow any data-* attr on Vue components 10 | [key: `data${string}`]: string; 11 | } 12 | } 13 | declare module 'vue' { 14 | export interface HTMLAttributes { 15 | // Allow any data-* attr on HTML elements 16 | [key: `data${string}`]: string; 17 | } 18 | } 19 | 20 | export {}; 21 | -------------------------------------------------------------------------------- /frontend/types/global/plugins.d.ts: -------------------------------------------------------------------------------- 1 | import type en from '@/../locales/en.json'; 2 | import type { JTransitionProps } from '@/components/lib/JTransition.vue'; 3 | import type { RemotePlugin } from '@/plugins/remote/types'; 4 | 5 | /** 6 | * The object that represents RouteMeta is defined at @/plugins/router/middleware/meta 7 | */ 8 | interface RouteTransitionPayload { 9 | enter?: NonNullable; 10 | leave?: JTransitionProps['name']; 11 | mode?: JTransitionProps['mode']; 12 | } 13 | 14 | interface LayoutPayload { 15 | readonly name?: 'default' | 'fullpage' | 'server'; 16 | transparent?: boolean; 17 | transition: RouteTransitionPayload; 18 | } 19 | declare module 'vue-router' { 20 | interface RouteMeta { 21 | readonly layout: LayoutPayload; 22 | readonly admin?: boolean; 23 | title?: string | null; 24 | } 25 | } 26 | 27 | declare module 'vue' { 28 | interface ComponentCustomProperties { 29 | readonly $remote: RemotePlugin; 30 | } 31 | } 32 | 33 | declare module 'vue-i18n' { 34 | type messages = typeof en; 35 | 36 | export interface DefineLocaleMessage extends messages {} 37 | } 38 | 39 | /** 40 | * This is important: https://stackoverflow.com/a/64189046 41 | * https://www.typescriptlang.org/docs/handbook/modules.html 42 | */ 43 | 44 | export { }; 45 | -------------------------------------------------------------------------------- /frontend/types/global/util.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * BetterOmit still provides IntelliSense fedback, unlike the built-in Omit type. 3 | * See https://github.com/microsoft/TypeScript/issues/56135 4 | */ 5 | type BetterOmit = T extends Record 6 | ? { 7 | [P in keyof T as P extends K ? never : P]: T[P] 8 | } 9 | : T; 10 | 11 | /** 12 | * Make all the properties of a type mutable. 13 | */ 14 | type Mutable = { 15 | -readonly [K in keyof T]: T[K]; 16 | }; 17 | 18 | /** 19 | * Gets the last item of a tuple 20 | */ 21 | type Tail = L extends readonly [] ? L : L extends readonly [unknown?, ...infer LTail] ? LTail : L; 22 | 23 | /** 24 | * Sets a type as nullish 25 | */ 26 | type Nullish = T | null | undefined; 27 | -------------------------------------------------------------------------------- /frontend/types/modules/virtual.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virtual:locales/date-fns' { 2 | import * as locales from 'date-fns/locale'; 3 | 4 | export = locales; 5 | } 6 | 7 | declare module 'virtual:locales/vuetify' { 8 | import type * as locales from 'vuetify/locale'; 9 | 10 | const typeWithoutRtl: BetterOmit; 11 | 12 | export = typeWithoutRtl; 13 | } 14 | 15 | declare module 'virtual:commit' { 16 | export const commit_hash: string | undefined; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetUno } from 'unocss'; 2 | 3 | export default defineConfig({ 4 | presets: [ 5 | presetUno({ 6 | prefix: 'uno-', 7 | preflight: false 8 | }) 9 | ], 10 | theme: { 11 | colors: { 12 | background: 'rgb(var(--j-color-background))' 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jellyfin-vue", 3 | "private": true, 4 | "homepage": "https://jellyfin.org/", 5 | "bugs": { 6 | "url": "https://github.com/jellyfin/jellyfin-vue/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/jellyfin/jellyfin-vue/" 11 | }, 12 | "license": "GPL-3.0-only", 13 | "author": "jellyfin-vue Contributors (https://github.com/jellyfin/jellyfin-vue/graphs/contributors)", 14 | "workspaces": [ 15 | "frontend", 16 | "packaging/tauri" 17 | ], 18 | "engines": { 19 | "node": ">=20.8.1 <21.0.0", 20 | "npm": ">=10.1.0", 21 | "yarn": "Yarn is not supported. Please use NPM." 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packaging/deb/README.md: -------------------------------------------------------------------------------- 1 | 1. Install `build-essential` and `debhelper`: 2 | 3 | `sudo apt install build-essential debhelper` 4 | 5 | 2. Build the package: 6 | 7 | `dpkg-buildpackage -us -uc` 8 | -------------------------------------------------------------------------------- /packaging/deb/debian/changelog: -------------------------------------------------------------------------------- 1 | jellyfin-vue (0.2.0) unstable; urgency=medium 2 | 3 | * First prerelease package version 4 | 5 | -- Jellyfin Packaging Team Sun, 08 Jan 2023 12:44:59 -0500 6 | -------------------------------------------------------------------------------- /packaging/deb/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /packaging/deb/debian/conffiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickm53/jellyfin-vue/57607f1adc5b0b22780e53cadd55cce115b97a54/packaging/deb/debian/conffiles -------------------------------------------------------------------------------- /packaging/deb/debian/control: -------------------------------------------------------------------------------- 1 | Source: jellyfin-vue 2 | Section: misc 3 | Priority: optional 4 | Maintainer: Jellyfin Team 5 | Build-Depends: debhelper (>= 10), 6 | nodejs 7 | Standards-Version: 3.9.4 8 | Homepage: https://jellyfin.org/ 9 | Vcs-Git: https://github.org/jellyfin/jellyfin-vue.git 10 | Vcs-Browser: https://github.org/jellyfin/jellyfin-vue 11 | 12 | Package: jellyfin-vue 13 | Recommends: jellyfin-server 14 | Architecture: all 15 | Description: Jellyfin is the Free Software Media System. 16 | This package provides the Jellyfin vue.js client. 17 | -------------------------------------------------------------------------------- /packaging/deb/debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Name: jellyfin-vue 3 | Source: https://github.com/jellyfin/jellyfin-vue 4 | 5 | Files: * 6 | Copyright: 2018-2023 Jellyfin Team 7 | License: GPL-3.0 8 | 9 | Files: debian/* 10 | Copyright: 2023 Joshua Boniface 11 | License: GPL-3.0 12 | 13 | License: GPL-3.0 14 | This package is free software; you can redistribute it and/or modify 15 | it under the terms of the GNU General Public License as published by 16 | the Free Software Foundation; either version 2 of the License, or 17 | (at your option) any later version. 18 | . 19 | This package is distributed in the hope that it will be useful, 20 | but WITHOUT ANY WARRANTY; without even the implied warranty of 21 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 22 | GNU General Public License for more details. 23 | . 24 | You should have received a copy of the GNU General Public License 25 | along with this program. If not, see 26 | . 27 | On Debian systems, the complete text of the GNU General 28 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 29 | -------------------------------------------------------------------------------- /packaging/deb/debian/gbp.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | pristine-tar = False 3 | cleaner = fakeroot debian/rules clean 4 | 5 | [import-orig] 6 | filter = [ ".git*", ".hg*", ".vs*", ".vscode*" ] 7 | -------------------------------------------------------------------------------- /packaging/deb/debian/install: -------------------------------------------------------------------------------- 1 | dist usr/share/jellyfin/ 2 | -------------------------------------------------------------------------------- /packaging/deb/debian/po/POTFILES.in: -------------------------------------------------------------------------------- 1 | [type: gettext/rfc822deb] templates 2 | -------------------------------------------------------------------------------- /packaging/deb/debian/po/templates.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: jellyfin-server\n" 10 | "Report-Msgid-Bugs-To: jellyfin-server@packages.debian.org\n" 11 | "POT-Creation-Date: 2015-06-12 20:51-0600\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #. Type: note 21 | #. Description 22 | #: ../templates:1001 23 | msgid "Jellyfin permission info:" 24 | msgstr "" 25 | 26 | #. Type: note 27 | #. Description 28 | #: ../templates:1001 29 | msgid "" 30 | "Jellyfin by default runs under a user named \"jellyfin\". Please ensure that the " 31 | "user jellyfin has read and write access to any folders you wish to add to your " 32 | "library. Otherwise please run jellyfin under a different user." 33 | msgstr "" 34 | 35 | #. Type: string 36 | #. Description 37 | #: ../templates:2001 38 | msgid "Username to run Jellyfin as:" 39 | msgstr "" 40 | 41 | #. Type: string 42 | #. Description 43 | #: ../templates:2001 44 | msgid "The user that jellyfin will run as." 45 | msgstr "" 46 | 47 | #. Type: note 48 | #. Description 49 | #: ../templates:3001 50 | msgid "Jellyfin still running" 51 | msgstr "" 52 | 53 | #. Type: note 54 | #. Description 55 | #: ../templates:3001 56 | msgid "Jellyfin is currently running. Please close it and try again." 57 | msgstr "" 58 | -------------------------------------------------------------------------------- /packaging/deb/debian/rules: -------------------------------------------------------------------------------- 1 | #! /usr/bin/make -f 2 | export DH_VERBOSE=1 3 | 4 | %: 5 | dh $@ 6 | 7 | # disable "make check" 8 | override_dh_auto_test: 9 | 10 | # disable stripping debugging symbols 11 | override_dh_clistrip: 12 | 13 | override_dh_auto_build: 14 | cp -ra root/{package.json,package-lock.json,.npmrc,frontend} $(CURDIR)/ 15 | npm ci --no-audit -w frontend 16 | npm run build -w frontend 17 | mv $(CURDIR)/frontend/dist $(CURDIR)/dist 18 | rm -rf $(CURDIR)/frontend rm -rf $(CURDIR)/node_modules $(CURDIR)/package.json $(CURDIR)/package-lock.json $(CURDIR)/.npmrc 19 | 20 | override_dh_auto_clean: 21 | test -d $(CURDIR)/frontend && rm -rf '$(CURDIR)/frontend' || true 22 | test -d $(CURDIR)/dist && rm -rf '$(CURDIR)/dist' || true 23 | test -d $(CURDIR)/node_modules && rm -rf '$(CURDIR)/node_modules' || true 24 | test -d $(CURDIR)/package.json && rm -rf '$(CURDIR)/package.json' || true 25 | test -d $(CURDIR)/package-lock.json && rm -rf '$(CURDIR)/package-lock.json' || true 26 | test -d $(CURDIR)/.npmrc && rm -rf '$(CURDIR)/.npmrc' || true 27 | -------------------------------------------------------------------------------- /packaging/deb/debian/source/format: -------------------------------------------------------------------------------- 1 | 1.0 2 | -------------------------------------------------------------------------------- /packaging/deb/debian/source/options: -------------------------------------------------------------------------------- 1 | tar-ignore='.git*' 2 | tar-ignore='**/.git' 3 | tar-ignore='**/.hg' 4 | tar-ignore='**/.vs' 5 | tar-ignore='**/.vscode' 6 | tar-ignore='deployment' 7 | tar-ignore='*.deb' 8 | tar-ignore='vue' 9 | tar-ignore='src/dist' 10 | -------------------------------------------------------------------------------- /packaging/deb/root: -------------------------------------------------------------------------------- 1 | ../.. -------------------------------------------------------------------------------- /packaging/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ## This dockerfile builds the client entirely in a Docker context 2 | 3 | # slim image can't be used since we need git to fetch the commit hash 4 | FROM node:20-slim AS build 5 | 6 | # Set build arguments 7 | ARG IS_STABLE=0 8 | ARG COMMIT_HASH 9 | # Set environment variables 10 | ENV IS_STABLE=$IS_STABLE 11 | ENV COMMIT_HASH=$COMMIT_HASH 12 | 13 | COPY package.json package-lock.json .npmrc /app/ 14 | COPY frontend /app/frontend 15 | WORKDIR /app/frontend 16 | 17 | # Build client 18 | RUN npm ci --no-audit && npm run build 19 | 20 | # Deploy built distribution to nginx 21 | FROM nginx:stable-alpine-slim 22 | 23 | COPY packaging/docker/contents/nginx.conf /etc/nginx/conf.d/default.conf 24 | COPY packaging/docker/contents/*.sh / 25 | COPY LICENSE /usr/share/licenses/jellyfin-vue.LICENSE 26 | 27 | RUN rm -rf /usr/share/nginx/html/* 28 | COPY --from=build /app/frontend/dist/ /usr/share/nginx/html/ 29 | RUN chmod +x /*.sh && /postunpack.sh && rm /postunpack.sh 30 | USER nginx 31 | 32 | EXPOSE 80 33 | 34 | # Set labels 35 | LABEL maintainer="Jellyfin Packaging Team - packaging@jellyfin.org" 36 | LABEL org.opencontainers.image.source="https://github.com/jellyfin/jellyfin-vue" 37 | -------------------------------------------------------------------------------- /packaging/docker/README.md: -------------------------------------------------------------------------------- 1 | This Dockerfile must have it's context at the root of the repository. From this folder: 2 | 3 | `` 4 | docker build ../.. -t your/tag -f Dockerfile 5 | `` 6 | 7 | Alternatively, with the shell at the root of this repository: 8 | 9 | `` 10 | docker build . -t your/tag -f packaging/docker/Dockerfile 11 | `` 12 | -------------------------------------------------------------------------------- /packaging/docker/contents/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ## If the command has not been replaced by the user (i.e docker run image /bin/sh), 4 | ## follow through the setup process 5 | if [[ "$*" = "nginx -g daemon off;" ]]; then 6 | echo "==== Starting Jellyfin Vue setup ====" 7 | echo 8 | /setup.sh 9 | echo 10 | echo "==== Setup finished! ====" 11 | echo -e "\n" 12 | fi 13 | 14 | exec "$@" 15 | -------------------------------------------------------------------------------- /packaging/docker/contents/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | root /usr/share/nginx/html; 4 | location / { 5 | # First attempt to serve request as file, then as directory, then fall back to redirecting to index.html 6 | # This is needed for history mode in Vue router: https://router.vuejs.org/guide/essentials/history-mode.html#nginx 7 | try_files $uri $uri/ /index.html; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packaging/docker/contents/postunpack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Configuring environment for nginx..." 3 | 4 | NGINX_CONFIG_FILE=/etc/nginx/nginx.conf 5 | 6 | ## setup.sh dependencies 7 | apk add --no-cache jq 8 | 9 | # CONTAINER ROOTLESS SETUP 10 | ### Set correct permissions and make frontend config.json file editable for the runtime user 11 | mkdir -p /run/nginx 12 | chown nginx:nginx -R /run/nginx /var/cache/nginx /usr/share/nginx/html/config.json 13 | sed -i 's|/var/run|/var/run/nginx|g' $NGINX_CONFIG_FILE 14 | ## The 'user' config option is useless when running rootless and gives a warning 15 | sed -i '/^user /d' $NGINX_CONFIG_FILE 16 | ## Allow to open privileged ports 17 | apk add --no-cache libcap 18 | setcap CAP_NET_BIND_SERVICE=+eip /usr/sbin/nginx 19 | apk --purge del libcap 20 | 21 | # Trim image 22 | apk --purge del apk-tools 23 | rm -rf /docker-entrypoint.d /.dockerenv /usr/sbin/nginx-debug 24 | rm -rf /usr/share/zoneinfo 25 | rm -rf /sbin/apk /etc/apk /lib/apk /usr/share/apk /var/lib/apk 26 | rm -rf /usr/lib/libcrypto* /usr/lib/libintl* /usr/lib/libssl* \ 27 | /usr/lib/engines-3 /usr/lib/modules-load.d /usr/lib/nginx /usr/lib/ossl-modules 28 | -------------------------------------------------------------------------------- /packaging/docker/contents/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CONFIG_FILE_PATH="/usr/share/nginx/html/config.json" 4 | echo "Writing data to $CONFIG_FILE_PATH..." 5 | 6 | if [[ "$HISTORY_ROUTER_MODE" == "0" ]]; then 7 | ROUTER_MODE="hash" 8 | else 9 | ROUTER_MODE="history" 10 | fi 11 | 12 | if [[ "$DISABLE_SERVER_SELECTION" == "1" ]]; then 13 | ALLOW_SERVER_SELECTION=false 14 | else 15 | ALLOW_SERVER_SELECTION=true 16 | fi 17 | 18 | echo "DEFAULT_SERVERS value: $DEFAULT_SERVERS" 19 | echo "ALLOW_SERVER_SELECTION value: $ALLOW_SERVER_SELECTION" 20 | echo "ROUTER_MODE value: $ROUTER_MODE" 21 | 22 | output=$(jq -r --arg R_MODE "$ROUTER_MODE" --arg SERVS "$DEFAULT_SERVERS" ' 23 | .defaultServerURLs = ($SERVS | split(",")) | 24 | .routerMode = $R_MODE | 25 | .allowServerSelection = ('"$ALLOW_SERVER_SELECTION"') 26 | ' $CONFIG_FILE_PATH 27 | ) 28 | 29 | echo "$output" > $CONFIG_FILE_PATH 30 | -------------------------------------------------------------------------------- /packaging/tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jellyfin-vue-tauri" 3 | version = "0.0.0" 4 | description = "Jellyfin Tauri client" 5 | authors = [ 6 | "jellyfin-vue Contributors (https://github.com/jellyfin/jellyfin-vue/graphs/contributors)" 7 | ] 8 | license = "GPL-3.0-only" 9 | repository = "https://github.com/jellyfin/jellyfin-vue" 10 | default-run = "jellyfin-vue-tauri" 11 | build = "src/build.rs" 12 | edition = "2021" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [build-dependencies] 17 | tauri-build = { version = "=2.0.0-rc.2", features = [] } 18 | 19 | [dependencies] 20 | tauri = { version = "=2.0.0-rc.2", features = [] } 21 | 22 | [features] 23 | default = ["custom-protocol"] 24 | custom-protocol = ["tauri/custom-protocol"] 25 | 26 | [profile.release] 27 | strip = true 28 | lto = true 29 | opt-level = 3 30 | -------------------------------------------------------------------------------- /packaging/tauri/README.md: -------------------------------------------------------------------------------- 1 | The `apt_packages` file contains the system dependencies for Debian 2 | as mentioned in [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites/#1-system-dependencies) 3 | -------------------------------------------------------------------------------- /packaging/tauri/apt_packages: -------------------------------------------------------------------------------- 1 | libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev 2 | -------------------------------------------------------------------------------- /packaging/tauri/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jellyfin-vue/tauri", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "start": "npm run gen-icon && tauri dev", 7 | "gen-icon": "tauri icon ../../frontend/public/icon.svg", 8 | "build": "npm run gen-icon && tauri build", 9 | "clean": "git clean -fxd" 10 | }, 11 | "dependencies": { 12 | "@jellyfin-vue/frontend": "*" 13 | }, 14 | "devDependencies": { 15 | "@tauri-apps/cli": "2.0.0-rc.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packaging/tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 2 4 | newline_style = "Auto" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2018" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /packaging/tauri/src/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /packaging/tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .run(tauri::generate_context!()) 9 | .expect("error while running tauri application"); 10 | } 11 | -------------------------------------------------------------------------------- /packaging/tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "bundle": { 3 | "active": true, 4 | "targets": [ 5 | "appimage", 6 | "msi", 7 | "app" 8 | ], 9 | "longDescription": "", 10 | "shortDescription": "", 11 | "resources": [], 12 | "externalBin": [], 13 | "copyright": "", 14 | "category": "Entertainment", 15 | "linux": { 16 | "appimage": { 17 | "bundleMediaFramework": true 18 | } 19 | } 20 | }, 21 | "build": { 22 | "beforeBuildCommand": "npm run build -w @jellyfin-vue/frontend", 23 | "frontendDist": "../../frontend/dist", 24 | "beforeDevCommand": "npm start -w @jellyfin-vue/frontend", 25 | "devUrl": "http://127.0.0.1:3000" 26 | }, 27 | "productName": "jellyfin-vue", 28 | "version": "../../frontend/package.json", 29 | "identifier": "org.jellyfin.vue.tauri", 30 | "plugins": {}, 31 | "app": { 32 | "security": { 33 | "csp": null 34 | }, 35 | "windows": [ 36 | { 37 | "title": "Jellyfin Vue", 38 | "width": 800, 39 | "height": 600, 40 | "resizable": true, 41 | "fullscreen": false 42 | } 43 | ] 44 | } 45 | } 46 | --------------------------------------------------------------------------------