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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user