From 647ceb34702b84c3106223ee86386d96c7b0c045 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 11 Jun 2025 21:34:40 +0200 Subject: [PATCH 1/3] Aligned search with new pageinated api: - Added endpoint to search for items and remove old endpoint - Added tests for new endpoint - Removed old frontend components, now using unified list component for albums and tracks --- .../server/routes/library/resources.py | 201 +++++++---- .../integration/test_routes/test_library.py | 38 ++- frontend/src/api/library.ts | 98 ++++-- .../src/components/common/hooks/useSearch.tsx | 106 ------ .../src/components/library/search/context.tsx | 152 +++++++++ frontend/src/routes/library/browse/albums.tsx | 51 +-- frontend/src/routes/library/search.tsx | 319 +++++++++++------- 7 files changed, 606 insertions(+), 359 deletions(-) delete mode 100644 frontend/src/components/common/hooks/useSearch.tsx create mode 100644 frontend/src/components/library/search/context.tsx diff --git a/backend/beets_flask/server/routes/library/resources.py b/backend/beets_flask/server/routes/library/resources.py index 84a475a7..8472629d 100644 --- a/backend/beets_flask/server/routes/library/resources.py +++ b/backend/beets_flask/server/routes/library/resources.py @@ -16,6 +16,7 @@ Any, Awaitable, Callable, + Literal, ParamSpec, Sequence, TypedDict, @@ -158,20 +159,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: return make_response -@resource_bp.route("/item/", methods=["GET", "DELETE", "PATCH"]) -@resource(Item, patchable=True) -async def item(id: int): - item = g.lib.get_item(id) - if not item: - raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.") - - return item - - -@resource_bp.route("/item/query/", methods=["GET", "DELETE", "PATCH"]) -@resource_query(Item, patchable=True) -async def item_query(query: str): - return g.lib.items(query) +# ---------------------------------- Albums ---------------------------------- # @resource_bp.route("/album/", methods=["GET", "DELETE", "PATCH"]) @@ -183,57 +171,6 @@ async def album(id: int): return item -@resource_bp.route("/album/query/", methods=["GET", "DELETE", "PATCH"]) -@resource_query(Album, patchable=False) -async def album_query(query: str): - return g.lib.albums(query) - - -# Artists are handled slightly differently, as they are not a beets model but can be -# derived from the items. -@resource_bp.route("/artist//albums", methods=["GET"]) -async def albums_by_artist(artist_name: str): - """Get all items for a specific artist.""" - log.debug(f"Album query for artist '{artist_name}'") - - with g.lib.transaction() as tx: - rows = tx.query( - f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0", - (artist_name,), - ) - - expanded = expanded_response() - minimal = minimal_response() - - return jsonify( - [ - _rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal) - for row in rows - ] - ) - - -# Items by artist are handled slightly differently, as they are not a beets model but can be -# derived from the items. -@resource_bp.route("/artist//items", methods=["GET"]) -async def items_by_artist(artist_name: str): - """Get all items for a specific artist.""" - log.debug(f"Item query for artist '{artist_name}'") - - with g.lib.transaction() as tx: - rows = tx.query( - f"SELECT id FROM items WHERE instr(artist, ?) > 0", - (artist_name,), - ) - - expanded = expanded_response() - minimal = minimal_response() - - return jsonify( - [_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows] - ) - - @resource_bp.route("/album/bf_id/", methods=["GET"]) @resource(Album, patchable=False) async def album_by_bf_id(bf_id: str): @@ -292,7 +229,6 @@ async def all_albums(query: str = ""): sub_query = parse_query_string(query, Album) - start = time.perf_counter() paginated_query = PaginatedQuery( cursor=cursor, sub_query=sub_query, @@ -322,6 +258,128 @@ async def all_albums(query: str = ""): ) +# Artists are handled slightly differently, as they are not a beets model but can be +# derived from the items. +@resource_bp.route("/artist//albums", methods=["GET"]) +async def albums_by_artist(artist_name: str): + """Get all items for a specific artist.""" + log.debug(f"Album query for artist '{artist_name}'") + + with g.lib.transaction() as tx: + rows = tx.query( + f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0", + (artist_name,), + ) + + expanded = expanded_response() + minimal = minimal_response() + + return jsonify( + [ + _rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal) + for row in rows + ] + ) + + +# ----------------------------------- Items ---------------------------------- # + + +@resource_bp.route("/item/", methods=["GET", "DELETE", "PATCH"]) +@resource(Item, patchable=True) +async def item(id: int): + item = g.lib.get_item(id) + if not item: + raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.") + + return item + + +@resource_bp.route("/items", methods=["GET"], defaults={"query": ""}) +@resource_bp.route("/items/", methods=["GET"]) +async def all_items(query: str = ""): + """Get all items in the library. + + If a query is provided, it will be used to filter the items. + """ + log.debug(f"Item query: {query}") + params = dict(request.args) + cursor = pop_query_param(params, "cursor", Cursor.from_string, None) + if cursor is None: + order_by_column = pop_query_param(params, "order_by", str, "added") + order_by_direction = pop_query_param(params, "order_dir", str, "DESC") + cursor = Cursor( + order_by_column=order_by_column, + order_by_direction=order_by_direction, + last_order_by_value=None, + last_id=None, + ) + + n_items = pop_query_param( + params, + "n_items", + int, + 50, # Default number of items per page + ) + + if len(params) > 0: + raise InvalidUsageException( + "Unexpected query parameters: , ".join(params.keys()) + ) + + sub_query = parse_query_string(query, Item) + + paginated_query = PaginatedQuery( + cursor=cursor, + sub_query=sub_query, + n_items=n_items, + table="items", + ) + items = list(g.lib.items(paginated_query, paginated_query)) + + # Update cursor + next_url: str | None = None + + total = paginated_query.total(g.lib) + if len(items) == n_items and len(items) > 0: + last_item = items[-1] + + cursor.last_order_by_value = str( + getattr(last_item, cursor.order_by_column, None) + ) + cursor.last_id = str(last_item.id) + next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}" + + return jsonify( + { + "items": [_rep(item, expand=False, minimal=True) for item in items], + "next": next_url, + "total": total, + } + ) + + +# Items by artist are handled slightly differently, as they are not a beets model but can be +# derived from the items. +@resource_bp.route("/artist//items", methods=["GET"]) +async def items_by_artist(artist_name: str): + """Get all items for a specific artist.""" + log.debug(f"Item query for artist '{artist_name}'") + + with g.lib.transaction() as tx: + rows = tx.query( + f"SELECT id FROM items WHERE instr(artist, ?) > 0", + (artist_name,), + ) + + expanded = expanded_response() + minimal = minimal_response() + + return jsonify( + [_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows] + ) + + # ----------------------------------- Util ----------------------------------- # @@ -429,13 +487,20 @@ class PaginatedQuery(Query, Sort): _sub_query: tuple[Query, Sort] | None + table: Literal["albums", "items"] + def __init__( - self, cursor: Cursor, sub_query: tuple[Query, Sort], n_items=50 + self, + cursor: Cursor, + sub_query: tuple[Query, Sort], + n_items=50, + table: Literal["albums", "items"] = "albums", ) -> None: super().__init__() self.n_items = n_items self.cursor = cursor self._sub_query = sub_query + self.table = table def clause(self) -> tuple[str | None, Sequence[Any]]: """Return the SQL clause and values for the query.""" @@ -472,7 +537,7 @@ def total(self, lib: Library) -> int: vs = () with g.lib.transaction() as tx: - count = tx.query(f"SELECT COUNT(*) FROM albums WHERE {cs}", vs)[0][0] + count = tx.query(f"SELECT COUNT(*) FROM {self.table} WHERE {cs}", vs)[0][0] return count diff --git a/backend/tests/integration/test_routes/test_library.py b/backend/tests/integration/test_routes/test_library.py index 695676d7..51e0d6d1 100644 --- a/backend/tests/integration/test_routes/test_library.py +++ b/backend/tests/integration/test_routes/test_library.py @@ -108,7 +108,7 @@ async def test_get_artist(self, client: Client): # ----------------------------------- album ---------------------------------- # -class TestAlbumsEndpoints(IsolatedBeetsLibraryMixin): +class TestAlbumEndpoints(IsolatedBeetsLibraryMixin): """Test class for the Albums endpoint in the API. This class contains tests for retrieving albums and individual album details @@ -264,7 +264,7 @@ async def test_with_query( # ----------------------------------- Items ---------------------------------- # -class TestItemsEndpoint(IsolatedBeetsLibraryMixin): +class TestItemEndpoint(IsolatedBeetsLibraryMixin): """Test class for the Items endpoint in the API. This class contains tests for retrieving items and individual item details @@ -293,6 +293,40 @@ async def test_get_item(self, client: Client): assert data["id"] == item.id, "Data id does not match item id" +class TestItemsPagination(IsolatedBeetsLibraryMixin): + """Test if pagination of items works as expected""" + + @pytest.fixture(autouse=True) + def items(self): # type: ignore + """Fixture to add items to the beets library before running tests.""" + nItems = 100 + if len(self.beets_lib.items()) == 0: + for i in range(nItems): + artist = "Even" if i % 2 == 0 else f"Odd" + self.beets_lib.add( + beets_lib_item(artist=f"{artist}", album=f"Album {i}") + ) + + assert len(self.beets_lib.items()) == nItems + + async def test_get_items(self, client: Client): + """Test the GET request to retrieve all items with pagination. + + Asserts: + - The response status code is 200. + - The returned data contains the expected number of items. + - The next cursor is provided for pagination. + """ + response = await client.get("/api_v1/library/items/?n_items=10") + data = await response.get_json() + assert response.status_code == 200, "Response status code is not 200" + assert "items" in data, "Items are not provided in the response" + assert len(data["items"]) == 10, "Data length is not 10" + assert "next" in data, "Next cursor is not provided" + assert "total" in data, "Total count is not provided" + assert data["total"] == 100, "Total count does not match expected value" + + # ---------------------------------------------------------------------------- # # Test art # # ---------------------------------------------------------------------------- # diff --git a/frontend/src/api/library.ts b/frontend/src/api/library.ts index 433cd129..7958b12f 100644 --- a/frontend/src/api/library.ts +++ b/frontend/src/api/library.ts @@ -76,22 +76,6 @@ function _url_parse_minimal_expand( return params.length ? `${url}?${params.join("&")}` : url; } -// An album by its ID -export const albumQueryOptions = ( - id: number, - expand: Expand = true as Expand, - minimal: Minimal = true as Minimal -) => ({ - queryKey: ["album", id, expand, minimal], - queryFn: async (): Promise> => { - console.log("albumQueryOptions", id, expand, minimal); - const url = _url_parse_minimal_expand(`/library/album/${id}`, minimal, expand); - const response = await fetch(url); - console.log("albumQueryOptions response", response); - return (await response.json()) as Album; - }, -}); - // An item by its ID export const itemQueryOptions = ( id: number, @@ -105,27 +89,55 @@ export const itemQueryOptions = ( }, }); -// Search for an item or album -export const searchQueryOptions = ( - searchFor: string, - type: T -) => - queryOptions({ - queryKey: ["search", type, searchFor], - queryFn: async ({ signal }) => { - const expand = false; - const minimal = true; - const url = _url_parse_minimal_expand( - `/library/${type}/query/${encodeURIComponent(searchFor)}`, - minimal, - expand - ); - const response = await fetch(url, { signal }); - return (await response.json()) as (T extends "item" - ? Item - : Album)[]; +interface ItemsPageResponse { + items: ItemResponseMinimal[]; + total: number; + next: string | null; +} + +export const itemsInfiniteQueryOptions = ({ + query, + orderBy, + orderDirection = "ASC", +}: { + query: string; + orderBy?: "title"; + orderDirection?: "ASC" | "DESC"; +}) => { + const params = new URLSearchParams(); + params.set("n_items", "100"); // Number of items per page + if (orderBy) params.set("order_by", orderBy); + if (orderDirection) params.set("order_dir", orderDirection); + const paramsStr = params.toString(); + + let initUrl = `/api_v1/library/items`; + if (query) { + initUrl += `/${encodeURIComponent(query)}`; + } + if (paramsStr) { + initUrl += `?${paramsStr}`; + } + + return infiniteQueryOptions({ + queryKey: ["items", query, orderBy, orderDirection], + queryFn: async ({ pageParam }) => { + const response = await fetch(pageParam.replace("/api_v1", "")); + return (await response.json()) as ItemsPageResponse; + }, + initialPageParam: initUrl, + getNextPageParam: (lastPage) => { + return lastPage.next; + }, + select: (data) => { + return { + items: data.pages.flatMap((page) => page.items), + total: data.pages.at(-1)?.total ?? 0, + }; }, }); +}; + +/* --------------------------------- Albums --------------------------------- */ // An album imported by us export const albumImportedOptions = ( @@ -146,6 +158,22 @@ export const albumImportedOptions = ( + id: number, + expand: Expand = true as Expand, + minimal: Minimal = true as Minimal +) => ({ + queryKey: ["album", id, expand, minimal], + queryFn: async (): Promise> => { + console.log("albumQueryOptions", id, expand, minimal); + const url = _url_parse_minimal_expand(`/library/album/${id}`, minimal, expand); + const response = await fetch(url); + console.log("albumQueryOptions response", response); + return (await response.json()) as Album; + }, +}); + interface AlbumsPageResponse { albums: AlbumResponseMinimal[]; total: number; diff --git a/frontend/src/components/common/hooks/useSearch.tsx b/frontend/src/components/common/hooks/useSearch.tsx deleted file mode 100644 index 1d75dc24..00000000 --- a/frontend/src/components/common/hooks/useSearch.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { - createContext, - Dispatch, - SetStateAction, - useCallback, - useContext, - useState, -} from "react"; -import { useQuery } from "@tanstack/react-query"; - -import { queryClient } from "@/api/common"; -import { Album, Item, searchQueryOptions } from "@/api/library"; -import { useDebounce } from "@/components/common/hooks/useDebounce"; - -export type SearchType = "item" | "album"; - -interface SearchContextType { - query: string; - setQuery: Dispatch>; - type: T; - setType: Dispatch>; - selectedResult?: number; - setSelectedResult: Dispatch>; - results?: (T extends "item" ? Item : Album)[] | null; - sentQuery: string; - isFetching: boolean; - isError: boolean; - error: Error | null; - cancelSearch: () => void; - resetSearch: () => void; -} - -const SearchContext = createContext | null>(null); - -export function SearchContextProvider({ children }: { children: React.ReactNode }) { - const [query, setQuery] = useState(""); - const [type, setType] = useState("item"); - const [selectedResult, setSelectedResult] = useState(undefined); - - // Debounce search by 750ms - let debouncedQuery = useDebounce(query, 750); - - // deal with trailing escape-characters the same way as in the backend, - // so we correctly reflect frontend-side what we are actually searching for - if ( - debouncedQuery.endsWith("\\") && - (debouncedQuery.length - debouncedQuery.replace(/\\+$/, "").length) % 2 === 1 - ) { - debouncedQuery = debouncedQuery.slice(0, -1); - } - - const { - data: data, - isFetching, - isError, - error, - } = useQuery({ - ...searchQueryOptions(debouncedQuery, type), - enabled: debouncedQuery.length > 0, - }); - - // Cancel a currently running query - // reactquery also does this on demount if abort signals are set - const cancelSearch = useCallback(() => { - queryClient - .cancelQueries({ queryKey: ["search", type, query] }) - .catch(console.error); - setSelectedResult(undefined); - }, [type, query, setSelectedResult]); - - // Reset the search to the default state - const resetSearch = useCallback(() => { - setQuery(""); - setSelectedResult(undefined); - }, []); - - return ( - - {children} - - ); -} - -export function useSearchContext() { - const context = useContext(SearchContext); - if (!context) { - throw new Error("useSeachContext must be used within a SearchContextProvider"); - } - return context; -} diff --git a/frontend/src/components/library/search/context.tsx b/frontend/src/components/library/search/context.tsx new file mode 100644 index 00000000..e71d33a0 --- /dev/null +++ b/frontend/src/components/library/search/context.tsx @@ -0,0 +1,152 @@ +import { + createContext, + Dispatch, + SetStateAction, + useCallback, + useContext, + useState, +} from "react"; +import { useInfiniteQuery, UseInfiniteQueryResult } from "@tanstack/react-query"; + +import { queryClient } from "@/api/common"; +import { + Album, + albumsInfiniteQueryOptions, + Item, + itemsInfiniteQueryOptions, +} from "@/api/library"; +import { useDebounce } from "@/components/common/hooks/useDebounce"; + +import { useLocalStorage } from "../../common/hooks/useLocalStorage"; + +export type SearchType = "item" | "album"; +type OrderBy = T extends "item" + ? Parameters[0]["orderBy"] + : Parameters[0]["orderBy"]; + +interface SearchContextType { + query: string; + debouncedQuery?: string; // Optional for debounced query, if needed + setQuery: Dispatch>; + queryState: { + orderByItems: OrderBy<"item">; + orderByAlbums: OrderBy<"album">; + orderDirection: "ASC" | "DESC"; + }; + setQueryState: (value: { + orderByItems: OrderBy<"item">; + orderByAlbums: OrderBy<"album">; + orderDirection: "ASC" | "DESC"; + }) => void; + type: SearchType; + setType: Dispatch>; + queryItems?: UseInfiniteQueryResult< + { + items: Item[]; + total: number; + }, + Error + >; + queryAlbums?: UseInfiniteQueryResult< + { + albums: Album[]; + total: number; + }, + Error + >; + cancelSearch: () => void; + resetSearch: () => void; +} + +const SearchContext = createContext(null); + +const STORAGE_KEY = "library.search"; +const DEFAULT_STORAGE_VALUE = { + query: "", + orderByItems: "title" as const, + orderByAlbums: "album" as const, + orderDirection: "ASC" as const, +}; + +export function SearchContextProvider({ children }: { children: React.ReactNode }) { + const [query, setQuery] = useState(""); + const [queryState, setQueryState] = useLocalStorage<{ + orderByItems: Parameters[0]["orderBy"]; + orderByAlbums: Parameters[0]["orderBy"]; + orderDirection: "ASC" | "DESC"; + }>(STORAGE_KEY, DEFAULT_STORAGE_VALUE); + // Debounce search by 750ms + let debouncedQuery = useDebounce(query, 750); + + const [type, setType] = useState("item"); + + // deal with trailing escape-characters the same way as in the backend, + // so we correctly reflect frontend-side what we are actually searching for + if ( + debouncedQuery.endsWith("\\") && + (debouncedQuery.length - debouncedQuery.replace(/\\+$/, "").length) % 2 === 1 + ) { + debouncedQuery = debouncedQuery.slice(0, -1); + } + + // Getting typing to work here is kinda tricky, no idea but i cant figure it out + // that's why we have two separate queries for items and albums and not one generic one + const queryItems = useInfiniteQuery({ + ...itemsInfiniteQueryOptions({ + query: debouncedQuery, + orderBy: queryState.orderByItems, + orderDirection: queryState.orderDirection, + }), + enabled: debouncedQuery.length > 0 && type === "item", + }); + + const queryAlbums = useInfiniteQuery({ + ...albumsInfiniteQueryOptions({ + query: debouncedQuery, + orderBy: queryState.orderByAlbums, + orderDirection: queryState.orderDirection, + }), + enabled: debouncedQuery.length > 0 && type === "album", + }); + + // Cancel a currently running query + // reactquery also does this on demount if abort signals are set + const cancelSearch = useCallback(() => { + queryClient.cancelQueries({ queryKey: ["albums"] }).catch(console.error); + queryClient.cancelQueries({ queryKey: ["items"] }).catch(console.error); + }, []); + + // Reset the search to the default state + const resetSearch = useCallback(() => { + setQuery(""); + setQueryState(DEFAULT_STORAGE_VALUE); + }, [setQueryState]); + + return ( + + {children} + + ); +} + +export function useSearchContext() { + const context = useContext(SearchContext); + if (!context) { + throw new Error("useSeachContext must be used within a SearchContextProvider"); + } + return context; +} diff --git a/frontend/src/routes/library/browse/albums.tsx b/frontend/src/routes/library/browse/albums.tsx index bdc6baf8..ee825fcf 100644 --- a/frontend/src/routes/library/browse/albums.tsx +++ b/frontend/src/routes/library/browse/albums.tsx @@ -1,10 +1,11 @@ import { Disc3Icon } from "lucide-react"; -import { memo, useEffect, useState, useTransition } from "react"; +import { memo, useEffect, useState } from "react"; import { Box, BoxProps, Divider, Skeleton, Typography, useTheme } from "@mui/material"; import { useInfiniteQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { albumsInfiniteQueryOptions } from "@/api/library"; +import { useDebounce } from "@/components/common/hooks/useDebounce"; import { getStorageValue, useLocalStorage, @@ -130,30 +131,37 @@ function AlbumsHeader({ sx, ...props }: BoxProps) { } function View({ sx, ...props }: BoxProps) { - const [isTransitioning, startTransition] = useTransition(); const [overscanStopIndex, setOverScanStopIndex] = useState(0); const [view, setView] = useState<"list" | "grid">("list"); + const [search, setSearch] = useState(""); const [queryState, setQueryState] = useLocalStorage<{ - query: string; orderBy: "album" | "albumartist" | "year"; orderDirection: "ASC" | "DESC"; }>(STORAGE_KEY, DEFAULT_STORAGE_VALUE); - const { data, fetchNextPage, isError, isPending, isFetching } = useInfiniteQuery( - albumsInfiniteQueryOptions({ - query: queryState.query, - orderBy: queryState.orderBy, - orderDirection: queryState.orderDirection, - }) - ); + const debouncedQuery = useDebounce(search, 500); + + const { data, fetchNextPage, isError, isPending, isFetching, hasNextPage } = + useInfiniteQuery( + albumsInfiniteQueryOptions({ + query: debouncedQuery, + orderBy: queryState.orderBy, + orderDirection: queryState.orderDirection, + }) + ); const numLoaded = data?.albums.length || 0; // Fetch new pages on scroll useEffect(() => { - if (overscanStopIndex >= numLoaded - 10 && !isFetching && !isError) { + if ( + overscanStopIndex >= numLoaded - 10 && + !isFetching && + !isError && + hasNextPage + ) { void fetchNextPage(); } - }, [overscanStopIndex, numLoaded, fetchNextPage, isFetching, isError]); + }, [overscanStopIndex, numLoaded, fetchNextPage, isFetching, isError, hasNextPage]); return ( { - startTransition(() => { - setQueryState({ - ...queryState, - query: newQuery, - }); - }); + setSearch(newQuery); }} /> { +export const LoadingRow = memo(({ style }: { style: React.CSSProperties }) => { const theme = useTheme(); return ( { * Click on it to navigate to the album page. * Implements a loading state */ -function AlbumListRow({ +export function AlbumListRow({ data: album, style, isScrolling, @@ -470,7 +473,6 @@ function AlbumListRow({ to={`/library/album/$albumId`} key={album.id} params={{ albumId: album.id }} - preloadDelay={2000} style={style} > )} + - @@ -73,8 +81,9 @@ function SearchPage() { } function SearchBar() { + const theme = useTheme(); const searchFieldRef = useRef(null); - const { query, setQuery, type, setType, setSelectedResult } = useSearchContext(); + const { query, setQuery, type, setType } = useSearchContext(); useEffect(() => { if (searchFieldRef.current) { @@ -82,20 +91,6 @@ function SearchBar() { } }, [searchFieldRef]); - function handleTypeChange( - _e: React.MouseEvent, - newType: SearchType | null - ) { - if (newType !== null && newType !== type) { - setType(newType); - setSelectedResult(undefined); - } - } - - function handleInput(e: React.ChangeEvent) { - setQuery(e.target.value); - } - return ( setQuery(e.target.value)} slotProps={{ input: { endAdornment: ( @@ -126,14 +121,23 @@ function SearchBar() { {/* Type selector */} , + v: "album" | "item" | null + ) => { + if (v) setType(v); + }} + color="primary" exclusive - onChange={handleTypeChange} - aria-label="Search Type" + aria-label="Filter type" > - Item - Album + + + + + + ); @@ -144,7 +148,10 @@ function CancelSearchButton({ }: { searchFieldRef: React.RefObject; }) { - const { cancelSearch, resetSearch, isFetching, query } = useSearchContext(); + const { cancelSearch, resetSearch, queryAlbums, queryItems, query } = + useSearchContext(); + + const isFetching = queryItems?.isFetching || queryAlbums?.isFetching; return ( @@ -177,10 +184,16 @@ function CancelSearchButton({ } function SearchResults() { - const { isError, error, isFetching, type, sentQuery, results } = useSearchContext(); + const { queryAlbums, queryItems, type, debouncedQuery } = useSearchContext(); + + const isError = type === "item" ? queryItems?.isError : queryAlbums?.isError; + const error = type === "item" ? queryItems?.error : queryAlbums?.error; + const isFetching = + type === "item" ? queryItems?.isFetching : queryAlbums?.isFetching; + const results = + type === "item" ? queryItems?.data?.items : queryAlbums?.data?.albums; if (isError) { - console.error("Error loading search results", error); return ( Error loading results: @@ -189,12 +202,12 @@ function SearchResults() { ); } - if (isFetching) { + if (isFetching && results?.length === 0) { return ( - Searching {type}s with {sentQuery} ... + Searching {type}s with {debouncedQuery} ... ); @@ -208,110 +221,168 @@ function SearchResults() { return ( - No {type}s found with {sentQuery} + No {type}s found with {debouncedQuery} ); } - if (type === "item") { - return []} />; - } else { - return []} />; - } -} - -export interface RouteParams { - type?: SearchType; - id?: number; + return ( + + {type === "item" && } + {type === "album" && } + + ); } -function ItemResultsBox({ results }: { results: Item[] }) { - const { selectedResult, setSelectedResult } = useSearchContext(); - - const data = useMemo(() => { - return results.map((item) => ({ - className: styles.listItem, - "data-selected": selectedResult !== undefined && selectedResult === item.id, - onClick: () => - setSelectedResult((prev) => (prev === item.id ? undefined : item.id)), - label: ( - - - {item.artist} - - - {item.name} - - ), - })); - }, [results, selectedResult, setSelectedResult]); +const LISTROWHEIGHT = 50; + +function ItemsListAutoFetchData({ + ...props +}: Omit< + FixedListProps, + "data" | "itemCount" | "itemHeight" | "children" +>) { + const [overScanStopIndex, setOverScanStopIndex] = useState(0); + const { queryItems } = useSearchContext(); + + const data = queryItems?.data || { + items: [], + total: 0, + }; + const numLoaded = data.items.length; + const isFetching = queryItems?.isFetching; + const isError = queryItems?.isError; + const fetchNextPage = queryItems?.fetchNextPage; + const hasNextPage = queryItems?.hasNextPage; + + // Fetch new pages on scroll + useEffect(() => { + if ( + overScanStopIndex >= numLoaded - 10 && + !isFetching && + !isError && + hasNextPage + ) { + void fetchNextPage?.(); + } + }, [overScanStopIndex, numLoaded, fetchNextPage, isFetching, isError, hasNextPage]); return ( - - {List.Item} - + { + setOverScanStopIndex(overscanStopIndex); + }} + {...props} + > + {ItemsListRow} + ); } -function AlbumResultsBox({ results }: { results: Album[] }) { - const { selectedResult, setSelectedResult } = useSearchContext(); - - const data = useMemo(() => { - return results.map((album) => ({ - className: styles.listItem, - "data-selected": selectedResult !== undefined && selectedResult == album.id, - onClick: () => setSelectedResult(album.id), - label: ( - - - {album.albumartist} - - - {album.name} - - ), - })); - }, [results, selectedResult, setSelectedResult]); +function AlbumsListAutoFetchData({ + ...props +}: Omit< + FixedListProps, + "data" | "itemCount" | "itemHeight" | "children" +>) { + const [overScanStopIndex, setOverScanStopIndex] = useState(0); + const { queryAlbums } = useSearchContext(); + + const data = queryAlbums?.data || { + albums: [], + total: 0, + }; + const numLoaded = data.albums.length; + const isFetching = queryAlbums?.isFetching; + const isError = queryAlbums?.isError; + const fetchNextPage = queryAlbums?.fetchNextPage; + const hasNextPage = queryAlbums?.hasNextPage; + + // Fetch new pages on scroll + useEffect(() => { + if ( + overScanStopIndex >= numLoaded - 10 && + !isFetching && + !isError && + hasNextPage + ) { + console.log("Fetching next page of albums"); + void fetchNextPage?.(); + } + }, [overScanStopIndex, numLoaded, fetchNextPage, isFetching, isError, hasNextPage]); return ( - - {List.Item} - + { + setOverScanStopIndex(overscanStopIndex); + }} + {...props} + /> ); } -function SearchResultDetails() { - const { type, selectedResult } = useSearchContext(); - - if (selectedResult === undefined) { - return null; +function ItemsListRow({ data: item, style }: FixedListChildrenProps>) { + const theme = useTheme(); + if (!item) { + return ; } - return ( - <> - - - - - } - > - {type === "item" && } - {type === "album" && } - - - + + ({ + display: "flex", + alignItems: "center", + paddingInline: 1, + justifyContent: "space-between", + ":hover": { + background: `linear-gradient(to left, transparent 0%, ${theme.palette.primary.muted} 100%)`, + color: "primary.contrastText", + }, + })} + > + + + {item.name || "Unknown Title"} + + + + {item.artist || "Unknown Artist"} + + + + {item.album || "Unknown Album"} + + + + + + ); } From 339a311e3f1eec389e786c8325ee7dbdd726c6b6 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 12 Jun 2025 19:46:47 +0200 Subject: [PATCH 2/3] Preserve state when going back to search --- frontend/src/components/library/search/context.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/library/search/context.tsx b/frontend/src/components/library/search/context.tsx index e71d33a0..2f785a40 100644 --- a/frontend/src/components/library/search/context.tsx +++ b/frontend/src/components/library/search/context.tsx @@ -69,12 +69,12 @@ const DEFAULT_STORAGE_VALUE = { }; export function SearchContextProvider({ children }: { children: React.ReactNode }) { - const [query, setQuery] = useState(""); + const [query, setQuery] = useLocalStorage(STORAGE_KEY + ".query", ""); const [queryState, setQueryState] = useLocalStorage<{ orderByItems: Parameters[0]["orderBy"]; orderByAlbums: Parameters[0]["orderBy"]; orderDirection: "ASC" | "DESC"; - }>(STORAGE_KEY, DEFAULT_STORAGE_VALUE); + }>(STORAGE_KEY + ".query_state", DEFAULT_STORAGE_VALUE); // Debounce search by 750ms let debouncedQuery = useDebounce(query, 750); From 1abc3346aa361c32a7fdb45c93a6cc8d2587c965 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Thu, 12 Jun 2025 19:49:35 +0200 Subject: [PATCH 3/3] Fix typing --- frontend/src/components/library/search/context.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/library/search/context.tsx b/frontend/src/components/library/search/context.tsx index 2f785a40..5c122cb3 100644 --- a/frontend/src/components/library/search/context.tsx +++ b/frontend/src/components/library/search/context.tsx @@ -27,7 +27,7 @@ type OrderBy = T extends "item" interface SearchContextType { query: string; debouncedQuery?: string; // Optional for debounced query, if needed - setQuery: Dispatch>; + setQuery: (value: string) => void; queryState: { orderByItems: OrderBy<"item">; orderByAlbums: OrderBy<"album">; @@ -120,7 +120,7 @@ export function SearchContextProvider({ children }: { children: React.ReactNode const resetSearch = useCallback(() => { setQuery(""); setQueryState(DEFAULT_STORAGE_VALUE); - }, [setQueryState]); + }, [setQuery, setQueryState]); return (