Compare commits

..

8 Commits

Author SHA1 Message Date
Niels van Velzen
429b6601d6 Don't wait for bitrate detection on launch
Also catch errors

(cherry picked from commit 7fc63f56d8)
2021-10-21 18:45:54 +02:00
Niels van Velzen
4ebc3e1f50 Change default video player to ExoPlayer instead of auto detect
(cherry picked from commit 65f4b75673)
2021-10-21 18:40:43 +02:00
Cameron
9a41127a4d "select" button on controller can now pause/unpause without extra navigation (#1207)
* fixed issue #1101

overrides PlaybackTransportRowPresenter's onProgressBarClicked to use CustomPlaybackTransportControlGlue's playPauseAction. Perhaps the lowercase 'p' is to blame for the default implementation not working.

* Update app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/CustomPlaybackTransportControlGlue.java

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>
(cherry picked from commit b59543c05e)
2021-10-21 18:40:32 +02:00
Niels van Velzen
4010c2e839 Fix trailing slashes causing connection issues in apiclient
(cherry picked from commit cb43e9024b)
2021-10-21 18:40:11 +02:00
Niels van Velzen
d8af891489 Act when setActiveSession returns false during login
(cherry picked from commit 283f75f5c7)
2021-10-21 18:40:01 +02:00
Niels van Velzen
b4753ebeae Fix skipBackwardLength instead of skipBackLength (#1182)
(cherry picked from commit 379e3e7c2c)
2021-10-07 18:51:42 +02:00
Niels van Velzen
e300dd4187 Check nullability for mFragment in PlaybackController
(cherry picked from commit f9fa23953f)
2021-10-07 18:51:28 +02:00
Niels van Velzen
f7dee09fd3 Remove build number from internal version reference
This fixes the "server version not supported" toast from showing the build number. 10.7.7.0 now shows as 10.7.7

(cherry picked from commit 9e48578366)
2021-10-07 18:51:19 +02:00
1261 changed files with 40962 additions and 68244 deletions

View File

@@ -2,17 +2,8 @@ root = true
[*]
insert_final_newline = true
max_line_length = 140
trim_trailing_whitespace = true
[{*.kts,*.kt}]
[{*.kts, *.kt}]
charset = utf-8
indent_style = tab
tab_width = 4
# Disable wildcard imports in IntelliJ/Android Studio
ij_kotlin_name_count_to_use_star_import = 1000
ij_kotlin_name_count_to_use_star_import_for_members = 1000
ij_kotlin_packages_to_use_import_on_demand = unset
[*.md]
trim_trailing_whitespace = false

2
.gitattributes vendored
View File

@@ -5,5 +5,3 @@
CONTRIBUTORS.md merge=union
app/src/main/res/values*/strings.xml merge=union
design/src/main/kotlin/token/* linguist-generated

View File

@@ -1,143 +0,0 @@
name: Media playback bug report
description: Create a bug report related to media playback
labels:
- bug
- playback
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug, including steps to reproduce it and the normally expected behavior.
You can also attach screenshots or screen recordings to help explain your issue.
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
Instead, I expect …
validations:
required: true
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: textarea
id: mediacapabilitiesreport
attributes:
label: Media capabilities report
description: |
Please share the media capability report generated from within the app. To do some follow these steps:
1. Open the Jellyfin for Android TV app
2. Connect to a server **that you have admin access to**
3. Go to Preferences -> Playback -> Advanced playback preferences
4. On the bottom, press the "Report playback capabilities to server" button
5. Go to the logs in your Jellyfin dashboard (in your browser)
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
7. Press the "Download" button and paste the file contents
placeholder: Paste capability report contents…
- type: markdown
attributes:
value: |
## Logs
- type: textarea
id: logs
attributes:
label: Client logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: textarea
id: logs-ffmpeg
attributes:
label: FFmpeg logs
description: |
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: markdown
attributes:
value: |
## Environment
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
validations:
required: true
- type: dropdown
id: installation-source
attributes:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
validations:
required: true
- type: input
id: android-version
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
validations:
required: true
- type: input
id: server-version
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -1,106 +0,0 @@
name: Bug report
description: Create a bug report
labels:
- bug
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug, including steps to reproduce it and the normally expected behavior.
You can also attach screenshots or screen recordings to help explain your issue.
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
Instead, I expect …
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: markdown
attributes:
value: |
## Environment
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
validations:
required: true
- type: dropdown
id: installation-source
attributes:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
validations:
required: true
- type: input
id: android-version
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
validations:
required: true
- type: input
id: server-version
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!-- Steps to reproduce the behavior: -->
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Logs**
<!-- Please paste any log errors. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**System (please complete the following information):**
- Android TV or Fire TV version: [e.g. Android 9 or Fire OS 7]
- Device manufacturer: [e.g. Amazon, Nvidia, Sony]
- Device model: [e.g. Fire TV Stick (2nd Gen) or Shield TV]
- Jellyfin server version: [e.g. 10.5.4]
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Jellyfin documentation
url: https://jellyfin.org/docs/general/getting-help.html

View File

@@ -1,56 +0,0 @@
name: Feature request
description: Request a new feature
labels:
- enhancement
body:
- type: checkboxes
id: before-posting
attributes:
label: "This request respects the following points:"
description: All conditions are **required**.
options:
- label: This request is **not** already on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/labels/enhancement) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- type: textarea
attributes:
label: Problem description
description: Describe the issue you are experiencing or the gap you are trying to address.
placeholder: |
The Android TV app does not support displaying lyrics, while the Jellyfin web interface does.
This creates an inconsistent user experience across platforms.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Provide a detailed description of what you would like to see implemented or changed.
placeholder: |
The app could display lyrics in the screensaver. Additionally, a lyrics section could be integrated into the page that opens when music is played.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Outline any other approaches you have thought about or explored to solve the problem.
placeholder: |
An alternative approach could involve adding a dedicated "Lyrics" button in the app, similar to the Jellyfin web interface.
Users could press this button to open a full-screen lyrics view.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false
- type: input
id: other-sources
attributes:
label: Other sources
description: If this topic has been discussed outside of GitHub, please link it.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -0,0 +1,14 @@
---
name: Feature Request
about: Request a new feature
title: ''
labels: enhancement
assignees: ''
---
**Describe the feature you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

View File

@@ -0,0 +1,29 @@
---
name: Media playback issue
about: Create a media playback issue report
title: ''
labels: playback
assignees: ''
---
**Checks**
- [ ] Issue does not happen in the browser (jellyfin-web)
**Media Info of the file**
<!-- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo) or copy the info from the web ui for the file with the playback issue. -->
**Logs**
<!-- Please paste any log messages from during the playback issue. -->
**FFmpeg Logs**
<!-- Please paste any FFmpeg logs if remuxing or transcoding appears to be part of the issue. -->
**System (please complete the following information):**
- Android TV or Fire TV version: [e.g. Android 9 or Fire OS 7]
- Device manufacturer: [e.g. Amazon, Nvidia, Sony]
- Device model: [e.g. Fire TV Stick (2nd Gen) or Shield TV]
- Jellyfin server version: [e.g. 10.5.4]
**Additional context**
<!-- Add any other context about the problem here. -->

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels:
- ci
- github-actions
- dependencies

View File

@@ -1,15 +1,11 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->

19
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 21
# Issues with these labels will never be considered stale
exemptLabels:
- enhancement
- confirmed
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -1,37 +1,29 @@
name: App / Build
name: App Build
on:
push:
branches:
- master
- release-*
pull_request:
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v2
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@v2
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
cache: gradle
- name: Assemble debug APKs
run: ./gradlew assembleDebug
- name: Create publish bundle
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
run: ./gradlew --build-cache --no-daemon --info assembleDebug
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v2
with:
name: build-artifacts
retention-days: 14
if-no-files-found: error
path: build/gh-app-publish/
path: app/build/outputs/apk/debug/*.apk

View File

@@ -1,4 +1,4 @@
name: App / Lint
name: App Lint
on:
push:
@@ -7,28 +7,22 @@ on:
- release-*
pull_request:
permissions:
contents: read
security-events: write
jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v2
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@v2
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
distribution: adopt
java-version: 11
cache: gradle
- name: Run detekt and lint task
run: ./gradlew --build-cache --no-daemon --info detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/upload-sarif@v1
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -1,4 +1,4 @@
name: App / Publish
name: App Publish
on:
push:
@@ -6,83 +6,62 @@ on:
- v*
jobs:
publish:
name: Publish
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
build:
runs-on: ubuntu-20.04
if: ${{ github.repository == 'jellyfin/jellyfin-androidtv' }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v2
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@v2
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
run: ./gradlew assemble bundleRelease versionTxt
- name: Sign APK
id: signApk
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
run: ./gradlew --no-daemon --info assemble versionTxt
- name: Sign proprietary APK
id: sign
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign app bundle
id: signAab
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Prepare release archive
run: |
mkdir -p build/jellyfin-publish
mv app/build/outputs/apk/*/jellyfin-androidtv-*-debug.apk build/jellyfin-publish/
mv app/build/outputs/apk/*/jellyfin-androidtv-*-release-unsigned.apk build/jellyfin-publish/
mv ${{ steps.signApk.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.apk
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
mv ${{ steps.sign.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.apk
mv app/build/version.txt build/jellyfin-publish/
- name: Upload release archive to GitHub release
uses: alexellis/upload-assets@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1
uses: alexellis/upload-assets@0.3.0
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
with:
asset_paths: '["build/jellyfin-publish/*"]'
- name: Upload release archive to repo.jellyfin.org
uses: burnett01/rsync-deployments@0dc935cdecc5f5e571865e60d2a6cdc673704823 # tag=5.2
uses: burnett01/rsync-deployments@5.1
with:
switches: -vrptz
path: build/jellyfin-publish/
remote_path: /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }}/
remote_host: ${{ secrets.REPO_HOST }}
remote_user: ${{ secrets.REPO_USER }}
remote_key: ${{ secrets.REPO_KEY }}
remote_path: /srv/repository/releases/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
uses: appleboy/ssh-action@v0.1.4
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}
key: ${{ secrets.REPO_KEY }}
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
envs: JELLYFIN_VERSION
script_stop: true
script: |
if [ -d "/srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}" ] && [ -n "${{ env.JELLYFIN_VERSION }}" ]; then
sudo rm -r /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
fi
sudo mv /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }} /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
cd /srv/repository/main/client/androidtv;
sudo rm -rf *.apk version.txt;
sudo ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
sudo ln -s versions/v${JELLYFIN_VERSION}/version.txt .;
cd /srv/repository/releases/client/androidtv;
rm -rf *.apk version.txt;
ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
ln -s versions/v${JELLYFIN_VERSION}/version.txt .;

View File

@@ -1,4 +1,4 @@
name: App / Test
name: App Test
on:
push:
@@ -7,22 +7,17 @@ on:
- release-*
pull_request:
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v2
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@v2
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
cache: gradle
- name: Run test task
run: ./gradlew test
run: ./gradlew --build-cache --no-daemon --info test

17
.github/workflows/gradlew-update.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Gradle update
on:
schedule:
- cron: '0 4 * * *'
jobs:
update:
runs-on: ubuntu-latest
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Gradle / Validate wrapper
name: Gradle validate
on:
push:
@@ -8,15 +8,11 @@ on:
paths:
- '**/gradlе-wrapper.jar'
permissions:
contents: read
jobs:
validate:
name: Validate
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v2
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/wrapper-validation-action@v1

View File

@@ -0,0 +1,17 @@
name: Merge conflict labeler
on:
push:
pull_request_target:
types:
- synchronize
jobs:
triage:
runs-on: ubuntu-20.04
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- uses: eps1lon/actions-label-merge-conflict@v2.0.1
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,18 +0,0 @@
name: Repo / Label merge conflict
on:
push:
pull_request_target:
types:
- synchronize
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: Repo / Reply stale issue
on:
schedule:
- cron: '30 3 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
exempt-issue-labels: enhancement,confirmed
stale-issue-label: stale
stale-issue-message: |-
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).

1
.gitignore vendored
View File

@@ -4,6 +4,5 @@ local.properties
/.idea
.gradle
.kotlin/
build/
captures/

View File

@@ -1 +0,0 @@
java 21

View File

@@ -10,12 +10,6 @@
- [joern-h](https://github.com/joern-h)
- [Niels van Velzen](https://github.com/nielsvanvelzen)
- [GodTamIt](https://github.com/GodTamIt)
- [sparky3387](https://github.com/sparky3387)
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
- [Olaren15](https://github.com/Olaren15)
- [dtrexler](https://github.com/dtrexler)
# Emby Contributors

View File

@@ -25,6 +25,9 @@
<a href="https://matrix.to/#/+jellyfin:matrix.org">
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://www.reddit.com/r/jellyfin">
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
</a>
<br/>
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
@@ -32,46 +35,52 @@
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
</a>
<a href="https://f-droid.org/en/packages/org.jellyfin.androidtv/">
<img width="153" alt="Jellyfin on F-Droid" src="https://jellyfin.org/images/store-icons/fdroid.png"/>
</a>
<br/>
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
</p>
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices. We welcome all contributions and pull
requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start.
## Building
The app uses Gradle and requires the Android SDK. We recommend using Android Studio, which includes all required dependencies, for
development and building. For manual building without Android Studio make sure a compatible JDK and Android SDK are installed and in your
PATH, then use the Gradle wrapper (`./gradlew`) to build the project with the `assembleDebug` Gradle task to generate an apk file:
```shell
./gradlew assembleDebug
```
The task will create an APK file in the `/app/build/outputs/apk/debug` directory. This APK file uses a different app-id from our stable
builds and can be manually installed to your device.
## Branching
The `master` branch is the primary development branch and the target for all pull requests. It is **unstable** and may contain breaking
changes or unresolved bugs. For production deployments and forks, always use the latest `release-x.y.z` branch. Do not base production work
or long-lived forks on `master`.
Release branches are created at the start of a beta cycle and are kept up to date with each published release. Maintainers will cherry-pick
selected changes into release branches as needed for backports. These branches are reused for subsequent patch releases.
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
We welcome all contributions and pull requests! If you have a larger feature in mind please open an
issue so we can discuss the implementation before you start.
## Translating
Translations can be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv)
instance. Look through the following graphic to see if your native language could use some work! We cannot accept changes to translation
files via pull requests.
Translations can be improved very easily from our
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) instance.
Look through the following graphic to see if your native language could use some work!
<p align="center">
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/multi-auto.svg"/>
</a>
</p>
## Build Process
### Dependencies
- Android Studio
### Build
1. Clone or download this repository
```sh
git clone https://github.com/jellyfin/jellyfin-androidtv.git
cd jellyfin-androidtv
```
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
```sh
./gradlew assembleDebug
```
Add the Android SDK to you PATH environment variable or create the ANDROID_SDK_ROOT variable for
this to work.
### Deploy to device/emulator
```sh
./gradlew installDebug
```
*You can also replace the "Debug" with "Release" to get an optimized release binary.*

View File

@@ -18,8 +18,4 @@
<issue id="Typos" severity="ignore" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedAttribute">
<ignore regexp="usesCleartextTraffic" />
<ignore regexp="networkSecurityConfig" />
</issue>
</lint>

View File

@@ -1,29 +1,29 @@
plugins {
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
id("com.android.application")
kotlin("android")
kotlin("plugin.serialization") version Plugins.Versions.kotlin
kotlin("kapt")
}
android {
namespace = "org.jellyfin.androidtv"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdkVersion(30)
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
// Android version targets
minSdkVersion(21)
targetSdkVersion(30)
// Release version
applicationId = namespace
versionName = project.getVersionName()
versionCode = getVersionCode(versionName!!)
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
sourceSets["main"].java.srcDirs("src/main/kotlin")
sourceSets["test"].java.srcDirs("src/test/kotlin")
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
resValues = true
}
compileOptions {
@@ -31,13 +31,11 @@ android {
}
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
val release by getting {
isMinifyEnabled = false
// Set package names used in various XML files
resValue("string", "app_id", namespace!!)
resValue("string", "app_search_suggest_authority", "${namespace}.content")
resValue("string", "app_search_suggest_intent_data", "content://${namespace}.content/intent")
// Add applicationId as string for XML resources
resValue("string", "app_id", "org.jellyfin.androidtv")
// Set flavored application name
resValue("string", "app_name", "@string/app_name_release")
@@ -45,14 +43,12 @@ android {
buildConfigField("boolean", "DEVELOPMENT", "false")
}
debug {
val debug by getting {
// Use different application id to run release and debug at the same time
applicationIdSuffix = ".debug"
// Set package names used in various XML files
resValue("string", "app_id", namespace + applicationIdSuffix)
resValue("string", "app_search_suggest_authority", "${namespace + applicationIdSuffix}.content")
resValue("string", "app_search_suggest_intent_data", "content://${namespace + applicationIdSuffix}.content/intent")
// Add applicationId as string for XML resources
resValue("string", "app_id", "org.jellyfin.androidtv.debug")
// Set flavored application name
resValue("string", "app_name", "@string/app_name_debug")
@@ -61,22 +57,15 @@ android {
}
}
lint {
lintOptions {
lintConfig = file("$rootDir/android-lint.xml")
abortOnError = false
isAbortOnError = false
sarifReport = true
checkDependencies = true
}
testOptions.unitTests.all {
it.useJUnitPlatform()
}
}
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
tasks.register("versionTxt") {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
val versionTxt by tasks.registering {
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
@@ -87,12 +76,8 @@ tasks.register("versionTxt") {
dependencies {
// Jellyfin
implementation(projects.design)
implementation(projects.playback.core)
implementation(projects.playback.jellyfin)
implementation(projects.playback.media3.exoplayer)
implementation(projects.playback.media3.session)
implementation(projects.preference)
implementation(projects.playback)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
// Change version if desired
val sdkVersion = findProperty("sdk.version")?.toString()
@@ -110,12 +95,9 @@ dependencies {
// Android(x)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.preference)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.tvprovider)
@@ -125,42 +107,37 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.androidx.window)
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.accompanist.permissions)
// Dependency Injection
implementation(libs.bundles.koin)
// Media players
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libass.media3)
// GSON
implementation(libs.gson)
// Markdown
implementation(libs.bundles.markwon)
// Media players
implementation(libs.exoplayer)
implementation(libs.libvlc)
// Image utility
implementation(libs.bundles.coil)
implementation(libs.glide.core)
kapt(libs.glide.compiler)
implementation(libs.kenburnsview)
// Crash Reporting
implementation(libs.bundles.acra)
// Licenses
implementation(libs.aboutlibraries)
// Logging
implementation(libs.timber)
implementation(libs.slf4j.timber)
implementation(libs.slf4j.android)
// Debugging
if (getProperty("leakcanary.enable")?.toBoolean() == true)
debugImplementation(libs.leakcanary)
// Compatibility (desugaring)
coreLibraryDesugaring(libs.android.desugar)
// Testing
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions)
testImplementation(libs.mockk)
testImplementation(libs.junit)
testImplementation(libs.mockito)
}

View File

@@ -1 +0,0 @@
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough

View File

@@ -1,26 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
package="org.jellyfin.androidtv">
<!-- Android TV Integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<!-- AccountManager permissions -->
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.GET_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.MANAGE_ACCOUNTS"
android:maxSdkVersion="22" />
<!-- Generic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Search permissions (voice to text) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Device feature requirements -->
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@@ -28,106 +35,140 @@
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data
android:host="youtube.com"
android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
<application
android:name=".JellyfinApplication"
android:allowBackup="true"
android:appCategory="video"
android:banner="@mipmap/app_banner"
android:dataExtractionRules="@xml/backup_rules"
android:banner="@drawable/app_banner"
android:fullBackupContent="@xml/backup_content"
android:icon="@mipmap/app_icon"
android:icon="@drawable/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:roundIcon="@drawable/app_icon_round"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
<!-- Screensaver -->
<service
android:name=".integration.dream.LibraryDreamService"
android:exported="true"
android:permission="android.permission.BIND_DREAM_SERVICE">
android:name=".auth.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
android:initOrder="1000"
tools:node="merge">
<meta-data
android:name="org.jellyfin.androidtv.LogInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.di.KoinInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.SessionInitializer"
android:value="androidx.startup" />
</provider>
<receiver android:name=".util.RemoteControlReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<provider
android:name=".integration.MediaContentProvider"
android:authorities="${applicationId}.content"
android:exported="true"
android:initOrder="10" />
<provider
android:name=".integration.provider.ImageProvider"
android:authorities="${applicationId}.integration.provider.ImageProvider"
android:exported="true"
android:initOrder="10" />
<!-- Authentication -->
<activity
android:name=".ui.browsing.MainActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:launchMode="singleTask"
android:noHistory="true"
android:windowSoftInputMode="adjustNothing">
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity-alias
android:name=".startup.StartupActivity"
android:targetActivity=".ui.startup.StartupActivity" />
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing" />
android:name=".ui.playback.PlaybackOverlayActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.GenericFolderActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.UserViewActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.CollectionActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.ByLetterActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.ByGenreActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.preference.PreferencesActivity"
android:theme="@style/Theme.Jellyfin.Preferences" />
<activity
android:name=".ui.browsing.SuggestedMoviesActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.BrowsePersonsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.search.SearchActivity"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
</activity>
<activity
android:name=".ui.itemdetail.FullDetailsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.livetv.LiveTvGuideActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.GenericGridActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.BrowseRecordingsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.BrowseScheduleActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.itemdetail.PhotoPlayerActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.itemdetail.ItemListActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.playback.AudioNowPlayingActivity"
android:screenOrientation="landscape" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<activity
android:name=".ui.playback.nextup.NextUpActivity"
android:noHistory="true"
android:screenOrientation="landscape" />
<!-- Playback Rewrite - Temporary -->
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
</application>
</manifest>

View File

@@ -1,64 +1,134 @@
package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.jellyfin.androidtv.auth.SessionRepository
import org.jellyfin.androidtv.di.*
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.telemetry.TelemetryService
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.get
import org.koin.android.ext.android.getKoin
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.KoinExperimentalAPI
import org.koin.core.context.startKoin
import timber.log.Timber
import timber.log.Timber.DebugTree
import java.util.concurrent.TimeUnit
@Suppress("unused")
class JellyfinApplication : Application() {
class JellyfinApplication : TvApp(), LifecycleObserver {
@OptIn(KoinExperimentalAPI::class)
override fun onCreate() {
super.onCreate()
// Don't run in ACRA service
if (ACRA.isACRASenderServiceProcess()) return
// Enable improved logging for leaking resources
// https://wh0.github.io/2020/08/12/closeguard.html
if (BuildConfig.DEBUG) {
try {
Class.forName("dalvik.system.CloseGuard")
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
.invoke(null, true)
} catch (e: ReflectiveOperationException) {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException(e)
}
}
val notificationsRepository by inject<NotificationsRepository>()
notificationsRepository.addDefaultNotifications()
// Initialize the logging library
Timber.plant(DebugTree())
Timber.i("Application object created")
// Dependency Injection
startKoin {
androidContext(this@JellyfinApplication)
modules(
appModule,
authModule,
activityLifecycleCallbacksModule,
playbackModule,
preferenceModule,
utilsModule
)
}
// Register lifecycle callbacks
getKoin().getAll<ActivityLifecycleCallbacks>().forEach(::registerActivityLifecycleCallbacks)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// Restore system session
get<SessionRepository>().restoreDefaultSystemSession()
}
/**
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
*/
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onActivate() {
Timber.i("Process Lifecycle started")
get<SessionRepository>().restoreDefaultSession()
}
/**
* Called from the StartupActivity when the user session is started.
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
@DelicateCoroutinesApi
suspend fun onSessionStart() {
val workManager by inject<WorkManager>()
val socketListener by inject<SocketHandler>()
val autoBitrate by inject<AutoBitrate>()
// Update background worker
launch {
// Cancel all current workers
workManager.cancelAllWork().await()
// Cancel all current workers
workManager.cancelAllWork().await()
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
).await()
}
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
).await()
// Update WebSockets
launch { socketListener.updateSession() }
// Detect auto bitrate
GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() }
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
TelemetryService.init(this)
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = "https://collector.tracepot.com/a2eda9d9"
httpMethod = HttpSender.Method.POST
}
dialog {
withResTitle(R.string.acra_dialog_title)
withResText(R.string.acra_dialog_text)
withResTheme(R.style.Theme_Jellyfin)
}
limiter {}
}
}
}

View File

@@ -0,0 +1,22 @@
package org.jellyfin.androidtv
import android.content.Context
import android.util.Log
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
@GlideModule
class JellyfinGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) {
setDefaultRequestOptions(
// Set default disk cache strategy
RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE)
)
// Silence image load errors
setLogLevel(Log.ERROR)
}
}

View File

@@ -1,28 +0,0 @@
package org.jellyfin.androidtv
import android.content.Context
import androidx.startup.Initializer
import timber.log.Timber
class LogInitializer : Initializer<Unit> {
override fun create(context: Context) {
// Enable improved logging for leaking resources
// https://wh0.github.io/2020/08/12/closeguard.html
if (BuildConfig.DEBUG) {
try {
Class.forName("dalvik.system.CloseGuard")
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
.invoke(null, true)
} catch (e: ReflectiveOperationException) {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException(e)
}
}
// Initialize the logging library
Timber.plant(Timber.DebugTree())
Timber.i("Debug tree planted")
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}

View File

@@ -1,27 +0,0 @@
package org.jellyfin.androidtv
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
@Suppress("unused")
class SessionInitializer : Initializer<Unit> {
override fun create(context: Context) {
val koin = AppInitializer.getInstance(context)
.initializeComponent(KoinInitializer::class.java)
.koin
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
}
}
override fun dependencies() = listOf(KoinInitializer::class.java)
}

View File

@@ -0,0 +1,171 @@
package org.jellyfin.androidtv;
import static org.koin.java.KoinJavaComponent.inject;
import android.app.Activity;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.UserDto;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
import java.util.HashMap;
import kotlin.Lazy;
import timber.log.Timber;
public class TvApp extends Application {
public static final String DISPLAY_PREFS_APP_NAME = "ATV";
public static final int LIVE_TV_GUIDE_OPTION_ID = 1000;
public static final int LIVE_TV_RECORDINGS_OPTION_ID = 2000;
public static final int VIDEO_QUEUE_OPTION_ID = 3000;
public static final int LIVE_TV_SCHEDULE_OPTION_ID = 4000;
public static final int LIVE_TV_SERIES_OPTION_ID = 5000;
private static TvApp app;
private MediatorLiveData<UserDto> currentUser = new MediatorLiveData<UserDto>();
private BaseItemDto lastPlayedItem;
private PlaybackController playbackController;
private HashMap<String, DisplayPreferences> displayPrefsCache = new HashMap<>();
private Activity currentActivity;
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
@Override
public void onCreate() {
super.onCreate();
app = this;
}
@Nullable
public static TvApp getApplication() {
return app;
}
@Deprecated
@Nullable
public UserDto getCurrentUser() {
return currentUser.getValue();
}
@Deprecated
public LiveData<UserDto> getCurrentUserLiveData() {
return currentUser;
}
@Deprecated
public void setCurrentUser(UserDto currentUser) {
this.currentUser.postValue(currentUser);
TvManager.clearCache();
this.displayPrefsCache = new HashMap<>();
}
/**
* @deprecated This function is causing a **lot** of issues because not all activities will set their self as "currentactivity". Try to receive a Context instance instead.
*/
@Deprecated
@Nullable
public Activity getCurrentActivity() {
return currentActivity;
}
public void setCurrentActivity(Activity activity) {
currentActivity = activity;
}
@Nullable
public PlaybackController getPlaybackController() {
return playbackController;
}
public void setPlaybackController(PlaybackController playbackController) {
this.playbackController = playbackController;
}
@NonNull
public boolean canManageRecordings() {
UserDto currentUser = getCurrentUser();
return currentUser != null && currentUser.getPolicy().getEnableLiveTvManagement();
}
@NonNull
public DisplayPreferences getCachedDisplayPrefs(String key) {
return getCachedDisplayPrefs(key, DISPLAY_PREFS_APP_NAME);
}
@NonNull
public DisplayPreferences getCachedDisplayPrefs(String key, String app) {
String cacheKey = String.format("%s.%s", app, key);
return displayPrefsCache.containsKey(cacheKey) ? displayPrefsCache.get(cacheKey) : new DisplayPreferences();
}
public void updateDisplayPrefs(DisplayPreferences preferences) {
updateDisplayPrefs("ATV", preferences);
}
public void updateDisplayPrefs(String app, DisplayPreferences preferences) {
displayPrefsCache.put(String.format("%s.%s", app, preferences.getId()), preferences);
apiClient.getValue().UpdateDisplayPreferencesAsync(preferences, getCurrentUser().getId(), app, new EmptyResponse());
Timber.d("Display prefs updated for %s isFavorite: %s", preferences.getId(), preferences.getCustomPrefs().get("FavoriteOnly"));
}
public void getDisplayPrefsAsync(String key, Response<DisplayPreferences> response) {
getDisplayPrefsAsync(key, DISPLAY_PREFS_APP_NAME, response);
}
public void getDisplayPrefsAsync(final String key, String app, final Response<DisplayPreferences> outerResponse) {
String cacheKey = String.format("%s.%s", app, key);
if (displayPrefsCache.containsKey(cacheKey)) {
Timber.d("Display prefs loaded from cache %s", cacheKey);
outerResponse.onResponse(displayPrefsCache.get(cacheKey));
} else {
apiClient.getValue().GetDisplayPreferencesAsync(key, getCurrentUser().getId(), app, new Response<DisplayPreferences>() {
@Override
public void onResponse(DisplayPreferences response) {
if (response.getSortBy() == null) response.setSortBy("SortName");
if (response.getCustomPrefs() == null)
response.setCustomPrefs(new HashMap<String, String>());
displayPrefsCache.put(cacheKey, response);
Timber.d("Display prefs loaded and saved in cache %s", cacheKey);
outerResponse.onResponse(response);
}
@Override
public void onError(Exception exception) {
//Continue with defaults
Timber.e(exception, "Unable to load display prefs ");
DisplayPreferences prefs = new DisplayPreferences();
prefs.setId(key);
prefs.setSortBy("SortName");
prefs.setCustomPrefs(new HashMap<String, String>());
outerResponse.onResponse(prefs);
}
});
}
}
@Nullable
public BaseItemDto getLastPlayedItem() {
return lastPlayedItem;
}
public void setLastPlayedItem(BaseItemDto lastPlayedItem) {
this.lastPlayedItem = lastPlayedItem;
}
}

View File

@@ -0,0 +1,81 @@
package org.jellyfin.androidtv.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Bundle
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class AccountManagerHelper(
private val accountManager: AccountManager
) {
companion object {
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
const val ACCOUNT_DATA_NAME = "$ACCOUNT_TYPE.name"
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
)
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Update credentials
if (androidAccount == null) {
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
accountManager.addAccountExplicitly(
androidAccount,
"", // Leave password empty
Bundle()
)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_ID, accountManagerAccount.id.toString())
}
// Update name
if (androidAccount.name != accountManagerAccount.name) {
androidAccount = suspendCoroutine { continuation ->
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
}
}
accountManager.setUserData(androidAccount, ACCOUNT_DATA_NAME, accountManagerAccount.name)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_SERVER, accountManagerAccount.server.toString())
accountManager.setAuthToken(androidAccount, ACCOUNT_ACCESS_TOKEN_TYPE, accountManagerAccount.accessToken)
}
fun removeAccount(accountManagerAccount: AccountManagerAccount) {
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.first { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Remove current account info
@Suppress("DEPRECATION")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1)
accountManager.removeAccountExplicitly(androidAccount)
else
accountManager.removeAccount(androidAccount, null, null)
}
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
}.map(::getAccountData)
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
}?.let(::getAccountData)
}

View File

@@ -0,0 +1,80 @@
package org.jellyfin.androidtv.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.JellyfinApplication
import org.jellyfin.androidtv.util.apiclient.callApi
import org.jellyfin.androidtv.util.apiclient.callApiEmpty
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.apiclient.model.session.ClientCapabilities
import org.jellyfin.apiclient.model.session.GeneralCommandType
import timber.log.Timber
class ApiBinder(
private val application: JellyfinApplication,
private val api: ApiClient,
private val device: IDevice,
private val authenticationStore: AuthenticationStore,
) {
fun updateSession(session: Session?, resultCallback: (Boolean) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
val success = updateSessionInternal(session)
resultCallback(success)
} catch (throwable: Throwable) {
Timber.e(throwable, "Unable to update legacy API session.")
resultCallback(false)
}
}
}
private suspend fun updateSessionInternal(session: Session?): Boolean {
if (session == null) {
application.currentUser = null
return true
}
val server = authenticationStore.getServer(session.serverId)
if (server == null) {
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
return false
}
api.device = AuthenticationDevice(device, session.userId.toString())
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
api.EnableAutomaticNetworking(ServerInfo().apply {
id = session.serverId.toString()
name = server.name
address = server.address.removeSuffix("/")
userId = session.userId.toString()
accessToken = session.accessToken
})
// Update currentUser DTO
val user = callApi<UserDto> { callback -> api.GetUserAsync(session.userId.toString(), callback) }
application.currentUser = user
callApiEmpty { callback ->
api.ReportCapabilities(ClientCapabilities().apply {
playableMediaTypes = arrayListOf(MediaType.Video, MediaType.Audio)
supportsMediaControl = true
supportedCommands = arrayListOf(
GeneralCommandType.DisplayContent.toString(),
GeneralCommandType.DisplayMessage.toString(),
)
}, callback)
}
// Connect to WebSocket AFTER HTTP connection confirmed working
// to catch exceptions not catchable with the legacy websocket client
api.ensureWebSocket()
return true
}
}

View File

@@ -0,0 +1,29 @@
package org.jellyfin.androidtv.auth
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.sdk.model.DeviceInfo
import java.security.MessageDigest
/**
* Wrapper for [IDevice] that changes the device id to a combination of the original device id
* and the username. This is neccasary to prevent the server from trashing other sessions with
* the same device id.
*/
class AuthenticationDevice(
private val parent: IDevice,
private val username: String
) : IDevice {
// Hash the deviceId because we can't use special characters as the device id is send
// via HTTP headers
override val deviceId = MessageDigest.getInstance("SHA-1").run {
update("${parent.deviceId}+${username}".toByteArray())
digest().joinToString("") { "%02x".format(it) }
}
override val deviceName = parent.deviceName
fun toDeviceInfo() = DeviceInfo(
id = deviceId,
name = deviceName
)
}

View File

@@ -0,0 +1,267 @@
package org.jellyfin.androidtv.auth
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import org.jellyfin.androidtv.auth.model.*
import org.jellyfin.androidtv.preference.AuthenticationPreferences
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.apiclient.interaction.device.IDevice
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.KtorClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.operations.ImageApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.util.*
interface AuthenticationRepository {
fun getServers(): List<Server>
fun getUsers(server: UUID): List<PrivateUser>?
fun getUser(server: UUID, user: UUID): PrivateUser?
fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?)
fun authenticateUser(user: User): Flow<LoginState>
fun authenticateUser(user: User, server: Server): Flow<LoginState>
fun login(server: Server, username: String, password: String = ""): Flow<LoginState>
fun logout(user: User)
fun removeUser(user: User)
fun getUserImageUrl(server: Server, user: User): String?
}
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val device: IDevice,
private val accountManagerHelper: AccountManagerHelper,
private val authenticationStore: AuthenticationStore,
private val userApiClient: KtorClient,
private val authenticationPreferences: AuthenticationPreferences,
) : AuthenticationRepository {
private val serverComparator = compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name }
private val userComparator = compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name }
override fun getServers() = authenticationStore.getServers().map { (id, info) ->
Server(
id = id,
name = info.name,
address = info.address,
version = info.version,
loginDisclaimer = info.loginDisclaimer,
dateLastAccessed = Date(info.lastUsed),
)
}.sortedWith(serverComparator)
private fun mapUser(
serverId: UUID,
userId: UUID,
userInfo: AuthenticationStoreUser,
authInfo: AccountManagerAccount?,
) = PrivateUser(
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: serverId,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
override fun getUsers(server: UUID): List<PrivateUser>? =
authenticationStore.getUsers(server)?.mapNotNull { (userId, userInfo) ->
accountManagerHelper.getAccount(userId).let { authInfo ->
mapUser(server, userId, userInfo, authInfo)
}
}?.sortedWith(userComparator)
override fun getUser(server: UUID, user: UUID): PrivateUser? {
val userInfo = authenticationStore.getUser(server, user) ?: return null
val authInfo = accountManagerHelper.getAccount(user) ?: return null
return mapUser(server, user, userInfo, authInfo)
}
override fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?) {
val current = authenticationStore.getServer(id)
val server = if (current != null) current.copy(name = name, address = address, version = version, loginDisclaimer = loginDisclaimer, lastUsed = Date().time)
else AuthenticationStoreServer(name, address, version, loginDisclaimer)
authenticationStore.putServer(id, server)
}
/**
* Set the active session to the information in [user] and [server].
* Connects to the server and requests the info of the currently authenticated user.
*
* @return Whether the user information can be retrieved.
*/
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
return authenticated
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun authenticateUser(user: User): Flow<LoginState> = flow {
Timber.d("Authenticating serverless user %s", user)
emit(AuthenticatingState)
val server = authenticationStore.getServer(user.serverId)?.let {
Server(
id = user.serverId,
name = it.name,
address = it.address,
version = it.version,
loginDisclaimer = it.loginDisclaimer,
dateLastAccessed = Date(it.lastUsed),
)
}
if (server == null) emit(RequireSignInState)
else if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emitAll(authenticateUser(user, server))
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun authenticateUser(user: User, server: Server): Flow<LoginState> = flow {
Timber.d("Authenticating user %s", user)
emit(AuthenticatingState)
val account = accountManagerHelper.getAccount(user.id)
when {
!server.versionSupported -> emit(ServerVersionNotSupported(server))
// Access token found, proceed with sign in
!authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate] && account?.accessToken != null -> when {
// Update session
setActiveSession(user, server) -> {
// Update stored user
try {
val userInfo by UserApi(userApiClient).getCurrentUser()
val currentUser = authenticationStore.getUser(server.id, user.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, user.id, updatedUser)
accountManagerHelper.putAccount(AccountManagerAccount(user.id, server.id, updatedUser.name, account.accessToken))
emit(AuthenticatedState)
} catch(err: ApiClientException) {
Timber.e(err, "Unable to get current user data")
emit(RequireSignInState)
}
}
// Login failed
else -> when {
// No password required - try login
!user.requirePassword -> emitAll(login(server, user.name))
// Require new sign in
else -> emit(RequireSignInState)
}
}
// User is known to not require a password, try a sign in
!user.requirePassword -> emitAll(login(server, user.name))
// Account found without access token, require sign in
else -> emit(RequireSignInState)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun login(server: Server, username: String, password: String) = flow {
if (!server.versionSupported) {
emit(ServerVersionNotSupported(server))
return@flow
}
val api = jellyfin.createApi(server.address, deviceInfo = AuthenticationDevice(device, username).toDeviceInfo())
val userApi = UserApi(api)
val result = try {
val response = userApi.authenticateUserByName(username, password)
response.content
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(RequireSignInState)
return@flow
}
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val userId = userInfo.id
val currentUser = authenticationStore.getUser(server.id, userId)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, userId, updatedUser)
val accountManagerAccount = AccountManagerAccount(userId, server.id, updatedUser.name, result.accessToken)
accountManagerHelper.putAccount(accountManagerAccount)
val user = PrivateUser(
id = userId,
serverId = server.id,
name = updatedUser.name,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
// We just added the account so it should activate properly although in rare cases it doesn't.
// this is often caused by issues in the platforms account manager
if (setActiveSession(user, server)) emit(AuthenticatedState)
else {
// Try to remove the account and ask for sign in
accountManagerHelper.removeAccount(accountManagerAccount)
emit(RequireSignInState)
}
}
override fun logout(user: User) {
accountManagerHelper.getAccount(user.id)?.let(accountManagerHelper::removeAccount)
}
override fun removeUser(user: User) {
authenticationStore.removeUser(user.serverId, user.id)
logout(user)
}
override fun getUserImageUrl(server: Server, user: User): String? {
val api = jellyfin.createApi(baseUrl = server.address)
val imageApi = ImageApi(api)
return user.imageTag?.let { imageTag ->
imageApi.getUserImageUrl(
userId = user.id,
tag = imageTag,
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}
}
}

View File

@@ -0,0 +1,11 @@
package org.jellyfin.androidtv.auth
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class AuthenticationSortBy {
@EnumDisplayOptions(name = R.string.last_use)
LAST_USE,
@EnumDisplayOptions(name = R.string.alphabetical)
ALPHABETICAL
}

View File

@@ -1,31 +1,25 @@
package org.jellyfin.androidtv.auth.store
package org.jellyfin.androidtv.auth
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
import timber.log.Timber
import java.util.UUID
import java.util.*
/**
* Storage for authentication related entities. Stores servers with users inside, including
* access tokens.
* Storage for authentication related entities. Stores servers with users inside. Should be used in
* combination with the [AccountManagerHelper] to store access tokens and integrate with the
* operating system.
*
* The data is stored in a JSON file located in the applications data directory.
*/
class AuthenticationStore(
private val context: Context,
private val context: Context
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
@@ -33,7 +27,7 @@ class AuthenticationStore(
private val json = Json {
encodeDefaults = true
serializersModule = SerializersModule {
contextual(UUIDSerializer())
contextual(UUIDSerializer)
}
ignoreUnknownKeys = true
}
@@ -46,30 +40,14 @@ class AuthenticationStore(
// No store found
if (!storePath.exists()) return emptyMap()
// Parse JSON document
val root = try {
json.parseToJsonElement(storePath.readText()).jsonObject
} catch (e: SerializationException) {
Timber.e(e, "Unable to read JSON")
JsonObject(emptyMap())
}
// Check for version
val root = json.parseToJsonElement(storePath.readText()).jsonObject
return when (root["version"]?.jsonPrimitive?.intOrNull) {
// Migration was removed, clear stored servers
1 -> {
Timber.e("Migrating from version 1 is no longer possible")
emptyMap()
}
// Current version, return as-is
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {
Timber.e("Authentication Store is corrupt!")
emptyMap()
}
else -> {
Timber.e("Authentication Store is using an unknown version!")
emptyMap()
@@ -77,10 +55,10 @@ class AuthenticationStore(
}
}
private fun write(servers: Map<UUID, AuthenticationStoreServer>): Boolean {
private fun save(): Boolean {
val root = JsonObject(mapOf(
"version" to JsonPrimitive(2),
"servers" to json.encodeToJsonElement(servers)
"version" to JsonPrimitive(1),
"servers" to json.encodeToJsonElement(store)
))
storePath.writeText(json.encodeToString(root))
@@ -88,10 +66,6 @@ class AuthenticationStore(
return true
}
private fun save(): Boolean {
return write(store)
}
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users
@@ -113,6 +87,10 @@ class AuthenticationStore(
return save()
}
fun containsServer(server: UUID): Boolean = server in store
fun containsUser(server: UUID, user: UUID): Boolean = server in store && user in store[server]!!.users
/**
* Removes the server and stored users from the credential store.
*/

View File

@@ -0,0 +1,49 @@
package org.jellyfin.androidtv.auth
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import androidx.core.os.bundleOf
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
class AuthenticatorService : Service() {
private val authenticator by lazy {
Authenticator(this)
}
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
private inner class Authenticator(
private val service: AuthenticatorService
) : AbstractAccountAuthenticator(service) {
private val unsupportedOperationBundle = bundleOf(
AccountManager.KEY_ERROR_CODE to 1,
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
)
override fun addAccount(response: AccountAuthenticatorResponse, accountType: String, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle): Bundle = unsupportedOperationBundle
override fun confirmCredentials(response: AccountAuthenticatorResponse, account: Account, options: Bundle?): Bundle = unsupportedOperationBundle
override fun editProperties(response: AccountAuthenticatorResponse, accountType: String): Bundle = bundleOf(
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
)
override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse, account: Account): Bundle = bundleOf(
AccountManager.KEY_BOOLEAN_RESULT to true
)
override fun getAuthToken(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle = unsupportedOperationBundle
override fun getAuthTokenLabel(authTokenType: String): String? = null
override fun hasFeatures(response: AccountAuthenticatorResponse, account: Account, features: Array<String>): Bundle = unsupportedOperationBundle
override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String?, options: Bundle?): Bundle = unsupportedOperationBundle
}
}

View File

@@ -0,0 +1,97 @@
package org.jellyfin.androidtv.auth
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.preference.SystemPreferences
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
class LegacyAccountMigration(
private val context: Context,
private val authenticationStore: AuthenticationStore,
private val accountManagerHelper: AccountManagerHelper,
private val systemPreferences: SystemPreferences,
) {
companion object {
const val LEGACY_CREDENTIAL_FILE = "org.jellyfin.androidtv.login.json"
}
suspend fun migrate() {
val path = context.filesDir.resolve(LEGACY_CREDENTIAL_FILE)
if (path.exists() && !systemPreferences[SystemPreferences.legacyCredentialsMigrated]) {
Timber.d("Starting migration of legacy credentials from $path")
val json = Json {
serializersModule = SerializersModule {
contextual(UUIDSerializer)
}
ignoreUnknownKeys = true
}
val root = json.parseToJsonElement(path.readText()).jsonObject
val server = root["serverInfo"]?.jsonObject
val user = root["userDto"]?.jsonObject
val serverId = server?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
if (serverId != null) {
val serverName = server["Name"]?.jsonPrimitive?.content
val serverAddress = server["Address"]?.jsonPrimitive?.content
val serverVersion = server["Version"]?.jsonPrimitive?.content
if (!authenticationStore.containsServer(serverId)) {
Timber.i("Migrating server $serverId")
authenticationStore.putServer(
id = serverId,
server = AuthenticationStoreServer(
name = serverName ?: "",
address = serverAddress ?: "",
loginDisclaimer = null,
version = serverVersion,
)
)
}
val userId = user?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
if (userId != null && !authenticationStore.containsUser(serverId, userId)) {
Timber.i("Migrating user $userId")
val name = user["Name"]?.jsonPrimitive?.content ?: userId.toString()
val requirePassword = user["HasPassword"]?.jsonPrimitive?.booleanOrNull ?: true
val imageTag = user["PrimaryImageTag"]?.jsonPrimitive?.content
val accessToken = user["AccessToken"]?.jsonPrimitive?.content ?: server["AccessToken"]?.jsonPrimitive?.content
authenticationStore.putUser(
server = serverId,
userId = userId,
userInfo = AuthenticationStoreUser(
name = name,
requirePassword = requirePassword,
imageTag = imageTag
)
)
accountManagerHelper.putAccount(AccountManagerAccount(
id = userId,
server = serverId,
name = name,
accessToken = accessToken?.let {
// Convert empty string to null
if (it.isBlank()) null
else it
}
))
}
}
systemPreferences[SystemPreferences.legacyCredentialsMigrated] = true
} else {
Timber.d("Skipping migration of legacy credentials from $path (file does not exist)")
}
}
}

View File

@@ -0,0 +1,186 @@
package org.jellyfin.androidtv.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.jellyfin.androidtv.auth.model.*
import org.jellyfin.androidtv.util.sdk.toPublicUser
import org.jellyfin.androidtv.util.sdk.toServer
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.operations.BrandingApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.*
interface ServerRepository {
fun getStoredServers(): List<Server>
fun getDiscoveryServers(): Flow<Server>
suspend fun migrateLegacyCredentials()
fun getServerUsers(server: Server): LiveData<List<User>>
fun removeServer(serverId: UUID): Boolean
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun refreshServerInfo(server: Server): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
}
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationRepository: AuthenticationRepository,
private val authenticationStore: AuthenticationStore,
private val legacyAccountMigration: LegacyAccountMigration,
) : ServerRepository {
override fun getStoredServers() = authenticationRepository.getServers()
override fun getDiscoveryServers() = flow {
val servers = jellyfin.discovery.discoverLocalServers().mapNotNull(ServerDiscoveryInfo::toServer)
emitAll(servers)
}.flowOn(Dispatchers.IO)
private suspend fun getServerPublicUsers(server: Server): List<PublicUser> {
val client = jellyfin.createApi(server.address)
val userApi = UserApi(client)
return try {
val users by userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
emptyList()
}
}
public override suspend fun refreshServerInfo(server: Server): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
val now = Date().time
// Only update every 10 minutes
if (now - serverInfo.lastRefreshed < 600000 && serverInfo.version != null) return false
return try {
val client = jellyfin.createApi(server.address)
// Get login disclaimer
val branding by BrandingApi(client).getBrandingOptions()
val systemInfo by SystemApi(client).getPublicSystemInfo()
authenticationStore.putServer(server.id, serverInfo.copy(
name = systemInfo.serverName ?: serverInfo.name,
version = systemInfo.version ?: serverInfo.version,
loginDisclaimer = branding.loginDisclaimer ?: serverInfo.loginDisclaimer,
lastRefreshed = now
))
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
false
}
}
private fun getServerStoredUsers(server: Server): List<PrivateUser> = authenticationRepository
.getUsers(server.id)
.orEmpty()
override fun getServerUsers(server: Server): LiveData<List<User>> = liveData {
val users = mutableListOf<User>()
users.addAll(getServerStoredUsers(server))
emit(users.toList())
getServerPublicUsers(server)
.sortedBy { it.name }
.forEach { user ->
val index = users.indexOfFirst { it.id == user.id }
if (index == -1) users.add(user)
else {
users[index] = when(val currentUser = users[index]) {
is PublicUser -> user
is PrivateUser -> currentUser.copy(
name = user.name,
requirePassword = user.requirePassword,
imageTag = user.imageTag
)
}
}
}
emit(users.toList())
}
override suspend fun migrateLegacyCredentials() = legacyAccountMigration.migrate()
override fun removeServer(serverId: UUID) = authenticationStore.removeServer(serverId)
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates)
.firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
}
}
}
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
append(" good, ")
append(badRecommendations.size)
append(" bad")
})
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo!!
// Get branding info
val api = BrandingApi(jellyfin.createApi(chosenRecommendation.address))
val branding by api.getBrandingOptions()
val id = systemInfo.id!!.toUUID()
authenticationRepository.saveServer(
id = id,
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
)
emit(ConnectedState(id, systemInfo))
} else {
// No great or good recommendations, only add bad recommendations
emit(UnableToConnectState(addressCandidates))
}
}
}

