mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 02:58:28 +01:00
feat(sync): auto-refresh on Jellyfin LibraryChanged events
Handle the server's LibraryChanged WebSocket message to invalidate library-dependent React Query caches when items are added/updated/ removed, so newly added episodes/movies appear without a manual refresh. Debounced to coalesce a scan's burst of events. Add useRefreshLibraryOnFocus as a fallback that re-checks on screen focus (throttled, online-only, skips first focus), wired into home (mobile + TV) and the library pages.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<Set<string>>(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");
|
||||
|
||||
@@ -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<BaseItemDto | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -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<WebSocketMessage | null>(null);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
return getOrSetDeviceId();
|
||||
}, []);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | 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) => {
|
||||
|
||||
Reference in New Issue
Block a user