Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
3d4aa55c26 chore(deps): Update dependency react-native-reanimated to v4.4.1 2026-06-06 20:47:23 +00:00
76 changed files with 812 additions and 1888 deletions

29
.gitattributes vendored
View File

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

54
.github/renovate.json vendored
View File

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

View File

@@ -11,12 +11,6 @@ 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]'))
@@ -39,7 +33,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -122,7 +116,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -193,7 +187,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -225,10 +219,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -258,7 +252,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -290,7 +284,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
@@ -318,7 +312,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -350,10 +344,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -386,7 +380,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -418,7 +412,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

11
.gitignore vendored
View File

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

View File

@@ -1,41 +1,3 @@
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");
@@ -60,8 +22,6 @@ 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,

View File

@@ -1,24 +1,14 @@
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();
@@ -30,8 +20,6 @@ export default function FavoritesPage() {
return <TVFavorites />;
}
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
return (
<ScrollView
nestedScrollEnabled
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
}}
>
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
{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 />
)}
<Favorites />
</View>
</ScrollView>
);

View File

@@ -2,7 +2,6 @@ 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";
@@ -11,7 +10,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { Platform, useWindowDimensions, View } from "react-native";
import { 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";
@@ -53,13 +52,9 @@ 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;
@@ -82,7 +77,7 @@ export default function FavoritesSeeAllScreen() {
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -95,12 +90,12 @@ export default function FavoritesSeeAllScreen() {
return response.data.Items || [];
},
[api, itemType, user?.Id, filter],
[api, itemType, user?.Id],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType, filter],
queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
@@ -160,13 +155,15 @@ export default function FavoritesSeeAllScreen() {
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
@@ -197,7 +194,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items")}
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ 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";
@@ -19,7 +18,6 @@ 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,
@@ -32,7 +30,6 @@ import { storage } from "@/utils/mmkv";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings } = useSettings();
const params = useLocalSearchParams();
const {
id: seriesId,
@@ -140,7 +137,6 @@ 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'
@@ -161,7 +157,7 @@ const page: React.FC = () => {
</View>
) : null,
});
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
}, [allEpisodes, isLoading, item, isOffline]);
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;

View File

@@ -79,7 +79,7 @@
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1",
"react-native-reanimated": "4.4.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
@@ -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.9",
"expo-doctor": "1.19.7",
"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.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
@@ -1585,7 +1585,7 @@
"react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.21", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA=="],
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
"react-native-reanimated": ["react-native-reanimated@4.4.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.9.x" } }, "sha512-WCVBfhLE+AYI2l4inL6PC1vcfNOfmVYRSVSBkPiD12N3jvzByipnygwVpmunyhaNqbiSEDrFYcl7cOJnbHKykw=="],
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],

View File

@@ -1,28 +0,0 @@
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>
);
};

View File

@@ -29,7 +29,6 @@ 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";
@@ -139,9 +138,6 @@ 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} />
@@ -164,9 +160,6 @@ 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} />
@@ -185,7 +178,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
settings.useKefinTweaks,
]);
useEffect(() => {

View File

@@ -39,7 +39,6 @@ import {
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
TVWatchlistButton,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -753,7 +752,6 @@ 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>

View File

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

View File

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

View File

@@ -11,10 +11,8 @@ 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;
@@ -157,8 +155,6 @@ 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();
@@ -187,66 +183,36 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
)
return;
// 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,
},
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"),
];
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 = actions.findIndex((a) => a.destructive);
const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex:
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex === undefined || selectedIndex >= actions.length)
return;
actions[selectedIndex].action();
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);
}
},
);
}, [
@@ -254,9 +220,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite,
markAsPlayedStatus,
toggleFavorite,
isWatchlisted,
toggleWatchlist,
settings?.useKefinTweaks,
isOffline,
deleteFile,
item.Id,

View File

@@ -1,74 +0,0 @@
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>
);
};

View File

@@ -1,117 +0,0 @@
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>
);
};

View File

