Fix ebook match dialog to support generic provider IDs (#182)

* fix(metadata): match non-video items with content-aware providers

* fix(web): guard invalid match year parsing
This commit is contained in:
Quick
2026-06-18 20:19:47 -04:00
committed by GitHub
parent b84c040d27
commit de931f784a
9 changed files with 320 additions and 77 deletions
+29 -16
View File
@@ -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 {
+56 -2
View File
@@ -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)
}
+19 -17
View File
@@ -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,
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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
+3
View File
@@ -4046,6 +4046,8 @@ export interface ItemMatchSearchRequest {
imdb_id?: string;
tmdb_id?: string;
tvdb_id?: string;
provider_ids?: Record<string, string>;
library_id?: number;
}
export interface ItemMatchSearchResponse {
@@ -4054,6 +4056,7 @@ export interface ItemMatchSearchResponse {
export interface ItemMatchApplyRequest {
provider_ids: Record<string, string>;
library_id?: number;
}
// Image selector types
+173 -37
View File
@@ -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<ProviderIDInput[]>([
{ id: 0, provider: "", value: "" },
]);
const nextProviderIdInputId = useRef(1);
const [selectedCandidate, setSelectedCandidate] = useState<MatchCandidate | null>(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<Record<string, string>>((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"
/>
</div>
<div>
<Label htmlFor="match-imdb">IMDb ID</Label>
<Input
id="match-imdb"
value={imdbId}
onChange={(e) => setImdbId(e.target.value)}
placeholder="tt1234567"
/>
</div>
<div>
<Label htmlFor="match-tmdb">TMDB ID</Label>
<Input
id="match-tmdb"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
placeholder="12345"
/>
</div>
<div>
<Label htmlFor="match-tvdb">TVDB ID</Label>
<Input
id="match-tvdb"
value={tvdbId}
onChange={(e) => setTvdbId(e.target.value)}
placeholder="12345"
/>
</div>
{showVideoExternalIds ? (
<>
<div>
<Label htmlFor="match-imdb">IMDb ID</Label>
<Input
id="match-imdb"
value={imdbId}
onChange={(e) => setImdbId(e.target.value)}
placeholder="tt1234567"
/>
</div>
<div>
<Label htmlFor="match-tmdb">TMDB ID</Label>
<Input
id="match-tmdb"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
placeholder="12345"
/>
</div>
<div>
<Label htmlFor="match-tvdb">TVDB ID</Label>
<Input
id="match-tvdb"
value={tvdbId}
onChange={(e) => setTvdbId(e.target.value)}
placeholder="12345"
/>
</div>
</>
) : (
<div className="col-span-2 space-y-2">
<Label>Provider IDs</Label>
{providerIdInputs.map((entry, index) => (
<div
key={entry.id}
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_2rem] gap-2"
>
<Input
value={entry.provider}
onChange={(e) => updateProviderIdInput(index, "provider", e.target.value)}
placeholder="isbn"
aria-label="Provider"
/>
<Input
value={entry.value}
onChange={(e) => updateProviderIdInput(index, "value", e.target.value)}
placeholder="978..."
aria-label="Provider ID"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => removeProviderIdInput(index)}
disabled={providerIdInputs.length === 1 && !entry.provider && !entry.value}
aria-label="Remove provider ID"
title="Remove provider ID"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
onClick={addProviderIdInput}
aria-label="Add provider ID"
title="Add provider ID"
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
</div>
<Button
+8 -2
View File
@@ -297,7 +297,10 @@ export function useSearchItemMatchCandidates(contentId: string) {
});
}
type ApplyMatchItem = Pick<ItemDetail, "content_id" | "type" | "series_id" | "season_number">;
type ApplyMatchItem = Pick<ItemDetail, "content_id" | "series_id" | "season_number"> & {
type: string;
library_id?: number;
};
export function useApplyItemMatch() {
const queryClient = useQueryClient();
@@ -312,7 +315,10 @@ export function useApplyItemMatch() {
}) => {
return api(`/admin/items/${itemPathID(item.content_id)}/match/apply`, {
method: "POST",
body: JSON.stringify({ provider_ids: providerIds }),
body: JSON.stringify({
provider_ids: providerIds,
library_id: item.library_id,
}),
});
},
onSuccess: async (_, { item }) => {
+2 -2
View File
@@ -1871,7 +1871,7 @@ function UnmatchedItemsSection() {
library_id: matchItem.library_id,
title: matchItem.title,
year: matchItem.year,
type: matchItem.content_type as "movie" | "series" | "season" | "episode",
type: matchItem.content_type,
}}
open={true}
onOpenChange={(open) => {
@@ -2047,7 +2047,7 @@ function StaleIDsSection({ staleIDs }: { staleIDs: StaleMediaID[] }) {
library_id: matchItem.library_id,
title: matchItem.title,
year: matchItem.year,
type: matchItem.content_type as "movie" | "series" | "season" | "episode",
type: matchItem.content_type,
}}
open={true}
onOpenChange={(open) => {