diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml index af86644d..365afca2 100644 --- a/.github/ISSUE_TEMPLATE/issue_report.yml +++ b/.github/ISSUE_TEMPLATE/issue_report.yml @@ -75,10 +75,13 @@ body: id: version attributes: label: Streamyfin Version - description: What version of Streamyfin are you using? + description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings. options: - 0.54.1 - 0.51.0 + - 0.47.1 + - 0.30.2 + - 0.28.0 - Older - TestFlight/Development build validations: diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index a23ecdf2..8b5af9c8 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -1,67 +1,102 @@ -name: ๐Ÿ› Update Bug Report Template +name: ๐Ÿ› Update Issue Form Versions on: release: - types: [published] # Run on every published release on any branch + # Only full releases populate the dropdown (no drafts/prereleases). + types: [released] + schedule: + - cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed + workflow_dispatch: +# Fixed group so a release event and the weekly cron can't race on the same +# ci/update-issue-form branch โ€” runs queue instead of force-pushing over each other. concurrency: - group: update-issue-form-${{ github.event.release.tag_name || github.run_id }} - cancel-in-progress: true + group: update-issue-form + cancel-in-progress: false + +permissions: + contents: read jobs: - update-bug-report: + update-issue-form: + name: ๐Ÿ”ข Populate version dropdown + runs-on: ubuntu-24.04 permissions: contents: write pull-requests: write - issues: write - runs-on: ubuntu-24.04 - steps: - name: ๐Ÿ“ฅ Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: "๐ŸŸข Setup Node.js" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.x' - cache: 'npm' + # On `release` events GITHUB_SHA is the tagged commit โ€” without this the + # script would regenerate the form from the tag's (stale) copy and the bot + # PR would revert any form edits made on develop since that release. + ref: develop - - name: ๐Ÿ” Extract minor version from app.json - id: minor - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - result-encoding: string - script: | - const fs = require('fs-extra'); - const semver = require('semver'); - const content = fs.readJsonSync('./app.json'); - const version = content.expo.version; - const minorVersion = semver.minor(version); - return minorVersion.toString(); + bun-version: latest - - name: ๐Ÿ“ Update bug report version - uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 - with: - semver: '^0.${{ steps.minor.outputs.result }}.0' - dry_run: no-push + - name: ๐Ÿ”ข Populate version dropdown from GitHub releases + id: populate + run: bun scripts/update-issue-form.mjs + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} - - name: โš™๏ธ Update bug report node version dropdown - uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 - with: - dropdown: _node_version - package: node - semver: '>=24.0.0' - dry_run: no-push - - - name: ๐Ÿ“ฌ Commit and create pull request + - name: ๐Ÿ“ฌ Create pull request + id: cpr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - add-paths: .github/ISSUE_TEMPLATE/bug_report.yml - branch: ci-update-bug-report + add-paths: .github/ISSUE_TEMPLATE/issue_report.yml + branch: ci/update-issue-form base: develop delete-branch: true labels: โš™๏ธ ci, ๐Ÿค– github-actions - title: 'chore(): Update bug report template to match release version' + commit-message: "chore: update issue form version dropdown" + title: "chore: update issue form version dropdown" + # Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation. body: | - Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml` - Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + # ๐Ÿ“ฆ Pull Request + + ## ๐Ÿ“ Description + + Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`. + + **Version dropdown now lists:** ${{ steps.populate.outputs.versions }} + + Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' โ€” release {0}', github.event.release.tag_name) || '' }} ยท [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}). + + ## ๐Ÿท๏ธ Ticket / Issue + + N/A โ€” automated maintenance. + + ### ๐Ÿ–ผ๏ธ Screenshots / GIFs (if UI) + + N/A โ€” issue-template metadata only, no app UI. + + ## โœ… Checklist + + - [x] Iโ€™ve read the [contribution guidelines](CONTRIBUTING.md) + - [x] Verified that changes behave as expected for all platforms + - [x] Code passes lint/formatting and type checks (`tsc`/`biome`) + - [x] No secrets, hardcoded credentials, or private config files are included + - [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not) + + ## ๐Ÿ” Testing Instructions + + N/A โ€” generated by CI from published releases; review the dropdown diff in `issue_report.yml`. + + - name: ๐Ÿ”€ Enable auto-merge + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ github.token }} + # Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows + # (GitHub anti-recursion), so the required checks stay "Expected" until a + # maintainer kicks them (close/reopen the PR, or push an empty commit). + # Auto-merge is still worth enabling: once checks run and reviews land, + # the PR merges itself. + run: | + gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \ + || echo "::warning::Could not enable auto-merge โ€” enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now." diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md index 5426ca72..11a05f10 100644 --- a/GLOBAL_MODAL_GUIDE.md +++ b/GLOBAL_MODAL_GUIDE.md @@ -143,14 +143,6 @@ interface ModalOptions { } ``` -## Examples - -See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including: -- Simple content modal -- Modal with custom snap points -- Complex component in modal -- Success/error modals triggered from functions - ## Default Styling The modal uses these default styles (can be overridden via options): diff --git a/augmentations/index.ts b/augmentations/index.ts index abec02c9..0c193e83 100644 --- a/augmentations/index.ts +++ b/augmentations/index.ts @@ -1,4 +1,3 @@ export * from "./api"; export * from "./mmkv"; export * from "./number"; -export * from "./string"; diff --git a/augmentations/number.ts b/augmentations/number.ts index bef44ac5..c8146d65 100644 --- a/augmentations/number.ts +++ b/augmentations/number.ts @@ -3,7 +3,6 @@ declare global { bytesToReadable(decimals?: number): string; secondsToMilliseconds(): number; minutesToMilliseconds(): number; - hoursToMilliseconds(): number; } } @@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () { return this.valueOf() * (60).secondsToMilliseconds(); }; -Number.prototype.hoursToMilliseconds = function () { - return this.valueOf() * (60).minutesToMilliseconds(); -}; - export {}; diff --git a/augmentations/string.ts b/augmentations/string.ts deleted file mode 100644 index f4a50b55..00000000 --- a/augmentations/string.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare global { - interface String { - toTitle(): string; - } -} - -String.prototype.toTitle = function () { - return this.replaceAll("_", " ").replace( - /\w\S*/g, - (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), - ); -}; - -export {}; diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx deleted file mode 100644 index ccebb823..00000000 --- a/components/ExampleGlobalModalUsage.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Example Usage of Global Modal - * - * This file demonstrates how to use the global modal system from anywhere in your app. - * You can delete this file after understanding how it works. - */ - -import { Ionicons } from "@expo/vector-icons"; -import { TouchableOpacity, View } from "react-native"; -import { Text } from "@/components/common/Text"; -import { useGlobalModal } from "@/providers/GlobalModalProvider"; - -/** - * Example 1: Simple Content Modal - */ -export const SimpleModalExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal( - - Simple Modal - - This is a simple modal with just some text content. - - - Swipe down or tap outside to close. - - , - ); - }; - - return ( - - Open Simple Modal - - ); -}; - -/** - * Example 2: Modal with Custom Snap Points - */ -export const CustomSnapPointsExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal( - - - Custom Snap Points - - - This modal has custom snap points (25%, 50%, 90%). - - - - Try dragging the modal to different heights! - - - , - { - snapPoints: ["25%", "50%", "90%"], - enableDynamicSizing: false, - }, - ); - }; - - return ( - - Custom Snap Points - - ); -}; - -/** - * Example 3: Complex Component in Modal - */ -const SettingsModalContent = () => { - const { hideModal } = useGlobalModal(); - - const settings = [ - { - id: 1, - title: "Notifications", - icon: "notifications-outline" as const, - enabled: true, - }, - { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true }, - { - id: 3, - title: "Auto-play", - icon: "play-outline" as const, - enabled: false, - }, - ]; - - return ( - - Settings - - {settings.map((setting, index) => ( - - - - {setting.title} - - - - - - ))} - - - Close - - - ); -}; - -export const ComplexModalExample = () => { - const { showModal } = useGlobalModal(); - - const handleOpenModal = () => { - showModal(); - }; - - return ( - - Complex Component - - ); -}; - -/** - * Example 4: Modal Triggered from Function (e.g., API response) - */ -export const useShowSuccessModal = () => { - const { showModal } = useGlobalModal(); - - return (message: string) => { - showModal( - - - - - Success! - {message} - , - ); - }; -}; - -/** - * Main Demo Component - */ -export const GlobalModalDemo = () => { - const showSuccess = useShowSuccessModal(); - - return ( - - - Global Modal Examples - - - - - - - showSuccess("Operation completed successfully!")} - className='bg-orange-600 px-4 py-2 rounded-lg' - > - Show Success Modal - - - ); -}; diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx index 87cdacbc..7628d221 100644 --- a/components/TrackSheet.tsx +++ b/components/TrackSheet.tsx @@ -27,7 +27,7 @@ export const TrackSheet: React.FC = ({ const streams = useMemo( () => source?.MediaStreams?.filter((x) => x.Type === streamType), - [source], + [source, streamType], ); const selectedSteam = useMemo( diff --git a/components/common/LargePoster.tsx b/components/common/LargePoster.tsx deleted file mode 100644 index ab3b16fb..00000000 --- a/components/common/LargePoster.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Image } from "expo-image"; -import { View } from "react-native"; - -export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => { - if (!url) - return ( - - - - ); - - return ( - - - - ); -}; diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx deleted file mode 100644 index 02a8a256..00000000 --- a/components/common/VerticalSkeleton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { View, type ViewProps } from "react-native"; - -interface Props extends ViewProps { - index: number; -} - -export const VerticalSkeleton: React.FC = ({ index, ...props }) => { - return ( - - - - - - - ); -}; diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index 09de500d..c84364ce 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { api, item: library, }), - [library], + [api, library], ); const itemType = useMemo(() => { diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx deleted file mode 100644 index a28bba84..00000000 --- a/components/navigation/TabBarIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ - -import type { IconProps } from "@expo/vector-icons/build/createIconSet"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import type { ComponentProps } from "react"; - -export function TabBarIcon({ - style, - ...rest -}: IconProps["name"]>) { - return ; -} diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx deleted file mode 100644 index af42989b..00000000 --- a/components/posters/EpisodePoster.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; -import { View } from "react-native"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -type MoviePosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -export const EpisodePoster: React.FC = ({ - item, - showProgress = false, -}) => { - const [api] = useAtom(apiAtom); - - const url = useMemo(() => { - if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; - } - }, [item]); - - const [progress, _setProgress] = useState( - item.UserData?.PlayedPercentage || 0, - ); - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - return ( - - - - {showProgress && progress > 0 && ( - - )} - - ); -}; diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx deleted file mode 100644 index 47b62e4c..00000000 --- a/components/posters/ParentPoster.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -type PosterProps = { - id?: string; - showProgress?: boolean; -}; - -const ParentPoster: React.FC = ({ id }) => { - const [api] = useAtom(apiAtom); - - const url = useMemo( - () => `${api?.basePath}/Items/${id}/Images/Primary`, - [id], - ); - - if (!url || !id) - return ( - - ); - - return ( - - - - ); -}; - -export default ParentPoster; diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx deleted file mode 100644 index d41de008..00000000 --- a/components/settings/Dashboard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { View } from "react-native"; -import useRouter from "@/hooks/useAppRouter"; -import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; -import { useSettings } from "@/utils/atoms/settings"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; - -export const Dashboard = () => { - const { settings } = useSettings(); - const { sessions = [] } = useSessions({} as useSessionsProps); - const router = useRouter(); - - const { t } = useTranslation(); - - if (!settings) return null; - return ( - - - router.push("/settings/dashboard/sessions")} - title={t("home.settings.dashboard.sessions_title")} - showArrow - /> - - - ); -}; diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx deleted file mode 100644 index 3a0017ac..00000000 --- a/components/settings/DownloadSettings.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DownloadSettings() { - return null; -} diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx deleted file mode 100644 index 3a0017ac..00000000 --- a/components/settings/DownloadSettings.tv.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DownloadSettings() { - return null; -} diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 470d40a2..436b46c4 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -115,9 +115,6 @@ export const JellyseerrSettings = () => { ) : ( - - {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - {t("home.settings.plugins.jellyseerr.server_url")} diff --git a/constants/Languages.ts b/constants/Languages.ts deleted file mode 100644 index 8014e380..00000000 --- a/constants/Languages.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DefaultLanguageOption } from "@/utils/atoms/settings"; - -export const LANGUAGES: DefaultLanguageOption[] = [ - { label: "English", value: "eng" }, - { label: "Spanish", value: "spa" }, - { label: "Chinese (Mandarin)", value: "cmn" }, - { label: "Hindi", value: "hin" }, - { label: "Arabic", value: "ara" }, - { label: "French", value: "fra" }, - { label: "Russian", value: "rus" }, - { label: "Portuguese", value: "por" }, - { label: "Japanese", value: "jpn" }, - { label: "German", value: "deu" }, - { label: "Italian", value: "ita" }, - { label: "Korean", value: "kor" }, - { label: "Turkish", value: "tur" }, - { label: "Dutch", value: "nld" }, - { label: "Polish", value: "pol" }, - { label: "Vietnamese", value: "vie" }, - { label: "Thai", value: "tha" }, - { label: "Indonesian", value: "ind" }, - { label: "Greek", value: "ell" }, - { label: "Swedish", value: "swe" }, - { label: "Danish", value: "dan" }, - { label: "Norwegian", value: "nor" }, - { label: "Finnish", value: "fin" }, - { label: "Czech", value: "ces" }, - { label: "Hungarian", value: "hun" }, - { label: "Romanian", value: "ron" }, - { label: "Ukrainian", value: "ukr" }, - { label: "Hebrew", value: "heb" }, - { label: "Bengali", value: "ben" }, - { label: "Punjabi", value: "pan" }, - { label: "Tagalog", value: "tgl" }, - { label: "Swahili", value: "swa" }, - { label: "Malay", value: "msa" }, - { label: "Persian", value: "fas" }, - { label: "Urdu", value: "urd" }, -]; diff --git a/hooks/useControlsVisibility.ts b/hooks/useControlsVisibility.ts deleted file mode 100644 index caca0d84..00000000 --- a/hooks/useControlsVisibility.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useSharedValue } from "react-native-reanimated"; - -export const useControlsVisibility = (timeout = 3000) => { - const opacity = useSharedValue(1); - - const hideControlsTimerRef = useRef | null>( - null, - ); - - const showControls = useCallback(() => { - opacity.value = 1; - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - hideControlsTimerRef.current = setTimeout(() => { - opacity.value = 0; - }, timeout); - }, [timeout]); - - const hideControls = useCallback(() => { - opacity.value = 0; - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - }, []); - - useEffect(() => { - return () => { - if (hideControlsTimerRef.current) { - clearTimeout(hideControlsTimerRef.current); - } - }; - }, []); - - return { opacity, showControls, hideControls }; -}; diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts deleted file mode 100644 index 845161a1..00000000 --- a/hooks/useDownloadedFileOpener.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useCallback } from "react"; -import useRouter from "@/hooks/useAppRouter"; -import { usePlaySettings } from "@/providers/PlaySettingsProvider"; -import { writeToLog } from "@/utils/log"; - -export const useDownloadedFileOpener = () => { - const router = useRouter(); - const { setPlayUrl, setOfflineSettings } = usePlaySettings(); - - const openFile = useCallback( - async (item: BaseItemDto) => { - if (!item.Id) { - writeToLog("ERROR", "Attempted to open a file without an ID."); - console.error("Attempted to open a file without an ID."); - return; - } - const queryParams = new URLSearchParams({ - itemId: item.Id, - offline: "true", - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "0", - }); - try { - router.push(`/player/direct-player?${queryParams.toString()}`); - } catch (error) { - writeToLog("ERROR", "Error opening file", error); - console.error("Error opening file:", error); - } - }, - [setOfflineSettings, setPlayUrl, router], - ); - - return { openFile }; -}; diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts deleted file mode 100644 index 4d8a0136..00000000 --- a/hooks/useImageColors.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useAtom, useAtomValue } from "jotai"; -import { useEffect, useMemo } from "react"; -import { Platform } from "react-native"; -import type * as ImageColorsType from "react-native-image-colors"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -// Conditionally import react-native-image-colors only on non-TV platforms -const ImageColors = Platform.isTV - ? null - : (require("react-native-image-colors") as typeof ImageColorsType); - -import { - adjustToNearBlack, - calculateTextColor, - isCloseToBlack, - itemThemeColorAtom, -} from "@/utils/atoms/primaryColor"; -import { getItemImage } from "@/utils/getItemImage"; -import { storage } from "@/utils/mmkv"; - -/** - * Custom hook to extract and manage image colors for a given item. - * - * @param item - The BaseItemDto object representing the item. - * @param disabled - A boolean flag to disable color extraction. - * - */ -export const useImageColors = ({ - item, - url, - disabled, -}: { - item?: BaseItemDto | null; - url?: string | null; - disabled?: boolean; -}) => { - const api = useAtomValue(apiAtom); - const [, setPrimaryColor] = useAtom(itemThemeColorAtom); - - const isTv = Platform.isTV; - - const source = useMemo(() => { - if (!api) return; - if (url) return { uri: url }; - if (item) - return getItemImage({ - item, - api, - variant: "Primary", - quality: 80, - width: 300, - }); - return null; - }, [api, item, url]); - - useEffect(() => { - if (isTv) return; - if (disabled) return; - if (source?.uri) { - const _primary = storage.getString(`${source.uri}-primary`); - const _text = storage.getString(`${source.uri}-text`); - - if (_primary && _text) { - setPrimaryColor({ - primary: _primary, - text: _text, - }); - return; - } - - // Extract colors from the image - if (!ImageColors?.getColors) return; - - ImageColors.getColors(source.uri, { - fallback: "#fff", - cache: false, - }) - .then((colors: ImageColorsType.ImageColorsResult) => { - let primary = "#fff"; - let text = "#000"; - let backup = "#fff"; - - // Select the appropriate color based on the platform - if (colors.platform === "android") { - primary = colors.dominant; - backup = colors.vibrant; - } else if (colors.platform === "ios") { - primary = colors.detail; - backup = colors.primary; - } - - // Adjust the primary color if it's too close to black - if (primary && isCloseToBlack(primary)) { - if (backup && !isCloseToBlack(backup)) primary = backup; - primary = adjustToNearBlack(primary); - } - - // Calculate the text color based on the primary color - if (primary) text = calculateTextColor(primary); - - setPrimaryColor({ - primary, - text, - }); - - // Cache the colors in storage - if (source.uri && primary) { - storage.set(`${source.uri}-primary`, primary); - storage.set(`${source.uri}-text`, text); - } - }) - .catch((error: any) => { - console.error("Error getting colors", error); - }); - } - }, [isTv, source?.uri, setPrimaryColor, disabled]); - - if (isTv) return; -}; diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts index 2b442a58..de0e2828 100644 --- a/hooks/useWifiSSID.ts +++ b/hooks/useWifiSSID.ts @@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn { const fetchSSID = useCallback(async () => { if (Platform.isTV) return; const result = await getSSID(); - console.log("[WiFi Debug] Native module SSID:", result); setSSID(result); }, []); diff --git a/modules/wifi-ssid/index.ts b/modules/wifi-ssid/index.ts index 71bfbcfb..00d2bf83 100644 --- a/modules/wifi-ssid/index.ts +++ b/modules/wifi-ssid/index.ts @@ -15,7 +15,6 @@ const WifiSsidModule = */ export async function getSSID(): Promise { if (!WifiSsidModule) { - console.log("[WifiSsid] Module not available on this platform"); return null; } diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts index ebf8b116..0bc8c352 100644 --- a/providers/Downloads/hooks/useDownloadEventHandlers.ts +++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts @@ -142,31 +142,12 @@ export function useDownloadEventHandlers({ } else { // Transcoding - estimate from bitrate const process = processes.find((p) => p.id === processId); - console.log( - `[DPL] Transcoding detected, looking for process ${processId}, found:`, - process ? "yes" : "no", - ); - if (process) { - console.log(`[DPL] Process bitrate:`, { - key: process.maxBitrate.key, - value: process.maxBitrate.value, - runTimeTicks: process.item.RunTimeTicks, - }); - if (process.maxBitrate.value && process.item.RunTimeTicks) { - const { estimateDownloadSize } = require("@/utils/download"); - estimatedTotalBytes = estimateDownloadSize( - process.maxBitrate.value, - process.item.RunTimeTicks, - ); - console.log( - `[DPL] Calculated estimatedTotalBytes:`, - estimatedTotalBytes, - ); - } else { - console.log( - `[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`, - ); - } + if (process?.maxBitrate.value && process.item.RunTimeTicks) { + const { estimateDownloadSize } = require("@/utils/download"); + estimatedTotalBytes = estimateDownloadSize( + process.maxBitrate.value, + process.item.RunTimeTicks, + ); } } diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index ed9db754..c704d373 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -1,4 +1,5 @@ import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; +import { router } from "expo-router"; import { useAtomValue } from "jotai"; import { createContext, @@ -11,7 +12,6 @@ import { useState, } from "react"; import { AppState, type AppStateStatus } from "react-native"; -import useRouter from "@/hooks/useAppRouter"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; @@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [ ["episodes"], ] as const; +// Query keys that depend on per-user playback state (resume position, played +// status, favorites) and should be refreshed when the server reports a +// `UserDataChanged`. Scoped to the progression-based sections so finishing an +// episode does not pointlessly refetch "recently added" or suggestions. +const USER_DATA_CHANGE_QUERY_KEYS = [ + ["home", "continueAndNextUp"], + ["home", "resumeItems"], + ["home", "nextUp-all"], + ["home", "heroItems"], + ["resumeItems"], + ["nextUp-all"], + ["nextUp"], +] as const; + interface WebSocketMessage { MessageType: string; Data: any; @@ -38,10 +52,30 @@ interface WebSocketProviderProps { children: ReactNode; } +/** + * Handler invoked for every message of a given `MessageType`. Receives the + * message `Data` payload and the full message. + */ +type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void; + interface WebSocketContextType { ws: WebSocket | null; isConnected: boolean; + /** + * @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent + * message, so bursts arriving in the same tick are coalesced and lost. Kept + * for `useWebsockets` (GeneralCommand handling) until it is migrated. + */ lastMessage: WebSocketMessage | null; + /** + * Subscribe to a given message type. The handler is called synchronously for + * every matching message (no coalescing, unlike `lastMessage`). Returns an + * unsubscribe function to call on cleanup. + */ + subscribe: ( + messageType: string, + handler: WebSocketMessageHandler, + ) => () => void; sendMessage: (message: any) => void; clearLastMessage: () => void; } @@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const [ws, setWs] = useState(null); const [isConnected, setIsConnected] = useState(false); const [lastMessage, setLastMessage] = useState(null); - const router = useRouter(); const queryClient = useNetworkAwareQueryClient(); const deviceId = useMemo(() => { return getOrSetDeviceId(); @@ -63,8 +96,76 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const libraryChangeDebounceRef = useRef | null>( null, ); + const userDataChangeDebounceRef = useRef | null>(null); + // Handle for the onerror backoff timer. Tracked so a reconnect triggered by + // another path (foreground, network reconnect, effect re-run) can cancel a + // pending one โ€” an untracked timer would later open a second socket. + const reconnectTimeoutRef = useRef | null>( + null, + ); + + // Pub/sub registry: messageType -> set of handlers. Stored in a ref so + // subscribing/dispatching never triggers a re-render. + const listenersRef = useRef>>( + new Map(), + ); + + const subscribe = useCallback( + (messageType: string, handler: WebSocketMessageHandler) => { + const listeners = listenersRef.current; + let handlers = listeners.get(messageType); + if (!handlers) { + handlers = new Set(); + listeners.set(messageType, handlers); + } + handlers.add(handler); + return () => { + handlers?.delete(handler); + // Only drop the map entry if it still points at THIS set. After an + // unsubscribe + re-subscribe for the same type, a stale second call to + // this cleanup would otherwise delete the new subscribers' set and + // silently stop delivering their messages. + if ( + handlers && + handlers.size === 0 && + listeners.get(messageType) === handlers + ) { + listeners.delete(messageType); + } + }; + }, + [], + ); + + const dispatchMessage = useCallback((message: WebSocketMessage) => { + const handlers = listenersRef.current.get(message.MessageType); + if (!handlers || handlers.size === 0) return; + // Copy to tolerate handlers that unsubscribe during dispatch. + for (const handler of [...handlers]) { + // Isolate each handler so one throwing subscriber can't abort the rest + // (and isn't misreported as a parse failure by the outer onmessage catch). + try { + handler(message.Data, message); + } catch (error) { + console.error( + `Error handling WebSocket message type "${message.MessageType}":`, + error, + ); + } + } + }, []); const connectWebSocket = useCallback(() => { + // Cancel any reconnect queued by a previous onerror before opening a new + // socket, so we never end up with two live sockets โ€” each would double the + // message fan-out and double-invalidate queries. + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (!deviceId || !api?.accessToken || !isNetworkConnected) { return; } @@ -85,6 +186,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { newWebSocket.onopen = () => { setIsConnected(true); reconnectAttemptsRef.current = 0; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } keepAliveInterval = setInterval(() => { if (newWebSocket.readyState === WebSocket.OPEN) { newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); @@ -96,9 +201,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { // Don't log errors - this is expected when offline or server unreachable setIsConnected(false); + // Replace any still-pending reconnect so only one is ever queued; the + // previously untracked handle could leak and open a second socket. + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } if (reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; - setTimeout(() => { + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; connectWebSocket(); }, reconnectDelay); } @@ -113,7 +224,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { newWebSocket.onmessage = (e) => { try { const message = JSON.parse(e.data); - setLastMessage(message); // Store the last message in context + // Legacy single-slot state, still consumed by useWebsockets. + setLastMessage(message); + // Pub/sub: deliver to every subscriber without coalescing. + dispatchMessage(message); } catch (error) { console.error("Error parsing WebSocket message:", error); } @@ -124,9 +238,13 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { if (keepAliveInterval) { clearInterval(keepAliveInterval); } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } newWebSocket.close(); }; - }, [api, deviceId, isNetworkConnected]); + }, [api, deviceId, isNetworkConnected, dispatchMessage]); const handleLibraryChanged = useCallback( (data: any) => { @@ -157,47 +275,80 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { [queryClient], ); - useEffect(() => { - if (!lastMessage) { - return; - } - if (lastMessage.MessageType === "Play") { - handlePlayCommand(lastMessage.Data); - } else if (lastMessage.MessageType === "LibraryChanged") { - handleLibraryChanged(lastMessage.Data); - } - }, [lastMessage, router, handleLibraryChanged]); + const handleUserDataChanged = useCallback( + (data: any) => { + // Jellyfin sends UserDataChanged when playback position, played status + // or favorites change (e.g. finishing an episode). Only the + // progression-based home sections care about it. + if (!((data?.UserDataList?.length ?? 0) > 0)) { + return; + } + + // Finishing an item can emit several UserDataChanged messages, so + // debounce to invalidate the affected sections only once. + if (userDataChangeDebounceRef.current) { + clearTimeout(userDataChangeDebounceRef.current); + } + userDataChangeDebounceRef.current = setTimeout(() => { + for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + }, 800); + }, + [queryClient], + ); + + // Refresh library-dependent queries when the server reports a change. + useEffect( + () => subscribe("LibraryChanged", handleLibraryChanged), + [subscribe, handleLibraryChanged], + ); + + // Refresh "Continue Watching" / "Next Up" when playback state changes. + useEffect( + () => subscribe("UserDataChanged", handleUserDataChanged), + [subscribe, handleUserDataChanged], + ); useEffect(() => { return () => { if (libraryChangeDebounceRef.current) { clearTimeout(libraryChangeDebounceRef.current); } + if (userDataChangeDebounceRef.current) { + clearTimeout(userDataChangeDebounceRef.current); + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } }; }, []); - const handlePlayCommand = useCallback( - (data: any) => { - if (!data?.ItemIds?.length) { - return; - } + const handlePlayCommand = useCallback((data: any) => { + if (!data?.ItemIds?.length) { + return; + } - const itemId = data.ItemIds[0]; + const itemId = data.ItemIds[0]; - router.push({ - pathname: "/(auth)/player/direct-player", - params: { - itemId: itemId, - playCommand: data.PlayCommand || "PlayNow", - audioIndex: data.AudioStreamIndex?.toString(), - subtitleIndex: data.SubtitleStreamIndex?.toString(), - mediaSourceId: data.MediaSourceId || "", - bitrateValue: "", - offline: "false", - }, - }); - }, - [router], + router.push({ + pathname: "/(auth)/player/direct-player", + params: { + itemId: itemId, + playCommand: data.PlayCommand || "PlayNow", + audioIndex: data.AudioStreamIndex?.toString(), + subtitleIndex: data.SubtitleStreamIndex?.toString(), + mediaSourceId: data.MediaSourceId || "", + bitrateValue: "", + offline: "false", + }, + }); + }, []); + + // Server-initiated "Play me this item" remote command. + useEffect( + () => subscribe("Play", handlePlayCommand), + [subscribe, handlePlayCommand], ); useEffect(() => { @@ -267,7 +418,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, []); return ( {children} diff --git a/scripts/update-issue-form.mjs b/scripts/update-issue-form.mjs new file mode 100644 index 00000000..d7bc8192 --- /dev/null +++ b/scripts/update-issue-form.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env bun +/** + * Populates the "Streamyfin Version" dropdown in the issue report form with the + * latest GitHub releases. Run by the "Update Issue Form Versions" workflow on + * release events + a weekly cron (and manually via workflow_dispatch). + * + * Source: published, non-draft, non-prerelease GitHub releases, newest first. + * Non-version sentinels (e.g. "older", "TestFlight/Development build") are + * preserved at the end of the list. + * + * Usage: + * bun scripts/update-issue-form.mjs # rewrite the form in place + * ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs + * bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write + * + * Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI). + */ + +import { execFileSync } from "node:child_process"; +import { + appendFileSync, + readFileSync as read, + writeFileSync as write, +} from "node:fs"; + +const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml"; +const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate +const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10); +const LIMIT = + Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5; +const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin"; +const DRY = process.argv.includes("--dry-run"); + +// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1". +const isVersion = (s) => /^\d+\.\d+/.test(s.trim()); + +// 1. Fetch the latest published releases (newest first) โ€” drafts and prereleases +// aren't a full release users run, so they don't belong in the dropdown. +const raw = execFileSync( + "gh", + [ + "release", + "list", + "--repo", + REPO, + "--exclude-drafts", + "--exclude-pre-releases", + "--limit", + String(LIMIT), + "--json", + "tagName", + "--jq", + ".[].tagName", + ], + // Bounded timeout so a stuck gh process fails the job fast instead of + // holding the workflow open until the job-level timeout. + { encoding: "utf8", timeout: 30_000 }, +); +const seen = new Set(); +const versions = []; +for (const tag of raw.split("\n")) { + if (!tag) continue; + const ver = tag.trim().replace(/^v/, ""); + if (!isVersion(ver) || seen.has(ver)) continue; + seen.add(ver); + versions.push(ver); +} + +if (!versions.length) { + console.error("No release versions found โ€” leaving the form untouched."); + process.exit(1); +} + +// 2. rewrite the dropdown options, preserving non-version sentinels +// (e.g. "older", "TestFlight/Development build") at the end of the list. +const lines = read(FORM, "utf8").split("\n"); +const idIdx = lines.findIndex((l) => + l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)), +); +if (idIdx === -1) + throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`); +const optIdx = lines.findIndex( + (l, i) => i > idIdx && /^\s*options:\s*$/.test(l), +); +if (optIdx === -1) + throw new Error(`options: not found after id: ${DROPDOWN_ID}`); + +const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper +let end = optIdx + 1; +const sentinels = []; +while (end < lines.length && /^\s*-\s+/.test(lines[end])) { + const val = lines[end].replace(/^\s*-\s+/, ""); + if (!isVersion(val)) sentinels.push(val); + end++; +} + +const newOptions = [...versions, ...sentinels].map( + (v) => `${itemIndent}- ${v}`, +); +const updated = [ + ...lines.slice(0, optIdx + 1), + ...newOptions, + ...lines.slice(end), +].join("\n"); + +console.log( + `Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`, +); +if (DRY) { + console.log("--dry-run: not writing."); +} else { + write(FORM, updated); + console.log(`Updated ${FORM}.`); +} + +// Expose the resulting list for the workflow (PR description). +if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `versions=${versions.join(", ")}\n`, + ); +} diff --git a/translations/en.json b/translations/en.json index 1cad697a..93e8fe37 100644 --- a/translations/en.json +++ b/translations/en.json @@ -112,7 +112,7 @@ "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "downloads_feature_title": "Downloads", - "downloads_feature_description": "Download movies and series to watch offline. Use either the default method or install the optimize server to download files in the background.", + "downloads_feature_description": "Download movies and series to watch offline.", "chromecast_feature_description": "Cast movies and series to your Chromecast devices.", "centralised_settings_plugin_title": "Centralised Settings plugin", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", @@ -328,7 +328,6 @@ "plugins": { "plugins_title": "Plugins", "jellyseerr": { - "jellyseerr_warning": "This integration is in its early stages. Expect things to change.", "server_url": "Server URL", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_placeholder": "Seerr URL", @@ -442,10 +441,6 @@ "4_hours": "4 hours", "24_hours": "24 hours" } - }, - "dashboard": { - "title": "Dashboard", - "sessions_title": "Sessions" } }, "sessions": { diff --git a/utils/bToMb.ts b/utils/bToMb.ts deleted file mode 100644 index 79b7caf4..00000000 --- a/utils/bToMb.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Convert bits to megabits or gigabits - * - * Return nice looking string - * If under 1000Mb, return XXXMB, else return X.XGB - */ - -export function convertBitsToMegabitsOrGigabits(bits?: number | null): string { - if (!bits) return "0MB"; - - const megabits = bits / 1000000; - - if (megabits < 1000) { - return `${Math.round(megabits)}MB`; - } - const gigabits = megabits / 1000; - return `${gigabits.toFixed(1)}GB`; -} diff --git a/utils/collectionTypeToItemType.ts b/utils/collectionTypeToItemType.ts deleted file mode 100644 index 0889b8d6..00000000 --- a/utils/collectionTypeToItemType.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - BaseItemKind, - CollectionType, -} from "@jellyfin/sdk/lib/generated-client"; - -/** - * Converts a ColletionType to a BaseItemKind (also called ItemType) - * - * CollectionTypes - * readonly Unknown: "unknown"; - readonly Movies: "movies"; - readonly Tvshows: "tvshows"; - readonly Trailers: "trailers"; - readonly Homevideos: "homevideos"; - readonly Boxsets: "boxsets"; - readonly Books: "books"; - readonly Photos: "photos"; - readonly Livetv: "livetv"; - readonly Playlists: "playlists"; - readonly Folders: "folders"; - */ -export const colletionTypeToItemType = ( - collectionType?: CollectionType | null, -): BaseItemKind | undefined => { - if (!collectionType) return undefined; - - switch (collectionType) { - case CollectionType.Movies: - return BaseItemKind.Movie; - case CollectionType.Tvshows: - return BaseItemKind.Series; - case CollectionType.Homevideos: - return BaseItemKind.Video; - case CollectionType.Books: - return BaseItemKind.Book; - case CollectionType.Playlists: - return BaseItemKind.Playlist; - case CollectionType.Folders: - return BaseItemKind.Folder; - case CollectionType.Photos: - return BaseItemKind.Photo; - case CollectionType.Trailers: - return BaseItemKind.Trailer; - } - - return undefined; -}; diff --git a/utils/hls/parseM3U8ForSubtitles.ts b/utils/hls/parseM3U8ForSubtitles.ts deleted file mode 100644 index 5e0ad382..00000000 --- a/utils/hls/parseM3U8ForSubtitles.ts +++ /dev/null @@ -1,56 +0,0 @@ -import axios from "axios"; - -export interface SubtitleTrack { - index: number; - name: string; - uri: string; - language: string; - default: boolean; - forced: boolean; - autoSelect: boolean; -} - -export async function parseM3U8ForSubtitles( - url: string, -): Promise { - try { - const response = await axios.get(url, { responseType: "text" }); - const lines = response.data.split(/\r?\n/); - const subtitleTracks: SubtitleTrack[] = []; - let index = 0; - - lines.forEach((line: string) => { - if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) { - const attributes = parseAttributes(line); - const track: SubtitleTrack = { - index: index++, - name: attributes.NAME || "", - uri: attributes.URI || "", - language: attributes.LANGUAGE || "", - default: attributes.DEFAULT === "YES", - forced: attributes.FORCED === "YES", - autoSelect: attributes.AUTOSELECT === "YES", - }; - subtitleTracks.push(track); - } - }); - - return subtitleTracks; - } catch (error) { - console.error("Failed to fetch or parse the M3U8 file:", error); - throw error; - } -} - -function parseAttributes(line: string): { [key: string]: string } { - const attributes: { [key: string]: string } = {}; - const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g; - - for (const match of line.matchAll(regex)) { - const key = match[1]; - const value = match[2] ?? match[3]; // quoted or unquoted - attributes[key] = value; - } - - return attributes; -} diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts deleted file mode 100644 index 4e4f2074..00000000 --- a/utils/jellyfin/session/capabilities.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { AxiosResponse } from "axios"; -import type { Settings } from "../../atoms/settings"; -import { generateDeviceProfile } from "../../profiles/native"; -import { getAuthHeaders } from "../jellyfin"; - -interface PostCapabilitiesParams { - api: Api | null | undefined; - itemId: string | null | undefined; - sessionId: string | null | undefined; - deviceProfile: Settings["deviceProfile"]; -} - -/** - * Marks a media item as not played for a specific user. - * - * @param params - The parameters for marking an item as not played - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const postCapabilities = async ({ - api, - itemId, - sessionId, -}: PostCapabilitiesParams): Promise => { - if (!api || !itemId || !sessionId) { - throw new Error("Missing parameters for marking item as not played"); - } - - try { - const d = api.axiosInstance.post( - `${api.basePath}/Sessions/Capabilities/Full`, - { - playableMediaTypes: ["Audio", "Video"], - supportedCommands: [ - "PlayState", - "Play", - "ToggleFullscreen", - "DisplayMessage", - "Mute", - "Unmute", - "SetVolume", - "ToggleMute", - ], - supportsMediaControl: true, - id: sessionId, - DeviceProfile: generateDeviceProfile(), - }, - { - headers: getAuthHeaders(api), - }, - ); - return d; - } catch (_error) { - throw new Error("Failed to mark as not played"); - } -}; diff --git a/utils/jellyfin/tvshows/nextUp.ts b/utils/jellyfin/tvshows/nextUp.ts deleted file mode 100644 index 414a47a7..00000000 --- a/utils/jellyfin/tvshows/nextUp.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getAuthHeaders } from "../jellyfin"; - -interface NextUpParams { - itemId?: string | null; - userId?: string | null; - api?: Api | null; -} - -/** - * Fetches the next up episodes for a series or all series for a user. - * - * @param params - The parameters for fetching next up episodes - * @returns A promise that resolves to an array of BaseItemDto representing the next up episodes - */ -export const nextUp = async ({ - itemId, - userId, - api, -}: NextUpParams): Promise => { - if (!userId || !api) { - console.error("Invalid parameters for nextUp: missing userId or api"); - return []; - } - - try { - const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>( - `${api.basePath}/Shows/NextUp`, - { - params: { - SeriesId: itemId || undefined, - UserId: userId, - Fields: "MediaSourceCount", - }, - headers: getAuthHeaders(api), - }, - ); - - return response.data.Items; - } catch (_error) { - return []; - } -}; diff --git a/utils/jellyfin/user-library/getItemById.ts b/utils/jellyfin/user-library/getItemById.ts deleted file mode 100644 index 261733b6..00000000 --- a/utils/jellyfin/user-library/getItemById.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; - -/** - * Retrieves an item by its ID from the API. - * - * @param api - The Jellyfin API instance. - * @param itemId - The ID of the item to retrieve. - * @returns The item object or undefined if no item matches the ID. - */ -export const getItemById = async ( - api?: Api | null | undefined, - itemId?: string | null | undefined, -): Promise => { - if (!api || !itemId) { - return undefined; - } - - try { - const itemData = await getUserLibraryApi(api).getItem({ itemId }); - - const item = itemData.data; - if (!item) { - console.error("No items found with the specified ID:", itemId); - return undefined; - } - - return item; - } catch (error) { - console.error("Failed to retrieve the item:", error); - throw new Error(`Failed to retrieve the item due to an error: ${error}`); - } -}; diff --git a/utils/log.tsx b/utils/log.tsx index c633ec69..7dc1d53a 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => { return logs ? JSON.parse(logs) : []; }; -export const clearLogs = () => { - storage.remove("logs"); -}; - -export const dumpDownloadDiagnostics = (extra: any = {}) => { - const diagnostics = { - timestamp: new Date().toISOString(), - processes: extra?.processes || [], - nativeTasks: extra?.nativeTasks || [], - focusedProcess: extra?.focusedProcess || null, - }; - writeDebugLog("Download diagnostics", diagnostics); - return diagnostics; -}; - export function useLog() { const context = useContext(LogContext); if (context === null) { diff --git a/utils/secondsToTicks.ts b/utils/secondsToTicks.ts deleted file mode 100644 index df13813e..00000000 --- a/utils/secondsToTicks.ts +++ /dev/null @@ -1,5 +0,0 @@ -// seconds to ticks util - -export function secondsToTicks(seconds: number): number { - return seconds * 10000000; -} diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index bb5f7713..3aad1535 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -203,27 +203,6 @@ export async function hasAccountCredential( return stored !== null; } -/** - * Delete all credentials for all accounts on all servers. - */ -export async function clearAllCredentials(): Promise { - const previousServers = getPreviousServers(); - - for (const server of previousServers) { - for (const account of server.accounts) { - const key = credentialKey(server.address, account.userId); - await SecureStore.deleteItemAsync(key); - } - } - - // Clear all accounts from servers - const clearedServers = previousServers.map((server) => ({ - ...server, - accounts: [], - })); - storage.set("previousServers", JSON.stringify(clearedServers)); -} - /** * Add or update an account in a server's accounts list. */