View File

@@ -0,0 +1,167 @@
package org.jellyfin.androidtv.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.jellyfin.androidtv.preference.AuthenticationPreferences
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.*
import org.jellyfin.sdk.api.client.KtorClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class Session(
val userId: UUID,
val serverId: UUID,
val accessToken: String,
)
interface SessionRepository {
val currentSession: LiveData<Session?>
val currentSystemSession: LiveData<Session?>
fun restoreDefaultSession()
fun restoreDefaultSystemSession()
suspend fun switchCurrentSession(userId: UUID): Boolean
fun destroyCurrentSession()
}
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val accountManagerHelper: AccountManagerHelper,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: KtorClient,
private val systemApiClient: KtorClient,
) : SessionRepository {
private val _currentSession = MutableLiveData<Session?>()
override val currentSession: LiveData<Session?> get() = _currentSession
private val _currentSystemSession = MutableLiveData<Session?>()
override val currentSystemSession: LiveData<Session?> get() = _currentSystemSession
override fun restoreDefaultSession() {
Timber.d("Restoring default session")
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
when (behavior) {
DISABLED -> destroyCurrentSession()
LAST_USER -> setCurrentSession(createLastUserSession(), false)
SPECIFIC_USER -> setCurrentSession(createUserSession(userId), false)
}
}
override fun restoreDefaultSystemSession() {
Timber.d("Restoring default system session")
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return setCurrentSession(null, false)
val behavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.systemUserId].toUUIDOrNull()
when (behavior) {
DISABLED -> setCurrentSystemSession(null)
LAST_USER -> setCurrentSystemSession(createLastUserSession())
SPECIFIC_USER -> setCurrentSystemSession(createUserSession(userId))
}
}
override suspend fun switchCurrentSession(userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) return false
Timber.d("Switching current session to user $userId")
val session = createUserSession(userId)
if (session == null) {
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
return suspendCoroutine { continuation ->
setCurrentSession(session, true) { success ->
continuation.resume(success)
}
}
}
override fun destroyCurrentSession() {
Timber.d("Destroying current session")
_currentSession.value = null
userApiClient.applySession(null)
apiBinder.updateSession(null) { }
}
private fun setCurrentSession(session: Session?, includeSystemUser: Boolean, callback: ((Boolean) -> Unit)? = null) {
// No change in session - don't switch
if (session != null && currentSession.value?.userId == session.userId) return
if (session != null) authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
val systemUserBehavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
if (includeSystemUser && systemUserBehavior == LAST_USER) setCurrentSystemSession(session)
// Update session after binding the apiclient settings
apiBinder.updateSession(session) { success ->
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
if (success) {
userApiClient.applySession(session)
_currentSession.postValue(session)
} else {
userApiClient.applySession(null)
_currentSession.postValue(null)
}
callback?.invoke(success)
}
}
private fun setCurrentSystemSession(session: Session?) {
// No change in session - don't switch
if (session != null && currentSession.value?.userId == session.userId) return
_currentSystemSession.postValue(session)
systemApiClient.applySession(session)
}
private fun createLastUserSession(): Session? {
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
return createUserSession(lastUserId)
}
private fun createUserSession(userId: UUID?): Session? {
if (userId == null) return null
val account = accountManagerHelper.getAccount(userId)
if (account?.accessToken == null) return null
return Session(
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun KtorClient.applySession(session: Session?) {
if (session == null) {
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
}
}

View File

@@ -0,0 +1,10 @@
package org.jellyfin.androidtv.auth.model
import java.util.*
data class AccountManagerAccount(
val id: UUID,
val server: UUID,
val name: String,
val accessToken: String? = null,
)

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class AuthenticateMethod
data class AutomaticAuthenticateMethod(val user: User) : AuthenticateMethod()
data class CredentialAuthenticateMethod(val username: String, val password: String = "") : AuthenticateMethod()
data class QuickConnectAuthenticateMethod(val secret: String) : AuthenticateMethod()

View File

@@ -1,11 +0,0 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class AuthenticationSortBy(
override val nameRes: Int
) : PreferenceEnum {
LAST_USE(R.string.last_use),
ALPHABETICAL(R.string.alphabetical);
}

View File

@@ -5,9 +5,8 @@ package org.jellyfin.androidtv.auth.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.Instant
import java.util.UUID
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
import java.util.*
/**
* Locally stored server information. New properties require default values or deserialization will fail.
@@ -18,9 +17,7 @@ data class AuthenticationStoreServer(
val address: String,
val version: String? = null,
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
@SerialName("setup_completed") val setupCompleted: Boolean = true,
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
)

View File

@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.*
/**
* Locally stored user information. New properties require default values or deserialization will fail.
@@ -10,7 +10,7 @@ import java.time.Instant
@Serializable
data class AuthenticationStoreUser(
val name: String,
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
@SerialName("image_tag") val imageTag: String? = null,
@SerialName("access_token") val accessToken: String? = null,
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("require_password") val requirePassword: Boolean = true,
@SerialName("image_tag") val imageTag: String? = null
)

View File

@@ -0,0 +1,23 @@
package org.jellyfin.androidtv.auth.model
import androidx.annotation.StringRes
data class LoadingState(
val status: Status,
@StringRes val messageRes: Int? = null
) {
companion object {
val PENDING = LoadingState(Status.PENDING)
val LOADING = LoadingState(Status.LOADING)
val SUCCESS = LoadingState(Status.SUCCESS)
val ERROR = LoadingState(Status.ERROR)
fun error(@StringRes messageRes: Int? = null) = LoadingState(Status.ERROR, messageRes)
}
enum class Status {
PENDING,
LOADING,
SUCCESS,
ERROR
}
}

View File

@@ -1,11 +1,8 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.sdk.api.client.exception.ApiClientException
sealed class LoginState
data object AuthenticatingState : LoginState()
data object RequireSignInState : LoginState()
data object ServerUnavailableState : LoginState()
object AuthenticatingState : LoginState()
object RequireSignInState : LoginState()
object ServerUnavailableState : LoginState()
data class ServerVersionNotSupported(val server: Server) : LoginState()
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
data object AuthenticatedState : LoginState()
object AuthenticatedState : LoginState()

View File

@@ -1,23 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class QuickConnectState
/**
* State unknown until first poll completed.
*/
data object UnknownQuickConnectState : QuickConnectState()
/**
* Server does not have QuickConnect enabled.
*/
data object UnavailableQuickConnectState : QuickConnectState()
/**
* Connection is pending.
*/
data class PendingQuickConnectState(val code: String) : QuickConnectState()
/**
* User connected.
*/
data object ConnectedQuickConnectState : QuickConnectState()

View File

@@ -1,9 +1,8 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.ServerRepository
import org.jellyfin.sdk.model.ServerVersion
import java.time.Instant
import java.util.UUID
import java.util.*
/**
* Server model to use locally in place of ServerInfo model in ApiClient.
@@ -14,15 +13,11 @@ data class Server(
var address: String,
val version: String? = null,
val loginDisclaimer: String? = null,
val splashscreenEnabled: Boolean = false,
val setupCompleted: Boolean = true,
var dateLastAccessed: Instant = Instant.MIN,
var dateLastAccessed: Date = Date(0),
) {
val serverVersion = version?.let(ServerVersion::fromString)
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1
override fun equals(other: Any?) = other is Server
&& id == other.id
&& address == other.address

View File

@@ -1,10 +1,9 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.sdk.discovery.RecommendedServerIssue
import org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID
import java.util.*
sealed class ServerAdditionState
data class ConnectingState(val address: String) : ServerAdditionState()
data class UnableToConnectState(val addressCandidates: Map<String, Collection<RecommendedServerIssue>>) : ServerAdditionState()
data class UnableToConnectState(val addressCandidates: List<String>) : ServerAdditionState()
data class ConnectedState(val id: UUID, val publicInfo: PublicSystemInfo) : ServerAdditionState()

View File

@@ -1,6 +1,6 @@
package org.jellyfin.androidtv.auth.model
import java.util.UUID
import java.util.*
/**
* User model used locally.
@@ -10,10 +10,9 @@ sealed class User {
abstract val serverId: UUID
abstract val name: String
abstract val accessToken: String?
abstract val requirePassword: Boolean
abstract val imageTag: String?
abstract fun withToken(accessToken: String): User
override fun equals(other: Any?) = other is User
&& serverId == other.serverId
&& id == other.id
@@ -33,11 +32,10 @@ data class PrivateUser(
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
val lastUsed: Long,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}
) : User()
/**
* Represents a user stored server side. Found using the Public User endpoint.
@@ -47,7 +45,6 @@ data class PublicUser(
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}
) : User()

View File

@@ -1,217 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.auth.model.AutomaticAuthenticateMethod
import org.jellyfin.androidtv.auth.model.CredentialAuthenticateMethod
import org.jellyfin.androidtv.auth.model.LoginState
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.QuickConnectAuthenticateMethod
import org.jellyfin.androidtv.auth.model.RequireSignInState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.primaryImage
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.time.Instant
/**
* Repository to manage authentication of the user in the app.
*/
interface AuthenticationRepository {
fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState>
fun logout(user: User): Boolean
fun getUserImageUrl(server: Server, user: User): String?
}
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val authenticationPreferences: AuthenticationPreferences,
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
return when (method) {
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
is QuickConnectAuthenticateMethod -> authenticateQuickConnect(server, method.secret)
}
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
Timber.i("Authenticating user ${user.id}")
// Automatic logic is disabled when the always authenticate preference is enabled
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
val authStoreUser = authenticationStore.getUser(server.id, user.id)
// Try login with access token
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
// Require login
else flowOf(RequireSignInState)
}
private fun authenticateCredential(server: Server, username: String, password: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
val result = try {
val response = api.userApi.authenticateUserByName(username, password)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server trying to sign in $username")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(ApiClientErrorLoginState(err))
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
val result = try {
val response = api.userApi.authenticateWithQuickConnect(secret)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in with Quick Connect secret")
emit(ApiClientErrorLoginState(err))
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val user = PrivateUser(
id = userInfo.id,
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
imageTag = userInfo.primaryImage?.tag,
lastUsed = Instant.now().toEpochMilli(),
)
authenticateFinish(server, userInfo, accessToken)
val success = setActiveSession(user, server)
if (success) {
emit(AuthenticatedState)
} else {
Timber.w("Failed to set active session after authenticating")
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
}
}.flowOn(Dispatchers.IO)
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
} else try {
// Update user info
val userInfo by userApiClient.userApi.getCurrentUser()
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
emit(AuthenticatedState)
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to get current user data")
emit(ApiClientErrorLoginState(err))
}
}.flowOn(Dispatchers.IO)
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
imageTag = userInfo.primaryImage?.tag,
accessToken = accessToken,
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
imageTag = userInfo.primaryImage?.tag,
accessToken = accessToken,
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
}
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
}
}
return authenticated
}
override fun logout(user: User): Boolean {
val authStoreUser = authenticationStore
.getUser(user.serverId, user.id)
?.copy(accessToken = null)
return if (authStoreUser != null) authenticationStore.putUser(user.serverId, user.id, authStoreUser)
else false
}
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
JellyfinImage(
item = user.id,
source = JellyfinImageSource.USER,
type = ImageType.PRIMARY,
tag = primaryImageTag,
blurHash = null,
aspectRatio = null,
index = null
)
}?.getUrl(jellyfin.createApi(server.address))
}

