@media-engine/core 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -255
- package/README.ru.md +80 -0
- package/dist/cache/memory.d.ts +5 -0
- package/dist/cache/memory.js +43 -3
- package/dist/cache/memory.js.map +1 -1
- package/dist/cache/types.d.ts +2 -0
- package/dist/details/types.d.ts +6 -0
- package/dist/engine/availability.d.ts +6 -0
- package/dist/engine/availability.js +137 -0
- package/dist/engine/availability.js.map +1 -0
- package/dist/engine/circuit-breaker.d.ts +37 -0
- package/dist/engine/circuit-breaker.js +174 -0
- package/dist/engine/circuit-breaker.js.map +1 -0
- package/dist/engine/concurrency-limiter.d.ts +13 -0
- package/dist/engine/concurrency-limiter.js +109 -0
- package/dist/engine/concurrency-limiter.js.map +1 -0
- package/dist/engine/engine.d.ts +15 -5
- package/dist/engine/engine.js +510 -848
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/in-flight.d.ts +15 -0
- package/dist/engine/in-flight.js +80 -0
- package/dist/engine/in-flight.js.map +1 -0
- package/dist/engine/operation.d.ts +7 -0
- package/dist/engine/operation.js +49 -0
- package/dist/engine/operation.js.map +1 -0
- package/dist/engine/provider-calls.d.ts +52 -0
- package/dist/engine/provider-calls.js +262 -0
- package/dist/engine/provider-calls.js.map +1 -0
- package/dist/engine/query.d.ts +26 -0
- package/dist/engine/query.js +430 -0
- package/dist/engine/query.js.map +1 -0
- package/dist/engine/response-meta.d.ts +15 -0
- package/dist/engine/response-meta.js +33 -0
- package/dist/engine/response-meta.js.map +1 -0
- package/dist/engine/runtime.d.ts +5 -0
- package/dist/engine/runtime.js +48 -0
- package/dist/engine/runtime.js.map +1 -0
- package/dist/engine/search-discovery.d.ts +3 -0
- package/dist/engine/search-discovery.js +35 -0
- package/dist/engine/search-discovery.js.map +1 -0
- package/dist/engine/search-enrichment-shared.d.ts +23 -0
- package/dist/engine/search-enrichment-shared.js +68 -0
- package/dist/engine/search-enrichment-shared.js.map +1 -0
- package/dist/engine/search-enrichment.d.ts +42 -0
- package/dist/engine/search-enrichment.js +150 -0
- package/dist/engine/search-enrichment.js.map +1 -0
- package/dist/engine/search-identity-snapshot.d.ts +15 -0
- package/dist/engine/search-identity-snapshot.js +101 -0
- package/dist/engine/search-identity-snapshot.js.map +1 -0
- package/dist/engine/search-outcomes.d.ts +32 -0
- package/dist/engine/search-outcomes.js +118 -0
- package/dist/engine/search-outcomes.js.map +1 -0
- package/dist/engine/search-poster-enrichment.d.ts +31 -0
- package/dist/engine/search-poster-enrichment.js +93 -0
- package/dist/engine/search-poster-enrichment.js.map +1 -0
- package/dist/engine/search-result-freeze.d.ts +13 -0
- package/dist/engine/search-result-freeze.js +155 -0
- package/dist/engine/search-result-freeze.js.map +1 -0
- package/dist/engine/stale-fallback.d.ts +11 -0
- package/dist/engine/stale-fallback.js +60 -0
- package/dist/engine/stale-fallback.js.map +1 -0
- package/dist/engine/timeout-budget.d.ts +7 -0
- package/dist/engine/timeout-budget.js +31 -0
- package/dist/engine/timeout-budget.js.map +1 -0
- package/dist/engine/torrents.d.ts +22 -0
- package/dist/engine/torrents.js +188 -0
- package/dist/engine/torrents.js.map +1 -0
- package/dist/engine/types.d.ts +35 -0
- package/dist/errors/types.d.ts +1 -1
- package/dist/errors/types.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/media/types.d.ts +1 -1
- package/dist/merge/details-identity.d.ts +5 -0
- package/dist/merge/details-identity.js +63 -0
- package/dist/merge/details-identity.js.map +1 -0
- package/dist/merge/diversity.d.ts +15 -0
- package/dist/merge/diversity.js +52 -0
- package/dist/merge/diversity.js.map +1 -0
- package/dist/merge/fields.d.ts +25 -0
- package/dist/merge/fields.js +364 -0
- package/dist/merge/fields.js.map +1 -0
- package/dist/merge/grouping.d.ts +3 -0
- package/dist/merge/grouping.js +143 -0
- package/dist/merge/grouping.js.map +1 -0
- package/dist/merge/identity.d.ts +5 -0
- package/dist/merge/identity.js +21 -0
- package/dist/merge/identity.js.map +1 -0
- package/dist/merge/internal.d.ts +22 -0
- package/dist/merge/internal.js +15 -0
- package/dist/merge/internal.js.map +1 -0
- package/dist/merge/media-type.d.ts +5 -0
- package/dist/merge/media-type.js +15 -0
- package/dist/merge/media-type.js.map +1 -0
- package/dist/merge/priority.d.ts +9 -0
- package/dist/merge/priority.js +63 -0
- package/dist/merge/priority.js.map +1 -0
- package/dist/merge/scoring.d.ts +15 -0
- package/dist/merge/scoring.js +364 -0
- package/dist/merge/scoring.js.map +1 -0
- package/dist/merge/strategy.js +66 -955
- package/dist/merge/strategy.js.map +1 -1
- package/dist/merge/title.d.ts +4 -0
- package/dist/merge/title.js +22 -0
- package/dist/merge/title.js.map +1 -0
- package/dist/providers/registry.d.ts +4 -2
- package/dist/providers/registry.js +11 -2
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/types.d.ts +4 -0
- package/dist/response/types.d.ts +21 -0
- package/dist/search/types.d.ts +34 -0
- package/dist/testing/providers.js +2 -0
- package/dist/testing/providers.js.map +1 -1
- package/dist/torrent/index.d.ts +1 -0
- package/dist/torrent/index.js +2 -0
- package/dist/torrent/index.js.map +1 -0
- package/dist/torrent/types.d.ts +132 -0
- package/dist/torrent/types.js +2 -0
- package/dist/torrent/types.js.map +1 -0
- package/package.json +13 -4
package/dist/engine/types.d.ts
CHANGED
|
@@ -2,12 +2,47 @@ import type { Cache } from "../cache/index.js";
|
|
|
2
2
|
import type { MergeStrategy } from "../merge/index.js";
|
|
3
3
|
import type { MediaProvider } from "../providers/index.js";
|
|
4
4
|
import type { StreamingProvider } from "../streaming/index.js";
|
|
5
|
+
import type { TorrentProvider } from "../torrent/index.js";
|
|
6
|
+
export interface CircuitBreakerOptions {
|
|
7
|
+
failureThreshold?: number;
|
|
8
|
+
recoveryTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ProviderConcurrencyOptions {
|
|
11
|
+
defaultMaxConcurrent?: number;
|
|
12
|
+
maxQueueSize?: number;
|
|
13
|
+
providerLimits?: Readonly<Record<string, number>>;
|
|
14
|
+
}
|
|
15
|
+
export interface ProviderHealthStatus {
|
|
16
|
+
provider: string;
|
|
17
|
+
kind: "metadata" | "streaming" | "torrent";
|
|
18
|
+
circuitState: "closed" | "open" | "half-open" | "disabled";
|
|
19
|
+
consecutiveFailures: number;
|
|
20
|
+
totalRequests: number;
|
|
21
|
+
totalSuccesses: number;
|
|
22
|
+
totalFailures: number;
|
|
23
|
+
lastSuccessAt?: string;
|
|
24
|
+
lastFailureAt?: string;
|
|
25
|
+
lastFailureCode?: string;
|
|
26
|
+
failureCounts?: {
|
|
27
|
+
timeout: number;
|
|
28
|
+
rateLimited: number;
|
|
29
|
+
unavailable: number;
|
|
30
|
+
other: number;
|
|
31
|
+
};
|
|
32
|
+
retryAfterMs?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface MediaEngineOperationOptions {
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
5
37
|
export interface MediaEngineOptions {
|
|
6
38
|
providers?: MediaProvider[];
|
|
7
39
|
streamingProviders?: StreamingProvider[];
|
|
40
|
+
torrentProviders?: TorrentProvider[];
|
|
8
41
|
cache?: Cache;
|
|
9
42
|
mergeStrategy?: MergeStrategy;
|
|
10
43
|
timeoutMs?: number;
|
|
11
44
|
providerTimeouts?: Readonly<Record<string, number>>;
|
|
45
|
+
circuitBreaker?: CircuitBreakerOptions | false;
|
|
46
|
+
providerConcurrency?: ProviderConcurrencyOptions | false;
|
|
12
47
|
debug?: boolean;
|
|
13
48
|
}
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ProviderFailure } from "../response/types.js";
|
|
2
2
|
export type ErrorCode = "INVALID_QUERY" | "PROVIDER_ERROR" | "UNKNOWN_ERROR";
|
|
3
|
-
export type ProviderErrorCode = "PROVIDER_ERROR" | "PROVIDER_TIMEOUT" | "PROVIDER_UNAUTHORIZED" | "PROVIDER_RATE_LIMITED" | "PROVIDER_UNAVAILABLE" | "PROVIDER_INVALID_RESPONSE" | "PROVIDER_NOT_SUPPORTED";
|
|
3
|
+
export type ProviderErrorCode = "PROVIDER_ERROR" | "PROVIDER_TIMEOUT" | "PROVIDER_UNAUTHORIZED" | "PROVIDER_RATE_LIMITED" | "PROVIDER_UNAVAILABLE" | "PROVIDER_INVALID_RESPONSE" | "PROVIDER_RESPONSE_TOO_LARGE" | "PROVIDER_NOT_SUPPORTED";
|
|
4
4
|
export interface ProviderErrorOptions {
|
|
5
5
|
provider: string;
|
|
6
6
|
code: ProviderErrorCode;
|
package/dist/errors/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/errors/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/errors/types.ts"],"names":[],"mappings":"AAoCA,kEAAkE;AAClE,wDAAwD;AACxD,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,IAAI,CAAY;IAEzB,YAAY,OAAgC;QAC1C,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAEjD,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3B,CAAC;CACF;AAED,qFAAqF;AACrF,4EAA4E;AAC5E,MAAM,OAAO,aAAc,SAAQ,KAAK;IAC7B,QAAQ,CAAS;IACjB,IAAI,CAAoB;IACxB,SAAS,CAAU;IAE5B,YAAY,OAA6B;QACvC,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAEjD,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QACzB,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,KAAK,CAAC;IAC9C,CAAC;CACF;AAED,kEAAkE;AAClE,8EAA8E;AAC9E,MAAM,UAAU,kBAAkB,CAAC,KAAc;IAC/C,IAAI,KAAK,YAAY,gBAAgB,EAAE,CAAC;QACtC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,KAAK,YAAY,aAAa,EAAE,CAAC;QACnC,OAAO,IAAI,gBAAgB,CAAC;YAC1B,IAAI,EAAE,gBAAgB;YACtB,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;IACL,CAAC;IAED,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;QAC3B,OAAO,IAAI,gBAAgB,CAAC;YAC1B,IAAI,EAAE,eAAe;YACrB,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,KAAK,EAAE,KAAK;SACb,CAAC,CAAC;IACL,CAAC;IAED,OAAO,IAAI,gBAAgB,CAAC;QAC1B,IAAI,EAAE,eAAe;QACrB,OAAO,EAAE,eAAe;QACxB,KAAK,EAAE,KAAK;KACb,CAAC,CAAC;AACL,CAAC;AAED,oEAAoE;AACpE,yEAAyE;AACzE,MAAM,UAAU,iBAAiB,CAAC,QAAgB,EAAE,KAAc;IAChE,IAAI,KAAK,YAAY,aAAa,EAAE,CAAC;QACnC,OAAO;YACL,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,OAAO,EAAE,KAAK,CAAC,OAAO;SACvB,CAAC;IACJ,CAAC;IAED,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;QAC3B,OAAO;YACL,QAAQ;YACR,IAAI,EAAE,gBAAgB;YACtB,SAAS,EAAE,KAAK;YAChB,OAAO,EAAE,KAAK,CAAC,OAAO;SACvB,CAAC;IACJ,CAAC;IAED,OAAO;QACL,QAAQ;QACR,IAAI,EAAE,gBAAgB;QACtB,SAAS,EAAE,KAAK;QAChB,OAAO,EAAE,wBAAwB;KAClC,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
export declare const MEDIA_ENGINE_CORE_VERSION = "
|
|
1
|
+
export declare const MEDIA_ENGINE_CORE_VERSION = "1.0.0";
|
|
2
2
|
export type * from "./media/index.js";
|
|
3
3
|
export type * from "./search/index.js";
|
|
4
4
|
export type * from "./details/index.js";
|
|
5
5
|
export type * from "./response/index.js";
|
|
6
6
|
export type * from "./providers/index.js";
|
|
7
7
|
export type * from "./streaming/index.js";
|
|
8
|
+
export type * from "./torrent/index.js";
|
|
8
9
|
export * from "./merge/index.js";
|
|
9
10
|
export * from "./cache/index.js";
|
|
10
11
|
export * from "./engine/index.js";
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,yBAAyB,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,yBAAyB,GAAG,OAAO,CAAC;AASjD,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
|
package/dist/media/types.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface Image {
|
|
|
17
17
|
language?: string;
|
|
18
18
|
source?: string;
|
|
19
19
|
}
|
|
20
|
-
export type RatingSource = "imdb" | "tmdb" | "kinopoisk" | "shikimori" | "myAnimeList" | "aniList" | "internal";
|
|
20
|
+
export type RatingSource = "imdb" | "tmdb" | "kinopoisk" | "shikimori" | "myAnimeList" | "aniList" | "tvmaze" | "internal";
|
|
21
21
|
export interface Rating {
|
|
22
22
|
source: RatingSource;
|
|
23
23
|
value: number;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MediaType } from "../media/index.js";
|
|
2
|
+
import type { DetailsEntry } from "./internal.js";
|
|
3
|
+
import type { MergeContext } from "./types.js";
|
|
4
|
+
export declare function filterDetailsEntriesByIdentity(entries: DetailsEntry[], context: MergeContext): DetailsEntry[];
|
|
5
|
+
export declare function warnDetailsTypeConflicts(entries: DetailsEntry[], context: MergeContext, selectedType?: MediaType): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { hasSharedStrongId, strongIdConflicts } from "./identity.js";
|
|
2
|
+
import { STRONG_ID_KEYS } from "./internal.js";
|
|
3
|
+
// Keeps details attached to one strong-ID identity before any fields are combined.
|
|
4
|
+
// Оставляет details одной strong-ID сущности до объединения любых полей.
|
|
5
|
+
export function filterDetailsEntriesByIdentity(entries, context) {
|
|
6
|
+
const selectedIds = { ...readQueryExternalIds(context) };
|
|
7
|
+
const accepted = [];
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const ids = entry.result.details.ids;
|
|
10
|
+
const conflicts = strongIdConflicts(selectedIds, ids);
|
|
11
|
+
if (conflicts.length > 0 && !hasSharedStrongId(selectedIds, ids)) {
|
|
12
|
+
for (const key of conflicts) {
|
|
13
|
+
context.warnings?.push({
|
|
14
|
+
code: "EXTERNAL_ID_CONFLICT",
|
|
15
|
+
message: `Conflicting ${key} IDs while merging details; excluded ${ids?.[key]}.`,
|
|
16
|
+
provider: entry.result.provider,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
accepted.push(entry);
|
|
22
|
+
for (const key of STRONG_ID_KEYS) {
|
|
23
|
+
if (!selectedIds[key] && ids?.[key]) {
|
|
24
|
+
selectedIds[key] = ids[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return accepted;
|
|
29
|
+
}
|
|
30
|
+
// Emits warnings when details results contain conflicting media types.
|
|
31
|
+
// Добавляет warnings, когда details-результаты содержат конфликтующие типы медиа.
|
|
32
|
+
export function warnDetailsTypeConflicts(entries, context, selectedType) {
|
|
33
|
+
const primaryType = selectedType ?? entries[0]?.result.details.type;
|
|
34
|
+
if (!primaryType) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const entryType = entry.result.details.type;
|
|
39
|
+
const isCompatibleAnimeCatalogType = primaryType === "anime" && entryType === "series";
|
|
40
|
+
if (entryType !== primaryType && !isCompatibleAnimeCatalogType) {
|
|
41
|
+
context.warnings?.push({
|
|
42
|
+
code: "MEDIA_TYPE_CONFLICT",
|
|
43
|
+
message: `Conflicting media types while merging details; kept ${primaryType}.`,
|
|
44
|
+
provider: entry.result.provider,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function readQueryExternalIds(context) {
|
|
50
|
+
const query = context.query;
|
|
51
|
+
if (!query) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const ids = { ...query.ids };
|
|
55
|
+
for (const key of STRONG_ID_KEYS) {
|
|
56
|
+
const value = query[key];
|
|
57
|
+
if (typeof value === "string" && value.trim()) {
|
|
58
|
+
ids[key] = value.trim();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Object.keys(ids).length > 0 ? ids : undefined;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=details-identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"details-identity.js","sourceRoot":"","sources":["../../src/merge/details-identity.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaType } from "../media/index.js";
|
|
2
|
+
import type { SearchRankEvidence } from "../search/index.js";
|
|
3
|
+
export interface RankedSearchCandidate<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
score: number;
|
|
6
|
+
mediaType: MediaType;
|
|
7
|
+
title: string;
|
|
8
|
+
ranking: Omit<SearchRankEvidence, "scorePosition" | "diversityPosition" | "finalPosition" | "diversity">;
|
|
9
|
+
}
|
|
10
|
+
export interface DiversifiedSearchCandidate<T> extends RankedSearchCandidate<T> {
|
|
11
|
+
scorePosition: number;
|
|
12
|
+
diversityPosition: number;
|
|
13
|
+
diversityFamily: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function diversifySearchCandidates<T>(candidates: RankedSearchCandidate<T>[], enabled: boolean): DiversifiedSearchCandidate<T>[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { normalizeTitle } from "./title.js";
|
|
2
|
+
const DIVERSITY_WINDOW_SIZE = 10;
|
|
3
|
+
const MAX_RESULTS_PER_FAMILY = 2;
|
|
4
|
+
const SCORE_TOLERANCE = 0.03;
|
|
5
|
+
const TITLE_SCORE_TOLERANCE = 0.05;
|
|
6
|
+
// Interleaves only similarly ranked title/type families inside a bounded leading window.
|
|
7
|
+
// Чередует только близкие по ranking title/type families внутри ограниченного верхнего окна.
|
|
8
|
+
export function diversifySearchCandidates(candidates, enabled) {
|
|
9
|
+
const positioned = candidates.map((candidate, index) => ({
|
|
10
|
+
...candidate,
|
|
11
|
+
scorePosition: index + 1,
|
|
12
|
+
diversityFamily: createDiversityFamily(candidate),
|
|
13
|
+
}));
|
|
14
|
+
if (!enabled || positioned.length < MAX_RESULTS_PER_FAMILY + 1) {
|
|
15
|
+
return positioned.map((candidate, index) => ({
|
|
16
|
+
...candidate,
|
|
17
|
+
diversityPosition: index + 1,
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
const pending = positioned.slice(0, DIVERSITY_WINDOW_SIZE);
|
|
21
|
+
const diversified = [];
|
|
22
|
+
const familyCounts = new Map();
|
|
23
|
+
while (pending.length > 0) {
|
|
24
|
+
const crowded = pending[0];
|
|
25
|
+
const crowdedCount = familyCounts.get(crowded.diversityFamily) ?? 0;
|
|
26
|
+
const alternativeIndex = crowdedCount >= MAX_RESULTS_PER_FAMILY
|
|
27
|
+
? pending.findIndex((candidate, index) => index > 0 &&
|
|
28
|
+
candidate.diversityFamily !== crowded.diversityFamily &&
|
|
29
|
+
(familyCounts.get(candidate.diversityFamily) ?? 0) < MAX_RESULTS_PER_FAMILY &&
|
|
30
|
+
isComparableCandidate(candidate, crowded))
|
|
31
|
+
: -1;
|
|
32
|
+
const [selected] = pending.splice(alternativeIndex > 0 ? alternativeIndex : 0, 1);
|
|
33
|
+
if (!selected) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
diversified.push(selected);
|
|
37
|
+
familyCounts.set(selected.diversityFamily, (familyCounts.get(selected.diversityFamily) ?? 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
return [...diversified, ...positioned.slice(DIVERSITY_WINDOW_SIZE)].map((candidate, index) => ({
|
|
40
|
+
...candidate,
|
|
41
|
+
diversityPosition: index + 1,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
function createDiversityFamily(candidate) {
|
|
45
|
+
const matchedTitle = candidate.ranking.titleMatch.matchedTitle ?? candidate.title;
|
|
46
|
+
return `${candidate.mediaType}:${normalizeTitle(matchedTitle)}`;
|
|
47
|
+
}
|
|
48
|
+
function isComparableCandidate(candidate, crowded) {
|
|
49
|
+
return (candidate.score >= crowded.score - SCORE_TOLERANCE &&
|
|
50
|
+
candidate.ranking.titleMatch.score >= crowded.ranking.titleMatch.score - TITLE_SCORE_TOLERANCE);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=diversity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diversity.js","sourceRoot":"","sources":["../../src/merge/diversity.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Genre, Image, MediaDetails, ProviderSource, Rating } from "../media/index.js";
|
|
2
|
+
import type { DetailsEntry, SearchEntry } from "./internal.js";
|
|
3
|
+
import type { MergeContext } from "./types.js";
|
|
4
|
+
export declare function selectTitle(entries: SearchEntry[], context: MergeContext): string | undefined;
|
|
5
|
+
export declare function selectDetailsTitle(entries: DetailsEntry[], context: MergeContext): string | undefined;
|
|
6
|
+
export declare function selectYear(entries: SearchEntry[], context: MergeContext): number | undefined;
|
|
7
|
+
export declare function selectDetailsYear(entries: DetailsEntry[], context: MergeContext): number | undefined;
|
|
8
|
+
export declare function selectReleaseDate(entries: SearchEntry[]): string | undefined;
|
|
9
|
+
export declare function selectDetailsReleaseDate(entries: DetailsEntry[]): string | undefined;
|
|
10
|
+
export declare function selectDescription(entries: SearchEntry[], context?: MergeContext): string | undefined;
|
|
11
|
+
export declare function selectDetailsDescription(entries: DetailsEntry[], context?: MergeContext): string | undefined;
|
|
12
|
+
export declare function selectShortDescription(entries: SearchEntry[]): string | undefined;
|
|
13
|
+
export declare function selectBestImage(entries: SearchEntry[], field: "poster" | "backdrop"): Image | undefined;
|
|
14
|
+
export declare function selectBestDetailsImage(entries: DetailsEntry[], field: "poster" | "backdrop"): Image | undefined;
|
|
15
|
+
export declare function mergeGenres(entries: SearchEntry[]): Genre[] | undefined;
|
|
16
|
+
export declare function mergeDetailsGenres(entries: DetailsEntry[]): Genre[] | undefined;
|
|
17
|
+
export declare function mergeRatings(entries: SearchEntry[]): Rating[] | undefined;
|
|
18
|
+
export declare function mergeDetailsRatings(entries: DetailsEntry[]): Rating[] | undefined;
|
|
19
|
+
export declare function mergeAlternativeTitles(entries: SearchEntry[], selectedTitle: string): string[] | undefined;
|
|
20
|
+
export declare function mergeDetailsAlternativeTitles(entries: DetailsEntry[], selectedTitle: string): string[] | undefined;
|
|
21
|
+
export declare function mergeSources(entries: SearchEntry[]): ProviderSource[];
|
|
22
|
+
export declare function mergeDetailsSources(entries: DetailsEntry[]): ProviderSource[];
|
|
23
|
+
export declare function mergeDetailsImages(entries: DetailsEntry[]): Image[] | undefined;
|
|
24
|
+
export declare function firstDefined<Entry, T>(entries: Entry[], pick: (entry: Entry) => T | undefined): T | undefined;
|
|
25
|
+
export declare function firstMeaningfulDetailsStatus(entries: DetailsEntry[]): MediaDetails["status"];
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { normalizeTitle, titleCandidates } from "./title.js";
|
|
2
|
+
// Selects a display title, preferring the queried title when it matches.
|
|
3
|
+
// Выбирает отображаемый title, предпочитая title из запроса при совпадении.
|
|
4
|
+
export function selectTitle(entries, context) {
|
|
5
|
+
const queryTitle = "title" in (context.query ?? {}) ? context.query.title : undefined;
|
|
6
|
+
const titleValues = entries.flatMap((entry) => titleCandidates(entry.result.item));
|
|
7
|
+
const localizedTitleValues = filterLocalizedText(titleValues, context.language);
|
|
8
|
+
const exactQueryTitle = queryTitle
|
|
9
|
+
? selectExactQueryTitle(localizedTitleValues.length ? localizedTitleValues : titleValues, queryTitle)
|
|
10
|
+
: undefined;
|
|
11
|
+
if (exactQueryTitle) {
|
|
12
|
+
return exactQueryTitle;
|
|
13
|
+
}
|
|
14
|
+
const localizedTitle = selectLocalizedText(entries.flatMap((entry) => [
|
|
15
|
+
entry.result.item.title,
|
|
16
|
+
entry.result.item.originalTitle,
|
|
17
|
+
...(entry.result.item.alternativeTitles ?? []),
|
|
18
|
+
]), context.language);
|
|
19
|
+
if (localizedTitle) {
|
|
20
|
+
return localizedTitle;
|
|
21
|
+
}
|
|
22
|
+
return firstDefined(entries, (entry) => entry.result.item.title);
|
|
23
|
+
}
|
|
24
|
+
// Selects a details display title, preferring the queried title when it matches.
|
|
25
|
+
// Выбирает отображаемый title деталей, предпочитая title из запроса при совпадении.
|
|
26
|
+
export function selectDetailsTitle(entries, context) {
|
|
27
|
+
const localizedTitle = selectMostSupportedDetailsTitle(entries, context.language);
|
|
28
|
+
if (localizedTitle) {
|
|
29
|
+
return localizedTitle;
|
|
30
|
+
}
|
|
31
|
+
const queryTitle = "title" in (context.query ?? {}) ? context.query.title : undefined;
|
|
32
|
+
if (queryTitle) {
|
|
33
|
+
const normalizedQueryTitle = normalizeTitle(queryTitle);
|
|
34
|
+
const queryMatch = entries.find((entry) => normalizeTitle(entry.result.details.title) === normalizedQueryTitle);
|
|
35
|
+
if (queryMatch) {
|
|
36
|
+
return queryMatch.result.details.title;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return firstDefined(entries, (entry) => entry.result.details.title);
|
|
40
|
+
}
|
|
41
|
+
// Selects the localized title corroborated by the most independent providers.
|
|
42
|
+
// Выбирает локализованный title, подтвержденный большинством независимых провайдеров.
|
|
43
|
+
function selectMostSupportedDetailsTitle(entries, language) {
|
|
44
|
+
const candidates = new Map();
|
|
45
|
+
let order = 0;
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const localizedTitles = filterLocalizedText([
|
|
48
|
+
entry.result.details.title,
|
|
49
|
+
entry.result.details.originalTitle,
|
|
50
|
+
...(entry.result.details.alternativeTitles ?? []),
|
|
51
|
+
].filter((value) => Boolean(value?.trim())), language);
|
|
52
|
+
const providerTitles = new Set();
|
|
53
|
+
for (const value of localizedTitles) {
|
|
54
|
+
const key = normalizeTitle(value);
|
|
55
|
+
if (!key || providerTitles.has(key)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
providerTitles.add(key);
|
|
59
|
+
const existing = candidates.get(key);
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.support += 1;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
candidates.set(key, { value, support: 1, order });
|
|
65
|
+
}
|
|
66
|
+
order += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...candidates.values()].sort((left, right) => right.support - left.support || left.order - right.order)[0]?.value;
|
|
70
|
+
}
|
|
71
|
+
// Selects the primary year and emits warnings for conflicting years.
|
|
72
|
+
// Выбирает основной год и добавляет warnings при конфликтующих годах.
|
|
73
|
+
export function selectYear(entries, context) {
|
|
74
|
+
const primaryYear = firstDefined(entries, (entry) => entry.result.item.year);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const year = entry.result.item.year;
|
|
77
|
+
if (year !== undefined && primaryYear !== undefined && year !== primaryYear) {
|
|
78
|
+
context.warnings?.push({
|
|
79
|
+
code: "YEAR_CONFLICT",
|
|
80
|
+
message: `Conflicting years while merging search results; kept ${primaryYear}.`,
|
|
81
|
+
provider: entry.result.provider,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return primaryYear;
|
|
86
|
+
}
|
|
87
|
+
// Selects the primary details year and emits warnings for conflicting years.
|
|
88
|
+
// Выбирает основной год деталей и добавляет warnings при конфликтующих годах.
|
|
89
|
+
export function selectDetailsYear(entries, context) {
|
|
90
|
+
const primaryYear = firstDefined(entries, (entry) => entry.result.details.year);
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const year = entry.result.details.year;
|
|
93
|
+
if (year !== undefined && primaryYear !== undefined && year !== primaryYear) {
|
|
94
|
+
context.warnings?.push({
|
|
95
|
+
code: "YEAR_CONFLICT",
|
|
96
|
+
message: `Conflicting years while merging details; kept ${primaryYear}.`,
|
|
97
|
+
provider: entry.result.provider,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return primaryYear;
|
|
102
|
+
}
|
|
103
|
+
// Selects the most precise release date by string length.
|
|
104
|
+
// Выбирает наиболее точную дату релиза по длине строки.
|
|
105
|
+
export function selectReleaseDate(entries) {
|
|
106
|
+
return maxBy(entries
|
|
107
|
+
.map((entry) => entry.result.item.releaseDate)
|
|
108
|
+
.filter((releaseDate) => Boolean(releaseDate)), (releaseDate) => releaseDate.length);
|
|
109
|
+
}
|
|
110
|
+
// Selects the most precise details release date by string length.
|
|
111
|
+
// Выбирает наиболее точную дату релиза деталей по длине строки.
|
|
112
|
+
export function selectDetailsReleaseDate(entries) {
|
|
113
|
+
return maxBy(entries
|
|
114
|
+
.map((entry) => entry.result.details.releaseDate)
|
|
115
|
+
.filter((releaseDate) => Boolean(releaseDate)), (releaseDate) => releaseDate.length);
|
|
116
|
+
}
|
|
117
|
+
// Selects the longest useful description.
|
|
118
|
+
// Выбирает самое длинное полезное описание.
|
|
119
|
+
export function selectDescription(entries, context) {
|
|
120
|
+
const descriptions = entries
|
|
121
|
+
.map((entry) => entry.result.item.description)
|
|
122
|
+
.filter((description) => Boolean(description?.trim()));
|
|
123
|
+
const localized = filterLocalizedText(descriptions, context?.language);
|
|
124
|
+
return maxBy(localized.length ? localized : descriptions, (description) => description.trim().length);
|
|
125
|
+
}
|
|
126
|
+
// Selects the longest useful details description.
|
|
127
|
+
// Выбирает самое длинное полезное описание деталей.
|
|
128
|
+
export function selectDetailsDescription(entries, context) {
|
|
129
|
+
const descriptions = entries
|
|
130
|
+
.map((entry) => entry.result.details.description)
|
|
131
|
+
.filter((description) => Boolean(description?.trim()));
|
|
132
|
+
const localized = filterLocalizedText(descriptions, context?.language);
|
|
133
|
+
return maxBy(localized.length ? localized : descriptions, (description) => description.trim().length);
|
|
134
|
+
}
|
|
135
|
+
// Selects the first available short description by provider priority.
|
|
136
|
+
// Выбирает первое доступное короткое описание по приоритету провайдера.
|
|
137
|
+
export function selectShortDescription(entries) {
|
|
138
|
+
return firstDefined(entries, (entry) => entry.result.item.shortDescription);
|
|
139
|
+
}
|
|
140
|
+
// Selects the best valid poster or backdrop image.
|
|
141
|
+
// Выбирает лучшее валидное изображение poster или backdrop.
|
|
142
|
+
export function selectBestImage(entries, field) {
|
|
143
|
+
return selectBestEntryImage(entries, (entry) => entry.result.item[field]);
|
|
144
|
+
}
|
|
145
|
+
// Selects the best valid details poster or backdrop image.
|
|
146
|
+
// Выбирает лучшее валидное изображение poster или backdrop из деталей.
|
|
147
|
+
export function selectBestDetailsImage(entries, field) {
|
|
148
|
+
return selectBestEntryImage(entries, (entry) => entry.result.details[field]);
|
|
149
|
+
}
|
|
150
|
+
// Merges unique genres by normalized genre name.
|
|
151
|
+
// Объединяет уникальные жанры по нормализованному названию.
|
|
152
|
+
export function mergeGenres(entries) {
|
|
153
|
+
const genres = new Map();
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
for (const genre of entry.result.item.genres ?? []) {
|
|
156
|
+
const key = normalizeTitle(genre.name);
|
|
157
|
+
if (key && !genres.has(key)) {
|
|
158
|
+
genres.set(key, { ...genre });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return genres.size > 0 ? [...genres.values()] : undefined;
|
|
163
|
+
}
|
|
164
|
+
// Merges unique details genres by normalized genre name.
|
|
165
|
+
// Объединяет уникальные жанры деталей по нормализованному названию.
|
|
166
|
+
export function mergeDetailsGenres(entries) {
|
|
167
|
+
const genres = new Map();
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
for (const genre of entry.result.details.genres ?? []) {
|
|
170
|
+
const key = normalizeTitle(genre.name);
|
|
171
|
+
if (key && !genres.has(key)) {
|
|
172
|
+
genres.set(key, { ...genre });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return genres.size > 0 ? [...genres.values()] : undefined;
|
|
177
|
+
}
|
|
178
|
+
// Merges ratings while keeping one rating per source.
|
|
179
|
+
// Объединяет рейтинги, сохраняя один рейтинг на источник.
|
|
180
|
+
export function mergeRatings(entries) {
|
|
181
|
+
const ratings = new Map();
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
for (const rating of entry.result.item.ratings ?? []) {
|
|
184
|
+
if (!ratings.has(rating.source)) {
|
|
185
|
+
ratings.set(rating.source, { ...rating });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ratings.size > 0 ? [...ratings.values()] : undefined;
|
|
190
|
+
}
|
|
191
|
+
// Merges details ratings while keeping one rating per source.
|
|
192
|
+
// Объединяет рейтинги деталей, сохраняя один рейтинг на источник.
|
|
193
|
+
export function mergeDetailsRatings(entries) {
|
|
194
|
+
const ratings = new Map();
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
for (const rating of entry.result.details.ratings ?? []) {
|
|
197
|
+
if (!ratings.has(rating.source)) {
|
|
198
|
+
ratings.set(rating.source, { ...rating });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return ratings.size > 0 ? [...ratings.values()] : undefined;
|
|
203
|
+
}
|
|
204
|
+
// Merges alternative titles excluding the selected display title.
|
|
205
|
+
// Объединяет альтернативные названия, исключая выбранный отображаемый title.
|
|
206
|
+
export function mergeAlternativeTitles(entries, selectedTitle) {
|
|
207
|
+
const selectedKey = normalizeTitle(selectedTitle);
|
|
208
|
+
const titles = new Map();
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
addAlternativeTitle(titles, entry.result.item.title, selectedKey);
|
|
211
|
+
addAlternativeTitle(titles, entry.result.item.originalTitle, selectedKey);
|
|
212
|
+
for (const title of entry.result.item.alternativeTitles ?? []) {
|
|
213
|
+
addAlternativeTitle(titles, title, selectedKey);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return titles.size > 0 ? [...titles.values()] : undefined;
|
|
217
|
+
}
|
|
218
|
+
// Merges details alternative titles excluding the selected display title.
|
|
219
|
+
// Объединяет альтернативные названия деталей, исключая выбранный отображаемый title.
|
|
220
|
+
export function mergeDetailsAlternativeTitles(entries, selectedTitle) {
|
|
221
|
+
const selectedKey = normalizeTitle(selectedTitle);
|
|
222
|
+
const titles = new Map();
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
addAlternativeTitle(titles, entry.result.details.title, selectedKey);
|
|
225
|
+
addAlternativeTitle(titles, entry.result.details.originalTitle, selectedKey);
|
|
226
|
+
for (const title of entry.result.details.alternativeTitles ?? []) {
|
|
227
|
+
addAlternativeTitle(titles, title, selectedKey);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return titles.size > 0 ? [...titles.values()] : undefined;
|
|
231
|
+
}
|
|
232
|
+
// Builds public source attribution for a merged search result.
|
|
233
|
+
// Создает публичную атрибуцию источников для объединенного результата поиска.
|
|
234
|
+
export function mergeSources(entries) {
|
|
235
|
+
return entries.map((entry) => ({
|
|
236
|
+
provider: entry.result.source?.provider ?? entry.result.provider,
|
|
237
|
+
ids: entry.result.source?.ids ?? entry.result.item.ids,
|
|
238
|
+
url: entry.result.source?.url,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
// Builds public source attribution for merged details.
|
|
242
|
+
// Создает публичную атрибуцию источников для объединенных деталей.
|
|
243
|
+
export function mergeDetailsSources(entries) {
|
|
244
|
+
return entries.map((entry) => ({
|
|
245
|
+
provider: entry.result.source?.provider ?? entry.result.provider,
|
|
246
|
+
ids: entry.result.source?.ids ?? entry.result.details.ids,
|
|
247
|
+
url: entry.result.source?.url,
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
// Merges unique details images by URL.
|
|
251
|
+
// Объединяет уникальные изображения деталей по URL.
|
|
252
|
+
export function mergeDetailsImages(entries) {
|
|
253
|
+
const images = new Map();
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
const candidates = [
|
|
256
|
+
entry.result.details.poster,
|
|
257
|
+
entry.result.details.backdrop,
|
|
258
|
+
...(entry.result.details.images ?? []),
|
|
259
|
+
];
|
|
260
|
+
for (const image of candidates) {
|
|
261
|
+
if (isValidImageUrl(image) && !images.has(image.url)) {
|
|
262
|
+
images.set(image.url, { ...image });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return images.size > 0 ? [...images.values()] : undefined;
|
|
267
|
+
}
|
|
268
|
+
// Returns the first non-empty selected value from sorted entries.
|
|
269
|
+
// Возвращает первое непустое выбранное значение из отсортированных entries.
|
|
270
|
+
export function firstDefined(entries, pick) {
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
const value = pick(entry);
|
|
273
|
+
if (value !== undefined && value !== "") {
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
// Returns the first lifecycle status that carries useful provider information.
|
|
280
|
+
// Возвращает первый lifecycle status, который несет полезную информацию от provider.
|
|
281
|
+
export function firstMeaningfulDetailsStatus(entries) {
|
|
282
|
+
return firstDefined(entries, (entry) => {
|
|
283
|
+
const status = entry.result.details.status;
|
|
284
|
+
return status === "unknown" ? undefined : status;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function selectBestEntryImage(entries, pick) {
|
|
288
|
+
const images = entries
|
|
289
|
+
.map((entry, index) => ({ image: pick(entry), index }))
|
|
290
|
+
.filter((candidate) => isValidImageUrl(candidate.image));
|
|
291
|
+
return [...images].sort((left, right) => {
|
|
292
|
+
const areaDiff = imageArea(right.image) - imageArea(left.image);
|
|
293
|
+
if (areaDiff !== 0) {
|
|
294
|
+
return areaDiff;
|
|
295
|
+
}
|
|
296
|
+
return left.index - right.index;
|
|
297
|
+
})[0]?.image;
|
|
298
|
+
}
|
|
299
|
+
function selectExactQueryTitle(values, queryTitle) {
|
|
300
|
+
const normalizedQueryTitle = normalizeTitle(queryTitle);
|
|
301
|
+
return values.find((value) => normalizeTitle(value) === normalizedQueryTitle);
|
|
302
|
+
}
|
|
303
|
+
// Selects text matching an explicitly requested language while preserving fallback behavior.
|
|
304
|
+
// Выбирает текст на явно запрошенном языке, сохраняя fallback-поведение.
|
|
305
|
+
function selectLocalizedText(values, language) {
|
|
306
|
+
return filterLocalizedText(values.filter((value) => Boolean(value?.trim())), language)[0];
|
|
307
|
+
}
|
|
308
|
+
function filterLocalizedText(values, language) {
|
|
309
|
+
const baseLanguage = language?.split("-")[0]?.toLowerCase();
|
|
310
|
+
if (!baseLanguage) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
switch (baseLanguage) {
|
|
314
|
+
case "ru":
|
|
315
|
+
case "uk":
|
|
316
|
+
case "be":
|
|
317
|
+
return values.filter((value) => /[а-яёіїєґ]/iu.test(value));
|
|
318
|
+
case "ja":
|
|
319
|
+
return values.filter((value) => /[\u3040-\u30ff\u3400-\u9fff]/u.test(value));
|
|
320
|
+
case "en":
|
|
321
|
+
return values.filter((value) => /[a-z]/iu.test(value) && !/[а-яёіїєґ\u3040-\u30ff\u3400-\u9fff]/iu.test(value));
|
|
322
|
+
default:
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Adds one normalized alternative title if it is useful and unique.
|
|
327
|
+
// Добавляет одно нормализованное альтернативное название, если оно полезно и уникально.
|
|
328
|
+
function addAlternativeTitle(titles, title, selectedKey) {
|
|
329
|
+
if (!title?.trim()) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const key = normalizeTitle(title);
|
|
333
|
+
if (key && key !== selectedKey && !titles.has(key)) {
|
|
334
|
+
titles.set(key, title);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Returns the item with the highest computed score.
|
|
338
|
+
// Возвращает элемент с максимальной вычисленной оценкой.
|
|
339
|
+
function maxBy(items, score) {
|
|
340
|
+
return [...items].sort((left, right) => score(right) - score(left))[0];
|
|
341
|
+
}
|
|
342
|
+
// Calculates comparable image size from optional width and height.
|
|
343
|
+
// Вычисляет сравнимый размер изображения по опциональным width и height.
|
|
344
|
+
function imageArea(image) {
|
|
345
|
+
if (image.width && image.height) {
|
|
346
|
+
return image.width * image.height;
|
|
347
|
+
}
|
|
348
|
+
return image.width ?? image.height ?? 0;
|
|
349
|
+
}
|
|
350
|
+
// Checks whether an image has a valid HTTP(S) URL.
|
|
351
|
+
// Проверяет, есть ли у изображения валидный HTTP(S) URL.
|
|
352
|
+
function isValidImageUrl(image) {
|
|
353
|
+
if (!image?.url) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const url = new URL(image.url);
|
|
358
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
//# sourceMappingURL=fields.js.map
|