Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvino
935cacff81 fix(pr-validation): paginate issue comments + guard unreadable body file
Addresses review: github.rest.issues.listComments only returns the first page,
so the sticky-comment marker could be missed on busy PRs — use github.paginate.
And guard readFileSync so a missing/unreadable body file exits 2 (per the doc)
instead of crashing without JSON.
2026-06-01 20:22:28 +02:00
Gauvino
5f59dce0c7 fix(pr-validation): run under pull_request_target + drop DoS-prone comment loop
Security audit fixes:
- The jobs gated on github.event_name == 'pull_request' but the trigger is
  pull_request_target, so they never ran (validation was silently disabled).
  Gate on 'pull_request_target'.
- Replace the loop-until-stable HTML-comment strip with a single linear pass
  (+ trailing-unterminated strip): still leaves no <!-- (CodeQL-clean) but
  removes the quadratic re-scan a crafted nested-comment body could abuse.
2026-06-01 20:14:24 +02:00
Gauvino
3de9b65b7d ci(pr-validation): validate PR title + body against the template
New .github/workflows/pr-validation.yml (pull_request_target, like seerr, so it
works on fork PRs without checking out fork code): moves the Conventional-Commits
title check out of the quality gate and adds a PR template check
(scripts/check-pr-template.mjs) — Description/Ticket/Testing filled, contribution
+ AI-disclosure boxes ticked (maintainers bypass AI), and Screenshots required
when the PR changes UI (.tsx under app/ or components/). Posts a sticky comment +
'blocked: template' label on failure, clears on success; skips bots + synchronize.
Robust comment stripping (CodeQL-safe). Inspired by seerr's pr-validation.
2026-06-01 17:24:03 +02:00
44 changed files with 504 additions and 912 deletions

View File

@@ -12,38 +12,6 @@ permissions:
contents: read contents: read
jobs: jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
dependency-review: dependency-review:
name: 🔍 Vulnerable Dependencies name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04

136
.github/workflows/pr-validation.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
name: 🚦 PR Validation
# Uses pull_request_target so the jobs get a write token even on fork PRs (to comment
# and label) — same as seerr. SECURITY: never check out or run the PR head's code here;
# we only read the title/body from the event payload and run our own scripts from the base.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
workflow_dispatch:
permissions: {}
concurrency:
group: pr-validation-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
validate_pr_template:
name: "📋 Validate PR Template"
# Skip pushes to an existing PR (the body rarely changes) and bot-authored PRs.
if: >-
github.event_name == 'pull_request_target' &&
github.event.action != 'synchronize' &&
github.actor != 'renovate[bot]' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
issues: write
contents: read
steps:
- name: "📥 Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: "📝 Write PR body to file"
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: printf '%s' "$PR_BODY" > /tmp/pr-body.txt
- name: "📂 List changed files"
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
--paginate --jq '.[].filename' > /tmp/pr-files.txt
- name: "🔎 Validate body against template"
id: check
env:
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
PR_FILES: /tmp/pr-files.txt
run: |
set +e
bun scripts/check-pr-template.mjs /tmp/pr-body.txt > /tmp/pr-issues.json
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: "💬 Report problems"
if: steps.check.outputs.code != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v8.0.0
with:
script: |
const fs = require('fs');
let issues;
try { issues = JSON.parse(fs.readFileSync('/tmp/pr-issues.json', 'utf8')); }
catch { issues = ["The PR template check could not parse the description. Please make sure it follows the template."]; }
if (!Array.isArray(issues) || issues.length === 0) issues = ["The PR description does not follow the template."];
const body = [
"👋 Thanks for the PR! A few things in the description need attention before review:",
"",
...issues.map((i) => `- ${i}`),
"",
"Please update the PR description ([template](https://github.com/${{ github.repository }}/blob/develop/.github/pull_request_template.md)). This check re-runs when you edit it.",
].join("\n");
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const marker = "<!-- pr-template-check -->";
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
const existing = comments.find((c) => c.body?.includes(marker));
const payload = `${marker}\n${body}`;
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: payload });
else await github.rest.issues.createComment({ owner, repo, issue_number, body: payload });
const label = "blocked: template";
try { await github.rest.issues.getLabel({ owner, repo, name: label }); }
catch { await github.rest.issues.createLabel({ owner, repo, name: label, color: "d93f0b", description: "PR description does not follow the template" }); }
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] });
core.setFailed(`PR template check failed:\n- ${issues.join("\n- ")}`);
- name: "✅ Clear problems on success"
if: steps.check.outputs.code == '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v8.0.0
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const marker = "<!-- pr-template-check -->";
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
try { await github.rest.issues.removeLabel({ owner, repo, issue_number, name: "blocked: template" }); } catch {}

View File

