Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
5d9fbf2839 Merge branch 'develop' into fix/series-next-up-cross-series 2026-06-07 13:57:49 +10:00
Alex Kim
d3808bc196 fix(series): filter Next Up items by SeriesId client-side
Some Jellyfin server versions ignore the seriesId query parameter on
/Shows/NextUp and return the global next-up list, causing the series
detail page to show episodes from unrelated series (matching the home
tab's Next Up row).

Defensively filter the response by SeriesId on the client and hide the
section entirely when there are no matching items, instead of rendering
an empty 'No items to display' block.
2026-06-06 00:18:06 +10:00
85 changed files with 1003 additions and 1871 deletions

29
.gitattributes vendored
View File

@@ -1,28 +1 @@
# Normalise line endings to LF for everyone. Files are stored as LF in git and
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
* text=auto eol=lf
# Windows-only scripts must stay CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary assets — never touched / never normalised
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.icns binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.mp3 binary
*.mp4 binary
*.mov binary
*.pdf binary
*.keystore binary
*.jks binary
*.p12 binary
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

54
.github/renovate.json vendored
View File

@@ -44,42 +44,22 @@
]
}
},
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
"matchPackageNames": [
"expo",
"react",
"react-dom",
"react-native",
"react-native-web",
"expo-*",
"@expo/*"
],
"groupName": "Expo SDK",
"dependencyDashboardApproval": true
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
"matchDatasources": ["maven"],
"registryUrls": [
"https://dl.google.com/dl/android/maven2/",
"https://repo.maven.apache.org/maven2/"
]
}
]
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
}

View File

@@ -11,15 +11,6 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
@@ -42,7 +33,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -125,7 +116,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -196,7 +187,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -228,10 +219,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -240,9 +231,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -263,7 +252,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -295,7 +284,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
@@ -323,7 +312,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -355,10 +344,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -367,7 +356,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive --profile ci_tv
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -391,7 +380,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -423,7 +412,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -1,51 +1,51 @@
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -97,11 +97,10 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive

View File

@@ -63,7 +63,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
@@ -88,7 +88,7 @@ jobs:
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -182,7 +182,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
show-progress: false

View File

@@ -1,60 +0,0 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

11
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
@@ -20,8 +21,10 @@ web-build/
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Native module build outputs (any module)
modules/*/android/build/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
# Generated Applications
Streamyfin.app
@@ -66,6 +69,10 @@ certs/
# Version and Backup Files
/version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

29
app.config.js Normal file
View File

@@ -0,0 +1,29 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
};

View File

@@ -1,79 +0,0 @@
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args: string[]): string | null => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
};
const buildMeta = {
commit:
(
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
process.env.GITHUB_SHA ||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
git(["rev-parse", "HEAD"]) ||
""
).slice(0, 7) || null,
branch:
process.env.EAS_BUILD_GIT_BRANCH ||
process.env.GITHUB_HEAD_REF ||
process.env.GITHUB_REF_NAME ||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
null,
profile:
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") {
config.plugins?.push("expo-background-task");
config.plugins?.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins?.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
@@ -131,17 +131,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.ts",
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -161,7 +161,9 @@ export default function FavoritesSeeAllScreen() {
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
@@ -192,7 +194,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items")}
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}

View File

@@ -137,12 +137,12 @@ export default function DownloadsPage() {
deleteFileByType("Episode")
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_series_successfully"),
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteOtherMedia = () =>
Promise.all(
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.series")}
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_series_button")}
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>

View File

@@ -179,15 +179,18 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm"),
t("home.settings.storage.clear_all_cache_confirm_desc"),
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[
{
text: t("common.cancel"),
text: t("common.cancel", "Cancel"),
style: "cancel",
},
{
text: t("common.ok"),
text: t("common.ok", "OK"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
@@ -240,8 +243,11 @@ export default function SettingsTV() {
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files"),
t("home.settings.storage.clear_all_cache_error_desc"),
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
);
}
},

View File

@@ -108,11 +108,10 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.19.7",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "5.9.3",
},
},
@@ -294,58 +293,6 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
"@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="],
@@ -970,8 +917,6 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1024,7 +969,7 @@
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
@@ -1654,7 +1599,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
@@ -1874,8 +1819,6 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],

View File

@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid],
);
const isPresentedRef = useRef(false);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else if (isPresentedRef.current) {
} else {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
if (index === -1) {
resetState();
onClose();
}

View File

@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select")}
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
</Text>
</TouchableOpacity>
</View>

View File

@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
<View style={styles.buttonContainer}>
<TVSubmitButton
onPress={handleSubmit}
label={t("login.login_button")}
label={t("login.login")}
loading={isLoading}
disabled={!password}
/>

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
TextInput fallback on Android TV (the native module is Apple-only). */}
{Platform.OS === "ios" ? (
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
</View>
<ScrollView

View File

@@ -3,6 +3,7 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -33,13 +34,16 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
staleTime: 0,
});
if (!items?.length)
return (
<View className='px-4'>
<Text className='text-lg font-bold mb-2'>{t("item_card.next_up")}</Text>
<Text className='opacity-50'>{t("item_card.no_items_to_display")}</Text>
</View>
);
// Defensive client-side filter: some Jellyfin server versions ignore the
// `seriesId` query param on /Shows/NextUp and return next-up items across all
// series (the same content as the home tab's Next Up row). Filter to ensure
// we only ever show episodes belonging to this series.
const filteredItems = useMemo(
() => items?.filter((item) => item.SeriesId === seriesId) ?? [],
[items, seriesId],
);
if (!filteredItems.length) return null;
return (
<View>
@@ -50,7 +54,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
showsHorizontalScrollIndicator={false}
data={items}
data={filteredItems}
renderItem={({ item, index }) => (
<TouchableItemRouter
item={item}

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
return (
<View {...props}>

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -97,14 +97,6 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View File

@@ -1,12 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter>
</receiver>
</application>

View File

@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -62,61 +61,31 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean {
val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
if (programIds != null) {
var deletedPrograms = 0
val channelKeys = allProgramIds.keys()
while (channelKeys.hasNext()) {
val channelIdStr = channelKeys.next()
val programIdsJson = allProgramIds.optString(channelIdStr)
if (programIdsJson.isBlank()) continue
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
}
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
}
// Also handle legacy format (flat { providerId: programId }) for migration
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
}
prefs.edit()
@@ -127,274 +96,128 @@ internal object TvRecommendationsPublisher {
return true
}
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray()
if (sections.length() == 0) {
Log.w(TAG, "synchronize(): no sections in payload")
return false
}
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
// Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
)
return true
}
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
}
// Create a new channel
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -402,10 +225,6 @@ internal object TvRecommendationsPublisher {
return channelId
}
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -430,67 +249,42 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it)
}
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let {
val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
val imageUri = Uri.parse(it)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -512,17 +306,13 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
try {
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
}
@@ -551,14 +341,9 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
@@ -587,10 +372,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
}
}

