diff --git a/integrations/jikan/relations.go b/integrations/jikan/relations.go index 1cbcca2..293d0fa 100644 --- a/integrations/jikan/relations.go +++ b/integrations/jikan/relations.go @@ -20,6 +20,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d" const watchOrderCacheTTL = time.Hour * 24 const maxWatchOrderEntries = 120 // cap to prevent huge relation chains +type WatchOrderMode string + +const ( + WatchOrderModeMain WatchOrderMode = "main" + WatchOrderModeComplete WatchOrderMode = "complete" +) + +func NormalizeWatchOrderMode(value string) WatchOrderMode { + switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) { + case WatchOrderModeComplete: + return WatchOrderModeComplete + default: + return WatchOrderModeMain + } +} + // watchOrderTypeLabel normalizes watch order type to display-friendly labels. func watchOrderTypeLabel(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) @@ -28,17 +44,35 @@ func watchOrderTypeLabel(value string) string { return "TV" case "movie": return "Movie" + case "ona": + return "ONA" + case "ova": + return "OVA" default: return strings.TrimSpace(value) } } -// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc). +func isTVWatchOrderType(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "tv") +} + +// isAllowedWatchOrderType returns true for the default uncluttered watch order types. func isAllowedWatchOrderType(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) return normalized == "tv" || normalized == "movie" } +func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool { + for _, entry := range entries { + if isTVWatchOrderType(entry.Type) { + return true + } + } + + return false +} + func relationCacheKey(id int) string { return fmt.Sprintf("relations:watch-order:%d", id) } @@ -166,15 +200,19 @@ func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ( return c.currentOnlyRelation(ctx, id) } -func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult) ([]watchorder.WatchOrderEntry, map[int]bool) { +func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) { allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder)) seen := make(map[int]bool) + shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder) for _, entry := range result.WatchOrder { if len(allowedEntries) >= maxWatchOrderEntries { break } - if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] { + if seen[entry.ID] { + continue + } + if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) { continue } @@ -267,13 +305,13 @@ type fetchResult struct { } // GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent). -func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) { +func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) { result, err := c.getWatchOrder(ctx, id) if err != nil { return c.handleWatchOrderError(ctx, id, err) } - allowedEntries, seen := buildAllowedWatchOrderEntries(result) + allowedEntries, seen := buildAllowedWatchOrderEntries(result, mode) fetched := c.fetchRelationResults(ctx, allowedEntries) relations := buildRelationsFromResults(fetched, id) relations, err = c.ensureCurrentRelation(ctx, id, seen, relations) @@ -290,6 +328,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, func (c *Client) WarmFullRelations(id int) { c.runAsyncRefresh(func(ctx context.Context) { - _, _ = c.GetFullRelations(ctx, id) + _, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain) }) } diff --git a/integrations/jikan/relations_test.go b/integrations/jikan/relations_test.go index 8a7d67a..4dff949 100644 --- a/integrations/jikan/relations_test.go +++ b/integrations/jikan/relations_test.go @@ -1,6 +1,9 @@ package jikan -import "testing" +import ( + "mal/integrations/watchorder" + "testing" +) func runBoolCases(t *testing.T, tests []struct { name string @@ -36,6 +39,138 @@ func TestIsAllowedWatchOrderType(t *testing.T) { runBoolCases(t, tests, isAllowedWatchOrderType) } +func TestNormalizeWatchOrderMode(t *testing.T) { + tests := []struct { + name string + input string + want WatchOrderMode + }{ + {name: "empty defaults main", input: "", want: WatchOrderModeMain}, + {name: "main", input: "main", want: WatchOrderModeMain}, + {name: "complete", input: "complete", want: WatchOrderModeComplete}, + {name: "case and whitespace", input: " COMPLETE ", want: WatchOrderModeComplete}, + {name: "unknown defaults main", input: "everything", want: WatchOrderModeMain}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got := NormalizeWatchOrderMode(testCase.input) + if got != testCase.want { + t.Fatalf("expected %q, got %q", testCase.want, got) + } + }) + } +} + +func TestHasTVWatchOrderEntry(t *testing.T) { + tests := []struct { + name string + entries []watchorder.WatchOrderEntry + want bool + }{ + { + name: "contains tv", + entries: []watchorder.WatchOrderEntry{ + {ID: 1, Type: "Movie"}, + {ID: 2, Type: " TV "}, + }, + want: true, + }, + { + name: "ona only", + entries: []watchorder.WatchOrderEntry{ + {ID: 1, Type: "ONA"}, + {ID: 2, Type: "Special"}, + }, + want: false, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got := hasTVWatchOrderEntry(testCase.entries) + if got != testCase.want { + t.Fatalf("expected %v, got %v", testCase.want, got) + } + }) + } +} + +func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) { + result := watchorder.WatchOrderResult{ + WatchOrder: []watchorder.WatchOrderEntry{ + {ID: 1, Type: "TV"}, + {ID: 2, Type: "Special"}, + {ID: 3, Type: "Movie"}, + {ID: 4, Type: "ONA"}, + }, + } + + entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain) + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + if entries[0].ID != 1 || entries[1].ID != 3 { + t.Fatalf("unexpected entries: %+v", entries) + } + + if !seen[1] || !seen[3] || seen[2] || seen[4] { + t.Fatalf("unexpected seen map: %+v", seen) + } +} + +func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.T) { + result := watchorder.WatchOrderResult{ + WatchOrder: []watchorder.WatchOrderEntry{ + {ID: 1, Type: "ONA"}, + {ID: 2, Type: "Special"}, + {ID: 3, Type: "Movie"}, + {ID: 1, Type: "ONA"}, + }, + } + + entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + if entries[0].ID != 1 || entries[1].ID != 2 || entries[2].ID != 3 { + t.Fatalf("unexpected entries: %+v", entries) + } + + if !seen[1] || !seen[2] || !seen[3] { + t.Fatalf("unexpected seen map: %+v", seen) + } +} + +func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.T) { + result := watchorder.WatchOrderResult{ + WatchOrder: []watchorder.WatchOrderEntry{ + {ID: 1, Type: "TV"}, + {ID: 2, Type: "Special"}, + {ID: 3, Type: "ONA"}, + {ID: 4, Type: "Movie"}, + }, + } + + entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeComplete) + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + for index, entry := range entries { + wantID := index + 1 + if entry.ID != wantID { + t.Fatalf("expected entry %d to have id %d, got %+v", index, wantID, entry) + } + } + + if !seen[1] || !seen[2] || !seen[3] || !seen[4] { + t.Fatalf("unexpected seen map: %+v", seen) + } +} + func TestWatchOrderTypeLabel(t *testing.T) { tests := []struct { name string @@ -44,6 +179,8 @@ func TestWatchOrderTypeLabel(t *testing.T) { }{ {name: "tv", input: "tv", want: "TV"}, {name: "movie", input: "movie", want: "Movie"}, + {name: "ona", input: "ona", want: "ONA"}, + {name: "ova", input: "ova", want: "OVA"}, {name: "trimmed passthrough", input: " tv special ", want: "tv special"}, } diff --git a/static/search.ts b/static/search.ts index 273bf04..25387dc 100644 --- a/static/search.ts +++ b/static/search.ts @@ -8,6 +8,12 @@ interface CommandPaletteItem { icon?: string; } +interface CommandPaletteResponse { + items: CommandPaletteItem[]; + hasNextPage: boolean; + nextPage?: number; +} + const commandPaletteInitializedKey = Symbol("commandPaletteInitialized"); const globalWindow = window as Window & { [commandPaletteInitializedKey]?: boolean }; @@ -27,8 +33,11 @@ let selectedIndex = 0; let fetchTimeout: number | undefined; let lastQuery = ""; let activeRequestController: AbortController | undefined; +let nextSearchPage: number | undefined; +let hasNextSearchPage = false; +let isFetchingNextPage = false; let lastFocusedSearchOpener: HTMLElement | null = null; -const responseCache = new Map(); +const responseCache = new Map(); const iconPaths: Record = { bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z", @@ -136,6 +145,9 @@ const buildPosterImage = (item: CommandPaletteItem): HTMLElement => { const clearResults = (): void => { resultItems = []; selectedIndex = 0; + nextSearchPage = undefined; + hasNextSearchPage = false; + isFetchingNextPage = false; searchResults?.replaceChildren(); }; @@ -409,6 +421,20 @@ const renderItems = (items: CommandPaletteItem[]): void => { selectItem(0, false); }; +const appendItems = (items: CommandPaletteItem[]): void => { + if (!searchResults || items.length === 0) { + return; + } + + const existingIDs = new Set(resultItems.map((item) => item.id)); + const nextItems = items.filter((item) => !existingIDs.has(item.id)); + if (nextItems.length === 0) { + return; + } + + renderItems([...resultItems, ...nextItems]); +}; + const visibleSearchItems = (items: CommandPaletteItem[], query: string): CommandPaletteItem[] => { if (query === "") { return []; @@ -417,6 +443,23 @@ const visibleSearchItems = (items: CommandPaletteItem[], query: string): Command return items.filter((item) => item.type === "anime"); }; +const parseCommandPaletteResponse = (payload: unknown): CommandPaletteResponse => { + if (Array.isArray(payload)) { + return { items: payload as CommandPaletteItem[], hasNextPage: false }; + } + + if (payload && typeof payload === "object" && Array.isArray((payload as CommandPaletteResponse).items)) { + const response = payload as CommandPaletteResponse; + return { + items: response.items, + hasNextPage: response.hasNextPage, + nextPage: response.nextPage, + }; + } + + return { items: [], hasNextPage: false }; +}; + const renderPendingQuery = (query: string): void => { if (!query) { return; @@ -441,7 +484,9 @@ const fetchSearchItems = (query: string): void => { const cached = responseCache.get(query); if (cached) { - renderItems(cached); + nextSearchPage = cached.nextPage; + hasNextSearchPage = cached.hasNextPage; + renderItems(visibleSearchItems(cached.items, query)); return; } @@ -453,18 +498,21 @@ const fetchSearchItems = (query: string): void => { fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal }) .then((res: Response) => { if (!res.ok) { - return []; + return { items: [], hasNextPage: false }; } return res.json(); }) - .then((items: CommandPaletteItem[]) => { + .then((payload: unknown) => { if (controller.signal.aborted || query !== lastQuery) { return; } - const visibleItems = visibleSearchItems(items, query); + const response = parseCommandPaletteResponse(payload); + const visibleItems = visibleSearchItems(response.items, query); activeRequestController = undefined; - responseCache.set(query, visibleItems); + nextSearchPage = response.nextPage; + hasNextSearchPage = response.hasNextPage; + responseCache.set(query, response); renderItems(visibleItems); }) .catch((err: unknown) => { @@ -478,6 +526,62 @@ const fetchSearchItems = (query: string): void => { }); }; +const fetchNextSearchPage = (): void => { + if (!lastQuery || !hasNextSearchPage || !nextSearchPage || isFetchingNextPage) { + return; + } + + isFetchingNextPage = true; + const query = lastQuery; + const page = nextSearchPage; + + fetch( + "/api/command-palette?q=" + encodeURIComponent(query) + "&page=" + encodeURIComponent(String(page)), + ) + .then((res: Response) => { + if (!res.ok) { + return { items: [], hasNextPage: false }; + } + return res.json(); + }) + .then((payload: unknown) => { + if (query !== lastQuery) { + return; + } + + const response = parseCommandPaletteResponse(payload); + const visibleItems = visibleSearchItems(response.items, query); + const cached = responseCache.get(query); + if (cached) { + responseCache.set(query, { + items: [...cached.items, ...response.items], + hasNextPage: response.hasNextPage, + nextPage: response.nextPage, + }); + } + nextSearchPage = response.nextPage; + hasNextSearchPage = response.hasNextPage; + appendItems(visibleItems); + }) + .catch((err: unknown) => { + console.error("Search overlay pagination error:", err); + }) + .finally(() => { + isFetchingNextPage = false; + }); +}; + +const onResultsScroll = (): void => { + if (!searchResults) { + return; + } + + const remainingScroll = searchResults.scrollHeight - searchResults.scrollTop - searchResults.clientHeight; + if (remainingScroll < 480) { + fetchNextSearchPage(); + } +}; + const scheduleFetch = (): void => { if (fetchTimeout) { window.clearTimeout(fetchTimeout); @@ -606,6 +710,7 @@ const initSearchOverlay = (): void => { }); searchInput.addEventListener("input", scheduleFetch); searchInput.addEventListener("keydown", onInputKeydown); + searchResults.addEventListener("scroll", onResultsScroll); document.addEventListener("click", onDocumentClick); document.addEventListener("keydown", onDocumentKeydown); searchDialog?.setAttribute("aria-hidden", "true"); diff --git a/templates/anime.gohtml b/templates/anime.gohtml index 7d60b98..285e13c 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -341,7 +341,7 @@
diff --git a/templates/components/watch_order.gohtml b/templates/components/watch_order.gohtml index 551f736..6800c81 100644 --- a/templates/components/watch_order.gohtml +++ b/templates/components/watch_order.gohtml @@ -1,6 +1,29 @@ {{define "watch_order"}} -
-

Watch Order

+
+
+

Watch Order

+ {{if ne .Mode "complete"}} + + {{else}} + + {{end}} +
{{range .Relations}}
@@ -21,7 +44,7 @@ {{define "watch_order_loading"}}