View File

@@ -1,271 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerAdditionState
import org.jellyfin.androidtv.auth.model.UnableToConnectState
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toServer
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.InvalidContentException
import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BrandingOptionsDto
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.time.Instant
import java.util.UUID
/**
* Repository to maintain servers.
*/
interface ServerRepository {
val storedServers: StateFlow<List<Server>>
val discoveredServers: StateFlow<List<Server>>
val currentServer: StateFlow<Server?>
suspend fun loadStoredServers()
suspend fun loadDiscoveryServers()
fun setCurrentServer(server: Server?)
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun getServer(id: UUID, eagerUpdate: Boolean = false): Server?
suspend fun updateServer(server: Server, force: Boolean = false): Boolean
suspend fun deleteServer(server: UUID): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 11, 0)
}
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
) : ServerRepository {
// State
private val _storedServers = MutableStateFlow(emptyList<Server>())
override val storedServers = _storedServers.asStateFlow()
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
override val discoveredServers = _discoveredServers.asStateFlow()
private val _currentServer = MutableStateFlow<Server?>(null)
override val currentServer = _currentServer.asStateFlow()
// Loading data
override suspend fun loadStoredServers() {
authenticationStore.getServers()
.map { (id, entry) -> entry.asServer(id) }
.sortedWith(compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name })
.let { _storedServers.emit(it) }
}
override suspend fun loadDiscoveryServers() {
val servers = mutableListOf<Server>()
jellyfin.discovery
.discoverLocalServers()
.map(ServerDiscoveryInfo::toServer)
.collect { server ->
servers += server
_discoveredServers.emit(servers.toList())
}
}
override fun setCurrentServer(server: Server?) {
_currentServer.value = server
}
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.i("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.i("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
}
}
}
Timber.i(buildString {
append("Recommendations: ")
if (greatRecommendation == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
append(" good, ")
append(badRecommendations.size)
append(" bad")
})
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
// Get branding info
val api = jellyfin.createApi(chosenRecommendation.address)
val branding = api.getBrandingOptionsOrDefault()
val id = systemInfo.id!!.toUUID()
val server = authenticationStore.getServer(id)?.copy(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Instant.now().toEpochMilli()
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
)
authenticationStore.putServer(id, server)
loadStoredServers()
emit(ConnectedState(id, systemInfo))
} else {
// No great or good recommendations, only add bad recommendations
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
.groupBy { it.address }
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}.flowOn(Dispatchers.IO)
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): Server? {
val server = authenticationStore.getServer(id) ?: return null
val updatedServer = try {
val forceUpdate = eagerUpdate && server.version
?.let(ServerVersion::fromString)
?.let { version -> version < ServerRepository.minimumServerVersion } == true
updateServerInternal(id, server, forceUpdate)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
null
}
return (updatedServer ?: server).asServer(id)
}
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
return try {
updateServerInternal(server.id, serverInfo, force) != null
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
false
}
}
private suspend fun updateServerInternal(
id: UUID,
server: AuthenticationStoreServer,
forceUpdate: Boolean
): AuthenticationStoreServer? {
val now = Instant.now().toEpochMilli()
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
val newServer = withContext(Dispatchers.IO) {
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
}
authenticationStore.putServer(id, newServer)
return newServer
}
override suspend fun deleteServer(server: UUID): Boolean {
val success = authenticationStore.removeServer(server)
if (success) loadStoredServers()
return success
}
// Helper functions
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
id = id,
name = name,
address = address,
version = version,
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
)
/**
* Try to retrieve the branding options. If the response JSON is invalid it will return a default value.
* This makes sure we can still work with older Jellyfin versions.
*/
private suspend fun ApiClient.getBrandingOptionsOrDefault() = try {
brandingApi.getBrandingOptions().content
} catch (exception: InvalidContentException) {
Timber.w(exception, "Invalid branding options response, using default value")
BrandingOptionsDto(
loginDisclaimer = null,
customCss = null,
splashscreenEnabled = false,
)
}
}

