Compare commits

...

4 Commits

Author SHA1 Message Date
Gauvino
06510d2bd6 chore(security): harden helpers + document conflict-labeler safety
From the workflow security audit:
- symlink-native-dirs.js: drop the execSync shell strings for fs.symlink/mkdir
  (removes a latent shell-injection surface; also clears dead commented code).
- automerge.sh: add 'set -euo pipefail' and restore the starting branch on exit
  so a mid-merge failure can't leave the repo on the wrong branch.
- conflict.yml: document that this pull_request_target workflow must never check
  out or run PR-head code (it only labels via the API today).
2026-06-01 20:35:05 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
lance chant
338fb9713b fix: qr code scanning not working ios (#1619)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:54 +02:00
lance chant
939fd2512d fix: max episodes count (#1554)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:34 +02:00
11 changed files with 173 additions and 161 deletions

View File

@@ -3,6 +3,11 @@ name: 🏷🔀Merge Conflict Labeler
on: on:
push: push:
branches: [develop] branches: [develop]
# SECURITY: pull_request_target runs with the base repo's write token and secrets.
# This job only labels via the API and is safe ONLY because it never checks out or
# runs the PR head's code. NEVER add `actions/checkout` of the PR head (or any `run:`
# that interpolates PR-controlled data) to this workflow — that would turn it into a
# full repo-compromise vector.
pull_request_target: pull_request_target:
branches: [develop] branches: [develop]
types: [synchronize] types: [synchronize]

View File

@@ -59,6 +59,7 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
{Platform.OS !== "ios" && (
<View className='mb-4'> <View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}> <ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem <ListItem
@@ -70,6 +71,7 @@ function SettingsMobile() {
/> />
</ListGroup> </ListGroup>
</View> </View>
)}
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />

View File

@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
}; };
const handleRefreshFromServer = useCallback(async () => { const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true); const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values // Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl); setUrl(newUrl);

View File

@@ -1,6 +1,6 @@
import { t } from "i18next"; import { t } from "i18next";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
</View> </View>
{/* Pair with Phone */} {/* Pair with Phone */}
{onStartPairing && ( {Platform.OS !== "ios" && onStartPairing && (
<View> <View>
<Button <Button
onPress={onStartPairing} onPress={onStartPairing}

View File

@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
} }
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}> <ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
>
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}
trigger={ trigger={

View File

@@ -229,7 +229,10 @@ export const PlaybackControlsSettings: React.FC = () => {
<ListItem <ListItem
title={t("home.settings.other.max_auto_play_episode_count")} title={t("home.settings.other.max_auto_play_episode_count")}
disabled={!settings.autoPlayNextEpisode} disabled={
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
> >
<PlatformDropdown <PlatformDropdown
groups={autoPlayEpisodeOptions} groups={autoPlayEpisodeOptions}

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")} {getFinishTime()} {t("player.ends_at", { time: 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")} {getFinishTime()} {t("player.ends_at", { time: getFinishTime() })}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,12 +1,22 @@
#!/bin/bash #!/bin/bash
[[ -z $(git status --porcelain) ]] && # Local helper: fast-forward master into develop and back. Aborts on any failure and
git checkout master && # restores the branch you started on. Not used in CI.
git pull --ff-only && set -euo pipefail
git checkout develop &&
git merge master && if [[ -n $(git status --porcelain) ]]; then
git push --follow-tags && echo "Error: working tree is not clean — commit or stash first." >&2
git checkout master && exit 1
git merge develop --ff-only && fi
git push &&
git checkout develop || start_branch=$(git rev-parse --abbrev-ref HEAD)
(echo "Error: Failed to merge" && exit 1) trap 'git checkout "$start_branch" >/dev/null 2>&1 || true' EXIT
git checkout master
git pull --ff-only
git checkout develop
git merge master
git push --follow-tags
git checkout master
git merge develop --ff-only
git push
git checkout develop

View File

@@ -1,62 +1,28 @@
#!/usr/bin/env node #!/usr/bin/env node
const _fs = require("node:fs"); // Symlinks the platform-specific native dirs to `ios` / `android` depending on EXPO_TV.
// Uses fs APIs (no shell) so there is no command-injection surface.
const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd(); const root = process.cwd();
// const tvosPath = path.join(root, 'iostv'); const isTV = process.env.EXPO_TV && process.env.EXPO_TV !== "0";
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([ const links = isTV
["tvos", path.join(root, "iostv")], ? { ios: path.join(root, "iostv"), android: path.join(root, "androidtv") }
["ios", path.join(root, "iosmobile")], : {
["android", path.join(root, "androidmobile")], ios: path.join(root, "iosmobile"),
["androidtv", path.join(root, "androidtv")], android: path.join(root, "androidmobile"),
]); };
// const platformPath = paths.get(platform); for (const [link, target] of Object.entries(links)) {
fs.mkdirSync(target, { recursive: true });
if (isTV) { try {
stdout = execSync( fs.unlinkSync(link); // replace an existing symlink/file (ln -nsf)
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`, } catch {
); // nothing to remove
console.log(stdout.toString()); }
stdout = execSync( fs.symlinkSync(target, link);
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get( console.log(`${link} -> ${target}`);
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
} }
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -6,6 +6,7 @@ import {
type SortOrder, type SortOrder,
SubtitlePlaybackMode, SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
@@ -121,6 +122,46 @@ export interface MaxAutoPlayEpisodeCount {
value: number; value: number;
} }
/**
* The plugin may send object-typed settings as plain primitives.
* Resolve to the proper option object from the available choices.
*/
const normalizePluginValue = (
settingsKey: keyof Settings,
value: unknown,
): unknown => {
if (typeof value !== "object" || value === null) {
const defaultVal = defaultValues[settingsKey];
if (
typeof defaultVal === "object" &&
defaultVal !== null &&
"key" in defaultVal &&
"value" in defaultVal
) {
// defaultBitrate needs a lookup because its keys are human-readable
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
// the fallback because their keys are just String(value) (e.g. "5").
if (settingsKey === "defaultBitrate") {
const match = BITRATES.find(
(b) => b.key === value || b.value === value,
);
if (match) return match;
}
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
// -1 key must match the translated dropdown label so the UI shows "Disabled"
if (
settingsKey === "maxAutoPlayEpisodeCount" &&
(value === 0 || value === -1)
) {
return { key: t("home.settings.other.disabled"), value: -1 };
}
return { key: String(value), value };
}
}
return value;
};
export type HomeSectionLatestResolver = { export type HomeSectionLatestResolver = {
parentId?: string; parentId?: string;
limit?: number; limit?: number;
@@ -427,8 +468,7 @@ export const useSettings = () => {
[_setPluginSettings], [_setPluginSettings],
); );
const refreshStreamyfinPluginSettings = useCallback( const refreshStreamyfinPluginSettings = useCallback(async () => {
async (forceOverride = false) => {
if (!api) { if (!api) {
return; return;
} }
@@ -441,37 +481,16 @@ export const useSettings = () => {
); );
setPluginSettings(newPluginSettings); setPluginSettings(newPluginSettings);
// Apply plugin values to settings // Locked/unlocked values are handled by the settings memo, which
// applies locked values at runtime without overwriting user storage.
// We only handle auto-enabling Streamystats here.
if (newPluginSettings && _settings) { if (newPluginSettings && _settings) {
const updates: Partial<Settings> = {};
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings;
const effectiveValue = getEffectiveSettingValue(
_settings,
settingsKey,
);
// Apply if forceOverride is true, or if neither persisted settings
// nor app defaults provide a meaningful value.
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
(updates as any)[settingsKey] = setting.value;
}
}
}
// Auto-enable Streamystats if server URL is provided
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl; const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
if ( if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
streamyStatsUrl?.value &&
_settings.searchEngine !== "Streamystats"
) {
updates.searchEngine = "Streamystats";
}
if (Object.keys(updates).length > 0) {
const newSettings = { const newSettings = {
...defaultValues, ...defaultValues,
..._settings, ..._settings,
...updates, searchEngine: "Streamystats",
} as Settings; } as Settings;
setSettings(newSettings); setSettings(newSettings);
saveSettings(newSettings); saveSettings(newSettings);
@@ -479,9 +498,7 @@ export const useSettings = () => {
} }
return newPluginSettings; return newPluginSettings;
}, }, [api, _settings]);
[api, _settings],
);
const updateSettings = (update: Partial<Settings>) => { const updateSettings = (update: Partial<Settings>) => {
if (!_settings) { if (!_settings) {
@@ -512,8 +529,13 @@ export const useSettings = () => {
Partial<Settings> Partial<Settings>
>((acc, [key, setting]) => { >((acc, [key, setting]) => {
if (setting) { if (setting) {
const { value, locked } = setting; let { value } = setting;
const { locked } = setting;
const settingsKey = key as keyof Settings; const settingsKey = key as keyof Settings;
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked (acc as any)[settingsKey] = locked

View File

@@ -27,6 +27,7 @@ export function startPairingListener(
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
if (!active) return;
if (__DEV__) console.error("[PairingService] Socket error:", err); if (__DEV__) console.error("[PairingService] Socket error:", err);
onError?.(err.message); onError?.(err.message);
cleanup(); cleanup();