The watchlist catalog page silently returned the stored list order while its sort dropdown claimed "Date Added", and mirroring a provider's list order (e.g. MDBList) was off by default, so synced watchlists appeared in first-sync-time order with no way to tell what was happening. - Web: the watchlist source now uses the same source-order sentinel as collections — the dropdown shows "List Order" as the default, and an explicit "Date Added" pick sends sort=added_at instead of being stripped (previously indistinguishable from the default). - Server: on personal lists (watchlist/favorites) an explicit added_at sort now takes the source-order path, where added_at means "date added to the list"; the query executor path sorted by the library's created_at instead. History keeps the executor path since its ID loading ignores the sort. - watchsync: new connections default sync_watchlist_order_enabled to true, with a migration flipping existing rows to match. Providers without the provides_watchlist_order capability ignore the flag at sync time. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
import {
|
|
createEmptyQueryDefinition,
|
|
normalizeQueryDefinition,
|
|
type CatalogSource,
|
|
type QueryDefinition,
|
|
type QueryGroup,
|
|
type QueryRule,
|
|
} from "@/api/types";
|
|
import { normalizeQuerySortField } from "@/lib/querySortOptions";
|
|
|
|
const GROUP_MATCH_PATTERN = /^groups\[(\d+)\]\[match\]$/;
|
|
const GROUP_RULE_PATTERN = /^groups\[(\d+)\]\[rules\]\[(\d+)\]\[(field|op|value)\]$/;
|
|
const GROUP_RULE_VALUE_PATTERN = /^groups\[(\d+)\]\[rules\]\[(\d+)\]\[value\]\[(\d+)\]$/;
|
|
|
|
export interface CatalogSearchState {
|
|
source: CatalogSource;
|
|
title?: string;
|
|
q?: string;
|
|
scope?: "home" | "library";
|
|
section_id?: string;
|
|
library_id?: number;
|
|
collection_id?: string;
|
|
person_id?: string;
|
|
type_override?: string;
|
|
uses_source_order?: boolean;
|
|
query_definition: QueryDefinition;
|
|
}
|
|
|
|
export interface SectionCatalogDestination {
|
|
scope: "home" | "library";
|
|
sectionId: string;
|
|
libraryId?: number;
|
|
title?: string;
|
|
}
|
|
|
|
const SECTION_BROWSE_SUPPORT_TYPES = new Set([
|
|
"collection",
|
|
"custom_filter",
|
|
"genre",
|
|
"random",
|
|
"recently_added",
|
|
]);
|
|
|
|
interface GroupBuilder {
|
|
match?: "all" | "any";
|
|
rules: Map<number, RuleBuilder>;
|
|
}
|
|
|
|
interface RuleBuilder {
|
|
field?: string;
|
|
op?: string;
|
|
value?: unknown;
|
|
values?: Map<number, unknown>;
|
|
}
|
|
|
|
const overlaySources = new Set<CatalogSource>([
|
|
"query",
|
|
"favorites",
|
|
"watchlist",
|
|
"history",
|
|
"person",
|
|
"library_collection",
|
|
"user_collection",
|
|
]);
|
|
|
|
function isCollectionSource(source: CatalogSource): boolean {
|
|
return source === "library_collection" || source === "user_collection";
|
|
}
|
|
|
|
// Sources whose default ordering is the stored list order rather than a sort
|
|
// field. The watchlist's stored order can mirror a watch provider's list
|
|
// order (e.g. MDBList) via sort_index.
|
|
export function catalogSourceSupportsSourceOrder(source: CatalogSource): boolean {
|
|
return isCollectionSource(source) || source === "watchlist";
|
|
}
|
|
|
|
export function catalogSourceAllowsOverlay(source: CatalogSource): boolean {
|
|
return overlaySources.has(source);
|
|
}
|
|
|
|
export function parseCatalogSearchParams(searchParams: URLSearchParams): CatalogSearchState {
|
|
const source = parseCatalogSource(searchParams.get("source"));
|
|
const title = readString(searchParams.get("title"));
|
|
|
|
const baseState: CatalogSearchState = {
|
|
source,
|
|
title,
|
|
q: readString(searchParams.get("q")),
|
|
scope: undefined,
|
|
section_id: undefined,
|
|
library_id: parsePositiveInt(searchParams.get("library_id")) ?? undefined,
|
|
collection_id: readString(searchParams.get("collection_id")),
|
|
person_id: readPersonId(searchParams.get("person_id")),
|
|
query_definition: createEmptyQueryDefinition(),
|
|
};
|
|
|
|
if (source === "section") {
|
|
baseState.scope = searchParams.get("scope") === "home" ? "home" : "library";
|
|
baseState.section_id = readString(searchParams.get("section_id"));
|
|
}
|
|
|
|
if (!catalogSourceAllowsOverlay(source)) {
|
|
return baseState;
|
|
}
|
|
|
|
const groups = parseCatalogGroups(searchParams);
|
|
const implicitGroups: QueryGroup[] = [];
|
|
const type = readString(searchParams.get("type"));
|
|
const genre = readString(searchParams.get("genre"));
|
|
const yearMin = parsePositiveInt(searchParams.get("year_min"));
|
|
const yearMax = parsePositiveInt(searchParams.get("year_max"));
|
|
const contentRating = readString(searchParams.get("content_rating"));
|
|
|
|
if (genre) {
|
|
implicitGroups.push({
|
|
match: "all",
|
|
rules: [{ field: "genre", op: "contains", value: genre }],
|
|
});
|
|
}
|
|
|
|
if (yearMin != null && yearMax != null) {
|
|
implicitGroups.push({
|
|
match: "all",
|
|
rules: [{ field: "year", op: "between", value: [yearMin, yearMax] }],
|
|
});
|
|
} else if (yearMin != null) {
|
|
implicitGroups.push({
|
|
match: "all",
|
|
rules: [{ field: "year", op: "gte", value: yearMin }],
|
|
});
|
|
} else if (yearMax != null) {
|
|
implicitGroups.push({
|
|
match: "all",
|
|
rules: [{ field: "year", op: "lte", value: yearMax }],
|
|
});
|
|
}
|
|
|
|
if (contentRating) {
|
|
implicitGroups.push({
|
|
match: "all",
|
|
rules: [{ field: "content_rating", op: "is", value: contentRating }],
|
|
});
|
|
}
|
|
|
|
const rawSort = readString(searchParams.get("sort"));
|
|
const rawOrder = readString(searchParams.get("order"));
|
|
const sort = normalizeQuerySortField(rawSort);
|
|
const hasExplicitSort = Boolean(rawSort || rawOrder);
|
|
const queryLimit = parsePositiveInt(searchParams.get("query_limit")) ?? undefined;
|
|
const mediaScope =
|
|
type === "movie" ||
|
|
type === "series" ||
|
|
type === "episode" ||
|
|
type === "audiobook" ||
|
|
type === "ebook" ||
|
|
type === "video"
|
|
? type
|
|
: undefined;
|
|
|
|
baseState.query_definition = normalizeQueryDefinition({
|
|
library_ids: baseState.library_id ? [baseState.library_id] : [],
|
|
media_scope: mediaScope === "episode" && isCollectionSource(source) ? undefined : mediaScope,
|
|
match: searchParams.get("match") === "any" ? "any" : "all",
|
|
groups: [...implicitGroups, ...groups],
|
|
sort:
|
|
sort || hasExplicitSort
|
|
? {
|
|
field: sort ?? "added_at",
|
|
order: rawOrder === "asc" || rawOrder === "desc" ? rawOrder : undefined,
|
|
}
|
|
: undefined,
|
|
limit: queryLimit,
|
|
});
|
|
baseState.uses_source_order = catalogSourceSupportsSourceOrder(source) && !hasExplicitSort;
|
|
|
|
return baseState;
|
|
}
|
|
|
|
export function buildCatalogHref(state: CatalogSearchState): string {
|
|
const params = buildCatalogApiSearchParams(state);
|
|
return `/catalog?${params.toString()}`;
|
|
}
|
|
|
|
export function buildQueryCatalogHref(q?: string): string {
|
|
return buildCatalogHref({
|
|
source: "query",
|
|
q,
|
|
query_definition: createEmptyQueryDefinition(),
|
|
});
|
|
}
|
|
|
|
export function buildPersonalCatalogHref(source: "favorites" | "watchlist" | "history"): string {
|
|
return buildCatalogHref({
|
|
source,
|
|
uses_source_order: catalogSourceSupportsSourceOrder(source),
|
|
query_definition: createEmptyQueryDefinition(),
|
|
});
|
|
}
|
|
|
|
export function buildPersonCatalogHref(personId: string): string {
|
|
return `/person/${personId}`;
|
|
}
|
|
|
|
export function buildSectionCatalogHref(destination: SectionCatalogDestination): string {
|
|
return buildCatalogHref({
|
|
source: "section",
|
|
scope: destination.scope,
|
|
section_id: destination.sectionId,
|
|
library_id: destination.scope === "library" ? destination.libraryId : undefined,
|
|
title: destination.title,
|
|
query_definition: createEmptyQueryDefinition(),
|
|
});
|
|
}
|
|
|
|
export function buildLibraryCollectionCatalogHref(collectionId: string, title?: string): string {
|
|
return buildCatalogHref({
|
|
source: "library_collection",
|
|
collection_id: collectionId,
|
|
title,
|
|
uses_source_order: true,
|
|
query_definition: createEmptyQueryDefinition(),
|
|
});
|
|
}
|
|
|
|
export function buildUserCollectionCatalogHref(collectionId: string, title?: string): string {
|
|
return buildCatalogHref({
|
|
source: "user_collection",
|
|
collection_id: collectionId,
|
|
title,
|
|
uses_source_order: true,
|
|
query_definition: createEmptyQueryDefinition(),
|
|
});
|
|
}
|
|
|
|
export function buildLegacyBrowseCatalogHref(searchParams: URLSearchParams): string | null {
|
|
const source = searchParams.get("source");
|
|
|
|
if (source === "collection") {
|
|
const collectionId = readString(searchParams.get("collection_id"));
|
|
if (!collectionId) {
|
|
return null;
|
|
}
|
|
|
|
return buildLibraryCollectionCatalogHref(collectionId, readString(searchParams.get("title")));
|
|
}
|
|
|
|
if (source !== "section") {
|
|
return null;
|
|
}
|
|
|
|
const sectionId = readString(searchParams.get("section_id"));
|
|
if (!sectionId) {
|
|
return null;
|
|
}
|
|
|
|
const scope = searchParams.get("scope") === "home" ? "home" : "library";
|
|
if (scope === "library") {
|
|
const libraryId = parsePositiveInt(searchParams.get("library_id"));
|
|
if (!libraryId) {
|
|
return null;
|
|
}
|
|
|
|
return buildSectionCatalogHref({
|
|
scope,
|
|
sectionId,
|
|
libraryId,
|
|
title: readString(searchParams.get("title")),
|
|
});
|
|
}
|
|
|
|
return buildSectionCatalogHref({
|
|
scope,
|
|
sectionId,
|
|
title: readString(searchParams.get("title")),
|
|
});
|
|
}
|
|
|
|
export function isSectionBrowseSupported(sectionType: string): boolean {
|
|
return SECTION_BROWSE_SUPPORT_TYPES.has(sectionType);
|
|
}
|
|
|
|
export function buildCatalogApiSearchParams(state: CatalogSearchState): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
params.set("source", state.source);
|
|
|
|
if (state.source === "section") {
|
|
if (state.scope) {
|
|
params.set("scope", state.scope);
|
|
}
|
|
if (state.section_id) {
|
|
params.set("section_id", state.section_id);
|
|
}
|
|
if (state.scope === "library" && state.library_id) {
|
|
params.set("library_id", String(state.library_id));
|
|
}
|
|
if (state.title) {
|
|
params.set("title", state.title);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
if (state.collection_id) {
|
|
params.set("collection_id", state.collection_id);
|
|
}
|
|
if (state.source === "person" && state.person_id) {
|
|
params.set("person_id", String(state.person_id));
|
|
}
|
|
if (state.title) {
|
|
params.set("title", state.title);
|
|
}
|
|
if (state.q) {
|
|
params.set("q", state.q);
|
|
}
|
|
const stateIsCollectionSource = isCollectionSource(state.source);
|
|
if (state.type_override && !(stateIsCollectionSource && state.type_override === "episode")) {
|
|
params.set("type", state.type_override);
|
|
} else if (
|
|
state.query_definition.media_scope &&
|
|
!(stateIsCollectionSource && state.query_definition.media_scope === "episode")
|
|
) {
|
|
params.set("type", state.query_definition.media_scope);
|
|
}
|
|
const effectiveLibraryID = state.library_id ?? state.query_definition.library_ids[0];
|
|
if (state.library_id) {
|
|
params.set("library_id", String(state.library_id));
|
|
} else if (effectiveLibraryID) {
|
|
params.set("library_id", String(effectiveLibraryID));
|
|
}
|
|
|
|
if (state.query_definition.sort.field === "relevance") {
|
|
params.set("sort", "relevance");
|
|
params.set("order", state.query_definition.sort.order);
|
|
} else if (
|
|
!state.uses_source_order &&
|
|
state.query_definition.sort.field &&
|
|
(state.query_definition.sort.field !== "added_at" ||
|
|
(state.source === "query" && effectiveLibraryID != null) ||
|
|
// Watchlist defaults to source order, so an explicit Date Added pick
|
|
// must be sent to distinguish it (the server maps it to list added-at).
|
|
state.source === "watchlist" ||
|
|
state.source === "library_collection" ||
|
|
state.source === "user_collection")
|
|
) {
|
|
params.set("sort", state.query_definition.sort.field);
|
|
if (state.query_definition.sort.order) {
|
|
params.set("order", state.query_definition.sort.order);
|
|
}
|
|
}
|
|
if (state.query_definition.limit != null && state.query_definition.limit > 0) {
|
|
params.set("query_limit", String(state.query_definition.limit));
|
|
}
|
|
|
|
state.query_definition.groups.forEach((group, groupIndex) => {
|
|
params.set(`groups[${groupIndex}][match]`, group.match);
|
|
group.rules.forEach((rule, ruleIndex) => {
|
|
params.set(`groups[${groupIndex}][rules][${ruleIndex}][field]`, rule.field);
|
|
params.set(`groups[${groupIndex}][rules][${ruleIndex}][op]`, rule.op);
|
|
if (Array.isArray(rule.value)) {
|
|
rule.value.forEach((entry, valueIndex) => {
|
|
params.set(
|
|
`groups[${groupIndex}][rules][${ruleIndex}][value][${valueIndex}]`,
|
|
String(entry),
|
|
);
|
|
});
|
|
} else {
|
|
params.set(`groups[${groupIndex}][rules][${ruleIndex}][value]`, String(rule.value));
|
|
}
|
|
});
|
|
});
|
|
|
|
return params;
|
|
}
|
|
|
|
function parseCatalogSource(value: string | null): CatalogSource {
|
|
switch (value) {
|
|
case "section":
|
|
case "library_collection":
|
|
case "user_collection":
|
|
case "favorites":
|
|
case "watchlist":
|
|
case "history":
|
|
case "person":
|
|
return value;
|
|
default:
|
|
return "query";
|
|
}
|
|
}
|
|
|
|
function readString(value: string | null): string | undefined {
|
|
const normalized = value?.trim();
|
|
return normalized ? normalized : undefined;
|
|
}
|
|
|
|
function readPersonId(value: string | null): string | undefined {
|
|
const normalized = value?.trim();
|
|
return normalized ? normalized : undefined;
|
|
}
|
|
|
|
function parsePositiveInt(value: string | null): number | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
function parseCatalogGroups(searchParams: URLSearchParams): QueryGroup[] {
|
|
const groups = new Map<number, GroupBuilder>();
|
|
|
|
searchParams.forEach((rawValue, key) => {
|
|
const groupMatch = key.match(GROUP_MATCH_PATTERN);
|
|
if (groupMatch) {
|
|
const groupIndex = Number(groupMatch[1]);
|
|
const group = ensureGroup(groups, groupIndex);
|
|
group.match = rawValue === "any" ? "any" : "all";
|
|
return;
|
|
}
|
|
|
|
const ruleMatch = key.match(GROUP_RULE_PATTERN);
|
|
if (ruleMatch) {
|
|
const groupIndex = Number(ruleMatch[1]);
|
|
const ruleIndex = Number(ruleMatch[2]);
|
|
const fieldName = ruleMatch[3];
|
|
const rule = ensureRule(groups, groupIndex, ruleIndex);
|
|
if (fieldName === "field") {
|
|
rule.field = rawValue;
|
|
} else if (fieldName === "op") {
|
|
rule.op = rawValue;
|
|
} else {
|
|
rule.value = parseScalar(rawValue);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const indexedValueMatch = key.match(GROUP_RULE_VALUE_PATTERN);
|
|
if (indexedValueMatch) {
|
|
const groupIndex = Number(indexedValueMatch[1]);
|
|
const ruleIndex = Number(indexedValueMatch[2]);
|
|
const valueIndex = Number(indexedValueMatch[3]);
|
|
const rule = ensureRule(groups, groupIndex, ruleIndex);
|
|
rule.values ??= new Map<number, unknown>();
|
|
rule.values.set(valueIndex, parseScalar(rawValue));
|
|
}
|
|
});
|
|
|
|
return Array.from(groups.entries())
|
|
.sort(([left], [right]) => left - right)
|
|
.map(([, group]) => ({
|
|
match: group.match ?? "all",
|
|
rules: Array.from(group.rules.entries())
|
|
.sort(([left], [right]) => left - right)
|
|
.map(([, rule]) => ({
|
|
field: rule.field ?? "genre",
|
|
op: rule.op ?? "is",
|
|
value:
|
|
rule.values && rule.values.size > 0
|
|
? (Array.from(rule.values.entries())
|
|
.sort(([left], [right]) => left - right)
|
|
.map(([, value]) => value) as QueryRule["value"])
|
|
: ((rule.value ?? "") as QueryRule["value"]),
|
|
})),
|
|
}))
|
|
.filter((group) => group.rules.length > 0);
|
|
}
|
|
|
|
function ensureGroup(groups: Map<number, GroupBuilder>, index: number): GroupBuilder {
|
|
let group = groups.get(index);
|
|
if (!group) {
|
|
group = { rules: new Map<number, RuleBuilder>() };
|
|
groups.set(index, group);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
function ensureRule(
|
|
groups: Map<number, GroupBuilder>,
|
|
groupIndex: number,
|
|
ruleIndex: number,
|
|
): RuleBuilder {
|
|
const group = ensureGroup(groups, groupIndex);
|
|
let rule = group.rules.get(ruleIndex);
|
|
if (!rule) {
|
|
rule = {};
|
|
group.rules.set(ruleIndex, rule);
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
function parseScalar(value: string): string | number | boolean {
|
|
if (value === "true") {
|
|
return true;
|
|
}
|
|
if (value === "false") {
|
|
return false;
|
|
}
|
|
if (/^-?\d+$/.test(value)) {
|
|
return Number(value);
|
|
}
|
|
return value;
|
|
}
|