View File

@@ -1,67 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.PublicUser
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toPublicUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
/**
* Repository to maintain users for servers.
* Authentication is done using the [AuthenticationRepository].
*/
interface ServerUserRepository {
//server
fun getStoredServerUsers(server: Server): List<PrivateUser>
suspend fun getPublicServerUsers(server: Server): List<PublicUser>
fun deleteStoredUser(user: PrivateUser)
}
class ServerUserRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
) : ServerUserRepository {
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
?.mapNotNull { (userId, userInfo) ->
val authInfo = authenticationStore.getUser(server.id, userId)
PrivateUser(
id = userId,
serverId = server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
}
?.sortedWith(compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name })
.orEmpty()
override suspend fun getPublicServerUsers(server: Server): List<PublicUser> {
// Create a fresh API because the shared one might be authenticated for a different server
val api = jellyfin.createApi(server.address)
return try {
val users = withContext(Dispatchers.IO) {
api.userApi.getPublicUsers().content
}
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
emptyList()
}
}
override fun deleteStoredUser(user: PrivateUser) {
// Remove user info from store
authenticationStore.removeUser(user.serverId, user.id)
}
}

View File

@@ -1,208 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.preference.PreferencesRepository
import org.jellyfin.androidtv.preference.TelemetryPreferences
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
data class Session(
val userId: UUID,
val serverId: UUID,
val accessToken: String,
)
enum class SessionRepositoryState {
READY,
RESTORING_SESSION,
SWITCHING_SESSION,
}
interface SessionRepository {
val currentSession: StateFlow<Session?>
val state: StateFlow<SessionRepositoryState>
suspend fun restoreSession(destroyOnly: Boolean)
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
fun destroyCurrentSession()
}
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
private val defaultDeviceInfo: DeviceInfo,
private val userRepository: UserRepository,
private val serverRepository: ServerRepository,
private val telemetryPreferences: TelemetryPreferences,
) : SessionRepository {
private val currentSessionMutex = Mutex()
private val _currentSession = MutableStateFlow<Session?>(null)
override val currentSession = _currentSession.asStateFlow()
private val _state = MutableStateFlow(SessionRepositoryState.READY)
override val state = _state.asStateFlow()
override suspend fun restoreSession(destroyOnly: Boolean): Unit = withContext(NonCancellable) {
currentSessionMutex.withLock {
Timber.i("Restoring session")
_state.value = SessionRepositoryState.RESTORING_SESSION
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
when {
alwaysAuthenticate -> destroyCurrentSession()
autoLoginBehavior == DISABLED -> destroyCurrentSession()
autoLoginBehavior == LAST_USER && !destroyOnly -> setCurrentSession(createLastUserSession())
autoLoginBehavior == SPECIFIC_USER && !destroyOnly -> {
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
}
}
_state.value = SessionRepositoryState.READY
}
}
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) {
Timber.d("Current session user is the same as the requested user")
return false
}
_state.value = SessionRepositoryState.SWITCHING_SESSION
Timber.i("Switching current session to user $userId")
val session = createUserSession(serverId, userId)
if (session == null) {
Timber.w("Could not switch to non-existing session for user $userId")
_state.value = SessionRepositoryState.READY
return false
}
val switched = setCurrentSession(session)
_state.value = SessionRepositoryState.READY
return switched
}
override fun destroyCurrentSession() {
Timber.i("Destroying current session")
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
_currentSession.value = null
_state.value = SessionRepositoryState.READY
}
private suspend fun setCurrentSession(session: Session?): Boolean {
var server: Server? = null
if (session != null) {
// No change in session - don't switch
if (currentSession.value?.userId == session.userId) return true
// Update last active user
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
// Check if server version is supported
server = serverRepository.getServer(session.serverId, true)
if (server == null || !server.versionSupported) return false
}
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && session != null) {
try {
val user = withContext(Dispatchers.IO) {
userApiClient.userApi.getCurrentUser().content
}
userRepository.setCurrentUser(user)
serverRepository.setCurrentServer(server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
}
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
return true
}
private fun createLastUserSession(): Session? {
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
else null
}
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
val account = authenticationStore.getUser(serverId, userId)
if (account?.accessToken == null) return null
return Session(
userId = userId,
serverId = serverId,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
if (session == null) {
update(
baseUrl = null,
accessToken = null,
deviceInfo = newDeviceInfo,
)
} else {
val server = authenticationStore.getServer(session.serverId)
?: return false
update(
baseUrl = server.address,
accessToken = session.accessToken,
deviceInfo = newDeviceInfo,
)
}
return true
}
}

