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:
Fredrik Burmester
2026-05-30 13:05:43 +02:00
parent f9b71ef648
commit 2166bb3867
5 changed files with 122 additions and 1 deletions

View File

@@ -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) => {