diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index ccf38d3ea..9a2239f64 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -40,6 +40,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; @@ -104,6 +105,10 @@ const Page = () => { const { orientation } = useOrientation(); + // Fallback refresh for newly added content when returning to the library + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 74e7c8b09..637e20418 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useDownload } from "@/providers/DownloadProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider"; @@ -89,6 +90,10 @@ const HomeMobile = () => { const [loadedSections, setLoadedSections] = useState>(new Set()); const { showIntro } = useIntroSheet(); + // Fallback refresh for newly added content when returning to the home screen + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + // Show intro modal on first launch useEffect(() => { const hasShownIntro = storage.getBoolean("hasShownIntro"); diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index c1994c7ef..40131767c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -35,6 +35,7 @@ import { Loader } from "@/components/Loader"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { @@ -86,6 +87,10 @@ export const Home = () => { const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); + // Fallback refresh for newly added content when returning to the home screen + // (primary path is the LibraryChanged WebSocket event). + useRefreshLibraryOnFocus(); + // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); const debounceTimerRef = useRef | null>(null); diff --git a/hooks/useRefreshLibraryOnFocus.ts b/hooks/useRefreshLibraryOnFocus.ts new file mode 100644 index 000000000..f89ebd58c --- /dev/null +++ b/hooks/useRefreshLibraryOnFocus.ts @@ -0,0 +1,50 @@ +import { useFocusEffect } from "expo-router"; +import { useCallback, useRef } from "react"; +import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; + +// Query keys that depend on the set of library items. Kept in sync with the +// LibraryChanged handler in WebSocketProvider. +const LIBRARY_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], +]; + +/** + * Fallback refresh for newly added/removed content. + * + * The primary path is the server's `LibraryChanged` WebSocket event (handled in + * WebSocketProvider). This hook is a safety net for cases where the socket was + * down or the change happened while the screen was unfocused: when the screen + * regains focus, it invalidates the library-dependent queries so React Query + * refetches the latest content. + * + * Skips the refresh on the very first focus (initial mount already fetches) and + * throttles to avoid refetch storms when quickly switching tabs. + */ +export function useRefreshLibraryOnFocus(throttleMs = 30_000) { + const queryClient = useNetworkAwareQueryClient(); + const hasFocusedOnce = useRef(false); + const lastRefreshRef = useRef(0); + + useFocusEffect( + useCallback(() => { + if (!hasFocusedOnce.current) { + hasFocusedOnce.current = true; + return; + } + + const now = Date.now(); + if (now - lastRefreshRef.current < throttleMs) { + return; + } + lastRefreshRef.current = now; + + for (const queryKey of LIBRARY_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey }); + } + }, [queryClient, throttleMs]), + ); +} diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 41af25cf6..ed9db7549 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -12,9 +12,22 @@ import { } 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"; +// Query keys that depend on the set of library items and should be refreshed +// when the server reports that the library changed (items added/removed/updated). +const LIBRARY_CHANGE_QUERY_KEYS = [ + ["home"], + ["library-items"], + ["nextUp-all"], + ["nextUp"], + ["resumeItems"], + ["seasons"], + ["episodes"], +] as const; + interface WebSocketMessage { MessageType: string; Data: any; @@ -42,10 +55,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const [isConnected, setIsConnected] = useState(false); const [lastMessage, setLastMessage] = useState(null); const router = useRouter(); + const queryClient = useNetworkAwareQueryClient(); const deviceId = useMemo(() => { return getOrSetDeviceId(); }, []); const reconnectAttemptsRef = useRef(0); + const libraryChangeDebounceRef = useRef | null>( + null, + ); const connectWebSocket = useCallback(() => { if (!deviceId || !api?.accessToken || !isNetworkConnected) { @@ -111,14 +128,53 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }; }, [api, deviceId, isNetworkConnected]); + const handleLibraryChanged = useCallback( + (data: any) => { + // Jellyfin sends LibraryChanged when a scan adds/updates/removes items. + // Only refresh when something actually changed in the item set. + const hasChanges = + (data?.ItemsAdded?.length ?? 0) > 0 || + (data?.ItemsRemoved?.length ?? 0) > 0 || + (data?.ItemsUpdated?.length ?? 0) > 0 || + (data?.FoldersAddedTo?.length ?? 0) > 0 || + (data?.FoldersRemovedFrom?.length ?? 0) > 0; + + if (!hasChanges) { + return; + } + + // A single scan can emit several LibraryChanged messages in quick + // succession, so debounce the invalidation to refetch only once. + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + libraryChangeDebounceRef.current = setTimeout(() => { + for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + }, 1000); + }, + [queryClient], + ); + useEffect(() => { if (!lastMessage) { return; } if (lastMessage.MessageType === "Play") { handlePlayCommand(lastMessage.Data); + } else if (lastMessage.MessageType === "LibraryChanged") { + handleLibraryChanged(lastMessage.Data); } - }, [lastMessage, router]); + }, [lastMessage, router, handleLibraryChanged]); + + useEffect(() => { + return () => { + if (libraryChangeDebounceRef.current) { + clearTimeout(libraryChangeDebounceRef.current); + } + }; + }, []); const handlePlayCommand = useCallback( (data: any) => {