View File

@@ -1,22 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.model.api.UserDto
/**
* Repository to get the current authenticated user.
*/
interface UserRepository {
val currentUser: StateFlow<UserDto?>
fun setCurrentUser(user: UserDto?)
}
class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun setCurrentUser(user: UserDto?) {
currentUser.value = user
}
}

View File

@@ -1,43 +0,0 @@
package org.jellyfin.androidtv.auth.store
import android.content.Context
import org.jellyfin.androidtv.auth.model.AuthenticationSortBy
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
sharedPreferences = context.getSharedPreferences("authentication", Context.MODE_PRIVATE)
) {
companion object {
// Preferences
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
val autoLoginServerId = stringPreference("auto_login_server_id", "")
val autoLoginUserId = stringPreference("auto_login_user_id", "")
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
// Persistent state
val lastServerId = stringPreference("last_server_id", "")
val lastUserId = stringPreference("last_user_id", "")
}
init {
runMigrations {
// v0.15.4 to v0.15.5
migration(toVersion = 2) {
// Unfortunately we cannot migrate the "specific user" login option
// so we'll reset the preference to disabled if it was used
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
putString("auto_login_user_id", "")
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
}
putString("last_user_id", "")
}
}
}
}

View File

@@ -0,0 +1,12 @@
package org.jellyfin.androidtv.constant;
public enum ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate
}

View File

@@ -1,9 +0,0 @@
package org.jellyfin.androidtv.constant
enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
MusicPlayback,
FavoriteUpdate,
}

View File