View File

@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -1,19 +1,12 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View>
> =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
> = requireNativeView("TvSearchModule");
/**
* Forwards its ref to the underlying native view so it can be used as a

View File

@@ -22,9 +22,7 @@
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
"postinstall": "patch-package"
},
"dependencies": {
@@ -131,11 +129,10 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.19.7",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "5.9.3"
},
"expo": {

View File

@@ -1,4 +1,4 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("expo/config-plugins");
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch(): string {
function buildPatch() {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch(): string {
].join("\n");
}
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
module.exports = function withRuntimeFrameworkHeaders(config) {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,5 +125,3 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

@@ -1,20 +1,10 @@
import {
type ConfigPlugin,
const {
withAndroidColors,
withAndroidColorsNight,
} from "expo/config-plugins";
} = require("expo/config-plugins");
interface ColorResourceItem {
$: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const withAndroidAlertColors = (config) => {
const setColor = (colorsList, name, value) => {
const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name,
);
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColors(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList;
return config;
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList;
return config;
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
return config;
};
export default withAndroidAlertColors;
module.exports = withAndroidAlertColors;

View File

@@ -1,12 +1,8 @@
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const { withAndroidManifest } = require("expo/config-plugins");
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
const _withGoogleCastAndroidManifest = (config) =>
withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
const mainApplication = mod.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
return mod;
});
export default withGoogleCastAndroidManifest;
module.exports = _withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { withDangerousMod } = require("expo/config-plugins");
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
withDangerousMod(expoConfig, [
"android",
(modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
},
]);
export default withChangeNativeAndroidTextToWhite;
module.exports = withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
const { withAppBuildGradle } = require("expo/config-plugins");
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
module.exports = function withExcludeMedia3Dash(config) {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -32,5 +32,3 @@ configurations.all {
return config;
});
};
export default withExcludeMedia3Dash;

View File

@@ -1,14 +1,6 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("@expo/config-plugins");
interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
const withGitPod = (config, { podName, podspecUrl }) => {
return withPodfile(config, (config) => {
const podfile = config.modResults.contents;
@@ -29,4 +21,4 @@ const withGitPod: ConfigPlugin<GitPodOptions> = (
});
};
export default withGitPod;
module.exports = withGitPod;

View File

@@ -1,21 +1,12 @@
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
const { withGradleProperties } = require("expo/config-plugins");
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
function setGradlePropertiesValue(config, key, value) {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property: AndroidConfig.Properties.PropertiesItem = {
const property = {
type: "property",
key,
value,
@@ -31,7 +22,7 @@ function setGradlePropertiesValue(
});
}
const withCustomGradleProperties: ConfigPlugin = (config) => {
module.exports = function withCustomPlugin(config) {
// Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -44,5 +35,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
);
return config;
};
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const { withXcodeProject } = require("@expo/config-plugins");
const withTVOSAppIcon: ConfigPlugin = (config) => {
const withTVOSAppIcon = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon: ConfigPlugin = (config) => {
});
};
export default withTVOSAppIcon;
module.exports = withTVOSAppIcon;

View File

@@ -1,10 +1,8 @@
import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
const {
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
} from "expo/config-plugins";
} = require("@expo/config-plugins");
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
@@ -12,29 +10,19 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier";
interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
function getBundleIdentifier(config) {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
}
function getAppGroupIdentifier(config: ExpoConfig): string {
function getAppGroupIdentifier(config) {
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
}
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
function getKeychainAccessGroupIdentifier(config) {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
}
// The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
function getBuildConfigurations(project, configurationListId) {
const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations
.map((config: { value: string }) => buildConfigurations[config.value])
.map((config) => buildConfigurations[config.value])
.filter(Boolean);
}
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
function ensureAppGroup(value, appGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier)
? groups
: [...groups, appGroupIdentifier];
}
function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier)
? groups
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
}
function ensureAppExtension(
appExtensions: unknown,
targetName: string,
bundleIdentifier: string,
appGroupIdentifier: string,
keychainAccessGroupIdentifier: string,
): AppExtensionConfig[] {
const extensionConfig: AppExtensionConfig = {
appExtensions,
targetName,
bundleIdentifier,
appGroupIdentifier,
keychainAccessGroupIdentifier,
) {
const extensionConfig = {
targetName,
bundleIdentifier,
entitlements: {
@@ -78,9 +63,7 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier],
},
};
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
// Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName,
@@ -95,7 +78,7 @@ function ensureAppExtension(
);
}
const withTVOSTopShelf: ConfigPlugin = (config) => {
const withTVOSTopShelf = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config);
@@ -210,4 +193,4 @@ const withTVOSTopShelf: ConfigPlugin = (config) => {
});
};
export default withTVOSTopShelf;
module.exports = withTVOSTopShelf;

View File

@@ -1,9 +1,9 @@
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
const { withEntitlementsPlist } = require("expo/config-plugins");
/**
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement: ConfigPlugin = (config) => {
const withTVUserManagement = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in
@@ -24,4 +24,4 @@ const withTVUserManagement: ConfigPlugin = (config) => {
});
};
export default withTVUserManagement;
module.exports = withTVUserManagement;

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { withDangerousMod } = require("@expo/config-plugins");
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
/**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
@@ -12,7 +12,7 @@ import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
*
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/
const withTVXcodeEnv: ConfigPlugin = (config) => {
const withTVXcodeEnv = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
/**
* Get the actual node binary path, handling nvm installations.
*/
function getNodeBinaryPath(): string | null {
function getNodeBinaryPath() {
try {
// First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath(): string | null {
return null;
}
export default withTVXcodeEnv;
module.exports = withTVXcodeEnv;

View File

@@ -1,29 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import {
AndroidConfig,
type ConfigPlugin,
type ExportedConfigWithProps,
withAndroidManifest,
} from "expo/config-plugins";
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
const path = require("node:path");
const fs = require("node:fs");
const fsPromises = fs.promises;
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
const withTrustLocalCerts: ConfigPlugin = (config) => {
const withTrustLocalCerts = (config) => {
return withAndroidManifest(config, async (mod) => {
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
return mod;
});
};
async function setCustomConfigAsync(
config: ExportedConfigWithProps<AndroidManifest>,
androidManifest: AndroidManifest,
): Promise<AndroidManifest> {
async function setCustomConfigAsync(config, androidManifest) {
const src_file_path = path.join(__dirname, "network_security_config.xml");
const res_file_path = path.join(
await AndroidConfig.Paths.getResourceFolderAsync(
@@ -56,4 +45,4 @@ async function setCustomConfigAsync(
return androidManifest;
}
export default withTrustLocalCerts;
module.exports = withTrustLocalCerts;

View File

@@ -40,7 +40,6 @@ import {
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
interface Server {
address: string;
@@ -54,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -136,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -170,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
}, DeviceId="${deviceId}", Version="0.54.1"`,
};
}, [deviceId]);

View File

@@ -1,273 +0,0 @@
#!/usr/bin/env bun
/**
* i18n key checker for Streamyfin.
*
* Detects:
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
* that does not exist in the source locale (translations/en.json). These are bugs —
* the app renders the raw key. Always fails CI.
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
* These are dead weight that also clutter every locale on Crowdin.
*
* Dynamic usage is handled conservatively:
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
* whitelist keys (in Streamyfin these are user-defined section
* titles, not translation keys).
* - Edge cases the static scan cannot see can be allow-listed in the config file.
*
* Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { extname, join, relative } from "node:path";
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name, def) => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
return v === undefined ? true : v;
};
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
const JSON_OUT = !!flag("json", false);
const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
srcDirs: ["."],
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
excludeDirs: [
"node_modules",
"ios",
"android",
".expo",
".git",
"dist",
"build",
"translations",
"scripts",
],
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v;
}
return out;
};
const globMatch = (key, pattern) => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir, files = []) => {
let entries;
try {
entries = readdirSync(dir);
} catch {
return files;
}
for (const name of entries) {
const full = join(dir, name);
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (st.isDirectory()) {
if (config.excludeDirs.includes(name)) continue;
walk(full, files);
} else if (config.srcExtensions.includes(extname(name))) {
files.push(full);
}
}
return files;
};
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
);
const sourceKeySet = new Set(sourceKeys);
// ---- scan code ----
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
const files = config.srcDirs.flatMap((d) =>
walk(join(ROOT, d === "." ? "" : d) || ROOT),
);
for (const file of files) {
const text = readFileSync(file, "utf8");
const clean = stripComments(text);
codeBlob += `\n${clean}`;
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
for (const m of clean.matchAll(TPL_DYN_RE)) {
const prefix = m[1];
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
else {
const idx = clean.slice(0, m.index).split("\n").length;
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
}
}
}
const prefixList = [...dynamicPrefixes];
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key) =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
// ---- compute ----
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
// affects the UNUSED calculation, never MISSING.
const missing = [...usedStatic]
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
.sort();
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
);
}
// ---- report ----
if (JSON_OUT) {
console.log(
JSON.stringify(
{
sourceKeys: sourceKeys.length,
missing,
unused,
dynamicPrefixes: prefixList,
fullyDynamic,
},
null,
2,
),
);
} else {
console.log(
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
);
if (prefixList.length)
console.log(
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
);
if (fullyDynamic.length)
console.log(
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
);
if (missing.length) {
console.log(
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
);
for (const k of missing) console.log(` - ${k}`);
} else console.log("\n✅ No missing keys.");
if (UNUSED_MODE !== "off") {
if (unused.length) {
console.log(
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
);
} else console.log("\n✅ No unused keys.");
}
}
const fail =
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
process.exit(fail ? 1 : 0);

View File

@@ -1,46 +0,0 @@
{
"localesDir": "translations",
"sourceLocale": "en",
"srcDirs": [
"app",
"components",
"hooks",
"providers",
"utils",
"modules",
"packages",
"constants"
],
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
"excludeDirs": [
"node_modules",
"ios",
"android",
".expo",
".git",
"dist",
"build",
"translations"
],
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
"ignoreUnused": [
"watchlists.add_to_watchlist",
"watchlists.remove_from_watchlist",
"watchlists.create_one_first",
"watchlists.no_compatible_watchlists",
"pin.confirm_pin",
"pin.pins_dont_match",
"player.search_subtitles",
"player.subtitle_search",
"player.subtitle_download_hint",
"player.subtitle_tracks",
"player.using_jellyfin_server",
"player.swipe_down_settings",
"player.stopPlayback",
"player.stopPlayingTitle",
"player.stopPlayingConfirm",
"home.settings.other.show_large_home_carousel",
"home.settings.logs.delete_all_logs",
"home.suggested_episodes"
]
}

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "التنزيلات",
"series": "مسلسلات",
"tvseries": "مسلسلات",
"movies": "أفلام",
"queue": "قائمة الانتظار",
"other_media": "وسائط أخرى",
@@ -524,7 +524,7 @@
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
"delete_all_movies_button": "حذف جميع الأفلام",
"delete_all_series_button": "حذف جميع المسلسلات",
"delete_all_tvseries_button": "حذف جميع المسلسلات",
"delete_all_button": "حذف الكل",
"delete_all_other_media_button": "حذف الوسائط الأخرى",
"active_download": "التنزيل الجاري",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
"download_deleted": "تم حذف التنزيل",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Descàrregues",
"series": "Sèries",
"tvseries": "Sèries",
"movies": "Pel·lícules",
"queue": "Cua",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No hi ha elements a la cua",
"no_downloaded_items": "No hi ha elements descarregats",
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
"delete_all_series_button": "Suprimeix totes les sèries",
"delete_all_tvseries_button": "Suprimeix totes les sèries",
"delete_all_button": "Suprimeix-ho tot",
"delete_all_other_media_button": "Delete other media",
"active_download": "Descàrrega activa",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
"deleted_all_series_successfully": "S'han suprimit totes les sèries correctament!",
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Stahování",
"series": "Televizní série",
"tvseries": "Televizní série",
"movies": "Filmy",
"queue": "Fronta",
"other_media": "Ostatní média",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Žádné položky ve frontě",
"no_downloaded_items": "Žádné stažené položky",
"delete_all_movies_button": "Odstranit všechny filmy",
"delete_all_series_button": "Odstranit všechny TV-série",
"delete_all_tvseries_button": "Odstranit všechny TV-série",
"delete_all_button": "Smazat vše",
"delete_all_other_media_button": "Odstranit ostatní média",
"active_download": "Aktivní stahování",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
"deleted_all_series_successfully": "Všechny série televizorů byly úspěšně smazány!",
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
"deleted_all_tvseries_successfully": "Všechny série televizorů byly úspěšně smazány!",
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
"download_deleted": "Stahování smazáno",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV-serier",
"tvseries": "TV-serier",
"movies": "Film",
"queue": "Kø",
"other_media": "Andre medier",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen downloadede elementer",
"delete_all_movies_button": "Slet alle film",
"delete_all_series_button": "Slet alle TV-serier",
"delete_all_tvseries_button": "Slet alle TV-serier",
"delete_all_button": "Slet alle",
"delete_all_other_media_button": "Slet andre medier",
"active_download": "Aktiv download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
"deleted_all_tvseries_successfully": "Alle TV-serier er slettet med succes!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
"deleted_media_successfully": "Slettede andre medier med succes!",
"failed_to_delete_media": "Kunne ikke slette andre medier",
"download_deleted": "Download Slettet",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "Serien",
"tvseries": "Serien",
"movies": "Filme",
"queue": "Warteschlange",
"other_media": "Andere Medien",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Keine Elemente in der Warteschlange",
"no_downloaded_items": "Keine heruntergeladenen Elemente",
"delete_all_movies_button": "Alle Filme löschen",
"delete_all_series_button": "Alle Serien löschen",
"delete_all_tvseries_button": "Alle Serien löschen",
"delete_all_button": "Alles löschen",
"delete_all_other_media_button": "Alle anderen Medien löschen",
"active_download": "Aktiver Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
"download_deleted": "Download gelöscht",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Λήψεις",
"series": "Τηλεόραση-Σειρά",
"tvseries": "Τηλεόραση-Σειρά",
"movies": "Ταινίες",
"queue": "Ουρά",
"other_media": "Άλλα μέσα",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
"delete_all_button": "Διαγραφή Όλων",
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
"active_download": "Ενεργή Λήψη",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
"deleted_all_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
"download_deleted": "Η Λήψη Διαγράφηκε",

File diff suppressed because it is too large Load Diff

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Elŝutoj",
"series": "Televidaj serioj",
"tvseries": "Televidaj serioj",
"movies": "Filmoj",
"queue": "Vico",
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
"no_items_in_queue": "Neniuj eroj en vico",
"no_downloaded_items": "Neniuj elŝutitaj eroj",
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
"delete_all_series_button": "Forigi ĉiujn Televidajn Seriojn",
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
"delete_all_button": "Forigi ĉion",
"active_download": "Aktiva elŝuto",
"no_active_downloads": "Neniuj aktivaj elŝutoj",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
"download_cancelled": "Elŝuto nuligita",
"could_not_cancel_download": "Ne povis nuligi elŝuton",
"download_completed": "Elŝuto finita",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Descargas",
"series": "Series",
"tvseries": "Series",
"movies": "Películas",
"queue": "Cola",
"other_media": "Otros medios",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No hay ítems en la cola",
"no_downloaded_items": "No hay ítems descargados",
"delete_all_movies_button": "Eliminar todas las películas",
"delete_all_series_button": "Eliminar todas las series",
"delete_all_tvseries_button": "Eliminar todas las series",
"delete_all_button": "Eliminar todo",
"delete_all_other_media_button": "Eliminar otros medios",
"active_download": "Descarga activa",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
"failed_to_delete_all_series": "Error al eliminar todas las series",
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
"failed_to_delete_media": "Error al eliminar otros medios",
"download_deleted": "Descarga eliminada",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Lataukset",
"series": "TV-sarjat",
"tvseries": "TV-sarjat",
"movies": "Elokuvat",
"queue": "Jonot",
"other_media": "Muu media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ei kohteita jonossa",
"no_downloaded_items": "Ei ladattuja kohteita",
"delete_all_movies_button": "Poista kaikki elokuvat",
"delete_all_series_button": "Poista kaikki TV-sarjat",
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
"delete_all_button": "Poista kaikki",
"delete_all_other_media_button": "Poista muu media",
"active_download": "Aktiivinen lataus",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
"deleted_all_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
"download_deleted": "Lataus Poistettu",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Téléchargements",
"series": "Séries",
"tvseries": "Séries",
"movies": "Films",
"queue": "File d'attente",
"other_media": "Autres médias",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
"no_downloaded_items": "Aucun média téléchargé",
"delete_all_movies_button": "Supprimer tous les films",
"delete_all_series_button": "Supprimer toutes les séries",
"delete_all_tvseries_button": "Supprimer toutes les séries",
"delete_all_button": "Supprimer tous les médias",
"delete_all_other_media_button": "Supprimer un autre média",
"active_download": "Téléchargement actif",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!",
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
"failed_to_delete_media": "Échec de la suppression d'un autre média",
"download_deleted": "Téléchargement supprimé",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "הורדות",
"series": "סדרות",
"tvseries": "סדרות",
"movies": "סרטים",
"queue": "תוֹר",
"other_media": "תוכן אחר",
@@ -524,7 +524,7 @@
"no_items_in_queue": "אין פרטים בתור",
"no_downloaded_items": "אין פריטים שהורדו",
"delete_all_movies_button": "מחק את כל הסרטים",
"delete_all_series_button": "מחק את כל הסדרות",
"delete_all_tvseries_button": "מחק את כל הסדרות",
"delete_all_button": "מחק הכל",
"delete_all_other_media_button": "מחק שאר תוכן",
"active_download": "הורדה פעילה",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "אתה לא מורשה להוריד קבצים.",
"deleted_all_movies_successfully": "כל הסרטים נמחקו בהצלחה!",
"failed_to_delete_all_movies": "נכשל במחיקת כל הסרטים",
"deleted_all_series_successfully": "כל הסדרות נמחקו בהצלחה!",
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
"deleted_all_tvseries_successfully": "כל הסדרות נמחקו בהצלחה!",
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
"download_deleted": "ההורדה נמחקה",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Letöltések",
"series": "Sorozatok",
"tvseries": "Sorozatok",
"movies": "Filmek",
"queue": "Sor",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nincs Elem a Sorban",
"no_downloaded_items": "Nincsenek Letöltött Elemek",
"delete_all_movies_button": "Összes Film Törlése",
"delete_all_series_button": "Összes Sorozat Törlése",
"delete_all_tvseries_button": "Összes Sorozat Törlése",
"delete_all_button": "Összes Törlése",
"delete_all_other_media_button": "Delete other media",
"active_download": "Aktív Letöltés",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
"deleted_all_series_successfully": "Az Összes Sorozat Sikeresen Törölve!",
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
"deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Letöltés Törölve",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Scaricati",
"series": "Serie TV",
"tvseries": "Serie TV",
"movies": "Film",
"queue": "Coda",
"other_media": "Altri supporti",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nessun elemento in coda",
"no_downloaded_items": "Nessun elemento scaricato",
"delete_all_movies_button": "Cancella tutti i film",
"delete_all_series_button": "Cancella tutte le serie TV",
"delete_all_tvseries_button": "Cancella tutte le serie TV",
"delete_all_button": "Cancella tutti",
"delete_all_other_media_button": "Elimina altri supporti",
"active_download": "Scaricamento in corso",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
"deleted_all_series_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
"deleted_media_successfully": "Eliminato altri supporti con successo!",
"failed_to_delete_media": "Impossibile eliminare altri media",
"download_deleted": "Download Eliminato",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "ダウンロード",
"series": "TVシリーズ",
"tvseries": "TVシリーズ",
"movies": "映画",
"queue": "キュー",
"other_media": "その他のメディア",
@@ -524,7 +524,7 @@
"no_items_in_queue": "キューにアイテムがありません",
"no_downloaded_items": "ダウンロードしたアイテムはありません",
"delete_all_movies_button": "すべての映画を削除",
"delete_all_series_button": "すべてのシリーズを削除",
"delete_all_tvseries_button": "すべてのシリーズを削除",
"delete_all_button": "すべて削除",
"delete_all_other_media_button": "他のメディアを削除する",
"active_download": "アクティブなダウンロード",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
"deleted_all_series_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
"deleted_media_successfully": "他のメディアを削除しました!",
"failed_to_delete_media": "他のメディアの削除に失敗しました",
"download_deleted": "ダウンロードが削除されました",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV-Series",
"tvseries": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_series_button": "Delete All TV-Series",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Nedlastinger",
"series": "TV-serier",
"tvseries": "TV-serier",
"movies": "Filmer",
"queue": "Kø",
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen nedlastede elementer",
"delete_all_movies_button": "Slett alle filmer",
"delete_all_series_button": "Slett alle TV-serier",
"delete_all_tvseries_button": "Slett alle TV-serier",
"delete_all_button": "Slett alt",
"active_download": "Aktiv nedlasting",
"no_active_downloads": "Ingen aktive nedlastinger",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
"deleted_all_series_successfully": "Alle TV-serier ble slettet!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
"deleted_all_tvseries_successfully": "Alle TV-serier ble slettet!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
"download_cancelled": "Nedlasting avbrutt",
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
"download_completed": "Nedlasting fullført",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "Series",
"tvseries": "Series",
"movies": "Films",
"queue": "Wachtrij",
"other_media": "Andere media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Geen items in wachtrij",
"no_downloaded_items": "Geen gedownloade items",
"delete_all_movies_button": "Verwijder alle films",
"delete_all_series_button": "Verwijder alle Series",
"delete_all_tvseries_button": "Verwijder alle Series",
"delete_all_button": "Verwijder alles",
"delete_all_other_media_button": "Andere media verwijderen",
"active_download": "Actieve download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
"deleted_all_series_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
"deleted_media_successfully": "Andere media succesvol verwijderd!",
"failed_to_delete_media": "Verwijderen van andere media mislukt",
"download_deleted": "Download verwijderd",

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Nedlastingar",
"series": "TV-seriar",
"tvseries": "TV-seriar",
"movies": "Filmar",
"queue": "Kø",
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
"no_items_in_queue": "Ingen element i køen",
"no_downloaded_items": "Ingen nedlasta element",
"delete_all_movies_button": "Slett alle filmar",
"delete_all_series_button": "Slett alle TV-seriar",
"delete_all_tvseries_button": "Slett alle TV-seriar",
"delete_all_button": "Slett alt",
"active_download": "Aktiv nedlasting",
"no_active_downloads": "Ingen aktive nedlastingar",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
"deleted_all_series_successfully": "Alle TV-seriar vart sletta!",
"failed_to_delete_all_series": "Kunne ikkje sletta alle TV-seriar",
"deleted_all_tvseries_successfully": "Alle TV-seriar vart sletta!",
"failed_to_delete_all_tvseries": "Kunne ikkje sletta alle TV-seriar",
"download_cancelled": "Nedlasting avbroten",
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
"download_completed": "Nedlasting fullført",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Nedlastinger",
"series": "TV-Serier",
"tvseries": "TV-Serier",
"movies": "Filmer",
"queue": "Kø",
"other_media": "Andre medier",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Ingen elementer i køen",
"no_downloaded_items": "Ingen nedlastede elementer",
"delete_all_movies_button": "Slett alle filmer",
"delete_all_series_button": "Slett alle TV-Serier",
"delete_all_tvseries_button": "Slett alle TV-Serier",
"delete_all_button": "Slett alle",
"delete_all_other_media_button": "Slett andre media",
"active_download": "Aktiv nedlasting",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
"deleted_all_series_successfully": "Alle TV-Serier ble slettet!",
"failed_to_delete_all_series": "Kunne ikke slette alle TV-Serier",
"deleted_all_tvseries_successfully": "Alle TV-Serier ble slettet!",
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-Serier",
"deleted_media_successfully": "Slettet andre media vellykket!",
"failed_to_delete_media": "Kunne ikke slette andre medier",
"download_deleted": "Nedlasting slettet",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Pobrane",
"series": "Seriale",
"tvseries": "Seriale",
"movies": "Filmy",
"queue": "Kolejka",
"other_media": "Inne media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Brak elementów w kolejce",
"no_downloaded_items": "Brak pobranych elementów",
"delete_all_movies_button": "Usuń wszystkie filmy",
"delete_all_series_button": "Usuń wszystkie seriale",
"delete_all_tvseries_button": "Usuń wszystkie seriale",
"delete_all_button": "Usuń wszystko",
"delete_all_other_media_button": "Usuń inne media",
"active_download": "Aktywne pobieranie",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nie masz uprawnień do pobierania plików.",
"deleted_all_movies_successfully": "Wszystkie filmy zostały pomyślnie usunięte!",
"failed_to_delete_all_movies": "Nie udało się usunąć wszystkich filmów",
"deleted_all_series_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
"failed_to_delete_all_series": "Nie udało się usunąć wszystkich seriali",
"deleted_all_tvseries_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
"failed_to_delete_all_tvseries": "Nie udało się usunąć wszystkich seriali",
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
"download_deleted": "Pobieranie usunięte",

View File

@@ -227,14 +227,14 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV/Séries",
"tvseries": "TV/Séries",
"movies": "Filmes",
"queue": "Fila",
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
"no_items_in_queue": "Nenhum item na fila",
"no_downloaded_items": "Nenhum item baixado",
"delete_all_movies_button": "Remover todos os filmes",
"delete_all_series_button": "Remover todos as TV/Séries",
"delete_all_tvseries_button": "Remover todos as TV/Séries",
"delete_all_button": "Remover tudo",
"active_download": "Downloads ativos",
"no_active_downloads": "Nenhum download ativo",
@@ -251,8 +251,8 @@
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
"deleted_all_movies_successfully": "Todos os filmes foram removidos com sucesso!",
"failed_to_delete_all_movies": "Falha ao remover todos os filmes",
"deleted_all_series_successfully": "Todos as TV/Séries foram removidos com sucesso!",
"failed_to_delete_all_series": "Falha ao remover todos as TV/Séries",
"deleted_all_tvseries_successfully": "Todos as TV/Séries foram removidos com sucesso!",
"failed_to_delete_all_tvseries": "Falha ao remover todos as TV/Séries",
"download_cancelled": "Download cancelado",
"could_not_cancel_download": "Não foi possível cancelar o download",
"download_completed": "Download completo",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV-Séries",
"tvseries": "TV-Séries",
"movies": "Filmes",
"queue": "Fila",
"other_media": "Outras mídias",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Nenhum item na fila",
"no_downloaded_items": "Nenhum item baixado",
"delete_all_movies_button": "Excluir todos os filmes",
"delete_all_series_button": "Excluir todas as séries",
"delete_all_tvseries_button": "Excluir todas as séries",
"delete_all_button": "Excluir todos os",
"delete_all_other_media_button": "Excluir outras mídias",
"active_download": "Download ativo",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
"deleted_all_movies_successfully": "Todos os filmes excluídos com sucesso!",
"failed_to_delete_all_movies": "Falha ao excluir todos os filmes",
"deleted_all_series_successfully": "Todas as TV-Series excluídas com sucesso!",
"failed_to_delete_all_series": "Falha ao excluir todas as séries",
"deleted_all_tvseries_successfully": "Todas as TV-Series excluídas com sucesso!",
"failed_to_delete_all_tvseries": "Falha ao excluir todas as séries",
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
"failed_to_delete_media": "Falha ao excluir outras mídias",
"download_deleted": "Download Excluído",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Descărcări",
"series": "Seriale",
"tvseries": "Seriale",
"movies": "Filme",
"queue": "Coadă",
"other_media": "Alte suporturi",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Niciun articol în coadă",
"no_downloaded_items": "Niciun element descărcat",
"delete_all_movies_button": "Șterge toate filmele",
"delete_all_series_button": "Șterge toate serialele",
"delete_all_tvseries_button": "Șterge toate serialele",
"delete_all_button": "Șterge tot",
"delete_all_other_media_button": "Șterge alte fișiere media",
"active_download": "Descărcare activă",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Nu aveți voie să descărcați fișiere.",
"deleted_all_movies_successfully": "Toate filmele au fost șterse cu succes!",
"failed_to_delete_all_movies": "Nu s-au putut șterge toate filmele",
"deleted_all_series_successfully": "Toate serialele au fost șterse cu succes!",
"failed_to_delete_all_series": "Nu s-au putut șterge toate serialele",
"deleted_all_tvseries_successfully": "Toate serialele au fost șterse cu succes!",
"failed_to_delete_all_tvseries": "Nu s-au putut șterge toate serialele",
"deleted_media_successfully": "Alte fișiere șterse cu succes!",
"failed_to_delete_media": "Ștergerea altor fișiere media a eșuat",
"download_deleted": "Descărcare ştearsă",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Загрузки",
"series": "Сериалы",
"tvseries": "Сериалы",
"movies": "Фильмы",
"queue": "Очередь",
"other_media": "Прочие файлы",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Нет элементов в очереди",
"no_downloaded_items": "Нет загруженных файлов",
"delete_all_movies_button": "Удалить все фильмы",
"delete_all_series_button": "Удалить все сериалы",
"delete_all_tvseries_button": "Удалить все сериалы",
"delete_all_button": "Удалить все",
"delete_all_other_media_button": "Удалить прочие файлы",
"active_download": "Загружается",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
"deleted_all_series_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_series": "Возникла ошибка при удалении всех сериалов",
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"deleted_media_successfully": "Остальные медиафайлы успешно удалены!",
"failed_to_delete_media": "Не удалось удалить остальные медиафайлы",
"download_deleted": "Загруженный контент удалён",

View File

@@ -229,14 +229,14 @@
},
"downloads": {
"downloads_title": "Shkarkimet",
"series": "Seriale TV",
"tvseries": "Seriale TV",
"movies": "Filma",
"queue": "Rradhë",
"queue_hint": "Rradhat dhe shkarkimet do të humbasin pas genstartit të aplikacionit",
"no_items_in_queue": "Nuk ka elemente në rradhë",
"no_downloaded_items": "Nuk ka shkarkime",
"delete_all_movies_button": "Fshijë të gjithë filmat",
"delete_all_series_button": "Fshijë të gjitha serialet TV",
"delete_all_tvseries_button": "Fshijë të gjitha serialet TV",
"delete_all_button": "Fshijë të gjitha",
"active_download": "Shkarkim aktiv",
"no_active_downloads": "Nuk ka shkarkime aktive",
@@ -253,8 +253,8 @@
"you_are_not_allowed_to_download_files": "Nuk keni të drejtë të shkarkoni skedarë.",
"deleted_all_movies_successfully": "Të gjithë filmat u fshinë me sukses!",
"failed_to_delete_all_movies": "Dështojë fshirja e të gjithë filmave",
"deleted_all_series_successfully": "Të gjitha serialet TV u fshinë me sukses!",
"failed_to_delete_all_series": "Dështojë fshirja e të gjitha serialeve TV",
"deleted_all_tvseries_successfully": "Të gjitha serialet TV u fshinë me sukses!",
"failed_to_delete_all_tvseries": "Dështojë fshirja e të gjitha serialeve TV",
"download_cancelled": "Shkarkimi u anulua",
"could_not_cancel_download": "Nuk mundet të anulohet shkarkimi",
"download_completed": "Shkarkimi u përfundua",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Nedladdningar",
"series": "TV-Serier",
"tvseries": "TV-Serier",
"movies": "Filmer",
"queue": "Kö",
"other_media": "Annan media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Inga objekt i Kön",
"no_downloaded_items": "Inga Nedladdade Objekt",
"delete_all_movies_button": "Ta Bort Alla Filmer",
"delete_all_series_button": "Ta Bort Alla TV-Serier",
"delete_all_tvseries_button": "Ta Bort Alla TV-Serier",
"delete_all_button": "Radera Allt",
"delete_all_other_media_button": "Ta Bort Andra Videor",
"active_download": "Aktiv Nedladdning",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har inte behörighet att ladda ner filer.",
"deleted_all_movies_successfully": "Alla Filmer Har Tagits Bort!",
"failed_to_delete_all_movies": "Det Gick Inte Att Ta Bort Alla Filmer",
"deleted_all_series_successfully": "Alla TV-Serier Har Tagits Bort!",
"failed_to_delete_all_series": "Det Gick Inte Att Ta Bort Alla TV-Serier",
"deleted_all_tvseries_successfully": "Alla TV-Serier Har Tagits Bort!",
"failed_to_delete_all_tvseries": "Det Gick Inte Att Ta Bort Alla TV-Serier",
"deleted_media_successfully": "Andra Medier Har Tagits Bort!",
"failed_to_delete_media": "Kunde Inte Ta Bort Andra Medier",
"download_deleted": "Nedladdning Borttagen",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV-Series",
"tvseries": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_series_button": "Delete All TV-Series",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Qaw' Doch",
"series": "TV Hem",
"tvseries": "TV Hem",
"movies": "DIS",
"queue": "ghom",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "ghom Doch pagh",
"no_downloaded_items": "Qaw' Doch pagh",
"delete_all_movies_button": "Hoch DIS yIQaw'",
"delete_all_series_button": "Hoch TV Hem yIQaw'",
"delete_all_tvseries_button": "Hoch TV Hem yIQaw'",
"delete_all_button": "Hoch yIQaw'",
"delete_all_other_media_button": "Delete other media",
"active_download": "chu' Qaw'",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Doch Qaw' je'laHbe'.",
"deleted_all_movies_successfully": "Hoch DIS Qaw' Qapla'!",
"failed_to_delete_all_movies": "Hoch DIS Qaw'laHbe'",
"deleted_all_series_successfully": "Hoch TV Hem Qaw' Qapla'!",
"failed_to_delete_all_series": "Hoch TV Hem Qaw'laHbe'",
"deleted_all_tvseries_successfully": "Hoch TV Hem Qaw' Qapla'!",
"failed_to_delete_all_tvseries": "Hoch TV Hem Qaw'laHbe'",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "İndirilenler",
"series": "Diziler",
"tvseries": "Diziler",
"movies": "Filmler",
"queue": "Sıra",
"other_media": "Diğer medya",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Sırada öğe yok",
"no_downloaded_items": "İndirilen öğe yok",
"delete_all_movies_button": "Tüm Filmleri Sil",
"delete_all_series_button": "Tüm Dizileri Sil",
"delete_all_tvseries_button": "Tüm Dizileri Sil",
"delete_all_button": "Tümünü Sil",
"delete_all_other_media_button": "Diğer medyayı sil",
"active_download": "Aktif indirme",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Dosyaları indirme izniniz yok.",
"deleted_all_movies_successfully": "Tüm filmler başarıyla silindi!",
"failed_to_delete_all_movies": "Filmler silinemedi",
"deleted_all_series_successfully": "Tüm diziler başarıyla silindi!",
"failed_to_delete_all_series": "Diziler silinemedi",
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
"failed_to_delete_all_tvseries": "Diziler silinemedi",
"deleted_media_successfully": "Diğer medya başarıyla silindi!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "İndirme silindi",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Завантаження",
"series": "ТБ-Серіали",
"tvseries": "ТБ-Серіали",
"movies": "Фільми",
"queue": "Черга",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Нема елементів в черзі",
"no_downloaded_items": "Нема завантажених елементів",
"delete_all_movies_button": "Видалити всі Фільми",
"delete_all_series_button": "Видалити всі ТБ-Серіали",
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
"delete_all_button": "Видалити Все",
"delete_all_other_media_button": "Delete other media",
"active_download": "Активне завантаження",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Вам не дозволено завантажувати файли.",
"deleted_all_movies_successfully": "Видалення всіх фільмів було успішне!",
"failed_to_delete_all_movies": "Не вдалося видалити усі фільми",
"deleted_all_series_successfully": "Успішно видалено всі серіали!",
"failed_to_delete_all_series": "Не вдалося видалити всі телесеріали",
"deleted_all_tvseries_successfully": "Успішно видалено всі серіали!",
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Tải xuống",
"series": "Chương trình TV",
"tvseries": "Chương trình TV",
"movies": "Phim",
"queue": "Hàng đợi",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Không có mục trong hàng đợi",
"no_downloaded_items": "Không có mục đã tải",
"delete_all_movies_button": "Xóa tất cả phim",
"delete_all_series_button": "Xóa tất cả chương trình TV",
"delete_all_tvseries_button": "Xóa tất cả chương trình TV",
"delete_all_button": "Xóa tất cả",
"delete_all_other_media_button": "Delete other media",
"active_download": "Đang tải xuống",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Bạn không có quyền tải nội dung.",
"deleted_all_movies_successfully": "Đã xóa tất cả phim thành công!",
"failed_to_delete_all_movies": "Xóa phim thất bại",
"deleted_all_series_successfully": "Đã xóa tất cả chương trình TV thành công!",
"failed_to_delete_all_series": "Xóa chương trình TV thất bại",
"deleted_all_tvseries_successfully": "Đã xóa tất cả chương trình TV thành công!",
"failed_to_delete_all_tvseries": "Xóa chương trình TV thất bại",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -224,14 +224,14 @@
},
"downloads": {
"downloads_title": "下载",
"series": "剧集",
"tvseries": "剧集",
"movies": "电影",
"queue": "队列",
"queue_hint": "应用重启后队列和下载将会丢失",
"no_items_in_queue": "队列中无项目",
"no_downloaded_items": "无已下载项目",
"delete_all_movies_button": "删除所有电影",
"delete_all_series_button": "删除所有剧集",
"delete_all_tvseries_button": "删除所有剧集",
"delete_all_button": "删除全部",
"active_download": "活跃下载",
"no_active_downloads": "无活跃下载",
@@ -248,8 +248,8 @@
"you_are_not_allowed_to_download_files": "您无权下载文件。",
"deleted_all_movies_successfully": "成功删除所有电影!",
"failed_to_delete_all_movies": "删除所有电影失败",
"deleted_all_series_successfully": "成功删除所有剧集!",
"failed_to_delete_all_series": "删除所有剧集失败",
"deleted_all_tvseries_successfully": "成功删除所有剧集!",
"failed_to_delete_all_tvseries": "删除所有剧集失败",
"download_cancelled": "下载已取消",
"could_not_cancel_download": "无法取消下载",
"download_completed": "下载完成",

View File

@@ -231,14 +231,14 @@
},
"downloads": {
"downloads_title": "下載",
"series": "電視劇",
"tvseries": "電視劇",
"movies": "電影",
"queue": "隊列",
"queue_hint": "應用重啟後隊列和下載將會丟失",
"no_items_in_queue": "隊列中無項目",
"no_downloaded_items": "無已下載項目",
"delete_all_movies_button": "刪除所有電影",
"delete_all_series_button": "刪除所有電視劇",
"delete_all_tvseries_button": "刪除所有電視劇",
"delete_all_button": "刪除全部",
"active_download": "活動下載",
"no_active_downloads": "無活動下載",
@@ -255,8 +255,8 @@
"you_are_not_allowed_to_download_files": "您無權下載文件。",
"deleted_all_movies_successfully": "成功刪除所有電影!",
"failed_to_delete_all_movies": "刪除所有電影失敗",
"deleted_all_series_successfully": "成功刪除所有電視劇!",
"failed_to_delete_all_series": "刪除所有電視劇失敗",
"deleted_all_tvseries_successfully": "成功刪除所有電視劇!",
"failed_to_delete_all_tvseries": "刪除所有電視劇失敗",
"download_cancelled": "下載已取消",
"could_not_cancel_download": "無法取消下載",
"download_completed": "下載完成",

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Downloads",
"series": "TV-Series",
"tvseries": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_series_button": "Delete All TV-Series",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",

View File

@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
console.log("Downloading item directly");
return {
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null,
};
}

View File

@@ -1,94 +0,0 @@
import * as Application from "expo-application";
import Constants from "expo-constants";
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
* clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
export interface BuildMeta {
commit?: string | null;
branch?: string | null;
profile?: string | null;
runNumber?: string | null;
builtAt?: string | null;
}
export interface VersionInfo {
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
version: string | null;
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
build: string | null;
/** Short git commit the build was made from, e.g. "a1b2c3d". */
commit: string | null;
/** Git branch the build was made from, e.g. "develop". */
branch: string | null;
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
profile: string | null;
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
runNumber: string | null;
isDev: boolean;
isProduction: boolean;
/** Graduated label for the Settings "App version" row (see tiering below). */
display: string;
}
/**
* Resolve a graduated version string for Settings.
*
* Tiering (most → least detailed):
* - dev / local build → `version · branch · commit` (full context for debugging)
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
* Actions run number maps the build to its run — artifacts + logs — without
* Expo access)
* - production (store / TestFlight) → `version` (build number intentionally
* not shown: TestFlight already displays it to testers, and the commit pins the
* binary better)
*/
export function getVersionInfo(): VersionInfo {
// Read native/config values defensively — a version string must never crash Settings
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
const read = <T>(fn: () => T): T | null => {
try {
return fn() ?? null;
} catch {
return null;
}
};
const version = read(() => Application.nativeApplicationVersion);
const build = read(() => Application.nativeBuildVersion);
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
{}) as BuildMeta;
const commit = meta.commit ?? null;
const branch = meta.branch ?? null;
const profile = meta.profile ?? null;
const runNumber = meta.runNumber ?? null;
const isDev = __DEV__ === true;
const isProduction =
typeof profile === "string" && profile.startsWith("production");
let display: string;
if (isDev) {
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
} else if (isProduction) {
display = version ?? build ?? "N/A";
} else {
display =
[version, commit, runNumber && `#${runNumber}`]
.filter(Boolean)
.join(" · ") || "N/A";
}
return {
version,
build,
commit,
branch,
profile,
runNumber,
isDev,
isProduction,
display,
};
}