@@ -11,15 +11,12 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -32,7 +29,6 @@ export default function MarlinSearchPage() {
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const urlResolver = useServerUrlResolver(reachabilityProbe);
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
@@ -131,17 +127,8 @@ export default function MarlinSearchPage() {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
onBlur={() => {
const candidate = value.trim();
if (candidate) {
urlResolver.resolve(candidate).then((r) => {
if (r.ok) setValue(r.url);
});
}
}}
/> />
</View> </View>
<ServerUrlStatusText state={urlResolver} className='mt-1' />
</DisabledSetting> </DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}

View File

@@ -11,14 +11,11 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
export default function StreamystatsPage() { export default function StreamystatsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -35,7 +32,6 @@ export default function StreamystatsPage() {
// Local state for all editable fields // Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || ""); const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
const urlResolver = useServerUrlResolver(reachabilityProbe);
const [useForSearch, setUseForSearch] = useState<boolean>( const [useForSearch, setUseForSearch] = useState<boolean>(
settings?.searchEngine === "Streamystats", settings?.searchEngine === "Streamystats",
); );
@@ -156,20 +152,9 @@ export default function StreamystatsPage() {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={setUrl} onChangeText={setUrl}
onBlur={() => {
const candidate = url.trim();
if (candidate) {
urlResolver.resolve(candidate).then((r) => {
if (r.ok) setUrl(r.url);
});
}
}}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
<View className='px-4 mt-1'>
<ServerUrlStatusText state={urlResolver} />
</View>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "} {t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen} open={dropdownOpen}
onOpenChange={setDropdownOpen} onOpenChange={setDropdownOpen}
trigger={ trigger={
<View> <View className='pl-1.5'>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}

View File

@@ -40,8 +40,6 @@ const Layout = () => {
keyboardDismissMode='none' keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE, fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600", fontWeight: "600",

View File

@@ -274,11 +274,6 @@ export default function DirectPlayerPage() {
}; };
if (itemId) { if (itemId) {
setItem(null);
setDownloadedItem(null);
// Clear the previous episode's stream so the loader gate stays closed
// until the new item's stream resolves (avoids a stale MPV source frame).
setStream(null);
fetchItemData(); fetchItemData();
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
@@ -321,12 +316,6 @@ export default function DirectPlayerPage() {
return null; return null;
} }
// Ensure item matches the current itemId to avoid race conditions
if (item.Id !== itemId) {
setStreamStatus({ isLoading: false, isError: false });
return null;
}
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
@@ -399,7 +388,6 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -1,7 +1,13 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -211,6 +217,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -241,11 +265,25 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of // Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// the trigger sizes the wrapper while the Host overlays the real Menu. // fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return ( return (
<View> <View style={triggerSize ?? { opacity: 0 }}>
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}> {/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger} {trigger}
</View> </View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>

View File

@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { import {
type ChapterEntry, type ChapterEntry,
chapterStartsMs, chapterStartsMs,
@@ -39,7 +38,6 @@ function ChapterListComponent({
onClose, onClose,
}: ChapterListProps) { }: ChapterListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null); const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]); const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -81,17 +79,7 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]} supportedOrientations={["portrait", "landscape"]}
> >
<Pressable onPress={onClose} style={styles.backdrop}> <Pressable onPress={onClose} style={styles.backdrop}>
<Pressable <Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text> <Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable <Pressable
@@ -172,12 +160,14 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
}, },
sheet: { sheet: {
backgroundColor: Colors.background, backgroundColor: Colors.background,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
maxHeight: "70%", maxHeight: "70%",
paddingBottom: 24,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",

View File

@@ -1,99 +0,0 @@
import { useCallback, useRef } from "react";
import { View } from "react-native";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import type { ResolveOptions } from "@/utils/serverUrl/resolve";
import type { ServerProbe } from "@/utils/serverUrl/types";
import { Input } from "./Input";
import { ServerUrlStatusText } from "./ServerUrlStatusText";
import { Text } from "./Text";
interface ServerUrlFieldProps {
/** Raw user input (controlled). */
value: string;
onChangeText: (text: string) => void;
/** Service-specific validator. Pass a stable (module-level) reference. */
probe: ServerProbe;
/** Called with the canonical URL once a candidate validates. */
onResolved?: (url: string, meta?: Record<string, unknown>) => void;
label?: string;
hint?: string;
placeholder?: string;
editable?: boolean;
resolveOptions?: ResolveOptions;
}
/**
* Unified server-URL input: the user types a loose address (`media.example.com`,
* `https://…`, `host:port`); on blur it auto-resolves via the given probe,
* adopts the canonical URL into the field, and persists it. A small status line
* (checking / resolved / error) shows underneath.
*/
export function ServerUrlField({
value,
onChangeText,
probe,
onResolved,
label,
hint,
placeholder,
editable = true,
resolveOptions,
}: ServerUrlFieldProps) {
const resolver = useServerUrlResolver(probe, resolveOptions);
const lastResolvedInput = useRef<string | null>(null);
const runResolve = useCallback(async () => {
const input = value.trim();
if (!input) {
resolver.reset();
lastResolvedInput.current = null;
return;
}
lastResolvedInput.current = input;
const result = await resolver.resolve(input);
if (result.ok) {
onChangeText(result.url); // adopt the canonical URL into the field
onResolved?.(result.url, result.meta);
}
}, [value, resolver, onChangeText, onResolved]);
const handleBlur = useCallback(() => {
const input = value.trim();
if (input && input !== lastResolvedInput.current) runResolve();
}, [value, runResolve]);
const handleChange = useCallback(
(text: string) => {
onChangeText(text);
// Editing invalidates a previous result; drop the stale status.
if (resolver.status !== "idle") resolver.reset();
lastResolvedInput.current = null;
},
[onChangeText, resolver],
);
return (
<View>
{label ? <Text className='font-bold mb-1'>{label}</Text> : null}
{hint ? <Text className='text-xs text-gray-500 mb-2'>{hint}</Text> : null}
<Input
value={value}
onChangeText={handleChange}
onBlur={handleBlur}
onSubmitEditing={runResolve}
placeholder={placeholder}
editable={editable}
extraClassName='border border-neutral-800'
keyboardType='url'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='go'
textContentType='URL'
clearButtonMode='never'
/>
<ServerUrlStatusText state={resolver} className='mt-2' />
</View>
);
}

View File

@@ -1,49 +0,0 @@
import { useTranslation } from "react-i18next";
import { ActivityIndicator, View } from "react-native";
import type { ServerUrlResolverState } from "@/hooks/useServerUrlResolver";
import { Text } from "./Text";
/**
* Compact status line for the server-URL resolver, for screens whose layout
* (e.g. ListItem rows) doesn't fit the full `ServerUrlField`. Renders nothing
* while idle.
*/
export function ServerUrlStatusText({
state,
className = "",
}: {
state: ServerUrlResolverState;
className?: string;
}) {
const { t } = useTranslation();
if (state.status === "idle") return null;
if (state.status === "resolving") {
return (
<View className={`flex-row items-center ${className}`}>
<ActivityIndicator size='small' color='#9ca3af' />
<Text className='text-xs text-neutral-400 ml-2'>
{t("server_url.resolving")}
</Text>
</View>
);
}
if (state.status === "ok") {
return (
<Text className={`text-xs text-green-500 ${className}`}>
{t("server_url.resolved", { url: state.resolvedUrl })}
</Text>
);
}
const message =
state.reason === "wrong-service"
? t("server_url.wrong_service")
: state.reason === "invalid"
? t("server_url.invalid_url")
: t("server_url.unreachable");
return <Text className={`text-xs text-red-500 ${className}`}>{message}</Text>;
}

View File

@@ -11,13 +11,10 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService"; import { sendCredentialsToTV } from "@/utils/pairingService";
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
type ScreenState = type ScreenState =
| "scanning" | "scanning"
@@ -52,7 +49,6 @@ export const CompanionLoginScreen: React.FC = () => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const serverResolver = useServerUrlResolver(jellyfinProbe);
// Pre-fill server URL and username from current session // Pre-fill server URL and username from current session
useEffect(() => { useEffect(() => {
@@ -409,16 +405,7 @@ export const CompanionLoginScreen: React.FC = () => {
autoCorrect={false} autoCorrect={false}
keyboardType='url' keyboardType='url'
returnKeyType='next' returnKeyType='next'
onBlur={() => {
const candidate = serverUrl.trim();
if (candidate) {
serverResolver.resolve(candidate).then((r) => {
if (r.ok) setServerUrl(r.url);
});
}
}}
/> />
<ServerUrlStatusText state={serverResolver} className='mt-2' />
</View> </View>
<View className='mb-5'> <View className='mb-5'>

View File

@@ -133,6 +133,7 @@ const HomeMobile = () => {
onPress={() => { onPress={() => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather <Feather

View File

@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) { if (loading) {
return null; return null;
} }
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
return ( return (
<View> <View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection <TVJellyseerrMovieSection
title={t("search.request_movies")} title={t("search.request_movies")}
items={movieResults} items={movieResults}
isFirstSection={false} isFirstSection={hasMovies}
onItemPress={onMoviePress} onItemPress={onMoviePress}
/> />
<TVJellyseerrTvSection <TVJellyseerrTvSection
title={t("search.request_series")} title={t("search.request_series")}
items={tvResults} items={tvResults}
isFirstSection={false} isFirstSection={!hasMovies && hasTv}
onItemPress={onTvPress} onItemPress={onTvPress}
/> />
<TVJellyseerrPersonSection <TVJellyseerrPersonSection
title={t("search.actors")} title={t("search.actors")}
items={personResults} items={personResults}
isFirstSection={false} isFirstSection={!hasMovies && !hasTv && hasPersons}
onItemPress={onPersonPress} onItemPress={onPersonPress}
/> />
</View> </View>

View File

@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch; forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */} our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View <View
style={{ style={{
marginBottom: 24, marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT, height: SEARCH_AREA_HEIGHT,
}} }}
> >
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */} {/* Library Search Results */}
{isLibraryMode && !loading && ( {isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}> <View style={{ gap: SECTION_GAP }}>
{sections.map((section) => ( {sections.map((section, index) => (
<TVSearchSection <TVSearchSection
key={section.key} key={section.key}
title={section.title} title={section.title}
items={section.items!} items={section.items!}
orientation={section.orientation || "vertical"} orientation={section.orientation || "vertical"}
// Never auto-focus a result. The native search field owns focus isFirstSection={index === 0}
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset. contentInset={{
// contentOffset only applies on initial mount; since this FlatList is left: edgePadding,
// reused across searches (stable key), a second search left the inset right: edgePadding,
// without the offset and the grid snapped flush to the left edge. }}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
}} }}
/> />

View File

@@ -31,12 +31,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
// Read the live (cached) downloads DB inside the query rather than the const router = useRouter();
// provider's downloadedItems snapshot, so refetches after
// updateDownloadedItem() reflect the latest state instead of a stale
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);

View File

@@ -7,11 +7,8 @@ import { toast } from "sonner-native";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { jellyseerrProbe } from "@/utils/serverUrl/probes/jellyseerr";
import { resolveServerUrl } from "@/utils/serverUrl/resolve";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { ServerUrlField } from "../common/ServerUrlField";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -29,44 +26,26 @@ export const JellyseerrSettings = () => {
string | undefined string | undefined
>(undefined); >(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<string>( const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
settings?.jellyseerrServerUrl ?? "", string | undefined
); >(settings?.jellyseerrServerUrl || undefined);
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
settings?.jellyseerrServerUrl ?? undefined,
);
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name) if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
// Prefer the already-resolved URL; otherwise resolve the raw input now jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
// (covers tapping Login before the field's on-blur resolve settled). );
let finalUrl = resolvedUrl || settings?.jellyseerrServerUrl || "";
if (!finalUrl && jellyseerrServerUrl) {
const resolved = await resolveServerUrl(
jellyseerrServerUrl,
jellyseerrProbe,
);
if (!resolved.ok) throw new Error("Invalid server url");
finalUrl = resolved.url;
}
if (!finalUrl) throw new Error("Missing server url");
const jellyseerrTempApi = new JellyseerrApi(finalUrl);
const testResult = await jellyseerrTempApi.test(); const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url"); if (!testResult.isValid) throw new Error("Invalid server url");
const loggedInUser = await jellyseerrTempApi.login( return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
user.Name,
jellyseerrPassword || "",
);
return { user: loggedInUser, url: finalUrl };
}, },
onSuccess: ({ user: loggedInUser, url }) => { onSuccess: (user) => {
setJellyseerrUser(loggedInUser); setJellyseerrUser(user);
setResolvedUrl(url); updateSettings({ jellyseerrServerUrl });
updateSettings({ jellyseerrServerUrl: url });
}, },
onError: () => { onError: () => {
toast.error(t("jellyseerr.failed_to_login")); toast.error(t("jellyseerr.failed_to_login"));
@@ -80,8 +59,7 @@ export const JellyseerrSettings = () => {
clearAllJellyseerData().finally(() => { clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
setJellyseerrPassword(undefined); setJellyseerrPassword(undefined);
setjellyseerrServerUrl(""); setjellyseerrServerUrl(undefined);
setResolvedUrl(undefined);
}); });
}; };
@@ -140,20 +118,30 @@ export const JellyseerrSettings = () => {
<Text className='text-xs text-red-600 mb-2'> <Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")} {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text> </Text>
<View className='mb-2'> <Text className='font-bold mb-1'>
<ServerUrlField {t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl} </Text>
onChangeText={setjellyseerrServerUrl} <View className='flex flex-col shrink mb-2'>
onResolved={(url) => setResolvedUrl(url)} <Text className='text-xs text-gray-600'>
probe={jellyseerrProbe} {t("home.settings.plugins.jellyseerr.server_url_hint")}
label={t("home.settings.plugins.jellyseerr.server_url")} </Text>
hint={t("home.settings.plugins.jellyseerr.server_url_hint")}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
editable={!loginToJellyseerrMutation.isPending}
/>
</View> </View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

@@ -12,9 +12,8 @@ import {
type LocalNetworkConfig, type LocalNetworkConfig,
updateServerLocalConfig, updateServerLocalConfig,
} from "@/utils/secureCredentials"; } from "@/utils/secureCredentials";
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
import { Button } from "../Button"; import { Button } from "../Button";
import { ServerUrlField } from "../common/ServerUrlField"; import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -163,12 +162,13 @@ export function LocalNetworkSettings(): React.ReactElement | null {
} }
> >
<View className=''> <View className=''>
<ServerUrlField <Input
placeholder={t("home.settings.network.local_url_placeholder")}
value={config.localUrl} value={config.localUrl}
onChangeText={handleLocalUrlChange} onChangeText={handleLocalUrlChange}
onResolved={(url) => saveConfig({ ...config, localUrl: url })} keyboardType='url'
probe={jellyfinProbe} autoCapitalize='none'
placeholder={t("home.settings.network.local_url_placeholder")} autoCorrect={false}
/> />
</View> </View>
</ListGroup> </ListGroup>

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,6 +75,9 @@ interface BottomControlsProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
// Chapter props
chapterPositions?: number[];
} }
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterPositions = [],
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useControlsSafeAreaInsets(); const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false); const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers. // Only expose chapter UI when there are at least two real markers.
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
right: insets.right, right:
left: insets.left, (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
bottom: Math.max(insets.bottom - 17, 0), left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
}, },
]} ]}
className={"flex flex-col px-2"} className={"flex flex-col px-2"}
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual} onPress={handleNextEpisodeManual}
/> />
)} )}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View> </View>
</View> </View>
<View <View

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter, goToNextChapter,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useControlsSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
top: "50%", top: "50%",
left: insets.left, left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: insets.right, right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",

