mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 00:10:24 +01:00
Compare commits
17 Commits
renovate/s
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
168bf2e54e | ||
|
|
6f0230c2ca | ||
|
|
d12beee529 | ||
|
|
02ffac167b | ||
|
|
4eb734c99f | ||
|
|
b7bae0072f | ||
|
|
eba72e9d73 | ||
|
|
1685571406 | ||
|
|
36ed7539a2 | ||
|
|
8f82ac481a | ||
|
|
a242ff69fd |
29
.gitattributes
vendored
29
.gitattributes
vendored
@@ -1 +1,28 @@
|
||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||
# 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
|
||||
|
||||
54
.github/renovate.json
vendored
54
.github/renovate.json
vendored
@@ -44,22 +44,42 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
"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
|
||||
},
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"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/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
30
.github/workflows/build-apps.yml
vendored
30
.github/workflows/build-apps.yml
vendored
@@ -11,6 +11,12 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead.
|
||||
env:
|
||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
@@ -33,7 +39,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -116,7 +122,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -187,7 +193,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -219,10 +225,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -252,7 +258,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -284,7 +290,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -312,7 +318,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -344,10 +350,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -380,7 +386,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -412,7 +418,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
|
||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
|
||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
102
.github/workflows/crowdin.yml
vendored
102
.github/workflows/crowdin.yml
vendored
@@ -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@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 }}
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
|
||||
7
.github/workflows/linting.yml
vendored
7
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
@@ -97,10 +97,11 @@ jobs:
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
show-progress: false
|
||||
|
||||
60
.github/workflows/trivy-scan.yml
vendored
Normal file
60
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
@@ -21,10 +20,8 @@ web-build/
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
# Module-specific Builds
|
||||
modules/mpv-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
# Native module build outputs (any module)
|
||||
modules/*/android/build/
|
||||
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
@@ -69,10 +66,6 @@ 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/
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
// 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) => {
|
||||
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,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
@@ -22,6 +60,8 @@ module.exports = ({ config }) => {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
config.extra = { ...config.extra, build: buildMeta };
|
||||
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
{watchlistEnabled && (
|
||||
<View className='pl-4 pr-4 flex flex-row mb-2'>
|
||||
<FavoritesTabButtons
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isWatchlist ? (
|
||||
<Favorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
seeAllNamespace='kefintweaksWatchlist'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
/>
|
||||
) : (
|
||||
<Favorites />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||
const filter: ItemFilter =
|
||||
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
@@ -155,15 +160,13 @@ export default function FavoritesSeeAllScreen() {
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||
</Text>
|
||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
@@ -194,7 +197,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", { defaultValue: "No items" })}
|
||||
{t("home.no_items")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -137,12 +137,12 @@ export default function DownloadsPage() {
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||
t("home.downloads.toasts.deleted_all_series_successfully"),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
|
||||
});
|
||||
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.tvseries")}
|
||||
{t("home.downloads.series")}
|
||||
</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_tvseries_button")}
|
||||
{t("home.downloads.delete_all_series_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
|
||||
@@ -179,18 +179,15 @@ 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", "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.",
|
||||
),
|
||||
t("home.settings.storage.clear_all_cache_confirm"),
|
||||
t("home.settings.storage.clear_all_cache_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel", "Cancel"),
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok", "OK"),
|
||||
text: t("common.ok"),
|
||||
onPress: async () => {
|
||||
try {
|
||||
// 1. Clear React Query Cache (memory & MMKV)
|
||||
@@ -243,11 +240,8 @@ export default function SettingsTV() {
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
Alert.alert(
|
||||
t("home.settings.toasts.error_deleting_files", "Error"),
|
||||
t(
|
||||
"home.settings.storage.clear_all_cache_error_desc",
|
||||
"An error occurred while clearing the cache.",
|
||||
),
|
||||
t("home.settings.toasts.error_deleting_files"),
|
||||
t("home.settings.storage.clear_all_cache_error_desc"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
buildOfflineSeriesFromEpisodes,
|
||||
getDownloadedEpisodesForSeries,
|
||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
@@ -137,6 +140,7 @@ const page: React.FC = () => {
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -108,7 +108,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.7",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
@@ -969,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.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
||||
|
||||
@@ -1599,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"],
|
||||
"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-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||
|
||||
|
||||
28
components/AddToKefinWatchlist.tsx
Normal file
28
components/AddToKefinWatchlist.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
|
||||
* Render only when settings.useKefinTweaks is enabled.
|
||||
*/
|
||||
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
color={isWatchlisted ? "purple" : "white"}
|
||||
onPress={toggleWatchlist}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
settings.useKefinTweaks,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
TVWatchlistButton,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
</Text>
|
||||
</TVButton>
|
||||
<TVFavoriteButton item={item} />
|
||||
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
|
||||
<TVPlayedButton item={item} />
|
||||
<TVRefreshButton itemId={item.Id} />
|
||||
</View>
|
||||
|
||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
const isPresentedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
} else if (isPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
isPresentedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
if (index >= 0) {
|
||||
isPresentedRef.current = true;
|
||||
} else if (index === -1 && isPresentedRef.current) {
|
||||
isPresentedRef.current = false;
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -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", "Select")}
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
t("common.mark_as_played"),
|
||||
t("common.mark_as_not_played"),
|
||||
isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||
t("common.cancel"),
|
||||
// Build options as { label, action } so dynamic entries (watchlist,
|
||||
// offline delete) don't break index-based handling.
|
||||
const actions: {
|
||||
label: string;
|
||||
action: () => void;
|
||||
destructive?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
label: t("common.mark_as_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.mark_as_not_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
action: toggleFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
if (settings?.useKefinTweaks) {
|
||||
actions.push({
|
||||
label: isWatchlisted
|
||||
? t("watchlists.remove_from_watchlist")
|
||||
: t("watchlists.add_to_watchlist"),
|
||||
action: toggleWatchlist,
|
||||
});
|
||||
}
|
||||
|
||||
if (isOffline && item.Id) {
|
||||
const id = item.Id;
|
||||
actions.push({
|
||||
label: t("home.downloads.delete_download"),
|
||||
action: () => deleteFile(id),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const options = [...actions.map((a) => a.label), t("common.cancel")];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
destructiveButtonIndex:
|
||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
await markAsPlayedStatus(true);
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||
return;
|
||||
actions[selectedIndex].action();
|
||||
},
|
||||
);
|
||||
}, [
|
||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
settings?.useKefinTweaks,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
|
||||
74
components/favorites/FavoritesTabButtons.tsx
Normal file
74
components/favorites/FavoritesTabButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import crashes the route tree on tvOS at module load.
|
||||
// Load it lazily and only off-TV; TV never renders this component.
|
||||
const { Button, Host, HStack, Spacer } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { buttonStyle } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface FavoritesTabButtonsProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<Host style={{ height: 40, flex: 1 }}>
|
||||
<HStack spacing={8}>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Favorites" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
label={t("tabs.favorites")}
|
||||
/>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Watchlist" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
label={t("favorites.watchlist")}
|
||||
/>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android UI
|
||||
return (
|
||||
<View className='flex flex-row gap-1 mr-1'>
|
||||
<TouchableOpacity onPress={() => setViewType("Favorites")}>
|
||||
<Tag
|
||||
text={t("tabs.favorites")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
|
||||
<Tag
|
||||
text={t("favorites.watchlist")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface TVFavoritesTabBadgeProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ duration: 150 });
|
||||
|
||||
// Design language: white for focused/selected, transparent white for unfocused
|
||||
const getBackgroundColor = () => {
|
||||
if (focused) return "#fff";
|
||||
if (isSelected) return "rgba(255,255,255,0.25)";
|
||||
return "rgba(255,255,255,0.1)";
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (focused) return "#000";
|
||||
return "#fff";
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVFavoritesTabBadgesProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||
enabled: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
enabled,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("tabs.favorites")}
|
||||
isSelected={viewType === "Favorites"}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
|
||||
/>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("favorites.watchlist")}
|
||||
isSelected={viewType === "Watchlist"}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
@@ -22,7 +25,24 @@ type FavoriteTypes =
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
interface FavoritesProps {
|
||||
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
|
||||
filter?: ItemFilter;
|
||||
/** Query key segment used to keep favorites/watchlist caches separate. */
|
||||
queryKeyBase?: string;
|
||||
emptyTitleKey?: string;
|
||||
emptyTextKey?: string;
|
||||
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||
seeAllNamespace?: string;
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
}: FavoritesProps = {}) => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -46,7 +66,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -68,7 +88,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
// Reset empty state when component mounts or dependencies change
|
||||
@@ -126,44 +146,68 @@ export const Favorites = () => {
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
params: {
|
||||
type: "Series",
|
||||
title: t(`${seeAllNamespace}.seeAllSeries`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
params: {
|
||||
type: "Movie",
|
||||
title: t(`${seeAllNamespace}.seeAllMovies`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
params: {
|
||||
type: "Episode",
|
||||
title: t(`${seeAllNamespace}.seeAllEpisodes`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
params: {
|
||||
type: "Video",
|
||||
title: t(`${seeAllNamespace}.seeAllVideos`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
params: {
|
||||
type: "BoxSet",
|
||||
title: t(`${seeAllNamespace}.seeAllBoxsets`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
params: {
|
||||
type: "Playlist",
|
||||
title: t(`${seeAllNamespace}.seeAllPlaylists`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
@@ -176,16 +220,16 @@ export const Favorites = () => {
|
||||
source={heart}
|
||||
/>
|
||||
<Text className='text-xl font-semibold text-white mb-2'>
|
||||
{t("favorites.noDataTitle")}
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||
{t("favorites.noData")}
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -193,7 +237,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -202,7 +246,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -210,7 +254,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -218,7 +262,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -226,7 +270,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -9,10 +12,12 @@ import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
@@ -33,7 +38,27 @@ export const Favorites = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const pageSize = 20;
|
||||
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const filter: ItemFilter =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
|
||||
const queryKeyBase =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
|
||||
// Translation namespace for the empty state, swapped for the KefinTweaks
|
||||
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
|
||||
const emptyNamespace =
|
||||
watchlistEnabled && viewType === "Watchlist"
|
||||
? "kefintweaksWatchlist"
|
||||
: "favorites";
|
||||
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
|
||||
const emptyTextKey = `${emptyNamespace}.noData`;
|
||||
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -53,7 +78,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -74,7 +99,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +111,7 @@ export const Favorites = () => {
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
}, [api, user, viewType]);
|
||||
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
@@ -127,46 +152,63 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
if (areAllEmpty()) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
{tabBadges}
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
<Image
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -181,17 +223,22 @@ export const Favorites = () => {
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection
|
||||
isFirstSection={!watchlistEnabled}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -199,28 +246,28 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login")}
|
||||
label={t("login.login_button")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
|
||||
@@ -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,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
// Graduated build identifier — see utils/version.ts:
|
||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit", production → "0.54.1 (42)".
|
||||
const { display: version } = getVersionInfo();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
36
components/tv/TVWatchlistButton.tsx
Normal file
36
components/tv/TVWatchlistButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
import { TVButton } from "./TVButton";
|
||||
|
||||
export interface TVWatchlistButtonProps {
|
||||
item: BaseItemDto;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist toggle (Likes-backed) for TV detail pages.
|
||||
* Render only when settings.useKefinTweaks is enabled.
|
||||
*/
|
||||
export const TVWatchlistButton: React.FC<TVWatchlistButtonProps> = ({
|
||||
item,
|
||||
disabled,
|
||||
}) => {
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<TVButton
|
||||
onPress={toggleWatchlist}
|
||||
variant='glass'
|
||||
square
|
||||
disabled={disabled}
|
||||
>
|
||||
<Ionicons
|
||||
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
size={28}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</TVButton>
|
||||
);
|
||||
};
|
||||
@@ -68,3 +68,5 @@ export { TVTrackCard } from "./TVTrackCard";
|
||||
// User switching
|
||||
export type { TVUserCardProps } from "./TVUserCard";
|
||||
export { TVUserCard } from "./TVUserCard";
|
||||
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||
|
||||
146
hooks/useWatchlist.ts
Normal file
146
hooks/useWatchlist.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Shared atom to store watchlist (Likes) status across all components
|
||||
// Maps itemId -> isWatchlisted
|
||||
const watchlistAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
|
||||
* Toggling watchlist membership toggles UserData.Likes on the item.
|
||||
*/
|
||||
export const useWatchlist = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
|
||||
|
||||
const itemId = item.Id ?? "";
|
||||
|
||||
// Get current watchlist status from shared state, falling back to item data
|
||||
const isWatchlisted = itemId
|
||||
? (watchlist[itemId] ?? item.UserData?.Likes)
|
||||
: item.UserData?.Likes;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (itemId && item.UserData?.Likes !== undefined) {
|
||||
setWatchlist((prev) => ({
|
||||
...prev,
|
||||
[itemId]: item.UserData!.Likes!,
|
||||
}));
|
||||
}
|
||||
}, [itemId, item.UserData?.Likes, setWatchlist]);
|
||||
|
||||
// Helper to update watchlist status in shared state
|
||||
const setIsWatchlisted = useCallback(
|
||||
(value: boolean | null | undefined) => {
|
||||
if (itemId && typeof value === "boolean") {
|
||||
setWatchlist((prev) => ({ ...prev, [itemId]: value }));
|
||||
}
|
||||
},
|
||||
[itemId, setWatchlist],
|
||||
);
|
||||
|
||||
// Use refs to avoid stale closure issues in mutationFn
|
||||
const itemRef = useRef(item);
|
||||
const apiRef = useRef(api);
|
||||
const userRef = useRef(user);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const itemQueryKeyPrefix = useMemo(
|
||||
() => ["item", item.Id] as const,
|
||||
[item.Id],
|
||||
);
|
||||
|
||||
const updateItemInQueries = useCallback(
|
||||
(newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: itemQueryKeyPrefix },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
[itemQueryKeyPrefix, queryClient],
|
||||
);
|
||||
|
||||
const watchlistMutation = useMutation({
|
||||
mutationFn: async (nextIsWatchlisted: boolean) => {
|
||||
const currentApi = apiRef.current;
|
||||
const currentUser = userRef.current;
|
||||
const currentItem = itemRef.current;
|
||||
|
||||
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watchlist == Jellyfin "Likes" rating:
|
||||
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
|
||||
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
|
||||
const path = `/UserItems/${currentItem.Id}/Rating`;
|
||||
|
||||
const response = await currentApi.post(
|
||||
path,
|
||||
{},
|
||||
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onMutate: async (nextIsWatchlisted: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||
|
||||
const previousIsWatchlisted = isWatchlisted;
|
||||
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||
queryKey: itemQueryKeyPrefix,
|
||||
});
|
||||
|
||||
setIsWatchlisted(nextIsWatchlisted);
|
||||
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
|
||||
|
||||
return { previousIsWatchlisted, previousQueries };
|
||||
},
|
||||
onError: (_err, _nextIsWatchlisted, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleWatchlist = useCallback(() => {
|
||||
watchlistMutation.mutate(!isWatchlisted);
|
||||
}, [watchlistMutation, isWatchlisted]);
|
||||
|
||||
return {
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
watchlistMutation,
|
||||
};
|
||||
};
|
||||
@@ -22,7 +22,9 @@
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run 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",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -129,7 +131,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.7",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
|
||||
@@ -40,6 +40,7 @@ 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;
|
||||
@@ -53,7 +54,7 @@ const initialApi = (() => {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
const jellyfinInstance = new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
return new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
273
scripts/check-i18n-keys.mjs
Normal file
273
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/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);
|
||||
46
scripts/i18n-keys.config.json
Normal file
46
scripts/i18n-keys.config.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"tvseries": "مسلسلات",
|
||||
"series": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"other_media": "وسائط أخرى",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||
"delete_all_series_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_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||
"download_deleted": "تم حذف التنزيل",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descàrregues",
|
||||
"tvseries": "Sèries",
|
||||
"series": "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_tvseries_button": "Suprimeix totes les sèries",
|
||||
"delete_all_series_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_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_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_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Stahování",
|
||||
"tvseries": "Televizní série",
|
||||
"series": "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_tvseries_button": "Odstranit všechny TV-série",
|
||||
"delete_all_series_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_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_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_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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-serier",
|
||||
"series": "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_tvseries_button": "Slet alle TV-serier",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle TV-serier er slettet med succes!",
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Serien",
|
||||
"series": "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_tvseries_button": "Alle Serien löschen",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
||||
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Λήψεις",
|
||||
"tvseries": "Τηλεόραση-Σειρά",
|
||||
"series": "Τηλεόραση-Σειρά",
|
||||
"movies": "Ταινίες",
|
||||
"queue": "Ουρά",
|
||||
"other_media": "Άλλα μέσα",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||
"delete_all_series_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_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||
"download_deleted": "Η Λήψη Διαγράφηκε",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,14 +229,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"tvseries": "Televidaj serioj",
|
||||
"series": "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_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||
"delete_all_series_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_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"download_cancelled": "Elŝuto nuligita",
|
||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||
"download_completed": "Elŝuto finita",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
"tvseries": "Series",
|
||||
"series": "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_tvseries_button": "Eliminar todas las series",
|
||||
"delete_all_series_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_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
||||
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Lataukset",
|
||||
"tvseries": "TV-sarjat",
|
||||
"series": "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_tvseries_button": "Poista kaikki TV-sarjat",
|
||||
"delete_all_series_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_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
"tvseries": "Séries",
|
||||
"series": "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_tvseries_button": "Supprimer toutes les séries",
|
||||
"delete_all_series_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_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_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_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é",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "הורדות",
|
||||
"tvseries": "סדרות",
|
||||
"series": "סדרות",
|
||||
"movies": "סרטים",
|
||||
"queue": "תוֹר",
|
||||
"other_media": "תוכן אחר",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "אין פרטים בתור",
|
||||
"no_downloaded_items": "אין פריטים שהורדו",
|
||||
"delete_all_movies_button": "מחק את כל הסרטים",
|
||||
"delete_all_tvseries_button": "מחק את כל הסדרות",
|
||||
"delete_all_series_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_tvseries_successfully": "כל הסדרות נמחקו בהצלחה!",
|
||||
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
|
||||
"deleted_all_series_successfully": "כל הסדרות נמחקו בהצלחה!",
|
||||
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
|
||||
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
||||
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
||||
"download_deleted": "ההורדה נמחקה",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Letöltések",
|
||||
"tvseries": "Sorozatok",
|
||||
"series": "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_tvseries_button": "Összes Sorozat Törlése",
|
||||
"delete_all_series_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_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
||||
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||
"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_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Letöltés Törölve",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Scaricati",
|
||||
"tvseries": "Serie TV",
|
||||
"series": "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_tvseries_button": "Cancella tutte le serie TV",
|
||||
"delete_all_series_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_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
|
||||
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
|
||||
"deleted_all_series_successfully": "Eliminate tutte le serie TV con successo!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "ダウンロード",
|
||||
"tvseries": "TVシリーズ",
|
||||
"series": "TVシリーズ",
|
||||
"movies": "映画",
|
||||
"queue": "キュー",
|
||||
"other_media": "その他のメディア",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "キューにアイテムがありません",
|
||||
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
||||
"delete_all_movies_button": "すべての映画を削除",
|
||||
"delete_all_tvseries_button": "すべてのシリーズを削除",
|
||||
"delete_all_series_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_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
|
||||
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
|
||||
"deleted_all_series_successfully": "すべてのシリーズを正常に削除しました!",
|
||||
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
|
||||
"deleted_media_successfully": "他のメディアを削除しました!",
|
||||
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
||||
"download_deleted": "ダウンロードが削除されました",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"series": "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_tvseries_button": "Delete All TV-Series",
|
||||
"delete_all_series_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_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -229,14 +229,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastinger",
|
||||
"tvseries": "TV-serier",
|
||||
"series": "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_tvseries_button": "Slett alle TV-serier",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle TV-serier ble slettet!",
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_all_series_successfully": "Alle TV-serier ble slettet!",
|
||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||
"download_cancelled": "Nedlasting avbrutt",
|
||||
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
|
||||
"download_completed": "Nedlasting fullført",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Series",
|
||||
"series": "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_tvseries_button": "Verwijder alle Series",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle series succesvol verwijderd!",
|
||||
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
||||
"deleted_all_series_successfully": "Alle series succesvol verwijderd!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -229,14 +229,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastingar",
|
||||
"tvseries": "TV-seriar",
|
||||
"series": "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_tvseries_button": "Slett alle TV-seriar",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle TV-seriar vart sletta!",
|
||||
"failed_to_delete_all_tvseries": "Kunne ikkje sletta alle TV-seriar",
|
||||
"deleted_all_series_successfully": "Alle TV-seriar vart sletta!",
|
||||
"failed_to_delete_all_series": "Kunne ikkje sletta alle TV-seriar",
|
||||
"download_cancelled": "Nedlasting avbroten",
|
||||
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
|
||||
"download_completed": "Nedlasting fullført",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastinger",
|
||||
"tvseries": "TV-Serier",
|
||||
"series": "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_tvseries_button": "Slett alle TV-Serier",
|
||||
"delete_all_series_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_tvseries_successfully": "Alle TV-Serier ble slettet!",
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-Serier",
|
||||
"deleted_all_series_successfully": "Alle TV-Serier ble slettet!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Pobrane",
|
||||
"tvseries": "Seriale",
|
||||
"series": "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_tvseries_button": "Usuń wszystkie seriale",
|
||||
"delete_all_series_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_tvseries_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
|
||||
"failed_to_delete_all_tvseries": "Nie udało się usunąć wszystkich seriali",
|
||||
"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_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",
|
||||
|
||||
@@ -227,14 +227,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV/Séries",
|
||||
"series": "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_tvseries_button": "Remover todos as TV/Séries",
|
||||
"delete_all_series_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_tvseries_successfully": "Todos as TV/Séries foram removidos com sucesso!",
|
||||
"failed_to_delete_all_tvseries": "Falha ao remover todos as TV/Séries",
|
||||
"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",
|
||||
"download_cancelled": "Download cancelado",
|
||||
"could_not_cancel_download": "Não foi possível cancelar o download",
|
||||
"download_completed": "Download completo",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Séries",
|
||||
"series": "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_tvseries_button": "Excluir todas as séries",
|
||||
"delete_all_series_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_tvseries_successfully": "Todas as TV-Series excluídas com sucesso!",
|
||||
"failed_to_delete_all_tvseries": "Falha ao excluir todas as séries",
|
||||
"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_media_successfully": "Outras mídias excluídas com sucesso!",
|
||||
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
||||
"download_deleted": "Download Excluído",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descărcări",
|
||||
"tvseries": "Seriale",
|
||||
"series": "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_tvseries_button": "Șterge toate serialele",
|
||||
"delete_all_series_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_tvseries_successfully": "Toate serialele au fost șterse cu succes!",
|
||||
"failed_to_delete_all_tvseries": "Nu s-au putut șterge toate serialele",
|
||||
"deleted_all_series_successfully": "Toate serialele au fost șterse cu succes!",
|
||||
"failed_to_delete_all_series": "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ă",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Загрузки",
|
||||
"tvseries": "Сериалы",
|
||||
"series": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"queue": "Очередь",
|
||||
"other_media": "Прочие файлы",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "Нет элементов в очереди",
|
||||
"no_downloaded_items": "Нет загруженных файлов",
|
||||
"delete_all_movies_button": "Удалить все фильмы",
|
||||
"delete_all_tvseries_button": "Удалить все сериалы",
|
||||
"delete_all_series_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_tvseries_successfully": "Все сериалы были успешно удалены!",
|
||||
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
|
||||
"deleted_all_series_successfully": "Все сериалы были успешно удалены!",
|
||||
"failed_to_delete_all_series": "Возникла ошибка при удалении всех сериалов",
|
||||
"deleted_media_successfully": "Остальные медиафайлы успешно удалены!",
|
||||
"failed_to_delete_media": "Не удалось удалить остальные медиафайлы",
|
||||
"download_deleted": "Загруженный контент удалён",
|
||||
|
||||
@@ -229,14 +229,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Shkarkimet",
|
||||
"tvseries": "Seriale TV",
|
||||
"series": "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_tvseries_button": "Fshijë të gjitha serialet TV",
|
||||
"delete_all_series_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_tvseries_successfully": "Të gjitha serialet TV u fshinë me sukses!",
|
||||
"failed_to_delete_all_tvseries": "Dështojë fshirja e të gjitha serialeve TV",
|
||||
"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",
|
||||
"download_cancelled": "Shkarkimi u anulua",
|
||||
"could_not_cancel_download": "Nuk mundet të anulohet shkarkimi",
|
||||
"download_completed": "Shkarkimi u përfundua",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedladdningar",
|
||||
"tvseries": "TV-Serier",
|
||||
"series": "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_tvseries_button": "Ta Bort Alla TV-Serier",
|
||||
"delete_all_series_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_tvseries_successfully": "Alla TV-Serier Har Tagits Bort!",
|
||||
"failed_to_delete_all_tvseries": "Det Gick Inte Att Ta Bort Alla TV-Serier",
|
||||
"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_media_successfully": "Andra Medier Har Tagits Bort!",
|
||||
"failed_to_delete_media": "Kunde Inte Ta Bort Andra Medier",
|
||||
"download_deleted": "Nedladdning Borttagen",
|
||||
@@ -675,8 +675,25 @@
|
||||
"videos": "Videor",
|
||||
"boxsets": "Box Set",
|
||||
"playlists": "Spellistor",
|
||||
"seeAllSeries": "Favoritmarkerade serier",
|
||||
"seeAllMovies": "Favoritmarkerade filmer",
|
||||
"seeAllEpisodes": "Favoritmarkerade avsnitt",
|
||||
"seeAllVideos": "Favoritmarkerade videor",
|
||||
"seeAllBoxsets": "Favoritmarkerade box set",
|
||||
"seeAllPlaylists": "Favoritmarkerade spellistor",
|
||||
"noDataTitle": "Inga favoriter än",
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst.",
|
||||
"watchlist": "Bevakningslista"
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Bevakade serier",
|
||||
"seeAllMovies": "Bevakade filmer",
|
||||
"seeAllEpisodes": "Bevakade avsnitt",
|
||||
"seeAllVideos": "Bevakade videor",
|
||||
"seeAllBoxsets": "Bevakade box set",
|
||||
"seeAllPlaylists": "Bevakade spellistor",
|
||||
"noDataTitle": "Inga bevakade objekt än",
|
||||
"noData": "Lägg till objekt i din bevakningslista för att se dem visas här."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Inga Länkar"
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"series": "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_tvseries_button": "Delete All TV-Series",
|
||||
"delete_all_series_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_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Qaw' Doch",
|
||||
"tvseries": "TV Hem",
|
||||
"series": "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_tvseries_button": "Hoch TV Hem yIQaw'",
|
||||
"delete_all_series_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_tvseries_successfully": "Hoch TV Hem Qaw' Qapla'!",
|
||||
"failed_to_delete_all_tvseries": "Hoch TV Hem Qaw'laHbe'",
|
||||
"deleted_all_series_successfully": "Hoch TV Hem Qaw' Qapla'!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirilenler",
|
||||
"tvseries": "Diziler",
|
||||
"series": "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_tvseries_button": "Tüm Dizileri Sil",
|
||||
"delete_all_series_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_tvseries_successfully": "Tüm diziler başarıyla silindi!",
|
||||
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||
"deleted_all_series_successfully": "Tüm diziler başarıyla silindi!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження",
|
||||
"tvseries": "ТБ-Серіали",
|
||||
"series": "ТБ-Серіали",
|
||||
"movies": "Фільми",
|
||||
"queue": "Черга",
|
||||
"other_media": "Other media",
|
||||
@@ -524,7 +524,7 @@
|
||||
"no_items_in_queue": "Нема елементів в черзі",
|
||||
"no_downloaded_items": "Нема завантажених елементів",
|
||||
"delete_all_movies_button": "Видалити всі Фільми",
|
||||
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
|
||||
"delete_all_series_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_tvseries_successfully": "Успішно видалено всі серіали!",
|
||||
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
|
||||
"deleted_all_series_successfully": "Успішно видалено всі серіали!",
|
||||
"failed_to_delete_all_series": "Не вдалося видалити всі телесеріали",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Tải xuống",
|
||||
"tvseries": "Chương trình TV",
|
||||
"series": "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_tvseries_button": "Xóa tất cả chương trình TV",
|
||||
"delete_all_series_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_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_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_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
|
||||
@@ -224,14 +224,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下载",
|
||||
"tvseries": "剧集",
|
||||
"series": "剧集",
|
||||
"movies": "电影",
|
||||
"queue": "队列",
|
||||
"queue_hint": "应用重启后队列和下载将会丢失",
|
||||
"no_items_in_queue": "队列中无项目",
|
||||
"no_downloaded_items": "无已下载项目",
|
||||
"delete_all_movies_button": "删除所有电影",
|
||||
"delete_all_tvseries_button": "删除所有剧集",
|
||||
"delete_all_series_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_tvseries_successfully": "成功删除所有剧集!",
|
||||
"failed_to_delete_all_tvseries": "删除所有剧集失败",
|
||||
"deleted_all_series_successfully": "成功删除所有剧集!",
|
||||
"failed_to_delete_all_series": "删除所有剧集失败",
|
||||
"download_cancelled": "下载已取消",
|
||||
"could_not_cancel_download": "无法取消下载",
|
||||
"download_completed": "下载完成",
|
||||
|
||||
@@ -231,14 +231,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下載",
|
||||
"tvseries": "電視劇",
|
||||
"series": "電視劇",
|
||||
"movies": "電影",
|
||||
"queue": "隊列",
|
||||
"queue_hint": "應用重啟後隊列和下載將會丟失",
|
||||
"no_items_in_queue": "隊列中無項目",
|
||||
"no_downloaded_items": "無已下載項目",
|
||||
"delete_all_movies_button": "刪除所有電影",
|
||||
"delete_all_tvseries_button": "刪除所有電視劇",
|
||||
"delete_all_series_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_tvseries_successfully": "成功刪除所有電視劇!",
|
||||
"failed_to_delete_all_tvseries": "刪除所有電視劇失敗",
|
||||
"deleted_all_series_successfully": "成功刪除所有電視劇!",
|
||||
"failed_to_delete_all_series": "刪除所有電視劇失敗",
|
||||
"download_cancelled": "下載已取消",
|
||||
"could_not_cancel_download": "無法取消下載",
|
||||
"download_completed": "下載完成",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"series": "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_tvseries_button": "Delete All TV-Series",
|
||||
"delete_all_series_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_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_series": "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",
|
||||
|
||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
|
||||
@@ -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/${item.Id}/Download?api_key=${api.accessToken}`,
|
||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||
mediaSource: streamDetails?.mediaSource ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
87
utils/version.ts
Normal file
87
utils/version.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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.js` into `extra.build`. */
|
||||
export interface BuildMeta {
|
||||
commit?: string | null;
|
||||
branch?: string | null;
|
||||
profile?: 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", "preview", or null for local. */
|
||||
profile: 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` (pin the exact source)
|
||||
* - production (store / TestFlight) → `version (build)` (store-correlatable; the
|
||||
* build number lets TestFlight reports pin a build whose version isn't a
|
||||
* published release. Note: TestFlight and the public App Store ship the same
|
||||
* binary — telling them apart needs a runtime iOS receipt check, intentionally
|
||||
* not done here.)
|
||||
*/
|
||||
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 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 ? `${version} (${build})` : (version ?? build ?? "N/A");
|
||||
} else {
|
||||
display = [version, commit].filter(Boolean).join(" · ") || version || "N/A";
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
build,
|
||||
commit,
|
||||
branch,
|
||||
profile,
|
||||
isDev,
|
||||
isProduction,
|
||||
display,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user