diff --git a/internal/api/handlers/admin_match.go b/internal/api/handlers/admin_match.go index 2eca8a73..c1e85b44 100644 --- a/internal/api/handlers/admin_match.go +++ b/internal/api/handlers/admin_match.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -56,12 +57,13 @@ func NewAdminMatchHandler( // --- Request/Response types --- type matchSearchRequest struct { - Title string `json:"title"` - Year int `json:"year"` - ImdbID string `json:"imdb_id"` - TmdbID string `json:"tmdb_id"` - TvdbID string `json:"tvdb_id"` - LibraryID *int `json:"library_id,omitempty"` + Title string `json:"title"` + Year int `json:"year"` + ImdbID string `json:"imdb_id"` + TmdbID string `json:"tmdb_id"` + TvdbID string `json:"tvdb_id"` + ProviderIDs map[string]string `json:"provider_ids,omitempty"` + LibraryID *int `json:"library_id,omitempty"` } type matchSearchResponse struct { @@ -119,7 +121,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite Title: req.Title, Year: req.Year, ContentType: item.Type, - ProviderIDs: make(map[string]string), + ProviderIDs: normalizeMatchProviderIDs(req.ProviderIDs), } if query.Title == "" { query.Title = item.Title @@ -129,15 +131,9 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite } // Inject any provider IDs from the request. - if req.ImdbID != "" { - query.ProviderIDs["imdb"] = req.ImdbID - } - if req.TmdbID != "" { - query.ProviderIDs["tmdb"] = req.TmdbID - } - if req.TvdbID != "" { - query.ProviderIDs["tvdb"] = req.TvdbID - } + setMatchProviderID(query.ProviderIDs, "imdb", req.ImdbID) + setMatchProviderID(query.ProviderIDs, "tmdb", req.TmdbID) + setMatchProviderID(query.ProviderIDs, "tvdb", req.TvdbID) candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID) if err != nil { @@ -211,6 +207,23 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http. }) } +func normalizeMatchProviderIDs(input map[string]string) map[string]string { + out := make(map[string]string, len(input)) + for provider, providerID := range input { + setMatchProviderID(out, provider, providerID) + } + return out +} + +func setMatchProviderID(providerIDs map[string]string, provider string, providerID string) { + provider = strings.ToLower(strings.TrimSpace(provider)) + providerID = strings.TrimSpace(providerID) + if provider == "" || providerID == "" { + return + } + providerIDs[provider] = providerID +} + // PoolFolderLookup implements MatchFolderLookup using a direct pgx pool query // against the media_item_libraries table. type PoolFolderLookup struct { diff --git a/internal/api/handlers/admin_match_test.go b/internal/api/handlers/admin_match_test.go index 8846a376..fb480108 100644 --- a/internal/api/handlers/admin_match_test.go +++ b/internal/api/handlers/admin_match_test.go @@ -199,6 +199,58 @@ func TestAdminMatchSearch_FallsBackToItemMetadata(t *testing.T) { } } +func TestAdminMatchSearch_AcceptsGenericProviderIDsAndLibraryID(t *testing.T) { + items := &fakeMatchItemLookup{ + items: map[string]*models.MediaItem{ + "ebook-1": {ContentID: "ebook-1", Title: "Example Book", Year: 2024, Type: "ebook"}, + }, + } + folders := &fakeMatchFolderLookup{ + folderIDs: map[string][]int{"ebook-1": {7, 8}}, + } + captureSvc := &capturingMetadataService{ + inner: &fakeMatchMetadataService{searchResults: []metadata.MatchCandidate{}}, + } + + h := NewAdminMatchHandler(items, folders, captureSvc) + router := buildMatchRouter(h) + + libraryID := 8 + body, _ := json.Marshal(matchSearchRequest{ + ProviderIDs: map[string]string{ + " ISBN ": " 9780000000001 ", + "goodreads": " ", + }, + TmdbID: "12345", + LibraryID: &libraryID, + }) + req := httptest.NewRequest(http.MethodPost, "/admin/items/ebook-1/match/search", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + if captureSvc.lastFolderID != 8 { + t.Fatalf("folderID = %d, want 8", captureSvc.lastFolderID) + } + if captureSvc.lastQuery.ContentType != "ebook" { + t.Fatalf("ContentType = %q, want ebook", captureSvc.lastQuery.ContentType) + } + if got := captureSvc.lastQuery.ProviderIDs["isbn"]; got != "9780000000001" { + t.Fatalf("ProviderIDs[isbn] = %q, want 9780000000001", got) + } + if got := captureSvc.lastQuery.ProviderIDs["tmdb"]; got != "12345" { + t.Fatalf("ProviderIDs[tmdb] = %q, want 12345", got) + } + if _, ok := captureSvc.lastQuery.ProviderIDs["goodreads"]; ok { + t.Fatalf("blank provider IDs should be ignored, got %v", captureSvc.lastQuery.ProviderIDs) + } +} + func TestAdminMatchApply_PreservesContentID(t *testing.T) { items := &fakeMatchItemLookup{ items: map[string]*models.MediaItem{ @@ -293,12 +345,14 @@ func TestAdminMatchApply_ItemNotFound(t *testing.T) { // capturingMetadataService wraps a fake to capture the query passed to SearchAndNormalize. type capturingMetadataService struct { - inner *fakeMatchMetadataService - lastQuery metadata.SearchQuery + inner *fakeMatchMetadataService + lastQuery metadata.SearchQuery + lastFolderID int } func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) { c.lastQuery = query + c.lastFolderID = folderID return c.inner.SearchAndNormalize(ctx, query, folderID) } diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 62b22ea1..b0162db6 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -879,10 +879,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } // Determine the item-level content level for phases 1-3. - itemLevel := "series" - if contentType == "movie" || contentType == "movies" { - itemLevel = "movie" - } + itemLevel := providerChainContentLevel(contentType) itemChain, err := resolveChain(itemLevel) if err != nil { return nil, err @@ -1008,10 +1005,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques if contentType == "" && req.ContentID != "" { if existing, err := s.itemRepo.GetByID(ctx, req.ContentID); err == nil { contentType = existing.Type - itemLevel = "series" - if contentType == "movie" || contentType == "movies" { - itemLevel = "movie" - } + itemLevel = providerChainContentLevel(contentType) itemChain, err = resolveChain(itemLevel) if err != nil { return nil, err @@ -1043,10 +1037,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } // Re-resolve itemChain now that contentType is known. - itemLevel = "series" - if contentType == "movie" || contentType == "movies" { - itemLevel = "movie" - } + itemLevel = providerChainContentLevel(contentType) itemChain, err = resolveChain(itemLevel) if err != nil { return nil, err @@ -2928,11 +2919,9 @@ func (s *MetadataService) SearchProviders(ctx context.Context, query SearchQuery query.Language = s.resolveFolderLanguage(ctx, folderID) } - // Search uses the item-level chain (movie or series). - contentLevel := "series" - if query.ContentType == "movie" || query.ContentType == "movies" { - contentLevel = "movie" - } + // Search uses the item-level chain. Video child records search through the + // series chain; non-video content searches through its own content level. + contentLevel := providerChainContentLevel(query.ContentType) chain, err := s.resolveChainCached(ctx, folderID, contentLevel) if err != nil { return nil, fmt.Errorf("resolving provider chain: %w", err) @@ -2953,6 +2942,19 @@ func (s *MetadataService) SearchProviders(ctx context.Context, query SearchQuery return allResults, nil } +func providerChainContentLevel(contentType string) string { + switch normalized := strings.ToLower(strings.TrimSpace(contentType)); normalized { + case "movie", "movies": + return "movie" + case "series", "show", "shows", "tv", "season", "seasons", "episode", "episodes": + return "series" + case "": + return "series" + default: + return normalized + } +} + // persistSeasonsAndEpisodes creates/updates seasons and episodes in the DB. func (s *MetadataService) persistSeasonsAndEpisodes( ctx context.Context, diff --git a/internal/metadata/service_content_level_test.go b/internal/metadata/service_content_level_test.go new file mode 100644 index 00000000..1ddd1f63 --- /dev/null +++ b/internal/metadata/service_content_level_test.go @@ -0,0 +1,29 @@ +package metadata + +import "testing" + +func TestProviderChainContentLevel(t *testing.T) { + tests := []struct { + name string + contentType string + want string + }{ + {name: "movie", contentType: "movie", want: "movie"}, + {name: "movies alias", contentType: "movies", want: "movie"}, + {name: "series", contentType: "series", want: "series"}, + {name: "season uses series chain", contentType: "season", want: "series"}, + {name: "episode uses series chain", contentType: "episode", want: "series"}, + {name: "ebook uses ebook chain", contentType: "ebook", want: "ebook"}, + {name: "manga uses manga chain", contentType: " Manga ", want: "manga"}, + {name: "audiobook uses audiobook chain", contentType: "audiobook", want: "audiobook"}, + {name: "empty defaults to series", contentType: "", want: "series"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := providerChainContentLevel(tt.contentType); got != tt.want { + t.Fatalf("providerChainContentLevel(%q) = %q, want %q", tt.contentType, got, tt.want) + } + }) + } +} diff --git a/internal/metadata/types.go b/internal/metadata/types.go index faada3ca..d175ac46 100644 --- a/internal/metadata/types.go +++ b/internal/metadata/types.go @@ -93,7 +93,7 @@ type MatchHints struct { type SearchQuery struct { Title string Year int - ContentType string // "movie" or "series" + ContentType string // media_items.type value ProviderIDs map[string]string // Accumulated IDs from prior providers Language string // ISO 639-1 code from library preference FilePath string diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c8fe294c..6c14dc26 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -4046,6 +4046,8 @@ export interface ItemMatchSearchRequest { imdb_id?: string; tmdb_id?: string; tvdb_id?: string; + provider_ids?: Record; + library_id?: number; } export interface ItemMatchSearchResponse { @@ -4054,6 +4056,7 @@ export interface ItemMatchSearchResponse { export interface ItemMatchApplyRequest { provider_ids: Record; + library_id?: number; } // Image selector types diff --git a/web/src/components/MatchItemDialog.tsx b/web/src/components/MatchItemDialog.tsx index 7abd52e0..99d3bab4 100644 --- a/web/src/components/MatchItemDialog.tsx +++ b/web/src/components/MatchItemDialog.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useMemo } from "react"; -import { Copy, Folder, Search } from "lucide-react"; +import { useState, useCallback, useMemo, useRef } from "react"; +import { Copy, Folder, Plus, Search, X } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import type { FileVersion, ItemDetail, MatchCandidate } from "@/api/types"; +import type { FileVersion, ItemDetail, ItemMatchSearchRequest, MatchCandidate } from "@/api/types"; import MediaLocations from "@/components/MediaLocations"; import { useSearchItemMatchCandidates, useApplyItemMatch } from "@/hooks/queries/items"; import { useCatalogItemDetail } from "@/hooks/queries/catalogRead"; @@ -15,27 +15,57 @@ import { cn } from "@/lib/utils"; type MatchableItem = Pick< ItemDetail, - "content_id" | "title" | "year" | "type" | "series_id" | "season_number" + "content_id" | "title" | "year" | "series_id" | "season_number" > & { + type: string; library_id?: number; versions?: FileVersion[]; folder_paths?: string[]; }; +type ProviderIDInput = { + id: number; + provider: string; + value: string; +}; + interface MatchItemDialogProps { item: MatchableItem; open: boolean; onOpenChange: (open: boolean) => void; } +function isVideoMatchType(type: string): boolean { + switch (type.trim().toLowerCase()) { + case "movie": + case "movies": + case "series": + case "show": + case "shows": + case "tv": + case "season": + case "seasons": + case "episode": + case "episodes": + return true; + default: + return false; + } +} + export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemDialogProps) { const [title, setTitle] = useState(item.title); const [year, setYear] = useState(item.year ? String(item.year) : ""); const [imdbId, setImdbId] = useState(""); const [tmdbId, setTmdbId] = useState(""); const [tvdbId, setTvdbId] = useState(""); + const [providerIdInputs, setProviderIdInputs] = useState([ + { id: 0, provider: "", value: "" }, + ]); + const nextProviderIdInputId = useRef(1); const [selectedCandidate, setSelectedCandidate] = useState(null); const isSeries = item.type === "series"; + const showVideoExternalIds = isVideoMatchType(item.type); const needsItemDetail = open && item.versions === undefined; const { data: enrichedItem, isLoading: enrichedItemLoading } = useCatalogItemDetail( needsItemDetail ? item.content_id : undefined, @@ -47,17 +77,73 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD const candidates = searchMutation.data?.candidates ?? []; const effectiveItem = needsItemDetail ? (enrichedItem ?? item) : item; + const genericProviderIds = useMemo(() => { + if (showVideoExternalIds) return {}; + + return providerIdInputs.reduce>((acc, entry) => { + const provider = entry.provider.trim().toLowerCase(); + const value = entry.value.trim(); + if (provider && value) { + acc[provider] = value; + } + return acc; + }, {}); + }, [providerIdInputs, showVideoExternalIds]); const handleSearch = useCallback(() => { setSelectedCandidate(null); - searchMutation.mutate({ + const normalizedYear = year.trim(); + const parsedYear = normalizedYear === "" ? undefined : Number.parseInt(normalizedYear, 10); + const request: ItemMatchSearchRequest = { title: title || undefined, - year: year ? parseInt(year, 10) : undefined, - imdb_id: imdbId || undefined, - tmdb_id: tmdbId || undefined, - tvdb_id: tvdbId || undefined, + year: parsedYear !== undefined && Number.isFinite(parsedYear) ? parsedYear : undefined, + library_id: item.library_id, + }; + + if (showVideoExternalIds) { + request.imdb_id = imdbId || undefined; + request.tmdb_id = tmdbId || undefined; + request.tvdb_id = tvdbId || undefined; + } else if (Object.keys(genericProviderIds).length > 0) { + request.provider_ids = genericProviderIds; + } + + searchMutation.mutate(request); + }, [ + title, + year, + item.library_id, + showVideoExternalIds, + imdbId, + tmdbId, + tvdbId, + genericProviderIds, + searchMutation, + ]); + + const updateProviderIdInput = useCallback( + (index: number, field: keyof ProviderIDInput, value: string) => { + setProviderIdInputs((current) => + current.map((entry, entryIndex) => + entryIndex === index ? { ...entry, [field]: value } : entry, + ), + ); + }, + [], + ); + + const addProviderIdInput = useCallback(() => { + const id = nextProviderIdInputId.current; + nextProviderIdInputId.current += 1; + setProviderIdInputs((current) => [...current, { id, provider: "", value: "" }]); + }, []); + + const removeProviderIdInput = useCallback((index: number) => { + setProviderIdInputs((current) => { + const next = current.filter((_, entryIndex) => entryIndex !== index); + return next.length > 0 ? next : [{ id: 0, provider: "", value: "" }]; }); - }, [title, year, imdbId, tmdbId, tvdbId, searchMutation]); + }, []); const handleApply = useCallback(() => { if (!selectedCandidate) return; @@ -129,33 +215,83 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD type="number" /> -
- - setImdbId(e.target.value)} - placeholder="tt1234567" - /> -
-
- - setTmdbId(e.target.value)} - placeholder="12345" - /> -
-
- - setTvdbId(e.target.value)} - placeholder="12345" - /> -
+ {showVideoExternalIds ? ( + <> +
+ + setImdbId(e.target.value)} + placeholder="tt1234567" + /> +
+
+ + setTmdbId(e.target.value)} + placeholder="12345" + /> +
+
+ + setTvdbId(e.target.value)} + placeholder="12345" + /> +
+ + ) : ( +
+ + {providerIdInputs.map((entry, index) => ( +
+ updateProviderIdInput(index, "provider", e.target.value)} + placeholder="isbn" + aria-label="Provider" + /> + updateProviderIdInput(index, "value", e.target.value)} + placeholder="978..." + aria-label="Provider ID" + /> + +
+ ))} + +
+ )}