@@ -1,8 +1,5 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { t } from "i18next";
@@ -25,24 +22,7 @@ type FavoriteTypes =
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
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 = {}) => {
export const Favorites = () => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -66,7 +46,7 @@ export const Favorites = ({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -88,7 +68,7 @@ export const Favorites = ({
return items;
},
[api, user, filter],
[api, user],
);
// Reset empty state when component mounts or dependencies change
@@ -146,68 +126,44 @@ export const Favorites = ({
const handleSeeAllSeries = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "Series",
title: t(`${seeAllNamespace}.seeAllSeries`),
filter,
},
params: { type: "Series", title: t("favorites.series") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
const handleSeeAllMovies = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "Movie",
title: t(`${seeAllNamespace}.seeAllMovies`),
filter,
},
params: { type: "Movie", title: t("favorites.movies") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
const handleSeeAllEpisodes = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "Episode",
title: t(`${seeAllNamespace}.seeAllEpisodes`),
filter,
},
params: { type: "Episode", title: t("favorites.episodes") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
const handleSeeAllVideos = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "Video",
title: t(`${seeAllNamespace}.seeAllVideos`),
filter,
},
params: { type: "Video", title: t("favorites.videos") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
const handleSeeAllBoxsets = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "BoxSet",
title: t(`${seeAllNamespace}.seeAllBoxsets`),
filter,
},
params: { type: "BoxSet", title: t("favorites.boxsets") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
const handleSeeAllPlaylists = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: {
type: "Playlist",
title: t(`${seeAllNamespace}.seeAllPlaylists`),
filter,
},
params: { type: "Playlist", title: t("favorites.playlists") },
} as any);
}, [router, filter, seeAllNamespace]);
}, [router]);
return (
<View className='flex flex-co gap-y-4'>
@@ -220,16 +176,16 @@ export const Favorites = ({
source={heart}
/>
<Text className='text-xl font-semibold text-white mb-2'>
{t(emptyTitleKey)}
{t("favorites.noDataTitle")}
</Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t(emptyTextKey)}
{t("favorites.noData")}
</Text>
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
@@ -237,7 +193,7 @@ export const Favorites = ({
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
@@ -246,7 +202,7 @@ export const Favorites = ({
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
@@ -254,7 +210,7 @@ export const Favorites = ({
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
@@ -262,7 +218,7 @@ export const Favorites = ({
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
@@ -270,7 +226,7 @@ export const Favorites = ({
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}

View File

@@ -1,8 +1,5 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useAtom } from "jotai";
@@ -12,12 +9,10 @@ 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;
@@ -38,27 +33,7 @@ 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,
@@ -78,7 +53,7 @@ export const Favorites = () => {
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -99,7 +74,7 @@ export const Favorites = () => {
return items;
},
[api, user, filter],
[api, user],
);
useEffect(() => {
@@ -111,7 +86,7 @@ export const Favorites = () => {
BoxSet: false,
Playlist: false,
});
}, [api, user, viewType]);
}, [api, user]);
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
@@ -152,63 +127,46 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize],
);
const tabBadges = (
<TVFavoritesTabBadges
viewType={viewType}
setViewType={setViewType}
enabled={watchlistEnabled}
hasTVPreferredFocus={watchlistEnabled}
/>
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
paddingTop: insets.top + TOP_PADDING,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
{tabBadges}
<View
<Image
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
<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>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
@@ -223,22 +181,17 @@ export const Favorites = () => {
}}
>
<View style={{ gap: SECTION_GAP }}>
{watchlistEnabled && (
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
{tabBadges}
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection={!watchlistEnabled}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
@@ -246,28 +199,28 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
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>
);
};

View File

@@ -68,5 +68,3 @@ export { TVTrackCard } from "./TVTrackCard";
// User switching
export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard";
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
export { TVWatchlistButton } from "./TVWatchlistButton";

View File

@@ -1,146 +0,0 @@
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,
};
};

View File

@@ -22,9 +22,7 @@
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
"postinstall": "patch-package"
},
"dependencies": {
@@ -102,7 +100,7 @@
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "8.0.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-reanimated": "4.3.1",
"react-native-reanimated": "4.4.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
@@ -131,7 +129,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.19.7",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -516,7 +516,7 @@
},
"downloads": {
"downloads_title": "Nedladdningar",
"series": "TV-Serier",
"tvseries": "TV-Serier",
"movies": "Filmer",
"queue": "Kö",
"other_media": "Annan media",
@@ -524,7 +524,7 @@
"no_items_in_queue": "Inga objekt i Kön",
"no_downloaded_items": "Inga Nedladdade Objekt",
"delete_all_movies_button": "Ta Bort Alla Filmer",
"delete_all_series_button": "Ta Bort Alla TV-Serier",
"delete_all_tvseries_button": "Ta Bort Alla TV-Serier",
"delete_all_button": "Radera Allt",
"delete_all_other_media_button": "Ta Bort Andra Videor",
"active_download": "Aktiv Nedladdning",
@@ -542,8 +542,8 @@
"you_are_not_allowed_to_download_files": "Du har inte behörighet att ladda ner filer.",
"deleted_all_movies_successfully": "Alla Filmer Har Tagits Bort!",
"failed_to_delete_all_movies": "Det Gick Inte Att Ta Bort Alla Filmer",
"deleted_all_series_successfully": "Alla TV-Serier Har Tagits Bort!",
"failed_to_delete_all_series": "Det Gick Inte Att Ta Bort Alla TV-Serier",
"deleted_all_tvseries_successfully": "Alla TV-Serier Har Tagits Bort!",
"failed_to_delete_all_tvseries": "Det Gick Inte Att Ta Bort Alla TV-Serier",
"deleted_media_successfully": "Andra Medier Har Tagits Bort!",
"failed_to_delete_media": "Kunde Inte Ta Bort Andra Medier",
"download_deleted": "Nedladdning Borttagen",
@@ -675,25 +675,8 @@
"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.",
"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."
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
},
"custom_links": {
"no_links": "Inga Länkar"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,6 +82,8 @@ export const useFilterOptions = () => {
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
];
console.log("filterOptions");
console.log(filterOptions);
return filterOptions;
};

View File

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

View File

@@ -1,87 +0,0 @@
import * as Application from "expo-application";
import Constants from "expo-constants";
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
* clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.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,
};
}