View File

@@ -219,6 +219,7 @@ export const Controls: FC<Props> = ({
hasNextChapter, hasNextChapter,
goToPreviousChapter, goToPreviousChapter,
goToNextChapter, goToNextChapter,
chapterPositions,
} = useChapterNavigation({ } = useChapterNavigation({
chapters: item.Chapters, chapters: item.Chapters,
progress, progress,
@@ -365,9 +366,7 @@ export const Controls: FC<Props> = ({
{ applyLanguagePreferences: true }, { applyLanguagePreferences: true },
); );
// Use setParams instead of replace to avoid unmounting/remounting the player, const queryParams = new URLSearchParams({
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }), ...(offline && { offline: "true" }),
itemId: item.Id ?? "", itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -376,17 +375,11 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(), bitrateValue: bitrateValue?.toString(),
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "", item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}); }).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, },
[ [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
); );
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
@@ -592,6 +585,7 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/> />
</Animated.View> </Animated.View>
</> </>

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at", { time: getFinishTime() })} {t("player.ends_at")} {getFinishTime()}
</Text> </Text>
</View> </View>
)} )}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text <Text
style={[styles.endsAtText, { fontSize: typography.callout }]} style={[styles.endsAtText, { fontSize: typography.callout }]}
> >
{t("player.ends_at", { time: getFinishTime() })} {t("player.ends_at")} {getFinishTime()}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -16,10 +17,10 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
getDownloadedEpisodesForSeason, getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers, getDownloadedSeasonNumbers,
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const insets = useControlsSafeAreaInsets(); const { settings } = useSettings();
const insets = useSafeAreaInsets();
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -57,11 +59,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
} }
}, []); }, []);
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot. The snapshot only refreshes on the
// provider refreshKey, so after updateDownloadedItem() invalidates
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -185,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop: insets.top, paddingTop:
paddingLeft: insets.left, (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingRight: insets.right, paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View <View

View File

@@ -5,11 +5,12 @@ import type {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -57,8 +58,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useControlsSafeAreaInsets(); const insets = useSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation(); const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -97,9 +99,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
top: insets.top, top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: insets.left, left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: insets.right, right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING, padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants"; import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20, left: Math.max(insets.left, 48) + 20,
} }
: { : {
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4, top:
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20, (settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
}; };
const textStyle = Platform.isTV const textStyle = Platform.isTV

View File

@@ -1,18 +0,0 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -1,4 +1,3 @@
import { File, Paths } from "expo-file-system";
import { useCallback } from "react"; import { useCallback } from "react";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -13,28 +12,36 @@ const useImageStorage = () => {
} }
}, []); }, []);
/**
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
* resolves to an empty payload under RN's New Architecture.
*/
const image2Base64 = useCallback(async (url?: string | null) => { const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null; if (!url) return null;
const tmpFile = new File( let blob: Blob;
Paths.cache,
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
);
try { try {
const downloaded = await File.downloadFileAsync(url, tmpFile, { // Fetch the data from the URL
idempotent: true, const response = await fetch(url);
}); blob = await response.blob();
return await downloaded.base64();
} catch (error) { } catch (error) {
console.warn("Error fetching image:", error); console.warn("Error fetching image:", error);
return null; return null;
} finally {
if (tmpFile.exists) tmpFile.delete();
} }
// Create a FileReader instance
const reader = new FileReader();
// Convert blob to base64
return new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Extract the base64 string (remove the data URL prefix)
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to convert image to base64"));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}, []); }, []);
const saveImage = useCallback( const saveImage = useCallback(

View File

@@ -48,7 +48,6 @@ import type {
TvDetails, TvDetails,
} from "@/utils/jellyseerr/server/models/Tv"; } from "@/utils/jellyseerr/server/models/Tv";
import { writeErrorLog } from "@/utils/log"; import { writeErrorLog } from "@/utils/log";
import { isVersionBelow } from "@/utils/serverUrl/semver";
interface SearchParams { interface SearchParams {
query: string; query: string;
@@ -142,13 +141,10 @@ export class JellyseerrApi {
.then((response) => { .then((response) => {
const { status, headers, data } = response; const { status, headers, data } = response;
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version && isVersionBelow(data.version, "2.0.0")) { if (data.version < "2.0.0") {
const error = t( const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseer_does_not_meet_requirements",
); );
writeErrorLog(
`Jellyseerr version ${data.version} is below the required 2.0.0`,
);
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);
} }

View File

@@ -109,35 +109,30 @@ export const usePlaybackManager = ({
staleTime: 0, staleTime: 0,
}); });
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
const previousItem = useMemo(() => { const previousItem = useMemo(() => {
if (!adjacentItems || currentIndex <= 0) return null; if (!adjacentItems || adjacentItems.length <= 1) {
const candidate = adjacentItems[currentIndex - 1]; return null;
return isNavigable(candidate) ? candidate : null; }
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
/** The next item in the series */ /** The next item in the series */
const nextItem = useMemo(() => { const nextItem = useMemo(() => {
if (!adjacentItems || currentIndex < 0) return null; if (!adjacentItems || adjacentItems.length <= 1) {
const candidate = adjacentItems[currentIndex + 1]; return null;
return isNavigable(candidate) ? candidate : null; }
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
/** /**
* Reports playback progress. * Reports playback progress.

View File

@@ -1,65 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
type ResolveFailureReason,
type ResolveOptions,
type ResolveResult,
resolveServerUrl,
} from "@/utils/serverUrl/resolve";
import type { ServerProbe } from "@/utils/serverUrl/types";
export type ServerUrlResolverState =
| { status: "idle" }
| { status: "resolving" }
| { status: "ok"; resolvedUrl: string; meta?: Record<string, unknown> }
| { status: "error"; reason: ResolveFailureReason };
/**
* Stateful wrapper around `resolveServerUrl` for screens.
*
* `resolve(input)` cancels any in-flight resolution, drives the state machine
* (idle → resolving → ok | error) and returns the raw result. Pass a stable
* (module-level) probe; memoize `options` if you supply one.
*/
export function useServerUrlResolver(
probe: ServerProbe,
options?: ResolveOptions,
) {
const [state, setState] = useState<ServerUrlResolverState>({
status: "idle",
});
const abortRef = useRef<AbortController | null>(null);
const resolve = useCallback(
async (input: string): Promise<ResolveResult> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState({ status: "resolving" });
const result = await resolveServerUrl(input, probe, {
...options,
signal: controller.signal,
});
// Ignore results from a resolution that was superseded/cancelled.
if (!controller.signal.aborted) {
setState(
result.ok
? { status: "ok", resolvedUrl: result.url, meta: result.meta }
: { status: "error", reason: result.reason },
);
}
return result;
},
[probe, options],
);
const reset = useCallback(() => {
abortRef.current?.abort();
setState({ status: "idle" });
}, []);
useEffect(() => () => abortRef.current?.abort(), []);
return { ...state, resolve, reset };
}

View File

@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }
} }
} }

View File

@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher()
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -82,6 +81,7 @@ class MpvPlayerView: ExpoView {
private func setupView() { private func setupView() {
clipsToBounds = true clipsToBounds = true
backgroundColor = .black backgroundColor = .black
configureAudioSession()
videoContainer = UIView() videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,26 +141,21 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func configureAudioSession() { private func configureAudioSession() {
let session = AVAudioSession.sharedInstance() let audioSession = AVAudioSession.sharedInstance()
do { do {
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: []) try audioSession.setCategory(
try session.setActive(true) .playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch { } catch {
print("Failed to configure audio session: \(error)") print("Failed to configure audio session: \(error)")
} }
} }
// MARK: - Audio Session & Notifications
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
private func setupNotifications() { private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio) // Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -275,7 +270,6 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
configureAudioSession()
setupRemoteCommands() setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -446,7 +440,6 @@ class MpvPlayerView: ExpoView {
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo() clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }
@@ -526,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing") // Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused()) syncNowPlaying(isPlaying: !isPaused())
} }
} }
@@ -638,9 +633,6 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)") print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts // Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
// is `false` when AVKit reports a failure to start, so reflect that.
onPictureInPictureChange(["isActive": didStartPictureInPicture])
} }
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -659,9 +651,6 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill { if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill displayLayer.videoGravity = .resizeAspectFill
} }
// Notify JS that PiP has fully stopped so the controls overlay can
// be re-mounted when the user returns to full screen.
onPictureInPictureChange(["isActive": false])
} }
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

View File

@@ -4,68 +4,28 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/** /**
* Get the downloads database from storage * Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/ */
export function getDownloadsDatabase(): DownloadsDatabase { export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY); const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) { if (file) {
cachedDb = JSON.parse(file) as DownloadsDatabase; return JSON.parse(file) as DownloadsDatabase;
return cachedDb;
} }
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
} }
/** /**
* Save the downloads database to storage * Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/ */
export function saveDownloadsDatabase(db: DownloadsDatabase): void { export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
} }
/** /**
* Get all downloaded items as a flat array * Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/ */
export function getAllDownloadedItems(): DownloadedItem[] { export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
const items: DownloadedItem[] = []; const items: DownloadedItem[] = [];
@@ -87,41 +47,34 @@ export function getAllDownloadedItems(): DownloadedItem[] {
} }
} }
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items; return items;
} }
/** /**
* Build or refresh the item index for O(1) lookups * Get a downloaded item by its ID
*/ */
function ensureItemIndex(): void { export function getDownloadedItemById(id: string): DownloadedItem | undefined {
if (itemIndex !== null && indexCacheVersion === cacheVersion) { const db = getDownloadsDatabase();
return; // Index is up-to-date
if (db.movies[id]) {
return db.movies[id];
} }
// Build new index from all items for (const series of Object.values(db.series)) {
itemIndex = new Map<string, DownloadedItem>(); for (const season of Object.values(series.seasons)) {
const items = getAllDownloadedItems(); for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
for (const item of items) { return episode;
if (item.item.Id) { }
itemIndex.set(item.item.Id, item); }
} }
} }
indexCacheVersion = cacheVersion; if (db.other?.[id]) {
} return db.other[id];
}
/** return undefined;
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
} }
/** /**
@@ -268,5 +221,4 @@ export function updateDownloadedItem(
*/ */
export function clearAllDownloadedItems(): void { export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
} }

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bun
/**
* Validates that a pull request body follows .github/pull_request_template.md:
* required sections are filled in and the key checklist items are ticked.
*
* Usage: bun scripts/check-pr-template.mjs <path-to-pr-body.txt>
* Output: a JSON array of human-readable problems (empty array = all good).
* Exit: 0 = ok, 1 = one or more problems, 2 = no body file given.
*
* Env: AUTHOR_ASSOCIATION — when OWNER/MEMBER/COLLABORATOR, the AI-disclosure
* check is skipped (maintainers self-police).
*/
import { existsSync, readFileSync } from "node:fs";
const bodyFile = process.argv[2];
if (!bodyFile) {
console.error("usage: bun scripts/check-pr-template.mjs <pr-body-file>");
process.exit(2);
}
let body;
try {
body = readFileSync(bodyFile, "utf8").replace(/\r\n/g, "\n");
} catch (e) {
console.error(`cannot read body file ${bodyFile}: ${e.message}`);
process.exit(2);
}
const association = (process.env.AUTHOR_ASSOCIATION || "").toUpperCase();
const isMaintainer = ["OWNER", "MEMBER", "COLLABORATOR"].includes(association);
// Strip HTML comments in a single linear pass: remove complete `<!-- … -->`
// blocks, then drop any leftover unterminated `<!-- …` to end-of-string. This
// leaves no `<!--` behind (satisfies CodeQL) without the quadratic re-scan loop
// a malicious deeply-nested body could abuse for CPU-DoS.
const stripComments = (s) =>
s
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<!--[\s\S]*$/, "")
.trim();
// Grab the text under a heading whose title contains `keyword`, up to the next heading
// or the end of the body.
const section = (keyword) => {
const re = new RegExp(
`(?:^|\\n)#{1,4}\\s*[^\\n]*${keyword}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4}\\s|$)`,
"i",
);
const m = body.match(re);
return m ? m[1] : null;
};
const isFilled = (content) => {
if (content == null) return false;
// Template guidance lives in HTML comments; once stripped, a real answer remains.
return stripComments(content).length > 0;
};
const issues = [];
if (section("Description") === null)
issues.push("The **Description** section is missing.");
else if (!isFilled(section("Description")))
issues.push(
"The **Description** section is empty — describe what changed and why.",
);
if (section("Ticket") === null)
issues.push("The **Ticket / Issue** section is missing.");
else if (!isFilled(section("Ticket")))
issues.push(
"The **Ticket / Issue** section is empty — link an issue or write `N/A`.",
);
if (section("Testing Instructions") === null)
issues.push("The **Testing Instructions** section is missing.");
else if (!isFilled(section("Testing Instructions")))
issues.push(
"The **Testing Instructions** section is empty — tell reviewers how to test this, or write `N/A`.",
);
const checklist = section("Checklist");
if (checklist === null) {
issues.push("The **Checklist** section is missing.");
} else {
if (!/- \[x\][^\n]*contribution guidelines/i.test(checklist))
issues.push(
"Please read and tick the **contribution guidelines** checklist item.",
);
if (!isMaintainer && !/- \[x\][^\n]*declared if AI/i.test(checklist))
issues.push(
"Please tick the **AI disclosure** checklist item (declare whether AI was used).",
);
}
// Require the Screenshots section when the PR changes UI (.tsx under app/ or components/).
// PR_FILES points to a newline list of changed paths (provided by the workflow).
const filesPath = process.env.PR_FILES;
if (filesPath && existsSync(filesPath)) {
const changed = readFileSync(filesPath, "utf8").split("\n").filter(Boolean);
const touchesUI = changed.some(
(f) =>
/^(app|components)\/.*\.tsx$/.test(f) && !/\.(test|spec)\.tsx$/.test(f),
);
if (touchesUI) {
const shots = section("Screenshots");
if (shots === null)
issues.push(
"This PR changes UI (`.tsx`) — add the **Screenshots / GIFs** section with before/after media.",
);
else if (!isFilled(shots))
issues.push(
"This PR changes UI — the **Screenshots / GIFs** section is empty; add screenshots (or write `N/A` if it's genuinely not visual).",
);
}
}
console.log(JSON.stringify(issues));
process.exit(issues.length ? 1 : 0);