@@ -1,89 +0,0 @@
package org.jellyfin.androidtv.constant
object Codec {
object Container {
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
const val `3GP` = "3gp"
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP3 = "mp3"
const val MP4 = "mp4"
const val MPEG = "mpeg"
const val MPEGTS = "mpegts"
const val MPG = "mpg"
const val OGM = "ogm"
const val OGV = "ogv"
const val TS = "ts"
const val VOB = "vob"
const val WEBM = "webm"
const val WMV = "wmv"
const val WTV = "wtv"
const val XVID = "xvid"
}
object Audio {
const val AAC = "aac"
const val AAC_LATM = "aac_latm"
const val AC3 = "ac3"
const val ALAC = "alac"
const val APE = "ape"
const val DCA = "dca"
const val DTS = "dts"
const val EAC3 = "eac3"
const val FLAC = "flac"
const val MLP = "mlp"
const val MP2 = "mp2"
const val MP3 = "mp3"
const val MPA = "mpa"
const val OGA = "oga"
const val OGG = "ogg"
const val OPUS = "opus"
const val PCM = "pcm"
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
const val WMAV2 = "wmav2"
}
object Video {
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
const val VC1 = "vc1"
}
object Subtitle {
const val ASS = "ass"
const val DVBSUB = "dvbsub"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
const val PGSSUB = "pgssub"
const val SMI = "smi"
const val SRT = "srt"
const val SSA = "ssa"
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View File

@@ -0,0 +1,36 @@
package org.jellyfin.androidtv.constant;
public class CodecTypes {
/* Audio Codec Types */
public static final String AAC = "aac";
public static final String AAC_LATM = "aac_latm";
public static final String AC3 = "ac3";
public static final String APE = "ape";
public static final String DCA = "dca";
public static final String DTS = "dts";
public static final String EAC3 = "eac3";
public static final String FLAC = "flac";
public static final String MP2 = "mp2";
public static final String MP3 = "mp3";
public static final String MPA = "mpa";
public static final String OPUS = "opus";
public static final String PCM = "pcm";
public static final String PCM_S16LE = "PCM_S16LE";
public static final String PCM_S24LE = "PCM_S24LE";
public static final String TRUEHD = "truehd";
public static final String VORBIS = "vorbis";
public static final String WAV = "wav";
public static final String WMA = "wma";
public static final String WMAV2 = "wmav2";
/* Video Codec Types */
public static final String AVC = "avc";
public static final String H264 = "h264";
public static final String H265 = "h265";
public static final String HEVC = "hevc";
public static final String MPEG2 = "mpeg2";
public static final String MPEG2VIDEO = "mpeg2video";
public static final String MPEG4 = "mpeg4";
public static final String VP8 = "vp8";
public static final String VP9 = "vp9";
}

View File

@@ -0,0 +1,29 @@
package org.jellyfin.androidtv.constant;
public class ContainerTypes {
public static final String _3GP = "3gp";
public static final String _3GPP = "3gpp";
public static final String ASF = "asf";
public static final String AVI = "avi";
public static final String DVR_MS = "dvr-ms";
public static final String M2TS = "m2ts";
public static final String M2V = "m2v";
public static final String M4V = "m4v";
public static final String MKV = "mkv";
public static final String MOV = "mov";
public static final String MP4 = "mp4";
public static final String MPEG = "mpeg";
public static final String MPEGTS = "mpegts";
public static final String MPG = "mpg";
public static final String OGA = "oga";
public static final String OGG = "ogg";
public static final String OGM = "ogm";
public static final String OGV = "ogv";
public static final String TS = "ts";
public static final String VOB = "vob";
public static final String WEBM = "webm";
public static final String WEBMA = "webma";
public static final String WMV = "wmv";
public static final String WTV = "wtv";
public static final String XVID = "xvid";
}

View File

@@ -0,0 +1,8 @@
package org.jellyfin.androidtv.constant;
public enum CustomMessage {
RefreshCurrentItem,
RemoveCurrentItem,
ActionComplete,
RefreshRows
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.constant
sealed interface CustomMessage {
data object RefreshCurrentItem : CustomMessage
data object ActionComplete : CustomMessage
}

View File

@@ -1,7 +1,6 @@
package org.jellyfin.androidtv.constant
object Extras {
const val Folder = "folder"
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
const val IncludeType = "type_include"
const val Folder = "Folder"
const val IncludeType = "IncludeType"
}

View File

@@ -1,18 +1,22 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class GridDirection(
override val nameRes: Int,
) : PreferenceEnum {
enum class GridDirection {
/**
* Horizontal.
*/
HORIZONTAL(R.string.grid_direction_horizontal),
@EnumDisplayOptions(R.string.grid_direction_horizontal)
HORIZONTAL,
/**
* Vertical.
*/
VERTICAL(R.string.grid_direction_vertical),
@EnumDisplayOptions(R.string.grid_direction_vertical)
VERTICAL;
companion object {
fun getGridDirection(direction: String?) = values().firstOrNull { it.name == direction }
}
}

View File

@@ -1,25 +1,22 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
/**
* All possible homesections, "synced" with jellyfin-web.
*
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
* All possible homesections, synced with jellyfin-web
*/
enum class HomeSectionType(
override val serializedName: String,
override val nameRes: Int,
) : PreferenceEnum {
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
RESUME("resume", R.string.home_section_resume),
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
NEXT_UP("nextup", R.string.home_section_next_up),
LIVE_TV("livetv", R.string.home_section_livetv),
NONE("none", R.string.home_section_none),
val id: String
) {
LATEST_MEDIA("latestmedia"),
LIBRARY_TILES_SMALL("smalllibrarytiles"),
LIBRARY_BUTTONS("librarybuttons"),
RESUME("resume"),
RESUME_AUDIO("resumeaudio"),
ACTIVE_RECORDINGS("activerecordings"),
NEXT_UP("nextup"),
LIVE_TV("livetv"),
NONE("none");
companion object {
fun getById(id: String) = values().firstOrNull { it.id == id }
}
}

View File

@@ -0,0 +1,7 @@
package org.jellyfin.androidtv.constant;
public class ImageType {
public static final String DEFAULT = "0";
public static final String THUMB = "1";
public static final String BANNER = "2";
}

View File

@@ -1,23 +0,0 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class ImageType(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Poster.
*/
POSTER(R.string.image_type_poster),
/**
* Thumbnail.
*/
THUMB(R.string.image_type_thumbnail),
/**
* Banner.
*/
BANNER(R.string.image_type_banner),
}

View File

@@ -1,8 +0,0 @@
package org.jellyfin.androidtv.constant
object LiveTvOption {
const val LIVE_TV_GUIDE_OPTION_ID = 1000
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
const val LIVE_TV_SERIES_OPTION_ID = 5000
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.constant;
public class MediaTypes {
public static final String HLS = "hls";
}

View File

@@ -0,0 +1,8 @@
package org.jellyfin.androidtv.constant;
public class PosterSize {
public static final String AUTO = "0";
public static final String SMALL = "1";
public static final String MED = "2";
public static final String LARGE = "3";
}

View File

@@ -1,32 +0,0 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class PosterSize(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Smallest.
*/
SMALLEST(R.string.image_size_smallest),
/**
* Small.
*/
SMALL(R.string.image_size_small),
/**
* Medium.
*/
MED(R.string.image_size_medium),
/**
* Large.
*/
LARGE(R.string.image_size_large),
/**
* Extra Large.
*/
X_LARGE(R.string.image_size_xlarge),
}

View File

@@ -1,25 +0,0 @@
package org.jellyfin.androidtv.constant
import android.content.Context
import org.jellyfin.androidtv.R
@Suppress("MagicNumber")
private val qualityOptions = setOf(
200.0, 180.0, 140.0, 120.0, 110.0, 100.0, // 100 >=
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
5.0, 3.0, 2.0, 1.0, // 1 >=
0.72, 0.42 // 0 >=
)
@Suppress("MagicNumber")
fun getQualityProfiles(
context: Context
): Map<String, String> = qualityOptions.associate {
val value = when {
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
}
it.toString().removeSuffix(".0") to value
}

View File

@@ -0,0 +1,31 @@
package org.jellyfin.androidtv.constant;
public enum QueryType {
Items,
NextUp,
Views,
Season,
Upcoming,
SimilarSeries,
SimilarMovies,
StaticPeople,
StaticChapters,
StaticServers,
Users,
Search,
Specials,
Trailers,
LiveTvChannel,
LiveTvProgram,
LiveTvRecording,
LiveTvRecordingGroup,
StaticItems,
Persons,
StaticAudioQueueItems,
AlbumArtists,
AudioPlaylists,
ContinueWatching,
LatestItems,
SeriesTimer,
Premieres
}

View File

@@ -1,28 +0,0 @@
package org.jellyfin.androidtv.constant
enum class QueryType {
Items,
NextUp,
Views,
Season,
Upcoming,
SimilarSeries,
SimilarMovies,
StaticPeople,
StaticChapters,
Search,
Specials,
AdditionalParts,
Trailers,
LiveTvChannel,
LiveTvProgram,
LiveTvRecording,
StaticItems,
Artists,
AlbumArtists,
AudioPlaylists,
LatestItems,
SeriesTimer,
Premieres,
Resume,
}

View File

@@ -0,0 +1,6 @@
package org.jellyfin.androidtv.constant;
public class ViewType {
public static final String SMART = "0";
public static final String GRID = "1";
}

View File

@@ -0,0 +1,196 @@
package org.jellyfin.androidtv.data.compat;
import java.util.ArrayList;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
/**
* Class AudioOptions.
*
* @deprecated
*/
@Deprecated
public class AudioOptions {
public AudioOptions() {
setContext(EncodingContext.Streaming);
setEnableDirectPlay(true);
setEnableDirectStream(true);
}
private boolean EnableDirectPlay;
public final boolean getEnableDirectPlay() {
return EnableDirectPlay;
}
public final void setEnableDirectPlay(boolean value) {
EnableDirectPlay = value;
}
private boolean EnableDirectStream;
public final boolean getEnableDirectStream() {
return EnableDirectStream;
}
public final void setEnableDirectStream(boolean value) {
EnableDirectStream = value;
}
private boolean ForceDirectPlay;
public final boolean getForceDirectPlay() {
return ForceDirectPlay;
}
public final void setForceDirectPlay(boolean value) {
ForceDirectPlay = value;
}
private boolean ForceDirectStream;
public final boolean getForceDirectStream() {
return ForceDirectStream;
}
public final void setForceDirectStream(boolean value) {
ForceDirectStream = value;
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(String value) {
ItemId = value;
}
private ArrayList<MediaSourceInfo> MediaSources;
public final ArrayList<MediaSourceInfo> getMediaSources() {
return MediaSources;
}
public final void setMediaSources(ArrayList<MediaSourceInfo> value) {
MediaSources = value;
}
private DeviceProfile Profile;
public final DeviceProfile getProfile() {
return Profile;
}
public final void setProfile(DeviceProfile value) {
Profile = value;
}
/**
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
*/
private String MediaSourceId;
public final String getMediaSourceId() {
return MediaSourceId;
}
public final void setMediaSourceId(String value) {
MediaSourceId = value;
}
private String DeviceId;
public final String getDeviceId() {
return DeviceId;
}
public final void setDeviceId(String value) {
DeviceId = value;
}
/**
* Allows an override of supported number of audio channels
* Example: DeviceProfile supports five channel, but user only has stereo speakers
*/
private Integer MaxAudioChannels = null;
public final Integer getMaxAudioChannels() {
return MaxAudioChannels;
}
public final void setMaxAudioChannels(Integer value) {
MaxAudioChannels = value;
}
/**
* The application's configured quality setting
*/
private Integer MaxBitrate = null;
public final Integer getMaxBitrate() {
return MaxBitrate;
}
public final void setMaxBitrate(Integer value) {
MaxBitrate = value;
}
/**
* Gets or sets the context.
*
* <value>The context.</value>
*/
private EncodingContext Context = EncodingContext.values()[0];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
Context = value;
}
/**
* Gets or sets the audio transcoding bitrate.
*
* <value>The audio transcoding bitrate.</value>
*/
private Integer AudioTranscodingBitrate = null;
public final Integer getAudioTranscodingBitrate() {
return AudioTranscodingBitrate;
}
public final void setAudioTranscodingBitrate(Integer value) {
AudioTranscodingBitrate = value;
}
/**
* Gets the maximum bitrate.
*
* @return System.Nullable&lt;System.Int32&gt;.
*/
public final Integer GetMaxBitrate(boolean isAudio) {
if (getMaxBitrate() != null) {
return getMaxBitrate();
}
if (getProfile() != null) {
if (getContext() == EncodingContext.Static) {
if (isAudio && getProfile().getMaxStaticMusicBitrate() != null) {
return getProfile().getMaxStaticMusicBitrate();
}
return getProfile().getMaxStaticBitrate();
}
return getProfile().getMaxStreamingBitrate();
}
return null;
}
}

View File

@@ -1,24 +0,0 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
open class AudioOptions {
var enableDirectPlay = true
var enableDirectStream = true
var itemId: UUID? = null
var mediaSources: List<MediaSourceInfo>? = null
var profile: DeviceProfile? = null
/**
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
*/
var mediaSourceId: String? = null
/**
* Allows an override of supported number of audio channels
* Example: DeviceProfile supports five channel, but user only has stereo speakers
*/
var maxAudioChannels: Int? = null
}

View File

@@ -0,0 +1,13 @@
package org.jellyfin.androidtv.data.compat;
import java.util.Comparator;
@Deprecated
public abstract class BaseStreamInfoSorter implements Comparator<StreamInfo> {
protected abstract int getValue(StreamInfo info);
@Override
public int compare(StreamInfo lhs, StreamInfo rhs) {
return Integer.compare(getValue(lhs), getValue(rhs));
}
}

View File

@@ -0,0 +1,30 @@
package org.jellyfin.androidtv.data.compat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Deprecated
public class ChainedComparator<T> implements Comparator<T> {
private List<Comparator<T>> simpleComparators;
public ChainedComparator(Comparator<T>... simpleComparators) {
List<Comparator<T>> list = new ArrayList<>();
for (Comparator<T> comparator : simpleComparators) {
list.add(comparator);
}
this.simpleComparators = list;
}
public int compare(T o1, T o2) {
for (Comparator<T> comparator : simpleComparators) {
int result = comparator.compare(o1, o2);
if (result != 0) {
return result;
}
}
return 0;
}
}

View File

@@ -0,0 +1,82 @@
package org.jellyfin.androidtv.data.compat;
/**
* Class DrawingUtils
*
* @deprecated
*/
@Deprecated
public final class DrawingUtils {
/**
* Resizes a set of dimensions
*
* @param size The original size object
* @param width A new fixed width, if desired
* @param height A new fixed height, if desired
* @param maxWidth A max fixed width, if desired
* @param maxHeight A max fixed height, if desired
* @return A new size object
*/
public static ImageSize Resize(ImageSize size, Double width, Double height, Double maxWidth, Double maxHeight) {
double newWidth = size.getWidth();
double newHeight = size.getHeight();
if (width != null && height != null) {
newWidth = width;
newHeight = height;
} else if (height != null) {
newWidth = GetNewWidth(newHeight, newWidth, height);
newHeight = height;
} else if (width != null) {
newHeight = GetNewHeight(newHeight, newWidth, width);
newWidth = width;
}
if (maxHeight != null && maxHeight < newHeight) {
newWidth = GetNewWidth(newHeight, newWidth, maxHeight);
newHeight = maxHeight;
}
if (maxWidth != null && maxWidth < newWidth) {
newHeight = GetNewHeight(newHeight, newWidth, maxWidth);
newWidth = maxWidth;
}
ImageSize tempVar = new ImageSize();
tempVar.setWidth(newWidth);
tempVar.setHeight(newHeight);
return tempVar;
}
/**
* Gets the new width.
*
* @param currentHeight Height of the current.
* @param currentWidth Width of the current.
* @param newHeight The new height.
* @return System.Double.
*/
private static double GetNewWidth(double currentHeight, double currentWidth, double newHeight) {
double scaleFactor = newHeight;
scaleFactor /= currentHeight;
scaleFactor *= currentWidth;
return scaleFactor;
}
/**
* Gets the new height.
*
* @param currentHeight Height of the current.
* @param currentWidth Width of the current.
* @param newWidth The new width.
* @return System.Double.
*/
private static double GetNewHeight(double currentHeight, double currentWidth, double newWidth) {
double scaleFactor = newWidth;
scaleFactor /= currentWidth;
scaleFactor *= currentHeight;
return scaleFactor;
}
}

View File

@@ -0,0 +1,93 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import timber.log.Timber;
/**
* Struct ImageSize
*
* @deprecated
*/
//C# TO JAVA CONVERTER WARNING: Java does not allow user-defined value types. The behavior of this class will differ from the original:
//ORIGINAL LINE: public struct ImageSize
@Deprecated
public final class ImageSize {
private double _height;
private double _width;
/**
* Gets or sets the height.
*
* <value>The height.</value>
*/
public double getHeight() {
return _height;
}
public void setHeight(double value) {
_height = value;
}
/**
* Gets or sets the width.
*
* <value>The width.</value>
*/
public double getWidth() {
return _width;
}
public void setWidth(double value) {
_width = value;
}
public boolean equals(ImageSize size) {
return (Double.valueOf(getWidth())).equals(size.getWidth()) && (Double.valueOf(getHeight())).equals(size.getHeight());
}
@Override
public String toString() {
return String.format("%1$s-%2$s", getWidth(), getHeight());
}
public ImageSize() {
}
public ImageSize(String value) {
_width = 0;
_height = 0;
ParseValue(value);
}
public ImageSize(int width, int height) {
_width = width;
_height = height;
}
private void ParseValue(String value) {
if (Utils.isNonEmpty(value)) {
String[] parts = value.split("[-]", -1);
if (parts.length == 2) {
try {
_width = Double.parseDouble(parts[0]);
_height = Double.parseDouble(parts[1]);
} catch (NumberFormatException ex) {
Timber.e(ex, "Could not parse width and height");
}
}
}
}
public ImageSize clone() {
ImageSize varCopy = new ImageSize();
varCopy._height = this._height;
varCopy._width = this._width;
return varCopy;
}
}

View File

@@ -0,0 +1,16 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
@Deprecated
public class PlaybackException extends RuntimeException {
private PlaybackErrorCode ErrorCode = PlaybackErrorCode.values()[0];
public final PlaybackErrorCode getErrorCode() {
return ErrorCode;
}
public final void setErrorCode(PlaybackErrorCode value) {
ErrorCode = value;
}
}

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.PlaybackErrorCode
class PlaybackException : RuntimeException() {
var errorCode = PlaybackErrorCode.NOT_ALLOWED
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,47 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.PlayMethod;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import org.jellyfin.androidtv.constant.ContainerTypes;
import org.jellyfin.androidtv.constant.MediaTypes;
import org.jellyfin.androidtv.util.Utils;
import java.util.ArrayList;
import java.util.UUID;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.DlnaProfileType;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
import org.jellyfin.apiclient.model.dto.NameValuePair;
import org.jellyfin.apiclient.model.entities.MediaStream;
import org.jellyfin.apiclient.model.entities.MediaStreamType;
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
import org.jellyfin.apiclient.model.mediainfo.TransportStreamTimestamp;
import org.jellyfin.apiclient.model.session.PlayMethod;
/**
* Class StreamInfo.
*
* @deprecated
*/
@Deprecated
public class StreamInfo {
private UUID ItemId;
private static final String START_TIME_TICKS = "StartTimeTicks";
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
private static final String STATIC = "Static";
public final UUID getItemId() {
public StreamInfo() {
setAudioCodecs(new String[]{});
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(UUID value) {
public final void setItemId(String value) {
ItemId = value;
}
@@ -30,14 +55,34 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
private PlayMethod PlayMethod = getPlayMethod().values()[0];
public final PlayMethod getPlayMethod() {
return playMethod;
return PlayMethod;
}
public final void setPlayMethod(PlayMethod value) {
playMethod = value;
PlayMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
Context = value;
}
private DlnaProfileType MediaType = DlnaProfileType.values()[0];
public final DlnaProfileType getMediaType() {
return MediaType;
}
public final void setMediaType(DlnaProfileType value) {
MediaType = value;
}
private String Container;
@@ -50,6 +95,236 @@ public class StreamInfo {
Container = value;
}
private String SubProtocol;
public final String getSubProtocol() {
return SubProtocol;
}
public final void setSubProtocol(String value) {
SubProtocol = value;
}
private long StartPositionTicks;
public final long getStartPositionTicks() {
return StartPositionTicks;
}
public final void setStartPositionTicks(long value) {
StartPositionTicks = value;
}
private String VideoCodec;
public final String getVideoCodec() {
return VideoCodec;
}
public final void setVideoCodec(String value) {
VideoCodec = value;
}
private String VideoProfile;
public final String getVideoProfile() {
return VideoProfile;
}
public final void setVideoProfile(String value) {
VideoProfile = value;
}
private boolean CopyTimestamps;
public final boolean getCopyTimestamps() {
return CopyTimestamps;
}
public final void setCopyTimestamps(boolean value) {
CopyTimestamps = value;
}
private boolean ForceLiveStream;
public final boolean getForceLiveStream() {
return ForceLiveStream;
}
public final void setForceLiveStream(boolean value) {
ForceLiveStream = value;
}
private boolean EnableSubtitlesInManifest;
public final boolean getEnableSubtitlesInManifest() {
return EnableSubtitlesInManifest;
}
public final void setEnableSubtitlesInManifest(boolean value) {
EnableSubtitlesInManifest = value;
}
private String[] AudioCodecs;
public final String[] getAudioCodecs() {
return AudioCodecs;
}
public final void setAudioCodecs(String[] value) {
AudioCodecs = value;
}
private Integer AudioStreamIndex = null;
public final Integer getAudioStreamIndex() {
return AudioStreamIndex;
}
public final void setAudioStreamIndex(Integer value) {
AudioStreamIndex = value;
}
private Integer SubtitleStreamIndex = null;
public final Integer getSubtitleStreamIndex() {
return SubtitleStreamIndex;
}
public final void setSubtitleStreamIndex(Integer value) {
SubtitleStreamIndex = value;
}
private Integer TranscodingMaxAudioChannels = null;
public final Integer getTranscodingMaxAudioChannels() {
return TranscodingMaxAudioChannels;
}
public final void setTranscodingMaxAudioChannels(Integer value) {
TranscodingMaxAudioChannels = value;
}
private Integer MaxAudioChannels = null;
public final Integer getMaxAudioChannels() {
return MaxAudioChannels;
}
public final void setMaxAudioChannels(Integer value) {
MaxAudioChannels = value;
}
private Integer AudioBitrate = null;
public final Integer getAudioBitrate() {
return AudioBitrate;
}
public final void setAudioBitrate(Integer value) {
AudioBitrate = value;
}
private Integer VideoBitrate = null;
public final Integer getVideoBitrate() {
return VideoBitrate;
}
public final void setVideoBitrate(Integer value) {
VideoBitrate = value;
}
private Integer VideoLevel = null;
public final Integer getVideoLevel() {
return VideoLevel;
}
public final void setVideoLevel(Integer value) {
VideoLevel = value;
}
private Integer MaxWidth = null;
public final Integer getMaxWidth() {
return MaxWidth;
}
public final void setMaxWidth(Integer value) {
MaxWidth = value;
}
private Integer MaxHeight = null;
public final Integer getMaxHeight() {
return MaxHeight;
}
public final void setMaxHeight(Integer value) {
MaxHeight = value;
}
private Integer MaxVideoBitDepth = null;
public final Integer getMaxVideoBitDepth() {
return MaxVideoBitDepth;
}
public final void setMaxVideoBitDepth(Integer value) {
MaxVideoBitDepth = value;
}
private Integer MaxRefFrames = null;
public final Integer getMaxRefFrames() {
return MaxRefFrames;
}
public final void setMaxRefFrames(Integer value) {
MaxRefFrames = value;
}
private Float MaxFramerate = null;
public final Float getMaxFramerate() {
return MaxFramerate;
}
public final void setMaxFramerate(Float value) {
MaxFramerate = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
return DeviceProfile;
}
public final void setDeviceProfile(DeviceProfile value) {
DeviceProfile = value;
}
private String DeviceProfileId;
public final String getDeviceProfileId() {
return DeviceProfileId;
}
public final void setDeviceProfileId(String value) {
DeviceProfileId = value;
}
private String DeviceId;
public final String getDeviceId() {
return DeviceId;
}
public final void setDeviceId(String value) {
DeviceId = value;
}
private Long RunTimeTicks = null;
public final Long getRunTimeTicks() {
@@ -60,6 +335,26 @@ public class StreamInfo {
RunTimeTicks = value;
}
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
public final TranscodeSeekInfo getTranscodeSeekInfo() {
return TranscodeSeekInfo;
}
public final void setTranscodeSeekInfo(TranscodeSeekInfo value) {
TranscodeSeekInfo = value;
}
private boolean EstimateContentLength;
public final boolean getEstimateContentLength() {
return EstimateContentLength;
}
public final void setEstimateContentLength(boolean value) {
EstimateContentLength = value;
}
private MediaSourceInfo MediaSource;
public final MediaSourceInfo getMediaSource() {
@@ -70,10 +365,24 @@ public class StreamInfo {
MediaSource = value;
}
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
}
public final void setSubtitleDeliveryMethod(SubtitleDeliveryMethod value) {
SubtitleDeliveryMethod = value;
}
private String SubtitleFormat;
public final String getSubtitleFormat() {
return SubtitleFormat;
}
public final void setSubtitleFormat(String value) {
SubtitleFormat = value;
}
private String PlaySessionId;
@@ -86,18 +395,526 @@ public class StreamInfo {
PlaySessionId = value;
}
private ArrayList<MediaSourceInfo> AllMediaSources;
public final ArrayList<MediaSourceInfo> getAllMediaSources() {
return AllMediaSources;
}
public final void setAllMediaSources(ArrayList<MediaSourceInfo> value) {
AllMediaSources = value;
}
public final String getMediaSourceId() {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final ArrayList<MediaStream> getSelectableAudioStreams() {
return getSelectableStreams(MediaStreamType.AUDIO);
public final boolean getIsDirectStream() {
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
}
public final ArrayList<org.jellyfin.sdk.model.api.MediaStream> getSelectableStreams(MediaStreamType type) {
ArrayList<org.jellyfin.sdk.model.api.MediaStream> list = new ArrayList<org.jellyfin.sdk.model.api.MediaStream>();
public final String ToUrl(String baseUrl, String accessToken) {
if (!Utils.isEmpty(getMediaUrl())) {
return getMediaUrl();
}
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (getPlayMethod() == PlayMethod.DirectPlay) {
return getMediaSource().getPath();
}
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : BuildParams(this, accessToken, false)) {
if (Utils.isEmpty(pair.getValue())) {
continue;
}
// Try to keep the url clean by omitting defaults
if (START_TIME_TICKS.equalsIgnoreCase(pair.getName()) && "0".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (SUBTITLE_STREAM_INDEX.equalsIgnoreCase(pair.getName()) && "-1".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (STATIC.equalsIgnoreCase(pair.getName()) && "false".equalsIgnoreCase(pair.getValue())) {
continue;
}
list.add(String.format("%1$s=%2$s", pair.getName(), pair.getValue()));
}
String queryString = Utils.join("&", list);
return GetUrl(baseUrl, queryString);
}
public final String ToDlnaUrl(String baseUrl, String accessToken) {
if (getPlayMethod() == PlayMethod.DirectPlay) {
return getMediaSource().getPath();
}
String dlnaCommand = BuildDlnaParam(this, accessToken);
return GetUrl(baseUrl, dlnaCommand);
}
private String GetUrl(String baseUrl, String queryString) {
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
String extension = Utils.isEmpty(getContainer()) ? "" : "." + getContainer();
// remove trailing slashes
baseUrl = baseUrl.replaceAll("[/]+$", "");
if (getMediaType() == DlnaProfileType.Audio) {
if (MediaTypes.HLS.equalsIgnoreCase(getSubProtocol())) {
return String.format("%1$s/audio/%2$s/master.m3u8?%3$s", baseUrl, getItemId(), queryString);
}
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
if (MediaTypes.HLS.equalsIgnoreCase(getSubProtocol())) {
return String.format("%1$s/videos/%2$s/master.m3u8?%3$s", baseUrl, getItemId(), queryString);
}
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
private static String BuildDlnaParam(StreamInfo item, String accessToken) {
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : BuildParams(item, accessToken, true)) {
list.add(pair.getValue());
}
return String.format("Params=%1$s", Utils.join(";", list));
}
private static ArrayList<NameValuePair> BuildParams(StreamInfo item, String accessToken, boolean isDlna) {
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
String tempVar = item.getDeviceProfileId();
list.add(new NameValuePair("DeviceProfileId", (tempVar != null) ? tempVar : ""));
String tempVar2 = item.getDeviceId();
list.add(new NameValuePair("DeviceId", (tempVar2 != null) ? tempVar2 : ""));
String tempVar3 = item.getMediaSourceId();
list.add(new NameValuePair("MediaSourceId", (tempVar3 != null) ? tempVar3 : ""));
list.add(new NameValuePair(STATIC, Boolean.valueOf(item.getIsDirectStream()).toString().toLowerCase()));
String tempVar4 = item.getVideoCodec();
list.add(new NameValuePair("VideoCodec", (tempVar4 != null) ? tempVar4 : ""));
list.add(new NameValuePair("AudioCodec", audioCodecs));
list.add(new NameValuePair("AudioStreamIndex", item.getAudioStreamIndex() != null ? String.valueOf(item.getAudioStreamIndex()) : ""));
list.add(new NameValuePair("SubtitleStreamIndex", item.getSubtitleStreamIndex() != null && item.getSubtitleDeliveryMethod() != org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod.External ? String.valueOf(item.getSubtitleStreamIndex()) : ""));
list.add(new NameValuePair("VideoBitrate", item.getVideoBitrate() != null ? String.valueOf(item.getVideoBitrate()) : ""));
list.add(new NameValuePair("AudioBitrate", item.getAudioBitrate() != null ? String.valueOf(item.getAudioBitrate()) : ""));
list.add(new NameValuePair("MaxAudioChannels", item.getMaxAudioChannels() != null ? String.valueOf(item.getMaxAudioChannels()) : ""));
list.add(new NameValuePair("MaxFramerate", item.getMaxFramerate() != null ? String.valueOf(item.getMaxFramerate()) : ""));
list.add(new NameValuePair("MaxWidth", item.getMaxWidth() != null ? String.valueOf(item.getMaxWidth()) : ""));
list.add(new NameValuePair("MaxHeight", item.getMaxHeight() != null ? String.valueOf(item.getMaxHeight()) : ""));
if (MediaTypes.HLS.equalsIgnoreCase(item.getSubProtocol()) && !item.getForceLiveStream()) {
list.add(new NameValuePair(START_TIME_TICKS, ""));
} else {
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
}
list.add(new NameValuePair("Level", item.getVideoLevel() != null ? String.valueOf(item.getVideoLevel()) : ""));
list.add(new NameValuePair("MaxRefFrames", item.getMaxRefFrames() != null ? String.valueOf(item.getMaxRefFrames()) : ""));
list.add(new NameValuePair("MaxVideoBitDepth", item.getMaxVideoBitDepth() != null ? String.valueOf(item.getMaxVideoBitDepth()) : ""));
String tempVar5 = item.getVideoProfile();
list.add(new NameValuePair("Profile", (tempVar5 != null) ? tempVar5 : ""));
// no longer used
list.add(new NameValuePair("Cabac", ""));
String tempVar6 = item.getPlaySessionId();
list.add(new NameValuePair("PlaySessionId", (tempVar6 != null) ? tempVar6 : ""));
list.add(new NameValuePair("api_key", (accessToken != null) ? accessToken : ""));
String liveStreamId = item.getMediaSource() == null ? null : item.getMediaSource().getLiveStreamId();
list.add(new NameValuePair("LiveStreamId", (liveStreamId != null) ? liveStreamId : ""));
if (isDlna) {
list.add(new NameValuePair("ItemId", item.getItemId()));
}
list.add(new NameValuePair("CopyTimestamps", Boolean.valueOf(item.getCopyTimestamps()).toString().toLowerCase()));
list.add(new NameValuePair("ForceLiveStream", Boolean.valueOf(item.getForceLiveStream()).toString().toLowerCase()));
list.add(new NameValuePair("SubtitleMethod", item.getSubtitleStreamIndex() != null && item.getSubtitleDeliveryMethod() != org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod.External ? item.getSubtitleDeliveryMethod().toString() : ""));
list.add(new NameValuePair("TranscodingMaxAudioChannels", item.getTranscodingMaxAudioChannels() != null ? String.valueOf(item.getTranscodingMaxAudioChannels()) : ""));
list.add(new NameValuePair("EnableSubtitlesInManifest", Boolean.valueOf(item.getEnableSubtitlesInManifest()).toString().toLowerCase()));
String tempVar7 = item.getMediaSource().getETag();
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
return list;
}
public final ArrayList<SubtitleStreamInfo> GetExternalSubtitles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return GetExternalSubtitles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> GetExternalSubtitles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = GetSubtitleProfiles(includeSelectedTrackOnly, enableAllProfiles, baseUrl, accessToken);
ArrayList<SubtitleStreamInfo> newList = new ArrayList<SubtitleStreamInfo>();
// First add the selected track
for (SubtitleStreamInfo stream : list) {
if (stream.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
newList.add(stream);
}
}
return newList;
}
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return GetSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = MediaTypes.HLS.equalsIgnoreCase(getSubProtocol()) ? 0 : (getPlayMethod() == PlayMethod.Transcode && !getCopyTimestamps() ? getStartPositionTicks() : 0);
// First add the selected track
if (getSubtitleStreamIndex() != null) {
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == MediaStreamType.Subtitle && stream.getIndex() == getSubtitleStreamIndex()) {
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
if (!includeSelectedTrackOnly) {
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == MediaStreamType.Subtitle && (getSubtitleStreamIndex() == null || stream.getIndex() != getSubtitleStreamIndex())) {
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
return list;
}
private void AddSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
if (enableAllProfiles) {
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.GetSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
tempVar.setIsForced(stream.getIsForced());
tempVar.setLanguage(stream.getLanguage());
String tempVar2 = stream.getLanguage();
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
tempVar.setFormat(subtitleProfile.getFormat());
tempVar.setIndex(stream.getIndex());
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
tempVar.setDisplayTitle(stream.getDisplayTitle());
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.File || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
if (!Utils.isEmpty(accessToken)) {
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
}
info.setIsExternalUrl(false);
} else {
info.setUrl(stream.getPath());
info.setIsExternalUrl(true);
}
}
return info;
}
/**
* Returns the audio stream that will be used
*/
public final MediaStream getTargetAudioStream() {
if (getMediaSource() != null) {
return getMediaSource().GetDefaultAudioStream(getAudioStreamIndex());
}
return null;
}
/**
* Returns the video stream that will be used
*/
public final MediaStream getTargetVideoStream() {
if (getMediaSource() != null) {
return getMediaSource().getVideoStream();
}
return null;
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final Integer getTargetAudioSampleRate() {
MediaStream stream = getTargetAudioStream();
return stream == null ? null : stream.getSampleRate();
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final Integer getTargetVideoBitDepth() {
MediaStream stream = getTargetVideoStream();
return stream == null || !getIsDirectStream() ? null : stream.getBitDepth();
}
/**
* Gets the target reference frames.
*
* <value>The target reference frames.</value>
*/
public final Integer getTargetRefFrames() {
MediaStream stream = getTargetVideoStream();
return stream == null || !getIsDirectStream() ? null : stream.getRefFrames();
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final Float getTargetFramerate() {
MediaStream stream = getTargetVideoStream();
Float tempVar = stream.getAverageFrameRate();
return getMaxFramerate() != null && !getIsDirectStream() ? getMaxFramerate() : stream == null ? null : (tempVar != null) ? tempVar : stream.getRealFrameRate();
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final Double getTargetVideoLevel() {
MediaStream stream = getTargetVideoStream();
return getVideoLevel() != null && !getIsDirectStream() ? getVideoLevel() : stream == null ? null : stream.getLevel();
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final Integer getTargetPacketLength() {
MediaStream stream = getTargetVideoStream();
return !getIsDirectStream() ? null : stream == null ? null : stream.getPacketLength();
}
/**
* Predicts the audio sample rate that will be in the output stream
*/
public final String getTargetVideoProfile() {
MediaStream stream = getTargetVideoStream();
return !Utils.isEmpty(getVideoProfile()) && !getIsDirectStream() ? getVideoProfile() : stream == null ? null : stream.getProfile();
}
/**
* Gets the target video codec tag.
*
* <value>The target video codec tag.</value>
*/
public final String getTargetVideoCodecTag() {
MediaStream stream = getTargetVideoStream();
return !getIsDirectStream() ? null : stream == null ? null : stream.getCodecTag();
}
/**
* Predicts the audio bitrate that will be in the output stream
*/
public final Integer getTargetAudioBitrate() {
MediaStream stream = getTargetAudioStream();
return getAudioBitrate() != null && !getIsDirectStream() ? getAudioBitrate() : stream == null ? null : stream.getBitRate();
}
/**
* Predicts the audio channels that will be in the output stream
*/
public final Integer getTargetAudioChannels() {
MediaStream stream = getTargetAudioStream();
Integer streamChannels = stream == null ? null : stream.getChannels();
if (getMaxAudioChannels() != null && !getIsDirectStream()) {
if (streamChannels != null) {
return Math.min(getMaxAudioChannels(), streamChannels);
}
return getMaxAudioChannels();
}
return streamChannels;
}
/**
* Predicts the audio codec that will be in the output stream
*/
public final String getTargetAudioCodec() {
MediaStream stream = getTargetAudioStream();
String inputCodec = stream == null ? null : stream.getCodec();
if (getIsDirectStream()) {
return inputCodec;
}
for (String codec : getAudioCodecs()) {
if (codec.equalsIgnoreCase(inputCodec)) {
return codec;
}
}
return getAudioCodecs().length == 0 ? null : getAudioCodecs()[0];
}
/**
* Predicts the audio channels that will be in the output stream
*/
public final Long getTargetSize() {
if (getIsDirectStream()) {
return getMediaSource().getSize();
}
if (getRunTimeTicks() != null) {
Integer totalBitrate = getTargetTotalBitrate();
double totalSeconds = getRunTimeTicks();
// Convert to ms
totalSeconds /= 10000;
// Convert to seconds
totalSeconds /= 1000;
return totalBitrate != null ? java.lang.Math.round(totalBitrate * totalSeconds) : (Long) null;
}
return null;
}
public final Integer getTargetVideoBitrate() {
MediaStream stream = getTargetVideoStream();
return getVideoBitrate() != null && !getIsDirectStream() ? getVideoBitrate() : stream == null ? null : stream.getBitRate();
}
public final TransportStreamTimestamp getTargetTimestamp() {
TransportStreamTimestamp defaultValue = ContainerTypes.M2TS.equalsIgnoreCase(getContainer()) ? TransportStreamTimestamp.Valid : TransportStreamTimestamp.None;
TransportStreamTimestamp tempVar = getMediaSource().getTimestamp();
return !getIsDirectStream() ? defaultValue : getMediaSource() == null ? defaultValue : (tempVar != null) ? tempVar : TransportStreamTimestamp.None;
}
public final Integer getTargetTotalBitrate() {
Integer tempVar = getTargetAudioBitrate();
Integer tempVar2 = getTargetVideoBitrate();
return ((tempVar != null) ? tempVar : 0) + ((tempVar2 != null) ? tempVar2 : 0);
}
public final Boolean getIsTargetAnamorphic() {
if (getIsDirectStream()) {
return getTargetVideoStream() == null ? null : getTargetVideoStream().getIsAnamorphic();
}
return false;
}
public final Integer getTargetWidth() {
MediaStream videoStream = getTargetVideoStream();
if (videoStream != null && videoStream.getWidth() != null && videoStream.getHeight() != null) {
ImageSize tempVar = new ImageSize();
tempVar.setWidth(videoStream.getWidth());
tempVar.setHeight(videoStream.getHeight());
ImageSize size = tempVar.clone();
Double maxWidth = getMaxWidth() != null ? (double) getMaxWidth() : (Double) null;
Double maxHeight = getMaxHeight() != null ? (double) getMaxHeight() : (Double) null;
ImageSize newSize = DrawingUtils.Resize(size.clone(), null, null, maxWidth, maxHeight);
return (int) newSize.getWidth();
}
return getMaxWidth();
}
public final Integer getTargetHeight() {
MediaStream videoStream = getTargetVideoStream();
if (videoStream != null && videoStream.getWidth() != null && videoStream.getHeight() != null) {
ImageSize tempVar = new ImageSize();
tempVar.setWidth(videoStream.getWidth());
tempVar.setHeight(videoStream.getHeight());
ImageSize size = tempVar.clone();
Double maxWidth = getMaxWidth() != null ? (double) getMaxWidth() : (Double) null;
Double maxHeight = getMaxHeight() != null ? (double) getMaxHeight() : (Double) null;
ImageSize newSize = DrawingUtils.Resize(size.clone(), null, null, maxWidth, maxHeight);
return (int) newSize.getHeight();
}
return getMaxHeight();
}
public final Integer getTargetVideoStreamCount() {
if (getIsDirectStream()) {
return GetMediaStreamCount(MediaStreamType.Video, Integer.MAX_VALUE);
}
return GetMediaStreamCount(MediaStreamType.Video, 1);
}
public final Integer getTargetAudioStreamCount() {
if (getIsDirectStream()) {
return GetMediaStreamCount(MediaStreamType.Audio, Integer.MAX_VALUE);
}
return GetMediaStreamCount(MediaStreamType.Audio, 1);
}
private Integer GetMediaStreamCount(MediaStreamType type, int limit) {
Integer count = getMediaSource().GetStreamCount(type);
if (count != null) {
count = Math.min(count, limit);
}
return count;
}
public final ArrayList<MediaStream> GetSelectableAudioStreams() {
return GetSelectableStreams(MediaStreamType.Audio);
}
public final ArrayList<MediaStream> GetSelectableSubtitleStreams() {
return GetSelectableStreams(MediaStreamType.Subtitle);
}
public final ArrayList<MediaStream> GetSelectableStreams(MediaStreamType type) {
ArrayList<MediaStream> list = new ArrayList<MediaStream>();
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (type == stream.getType()) {
list.add(stream);
}

View File

@@ -0,0 +1,20 @@
package org.jellyfin.androidtv.data.compat;
import java.util.ArrayList;
import java.util.Collections;
@Deprecated
public class StreamInfoSorter
{
public static ArrayList<StreamInfo> SortMediaSources(ArrayList<StreamInfo> streams, Integer maxBitrate)
{
ChainedComparator<StreamInfo> comparator = new ChainedComparator<>(
new StreamInfoSorterComparator(0),
new StreamInfoSorterComparator(1),
new StreamInfoSorterComparator(2)
);
Collections.sort(streams, comparator);
return streams;
}
}

View File

@@ -0,0 +1,47 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
import org.jellyfin.apiclient.model.session.PlayMethod;
@Deprecated
public class StreamInfoSorterComparator extends BaseStreamInfoSorter {
private int level;
public StreamInfoSorterComparator(int level) {
this.level = level;
}
@Override
protected int getValue(StreamInfo info) {
switch (level) {
case 0: {
// Nothing beats direct playing a file
if (info.getPlayMethod() == PlayMethod.DirectPlay && info.getMediaSource().getProtocol() == MediaProtocol.File) {
return 0;
}
return 1;
}
case 1: {
switch (info.getPlayMethod()) {
// Let's assume direct streaming a file is just as desirable as direct playing a remote url
case DirectStream:
case DirectPlay:
return 0;
default:
return 1;
}
}
case 2: {
switch (info.getMediaSource().getProtocol()) {
case File:
return 0;
default:
return 1;
}
}
}
throw new IllegalArgumentException("Unrecognized level");
}
}

View File

@@ -0,0 +1,96 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
@Deprecated
public class SubtitleStreamInfo {
private String Url;
public final String getUrl() {
return Url;
}
public final void setUrl(String value) {
Url = value;
}
private String Language;
public final String getLanguage() {
return Language;
}
public final void setLanguage(String value) {
Language = value;
}
private String Name;
public final String getName() {
return Name;
}
public final void setName(String value) {
Name = value;
}
private boolean IsForced;
public final boolean getIsForced() {
return IsForced;
}
public final void setIsForced(boolean value) {
IsForced = value;
}
private String Format;
public final String getFormat() {
return Format;
}
public final void setFormat(String value) {
Format = value;
}
private String DisplayTitle;
public final String getDisplayTitle() {
return DisplayTitle;
}
public final void setDisplayTitle(String value) {
DisplayTitle = value;
}
private int Index;
public final int getIndex() {
return Index;
}
public final void setIndex(int value) {
Index = value;
}
private SubtitleDeliveryMethod DeliveryMethod = SubtitleDeliveryMethod.values()[0];
public final SubtitleDeliveryMethod getDeliveryMethod() {
return DeliveryMethod;
}
public final void setDeliveryMethod(SubtitleDeliveryMethod value) {
DeliveryMethod = value;
}
private boolean IsExternalUrl;
public final boolean getIsExternalUrl() {
return IsExternalUrl;
}
public final void setIsExternalUrl(boolean value) {
IsExternalUrl = value;
}
}

View File

@@ -0,0 +1,28 @@
package org.jellyfin.androidtv.data.compat;
/**
* Class VideoOptions.
*
* @deprecated
*/
@Deprecated
public class VideoOptions extends AudioOptions {
private Integer AudioStreamIndex;
private Integer SubtitleStreamIndex;
public final Integer getAudioStreamIndex() {
return AudioStreamIndex;
}
public final void setAudioStreamIndex(Integer value) {
AudioStreamIndex = value;
}
public final Integer getSubtitleStreamIndex() {
return SubtitleStreamIndex;
}
public final void setSubtitleStreamIndex(Integer value) {
SubtitleStreamIndex = value;
}
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.data.compat
class VideoOptions : AudioOptions() {
var audioStreamIndex: Int? = null
var subtitleStreamIndex: Int? = null
}

View File

@@ -1,254 +0,0 @@
package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.media.AudioManager
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryChangedMessage
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMessage
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.get
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.time.Instant
import java.util.UUID
class SocketHandler(
private val context: Context,
private val api: ApiClient,
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
private val playbackControllerContainer: PlaybackControllerContainer,
private val navigationRepository: NavigationRepository,
private val audioManager: AudioManager,
private val itemLauncher: ItemLauncher,
private val playbackHelper: PlaybackHelper,
private val lifecycle: Lifecycle,
) {
init {
lifecycle.coroutineScope.launch(Dispatchers.IO) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
subscribe(this)
}
}
}
suspend fun updateSession() {
try {
withContext(Dispatchers.IO) {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
// Note: These are used in the PlaySessionSocketService
if (!audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
)
}
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update capabilities")
}
}
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
subscribe<PlaystateMessage>()
.onEach { message -> onPlayStateMessage(message) }
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
}
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
}
}
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
}
private fun onLibraryChanged(info: LibraryUpdateInfo) {
Timber.d(buildString {
appendLine("Library changed.")
appendLine("Added ${info.itemsAdded.size} items")
appendLine("Removed ${info.itemsRemoved.size} items")
appendLine("Updated ${info.itemsUpdated.size} items")
})
if (info.itemsAdded.any() || info.itemsRemoved.any())
dataRefreshService.lastLibraryChange = Instant.now()
}
private fun onPlayMessage(message: PlayMessage) {
val itemIds = message.data?.itemIds ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemIds,
false,
message.data?.startPositionTicks,
message.data?.startIndex,
context
)
}.onFailure { Timber.w(it, "Failed to start playback") }
}
@Suppress("ComplexMethod")
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
when {
mediaManager.hasAudioQueueItems() -> {
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
return@withContext
}
// PlaybackController
else -> {
val playbackController = playbackControllerContainer.playbackController
when (message.data?.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
null -> Unit
}
}
}
}
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
val playbackController = playbackControllerContainer.playbackController
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return@withContext
}
Timber.i("Launching $itemId")
when (itemKind) {
BaseItemKind.USER_VIEW,
BaseItemKind.COLLECTION_FOLDER -> {
val item by api.userLibraryApi.getItem(itemId = itemId)
itemLauncher.launchUserView(item)
}
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
}
}
private fun onDisplayMessage(header: String?, text: String?) {
val toastMessage = buildString {
if (!header.isNullOrBlank()) append(header, ": ")
append(text)
}
runBlocking(Dispatchers.Main) {
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
}
companion object {
const val TICKS_TO_MS = 10000L
}
}

View File

@@ -0,0 +1,213 @@
package org.jellyfin.androidtv.data.eventhandling;
import static org.koin.java.KoinJavaComponent.get;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.widget.Toast;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
import org.jellyfin.androidtv.ui.playback.PlaybackOverlayActivity;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.ApiEventListener;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.entities.LibraryUpdateInfo;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemsResult;
import org.jellyfin.apiclient.model.session.BrowseRequest;
import org.jellyfin.apiclient.model.session.MessageCommand;
import org.jellyfin.apiclient.model.session.PlayRequest;
import org.jellyfin.apiclient.model.session.PlaystateRequest;
import org.jellyfin.apiclient.model.session.SessionInfoDto;
import java.util.Arrays;
import timber.log.Timber;
public class TvApiEventListener extends ApiEventListener {
private final DataRefreshService dataRefreshService;
private final MediaManager mediaManager;
public TvApiEventListener(DataRefreshService dataRefreshService, MediaManager mediaManager) {
this.dataRefreshService = dataRefreshService;
this.mediaManager = mediaManager;
}
@Override
public void onPlaybackStopped(ApiClient client, SessionInfoDto info) {
TvApp app = TvApp.getApplication();
Timber.d("Got Playback stopped message from server");
if (info.getUserId().equals(app.getCurrentUser().getId())) {
dataRefreshService.setLastPlayback(System.currentTimeMillis());
if (info.getNowPlayingItem() == null) return;
switch (info.getNowPlayingItem().getType()) {
case "Movie":
dataRefreshService.setLastMoviePlayback(System.currentTimeMillis());
break;
case "Episode":
dataRefreshService.setLastTvPlayback(System.currentTimeMillis());
break;
}
}
}
@Override
public void onLibraryChanged(ApiClient client, LibraryUpdateInfo info) {
Timber.d("Library Changed. Added %o items. Removed %o items. Changed %o items.", info.getItemsAdded().size(), info.getItemsRemoved().size(), info.getItemsUpdated().size());
if (info.getItemsAdded().size() > 0 || info.getItemsRemoved().size() > 0)
dataRefreshService.setLastLibraryChange(System.currentTimeMillis());
}
@Override
public void onPlaystateCommand(ApiClient client, PlaystateRequest command) {
PlaybackController playbackController = TvApp.getApplication().getPlaybackController();
switch (command.getCommand()) {
case Stop:
if (mediaManager.isPlayingAudio())
mediaManager.stopAudio();
else {
Activity currentActivity = TvApp.getApplication().getCurrentActivity();
if(currentActivity instanceof PlaybackOverlayActivity)
currentActivity.finish();
}
break;
case Pause:
if (mediaManager.isPlayingAudio())
mediaManager.pauseAudio();
else if(playbackController != null)
playbackController.playPause();
break;
case Unpause:
if (mediaManager.hasAudioQueueItems())
mediaManager.resumeAudio();
else if(playbackController != null)
playbackController.playPause();
break;
case NextTrack:
if (mediaManager.hasAudioQueueItems())
mediaManager.nextAudioItem();
else if(playbackController != null)
playbackController.next();
break;
case PreviousTrack:
if (mediaManager.hasAudioQueueItems())
mediaManager.prevAudioItem();
else if(playbackController != null)
playbackController.prev();
break;
case Seek:
if (playbackController == null) break;
long pos = command.getSeekPositionTicks() / 10000;
playbackController.seek(pos);
break;
case Rewind:
if (playbackController == null) break;
playbackController.skip(-11000);
break;
case FastForward:
if (playbackController == null) break;
playbackController.skip(30000);
break;
}
}
@Override
public void onBrowseCommand(ApiClient client, BrowseRequest command) {
Timber.d("Browse command received");
if (TvApp.getApplication().getCurrentActivity() == null || videoPlaying()) {
Timber.i("Command ignored due to no activity or playback in progress");
return;
}
client.GetItemAsync(command.getItemId(), TvApp.getApplication().getCurrentUser().getId(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto response) {
//Create a rowItem and pass to our handler
ItemLauncher.launch(new BaseRowItem(0, response), null, -1, TvApp.getApplication().getCurrentActivity(), true);
}
});
}
private boolean videoPlaying() {
return (TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused()));
}
@Override
public void onPlayCommand(ApiClient client, PlayRequest command) {
if (videoPlaying()) {
TvApp.getApplication().getCurrentActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
Utils.showToast(TvApp.getApplication().getCurrentActivity(), TvApp.getApplication().getString(R.string.msg_remote_already_playing));
}
});
return;
}
if (command.getItemIds().length > 1) {
Timber.i("Playing multiple items by remote request");
if (TvApp.getApplication().getCurrentActivity() == null) {
Timber.e("No current activity. Cannot play");
return;
}
StdItemQuery query = new StdItemQuery(new ItemFields[]{
ItemFields.MediaSources,
ItemFields.ChildCount
});
query.setIds(command.getItemIds());
get(ApiClient.class).GetItemsAsync(query, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
if (response.getItems() != null && response.getItems().length > 0) {
PlaybackLauncher playbackLauncher = get(PlaybackLauncher.class);
if (playbackLauncher.interceptPlayRequest(TvApp.getApplication(), response.getItems()[0])) return;
//peek at first item to see what type it is
switch (response.getItems()[0].getMediaType()) {
case "Video":
Class activity = get(PlaybackLauncher.class).getPlaybackActivityClass(response.getItems()[0].getBaseItemType());
mediaManager.setCurrentVideoQueue(Arrays.asList(response.getItems()));
Intent intent = new Intent(TvApp.getApplication().getCurrentActivity(), activity);
TvApp.getApplication().getCurrentActivity().startActivity(intent);
break;
case "Audio":
mediaManager.playNow(Arrays.asList(response.getItems()));
break;
}
}
}
});
} else {
if (command.getItemIds().length > 0) {
Timber.i("Playing single item by remote request");
Context context = TvApp.getApplication().getCurrentActivity() != null ? TvApp.getApplication().getCurrentActivity() : TvApp.getApplication();
PlaybackHelper.retrieveAndPlay(command.getItemIds()[0], false, command.getStartPositionTicks() != null ? command.getStartPositionTicks() : 0, context);
}
}
}
@Override
public void onMessageCommand(ApiClient client, MessageCommand command) {
new Handler(TvApp.getApplication().getMainLooper()).post(() -> Toast.makeText(TvApp.getApplication(), command.getText(), Toast.LENGTH_LONG).show());
}
}

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.model
data class AppNotification(
val message: String,
val dismiss: () -> Unit,
val public: Boolean,
)

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.data.model;
import org.jellyfin.apiclient.model.entities.ChapterInfo;
public class ChapterItemInfo extends ChapterInfo {
private String itemId;
public String getItemId() {
return itemId;
}
public void setItemId(String itemId) {
this.itemId = itemId;
}
}

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