View File

@@ -1,12 +1,4 @@
{ {
"server_url": {
"resolving": "Checking…",
"resolved": "→ {{url}}",
"connected": "Connected to {{url}}",
"unreachable": "Server unreachable",
"wrong_service": "Reachable, but not the expected server",
"invalid_url": "Enter a valid address"
},
"login": { "login": {
"username_required": "Username Is Required", "username_required": "Username Is Required",
"error_title": "Error", "error_title": "Error",

View File

@@ -1,75 +0,0 @@
/**
* Generic server-URL candidate generator.
*
* Turns loose user input (`media.uruk.dev`, `https://media.uruk.dev`,
* `host:8096`, `http://10.0.0.5:3000/path`) into an ordered list of full URLs
* to probe — https first, http as fallback — while preserving any explicit
* port and path. Service-agnostic: unlike the Jellyfin SDK's `getAddressCandidates`
* it adds no Jellyfin-specific ports, so it suits Jellyseerr/Streamystats/etc.
*/
// scheme? host (port)? (path/query/hash)?
const URL_RE = /^(?:(https?):\/\/)?([^/:\s?#]+)(?::(\d+))?([/?#].*)?$/i;
export interface ParsedServerInput {
scheme?: "http" | "https";
host: string;
port?: string;
/** Normalized path+query+hash, without a trailing slash; "" when none. */
path: string;
}
function normalizePath(path?: string): string {
if (!path || path === "/") return "";
return path.replace(/\/+$/, "");
}
/** Parse loose user input. Returns null when it can't be understood. */
export function parseServerInput(input: string): ParsedServerInput | null {
const trimmed = input.trim();
if (!trimmed) return null;
const match = URL_RE.exec(trimmed);
if (!match) return null;
const [, scheme, host, port, rawPath] = match;
return {
scheme: scheme ? (scheme.toLowerCase() as "http" | "https") : undefined,
host: host.toLowerCase(),
port,
path: normalizePath(rawPath),
};
}
function buildUrl(
scheme: "http" | "https",
host: string,
port: string | undefined,
path: string,
): string {
return `${scheme}://${host}${port ? `:${port}` : ""}${path}`;
}
/**
* Ordered, de-duplicated candidate URLs for the given input.
*
* - Explicit scheme AND port → trusted as-is (single candidate).
* - Otherwise https is tried before http (prefer secure), keeping any port/path.
*
* @returns [] when the input can't be parsed.
*/
export function getServerUrlCandidates(input: string): string[] {
const parsed = parseServerInput(input);
if (!parsed) return [];
const { scheme, host, port, path } = parsed;
// Fully specified: don't second-guess the user.
if (scheme && port) return [buildUrl(scheme, host, port, path)];
// Secure-first; the typed scheme (if any) is still covered by this set.
const candidates = (["https", "http"] as const).map((s) =>
buildUrl(s, host, port, path),
);
return Array.from(new Set(candidates));
}

View File

@@ -1,16 +0,0 @@
export {
getServerUrlCandidates,
type ParsedServerInput,
parseServerInput,
} from "./candidates";
export { jellyfinProbe } from "./probes/jellyfin";
export { jellyseerrProbe } from "./probes/jellyseerr";
export { reachabilityProbe } from "./probes/reachability";
export {
type ResolveFailureReason,
type ResolveOptions,
type ResolveResult,
resolveServerUrl,
} from "./resolve";
export { isVersionBelow } from "./semver";
export type { ServerProbe, ServerProbeOutcome } from "./types";

View File

@@ -1,24 +0,0 @@
import axios from "axios";
import type { ServerProbe } from "../types";
/** Public, unauthenticated Jellyfin endpoint; `ProductName` confirms the service. */
const PRODUCT_NAME = "Jellyfin Server";
export const jellyfinProbe: ServerProbe = async (url, signal) => {
try {
const { status, data } = await axios.get(`${url}/System/Info/Public`, {
signal,
timeout: 8000, // backstop; the resolver aborts via signal first
});
if (status < 200 || status >= 300) return { status: "unreachable" };
if (data?.ProductName !== PRODUCT_NAME) return { status: "wrong-service" };
return {
status: "ok",
meta: { version: data?.Version, serverName: data?.ServerName },
};
} catch {
return { status: "unreachable" };
}
};

View File

@@ -1,30 +0,0 @@
import axios from "axios";
import type { ServerProbe } from "../types";
/**
* Probe for a Jellyseerr server. `/api/v1/status` is jellyseerr/overseerr
* specific and unauthenticated, so it both proves reachability and confirms we
* hit the right service. The minimum-version requirement is enforced at login
* time (see JellyseerrApi.test) — not surfaced here, to keep the field UI clean.
*/
export const jellyseerrProbe: ServerProbe = async (url, signal) => {
try {
const { status, data } = await axios.get(`${url}/api/v1/status`, {
signal,
timeout: 8000, // backstop; the resolver aborts via signal first
});
if (status < 200 || status >= 300) return { status: "unreachable" };
// A JSON body carrying version/commitTag identifies a real jellyseerr.
const looksLikeJellyseerr =
!!data &&
typeof data === "object" &&
(typeof data.version === "string" || "commitTag" in data);
if (!looksLikeJellyseerr) return { status: "wrong-service" };
return { status: "ok", meta: { version: data.version } };
} catch {
return { status: "unreachable" };
}
};

View File

@@ -1,23 +0,0 @@
import axios from "axios";
import type { ServerProbe } from "../types";
/**
* Minimal probe for services without a known/unauthenticated health endpoint
* (e.g. Marlin Search, Streamystats). Any HTTP response — even 4xx — proves the
* host is up and speaking HTTP at this protocol/port, which is enough to pick
* https vs http. It cannot detect a "wrong service".
*/
export const reachabilityProbe: ServerProbe = async (url, signal) => {
try {
await axios.get(url, {
signal,
timeout: 8000,
validateStatus: () => true, // any status = the server answered
});
return { status: "ok" };
} catch (error) {
// A delivered response that still threw counts as reachable.
if ((error as { response?: unknown })?.response) return { status: "ok" };
return { status: "unreachable" };
}
};

View File

@@ -1,88 +0,0 @@
import { getServerUrlCandidates } from "./candidates";
import type { ServerProbe, ServerProbeOutcome } from "./types";
export type ResolveFailureReason =
| "empty"
| "invalid"
| "wrong-service"
| "unreachable";
export type ResolveResult =
| { ok: true; url: string; meta?: Record<string, unknown> }
| { ok: false; reason: ResolveFailureReason };
export interface ResolveOptions {
/** Per-candidate probe timeout in ms. Default 5000. */
timeoutMs?: number;
/** Abort the whole resolution (cancels every in-flight probe). */
signal?: AbortSignal;
}
// Order in which to surface a failure when no candidate validated:
// the more specific/actionable the reason, the earlier it is reported.
const FAILURE_PRIORITY = [
"wrong-service",
"unreachable",
] as const satisfies ReadonlyArray<ResolveFailureReason>;
/**
* Resolve loose user input to a single working, canonical server URL.
*
* Generates candidates (https-first), probes them in parallel with a per-candidate
* timeout, and returns the first candidate (in preference order) the probe
* accepted. When none work, the most actionable failure is returned.
*/
export async function resolveServerUrl(
input: string,
probe: ServerProbe,
options: ResolveOptions = {},
): Promise<ResolveResult> {
const { timeoutMs = 5000, signal } = options;
if (!input.trim()) return { ok: false, reason: "empty" };
const candidates = getServerUrlCandidates(input);
if (candidates.length === 0) return { ok: false, reason: "invalid" };
const outcomes = await Promise.all(
candidates.map((url) => runProbe(url, probe, timeoutMs, signal)),
);
// Prefer the first candidate (https-first) that validated.
for (let i = 0; i < candidates.length; i++) {
const outcome = outcomes[i];
if (outcome.status === "ok") {
return { ok: true, url: candidates[i], meta: outcome.meta };
}
}
// Nothing validated: report the most useful failure.
for (const reason of FAILURE_PRIORITY) {
const hit = outcomes.find((outcome) => outcome.status === reason);
if (hit) {
return { ok: false, reason };
}
}
return { ok: false, reason: "unreachable" };
}
async function runProbe(
url: string,
probe: ServerProbe,
timeoutMs: number,
parentSignal?: AbortSignal,
): Promise<ServerProbeOutcome> {
const controller = new AbortController();
const abort = () => controller.abort();
parentSignal?.addEventListener("abort", abort);
const timer = setTimeout(abort, timeoutMs);
try {
return await probe(url, controller.signal);
} catch {
return { status: "unreachable" };
} finally {
clearTimeout(timer);
parentSignal?.removeEventListener("abort", abort);
}
}

View File

@@ -1,22 +0,0 @@
/**
* Strict numeric "below" comparison for dotted versions.
*
* Avoids the string-comparison bug (`"1.9.9" < "2.0.0"` works by luck but
* `"2.10.0" < "2.0.0"` is wrongly true). Non-numeric/pre-release suffixes on a
* segment are ignored (e.g. `2.0.0-beta` → 2.0.0).
*/
export function isVersionBelow(version: string, minimum: string): boolean {
const parse = (v: string) =>
v.split(".").map((segment) => Number.parseInt(segment, 10) || 0);
const a = parse(version);
const b = parse(minimum);
const length = Math.max(a.length, b.length);
for (let i = 0; i < length; i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x < y;
}
return false;
}

View File

@@ -1,15 +0,0 @@
/** Result of probing a single candidate URL for a specific service. */
export type ServerProbeOutcome =
| { status: "ok"; meta?: Record<string, unknown> }
| { status: "wrong-service" }
| { status: "unreachable" };
/**
* Validates one fully-qualified candidate URL for a given service.
* Implementations must resolve (never reject) — map errors to "unreachable".
* The provided signal is aborted on timeout or cancellation.
*/
export type ServerProbe = (
url: string,
signal: AbortSignal,
) => Promise<ServerProbeOutcome>;