kmcom-nuxt-layers 2.5.1 → 2.6.2
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/layers/core/app/utils/browserInfo.test.ts +81 -0
- package/layers/core/app/utils/featureClasses.test.ts +103 -0
- package/layers/core/app/utils/regex.test.ts +31 -0
- package/layers/feeds/nuxt.config.ts +7 -10
- package/layers/feeds/server/utils/feed-author.test.ts +71 -0
- package/layers/feeds/server/utils/feed-config.test.ts +162 -0
- package/layers/forms/app/utils/contact-schema.test.ts +75 -0
- package/layers/forms/app/utils/fieldProps.test.ts +141 -0
- package/layers/layout/app/utils/gridPlacementStyle.test.ts +310 -0
- package/layers/metadata/providers/comicvine/nuxt.config.ts +1 -1
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +13 -6
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +201 -0
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.ts +34 -15
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +26 -9
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/types.ts +1 -0
- package/layers/metadata/providers/comicvine/tsconfig.json +1 -1
- package/layers/metadata/providers/google-books/nuxt.config.ts +1 -1
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +76 -0
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +20 -2
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +144 -0
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts +21 -12
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +11 -4
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/types.ts +9 -1
- package/layers/metadata/providers/google-books/tsconfig.json +1 -1
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +25 -0
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +14 -6
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +142 -0
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts +30 -24
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +5 -1
- package/layers/metadata/providers/openlibrary/tsconfig.json +1 -1
- package/layers/metadata/providers/themoviedb/nuxt.config.ts +36 -0
- package/layers/metadata/providers/themoviedb/package.json +12 -0
- package/layers/metadata/providers/themoviedb/server/plugins/tmdb-register.ts +3 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +31 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +60 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +162 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.ts +83 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +37 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/types.ts +81 -0
- package/layers/metadata/providers/themoviedb/tsconfig.json +5 -0
- package/layers/metadata/server/api/metadata/lookup.get.ts +7 -2
- package/layers/metadata/server/api/metadata/search.get.ts +7 -2
- package/layers/metadata/server/api/metadata/status.get.ts +24 -0
- package/layers/metadata/server/api/metadata/sync.post.ts +14 -4
- package/layers/metadata/server/utils/metadata/cache.test.ts +39 -0
- package/layers/metadata/server/utils/metadata/cache.ts +15 -3
- package/layers/metadata/server/utils/metadata/errors.ts +1 -1
- package/layers/metadata/server/utils/metadata/provider-registry.test.ts +54 -0
- package/layers/metadata/server/utils/metadata/search.test.ts +198 -0
- package/layers/metadata/server/utils/metadata/search.ts +9 -4
- package/layers/navigation/app/utils/site.test.ts +41 -0
- package/layers/seo/app/utils/seoConfig.test.ts +58 -0
- package/layers/theme/server/utils/accent-css.test.ts +56 -0
- package/layers/theme/server/utils/fouc-config.test.ts +74 -0
- package/layers/visual/app/utils/colorTokens.test.ts +51 -0
- package/layers/visual/app/utils/gradientStyle.test.ts +76 -0
- package/layers/visual/app/utils/responsiveSizes.test.ts +71 -0
- package/package.json +7 -3
package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { MetadataCreator, MetadataRecord } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
import type { OpenLibraryEdition, OpenLibrarySearchDoc, OpenLibraryWork } from './types'
|
|
3
4
|
|
|
4
5
|
// fallow-ignore-next-line complexity
|
|
5
6
|
export function normaliseOpenLibrarySearchDoc(doc: OpenLibrarySearchDoc): MetadataRecord {
|
|
6
|
-
const creators: MetadataCreator[] = (doc.author_name ?? []).map((name, i) =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const creators: MetadataCreator[] = (doc.author_name ?? []).map((name, i) => {
|
|
8
|
+
const providerId = doc.author_key?.[i]?.replace('/authors/', '')
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
role: 'author',
|
|
12
|
+
...(providerId && { providerId }),
|
|
13
|
+
}
|
|
14
|
+
})
|
|
11
15
|
|
|
12
16
|
const id = doc.key.replace('/works/', '')
|
|
17
|
+
const isbn10 = doc.isbn?.find((i) => i.length === 10)
|
|
18
|
+
const isbn13 = doc.isbn?.find((i) => i.length === 13)
|
|
13
19
|
|
|
14
20
|
return {
|
|
15
21
|
id: `openlibrary:work:${id}`,
|
|
@@ -17,14 +23,14 @@ export function normaliseOpenLibrarySearchDoc(doc: OpenLibrarySearchDoc): Metada
|
|
|
17
23
|
providerId: id,
|
|
18
24
|
mediaType: 'book',
|
|
19
25
|
title: doc.title,
|
|
20
|
-
subtitle: doc.subtitle,
|
|
21
|
-
creators
|
|
22
|
-
publisher: doc.publisher
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
...(doc.subtitle && { subtitle: doc.subtitle }),
|
|
27
|
+
...(creators.length && { creators }),
|
|
28
|
+
...(doc.publisher?.[0] && { publisher: doc.publisher[0] }),
|
|
29
|
+
...(doc.first_publish_year && { publishedAt: String(doc.first_publish_year) }),
|
|
30
|
+
...(doc.cover_i && { coverUrl: openLibraryCoverUrl(doc.cover_i) }),
|
|
25
31
|
identifiers: {
|
|
26
|
-
isbn10
|
|
27
|
-
isbn13
|
|
32
|
+
...(isbn10 && { isbn10 }),
|
|
33
|
+
...(isbn13 && { isbn13 }),
|
|
28
34
|
openLibraryId: id,
|
|
29
35
|
},
|
|
30
36
|
sourceUrl: `https://openlibrary.org${doc.key}`,
|
|
@@ -44,10 +50,10 @@ export function normaliseOpenLibraryWork(work: OpenLibraryWork): MetadataRecord
|
|
|
44
50
|
providerId: id,
|
|
45
51
|
mediaType: 'book',
|
|
46
52
|
title: work.title,
|
|
47
|
-
subtitle: work.subtitle,
|
|
48
|
-
description: desc,
|
|
49
|
-
|
|
50
|
-
publishedAt: work.first_publish_date,
|
|
53
|
+
...(work.subtitle && { subtitle: work.subtitle }),
|
|
54
|
+
...(desc && { description: desc }),
|
|
55
|
+
...(work.covers?.[0] && { coverUrl: openLibraryCoverUrl(work.covers[0]) }),
|
|
56
|
+
...(work.first_publish_date && { publishedAt: work.first_publish_date }),
|
|
51
57
|
identifiers: { openLibraryId: id },
|
|
52
58
|
sourceUrl: `https://openlibrary.org${work.key}`,
|
|
53
59
|
raw: work,
|
|
@@ -65,13 +71,13 @@ export function normaliseOpenLibraryEdition(edition: OpenLibraryEdition): Metada
|
|
|
65
71
|
providerId: id,
|
|
66
72
|
mediaType: 'book',
|
|
67
73
|
title: edition.title,
|
|
68
|
-
subtitle: edition.subtitle,
|
|
69
|
-
publisher: edition.publishers
|
|
70
|
-
publishedAt: edition.publish_date,
|
|
71
|
-
|
|
74
|
+
...(edition.subtitle && { subtitle: edition.subtitle }),
|
|
75
|
+
...(edition.publishers?.[0] && { publisher: edition.publishers[0] }),
|
|
76
|
+
...(edition.publish_date && { publishedAt: edition.publish_date }),
|
|
77
|
+
...(edition.covers?.[0] && { coverUrl: openLibraryCoverUrl(edition.covers[0]) }),
|
|
72
78
|
identifiers: {
|
|
73
|
-
isbn10: edition.isbn_10
|
|
74
|
-
isbn13: edition.isbn_13
|
|
79
|
+
...(edition.isbn_10?.[0] && { isbn10: edition.isbn_10[0] }),
|
|
80
|
+
...(edition.isbn_13?.[0] && { isbn13: edition.isbn_13[0] }),
|
|
75
81
|
openLibraryId: id,
|
|
76
82
|
},
|
|
77
83
|
sourceUrl: `https://openlibrary.org${edition.key}`,
|
|
@@ -20,6 +20,10 @@ export const openLibraryProvider: MetadataProvider = {
|
|
|
20
20
|
},
|
|
21
21
|
|
|
22
22
|
async sync({ providerId, resourceType = 'work' }) {
|
|
23
|
-
return (await openLibraryProvider.lookup!({
|
|
23
|
+
return (await openLibraryProvider.lookup!({
|
|
24
|
+
provider: 'openlibrary',
|
|
25
|
+
providerId,
|
|
26
|
+
resourceType,
|
|
27
|
+
}))!
|
|
24
28
|
},
|
|
25
29
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'metadata-tmdb',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../../'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/metadata-tmdb': import.meta.dirname,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
compatibilityDate: '2026-06-30',
|
|
13
|
+
|
|
14
|
+
runtimeConfig: {
|
|
15
|
+
metadataTmdb: {
|
|
16
|
+
apiKey: '', // env: NUXT_METADATA_TMDB_API_KEY
|
|
17
|
+
baseUrl: 'https://api.themoviedb.org/3',
|
|
18
|
+
imageBaseUrl: 'https://image.tmdb.org/t/p',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
typescript: {
|
|
23
|
+
typeCheck: false,
|
|
24
|
+
strict: true,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
declare module '@nuxt/schema' {
|
|
29
|
+
type RuntimeConfig = {
|
|
30
|
+
metadataTmdb: {
|
|
31
|
+
apiKey: string
|
|
32
|
+
baseUrl: string
|
|
33
|
+
imageBaseUrl: string
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-metadata-tmdb",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nuxi dev",
|
|
8
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,metadata,metadata-tmdb pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
4
|
+
metadataTmdb: {
|
|
5
|
+
apiKey: 'test-key',
|
|
6
|
+
baseUrl: 'https://api.themoviedb.org/3',
|
|
7
|
+
imageBaseUrl: 'https://image.tmdb.org/t/p',
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const { tmdbImageUrl } = await import('./client')
|
|
12
|
+
|
|
13
|
+
describe('tmdbImageUrl', () => {
|
|
14
|
+
it('returns undefined when path is null', () => {
|
|
15
|
+
expect(tmdbImageUrl(null)).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('defaults size to w500', () => {
|
|
19
|
+
expect(tmdbImageUrl('/poster.jpg')).toBe('https://image.tmdb.org/t/p/w500/poster.jpg')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('uses a custom size when provided', () => {
|
|
23
|
+
expect(tmdbImageUrl('/poster.jpg', 'w200')).toBe('https://image.tmdb.org/t/p/w200/poster.jpg')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('prefixes imageBaseUrl from runtime config', () => {
|
|
27
|
+
expect(tmdbImageUrl('/backdrop.jpg')).toBe(
|
|
28
|
+
'https://image.tmdb.org/t/p/w500/backdrop.jpg'
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { TMDBFindResponse, TMDBMovie, TMDBSearchResponse, TMDBTVShow } from './types'
|
|
2
|
+
|
|
3
|
+
function getConfig() {
|
|
4
|
+
const config = useRuntimeConfig()
|
|
5
|
+
return config.metadataTmdb
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function tmdbImageUrl(path: string, size?: string): string
|
|
9
|
+
export function tmdbImageUrl(path: string | null, size?: string): string | undefined
|
|
10
|
+
export function tmdbImageUrl(path: string | null, size = 'w500'): string | undefined {
|
|
11
|
+
if (!path) return undefined
|
|
12
|
+
return `${getConfig().imageBaseUrl}/${size}${path}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function tmdbFetch<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
|
16
|
+
const { apiKey, baseUrl } = getConfig()
|
|
17
|
+
|
|
18
|
+
const url = new URL(`${baseUrl}${path}`)
|
|
19
|
+
// JWT (v4 Read Access Token) → Bearer header; hex string → legacy api_key param
|
|
20
|
+
const isJwt = apiKey.startsWith('eyJ')
|
|
21
|
+
if (!isJwt) url.searchParams.set('api_key', apiKey)
|
|
22
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return await $fetch<T>(url.toString(), {
|
|
26
|
+
...(isJwt && { headers: { Authorization: `Bearer ${apiKey}` } }),
|
|
27
|
+
})
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new MetadataProviderError('tmdb', `API error: ${String(err)}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function searchTmdb(query: string, limit = 10): Promise<TMDBSearchResponse> {
|
|
34
|
+
const res = await tmdbFetch<TMDBSearchResponse>('/search/multi', {
|
|
35
|
+
query,
|
|
36
|
+
language: 'en-US',
|
|
37
|
+
page: '1',
|
|
38
|
+
})
|
|
39
|
+
return { ...res, results: res.results.slice(0, limit) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function fetchTmdbMovie(id: string | number): Promise<TMDBMovie> {
|
|
43
|
+
return tmdbFetch<TMDBMovie>(`/movie/${id}`, {
|
|
44
|
+
append_to_response: 'external_ids,credits',
|
|
45
|
+
language: 'en-US',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fetchTmdbTV(id: string | number): Promise<TMDBTVShow> {
|
|
50
|
+
return tmdbFetch<TMDBTVShow>(`/tv/${id}`, {
|
|
51
|
+
append_to_response: 'external_ids,credits',
|
|
52
|
+
language: 'en-US',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function findByImdbId(imdbId: string): Promise<TMDBFindResponse> {
|
|
57
|
+
return tmdbFetch<TMDBFindResponse>(`/find/${imdbId}`, {
|
|
58
|
+
external_source: 'imdb_id',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { tmdbImageUrl } from './client'
|
|
4
|
+
import { normaliseTmdbMovie, normaliseTmdbSearchResult, normaliseTmdbTV } from './normalise'
|
|
5
|
+
import type { TMDBMovie, TMDBSearchMultiResult, TMDBTVShow } from './types'
|
|
6
|
+
|
|
7
|
+
// normalise.ts calls tmdbImageUrl() and getConfig() -> useRuntimeConfig() as Nitro auto-imports;
|
|
8
|
+
// stub the runtime config and delegate to the real (pure, string-building) client implementation.
|
|
9
|
+
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
10
|
+
metadataTmdb: {
|
|
11
|
+
apiKey: 'test-key',
|
|
12
|
+
baseUrl: 'https://api.themoviedb.org/3',
|
|
13
|
+
imageBaseUrl: 'https://image.tmdb.org/t/p',
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
vi.stubGlobal('tmdbImageUrl', tmdbImageUrl)
|
|
17
|
+
|
|
18
|
+
function baseMovie(overrides: Partial<TMDBMovie> = {}): TMDBMovie {
|
|
19
|
+
return {
|
|
20
|
+
id: 550,
|
|
21
|
+
title: 'Fight Club',
|
|
22
|
+
overview: '',
|
|
23
|
+
poster_path: null,
|
|
24
|
+
backdrop_path: null,
|
|
25
|
+
release_date: '',
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function baseTV(overrides: Partial<TMDBTVShow> = {}): TMDBTVShow {
|
|
31
|
+
return {
|
|
32
|
+
id: 1399,
|
|
33
|
+
name: 'Game of Thrones',
|
|
34
|
+
overview: '',
|
|
35
|
+
poster_path: null,
|
|
36
|
+
backdrop_path: null,
|
|
37
|
+
first_air_date: '',
|
|
38
|
+
...overrides,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function baseSearchResult(overrides: Partial<TMDBSearchMultiResult> = {}): TMDBSearchMultiResult {
|
|
43
|
+
return {
|
|
44
|
+
id: 1,
|
|
45
|
+
media_type: 'movie',
|
|
46
|
+
poster_path: null,
|
|
47
|
+
...overrides,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('normaliseTmdbMovie', () => {
|
|
52
|
+
it('filters directors from credits.crew by job === "Director"', () => {
|
|
53
|
+
const record = normaliseTmdbMovie(
|
|
54
|
+
baseMovie({
|
|
55
|
+
credits: {
|
|
56
|
+
cast: [],
|
|
57
|
+
crew: [
|
|
58
|
+
{ id: 1, name: 'David Fincher', job: 'Director', department: 'Directing' },
|
|
59
|
+
{ id: 2, name: 'Jim Uhls', job: 'Screenplay', department: 'Writing' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
expect(record.creators).toEqual([{ name: 'David Fincher', role: 'Director', providerId: '1' }])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('omits creators entirely when there are no directors', () => {
|
|
68
|
+
const record = normaliseTmdbMovie(baseMovie())
|
|
69
|
+
expect('creators' in record).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('omits description, publisher, publishedAt and coverUrl when source fields are absent', () => {
|
|
73
|
+
const record = normaliseTmdbMovie(baseMovie())
|
|
74
|
+
expect('description' in record).toBe(false)
|
|
75
|
+
expect('publisher' in record).toBe(false)
|
|
76
|
+
expect('publishedAt' in record).toBe(false)
|
|
77
|
+
expect('coverUrl' in record).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('includes description, publisher, publishedAt and coverUrl when source fields are present', () => {
|
|
81
|
+
const record = normaliseTmdbMovie(
|
|
82
|
+
baseMovie({
|
|
83
|
+
overview: 'A depressed man forms an underground fight club.',
|
|
84
|
+
production_companies: [{ id: 1, name: 'Fox 2000 Pictures' }],
|
|
85
|
+
release_date: '1999-10-15',
|
|
86
|
+
poster_path: '/poster.jpg',
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
expect(record.description).toBe('A depressed man forms an underground fight club.')
|
|
90
|
+
expect(record.publisher).toBe('Fox 2000 Pictures')
|
|
91
|
+
expect(record.publishedAt).toBe('1999-10-15')
|
|
92
|
+
expect(record.coverUrl).toBe('https://image.tmdb.org/t/p/w500/poster.jpg')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('includes imdbId in identifiers only when external_ids.imdb_id is present', () => {
|
|
96
|
+
const withImdb = normaliseTmdbMovie(baseMovie({ external_ids: { imdb_id: 'tt0137523' } }))
|
|
97
|
+
expect(withImdb.identifiers).toEqual({ tmdbId: '550', imdbId: 'tt0137523' })
|
|
98
|
+
|
|
99
|
+
const withoutImdb = normaliseTmdbMovie(baseMovie())
|
|
100
|
+
expect(withoutImdb.identifiers).toEqual({ tmdbId: '550' })
|
|
101
|
+
expect('imdbId' in (withoutImdb.identifiers as object)).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('stamps lastSyncedAt as a string', () => {
|
|
105
|
+
expect(normaliseTmdbMovie(baseMovie()).lastSyncedAt).toEqual(expect.any(String))
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('normaliseTmdbTV', () => {
|
|
110
|
+
it('maps created_by to Creator-role creators', () => {
|
|
111
|
+
const record = normaliseTmdbTV(baseTV({ created_by: [{ id: 9, name: 'David Benioff' }] }))
|
|
112
|
+
expect(record.creators).toEqual([{ name: 'David Benioff', role: 'Creator', providerId: '9' }])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('omits creators when created_by is absent', () => {
|
|
116
|
+
const record = normaliseTmdbTV(baseTV())
|
|
117
|
+
expect('creators' in record).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('takes publisher from the first network', () => {
|
|
121
|
+
const record = normaliseTmdbTV(baseTV({ networks: [{ id: 1, name: 'HBO' }] }))
|
|
122
|
+
expect(record.publisher).toBe('HBO')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('includes imdbId in identifiers only when external_ids.imdb_id is present', () => {
|
|
126
|
+
const record = normaliseTmdbTV(baseTV({ external_ids: { imdb_id: 'tt0944947' } }))
|
|
127
|
+
expect(record.identifiers).toEqual({ tmdbId: '1399', imdbId: 'tt0944947' })
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('normaliseTmdbSearchResult', () => {
|
|
132
|
+
it('uses title/release_date and the movie url path for movie results', () => {
|
|
133
|
+
const record = normaliseTmdbSearchResult(
|
|
134
|
+
baseSearchResult({ media_type: 'movie', title: 'Dune', release_date: '2021-10-22' })
|
|
135
|
+
)
|
|
136
|
+
expect(record.title).toBe('Dune')
|
|
137
|
+
expect(record.mediaType).toBe('movie')
|
|
138
|
+
expect(record.publishedAt).toBe('2021-10-22')
|
|
139
|
+
expect(record.sourceUrl).toBe('https://www.themoviedb.org/movie/1')
|
|
140
|
+
expect(record.id).toBe('tmdb:movie:1')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('uses name/first_air_date, tv url path and mediaType tv-show for tv results', () => {
|
|
144
|
+
const record = normaliseTmdbSearchResult(
|
|
145
|
+
baseSearchResult({
|
|
146
|
+
media_type: 'tv',
|
|
147
|
+
name: 'Dune: Prophecy',
|
|
148
|
+
first_air_date: '2024-11-17',
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
expect(record.title).toBe('Dune: Prophecy')
|
|
152
|
+
expect(record.mediaType).toBe('tv-show')
|
|
153
|
+
expect(record.publishedAt).toBe('2024-11-17')
|
|
154
|
+
expect(record.sourceUrl).toBe('https://www.themoviedb.org/tv/1')
|
|
155
|
+
expect(record.id).toBe('tmdb:tv:1')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('defaults title to empty string when the branch-specific field is missing', () => {
|
|
159
|
+
const record = normaliseTmdbSearchResult(baseSearchResult({ media_type: 'movie' }))
|
|
160
|
+
expect(record.title).toBe('')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MetadataCreator, MetadataRecord } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
import type { TMDBMovie, TMDBSearchMultiResult, TMDBTVShow } from './types'
|
|
4
|
+
|
|
5
|
+
// fallow-ignore-next-line complexity
|
|
6
|
+
export function normaliseTmdbMovie(movie: TMDBMovie): MetadataRecord {
|
|
7
|
+
const directors: MetadataCreator[] = (movie.credits?.crew ?? [])
|
|
8
|
+
.filter((c) => c.job === 'Director')
|
|
9
|
+
.map((c) => ({ name: c.name, role: 'Director', providerId: String(c.id) }))
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: `tmdb:movie:${movie.id}`,
|
|
13
|
+
provider: 'tmdb',
|
|
14
|
+
providerId: String(movie.id),
|
|
15
|
+
mediaType: 'movie',
|
|
16
|
+
title: movie.title,
|
|
17
|
+
...(movie.overview && { description: movie.overview }),
|
|
18
|
+
...(directors.length && { creators: directors }),
|
|
19
|
+
...(movie.production_companies?.[0]?.name && {
|
|
20
|
+
publisher: movie.production_companies[0].name,
|
|
21
|
+
}),
|
|
22
|
+
...(movie.release_date && { publishedAt: movie.release_date }),
|
|
23
|
+
...(movie.poster_path && { coverUrl: tmdbImageUrl(movie.poster_path) }),
|
|
24
|
+
identifiers: {
|
|
25
|
+
tmdbId: String(movie.id),
|
|
26
|
+
...(movie.external_ids?.imdb_id && { imdbId: movie.external_ids.imdb_id }),
|
|
27
|
+
},
|
|
28
|
+
sourceUrl: `https://www.themoviedb.org/movie/${movie.id}`,
|
|
29
|
+
raw: movie,
|
|
30
|
+
lastSyncedAt: new Date().toISOString(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// fallow-ignore-next-line complexity
|
|
35
|
+
export function normaliseTmdbTV(show: TMDBTVShow): MetadataRecord {
|
|
36
|
+
const creators: MetadataCreator[] = (show.created_by ?? []).map((c) => ({
|
|
37
|
+
name: c.name,
|
|
38
|
+
role: 'Creator',
|
|
39
|
+
providerId: String(c.id),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
id: `tmdb:tv:${show.id}`,
|
|
44
|
+
provider: 'tmdb',
|
|
45
|
+
providerId: String(show.id),
|
|
46
|
+
mediaType: 'tv-show',
|
|
47
|
+
title: show.name,
|
|
48
|
+
...(show.overview && { description: show.overview }),
|
|
49
|
+
...(creators.length && { creators }),
|
|
50
|
+
...(show.networks?.[0]?.name && { publisher: show.networks[0].name }),
|
|
51
|
+
...(show.first_air_date && { publishedAt: show.first_air_date }),
|
|
52
|
+
...(show.poster_path && { coverUrl: tmdbImageUrl(show.poster_path) }),
|
|
53
|
+
identifiers: {
|
|
54
|
+
tmdbId: String(show.id),
|
|
55
|
+
...(show.external_ids?.imdb_id && { imdbId: show.external_ids.imdb_id }),
|
|
56
|
+
},
|
|
57
|
+
sourceUrl: `https://www.themoviedb.org/tv/${show.id}`,
|
|
58
|
+
raw: show,
|
|
59
|
+
lastSyncedAt: new Date().toISOString(),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normaliseTmdbSearchResult(result: TMDBSearchMultiResult): MetadataRecord {
|
|
64
|
+
const isMovie = result.media_type === 'movie'
|
|
65
|
+
const title = (isMovie ? result.title : result.name) ?? ''
|
|
66
|
+
const date = isMovie ? result.release_date : result.first_air_date
|
|
67
|
+
const type = isMovie ? 'movie' : 'tv'
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: `tmdb:${type}:${result.id}`,
|
|
71
|
+
provider: 'tmdb',
|
|
72
|
+
providerId: String(result.id),
|
|
73
|
+
mediaType: isMovie ? 'movie' : 'tv-show',
|
|
74
|
+
title,
|
|
75
|
+
...(result.overview && { description: result.overview }),
|
|
76
|
+
...(date && { publishedAt: date }),
|
|
77
|
+
...(result.poster_path && { coverUrl: tmdbImageUrl(result.poster_path) }),
|
|
78
|
+
identifiers: { tmdbId: String(result.id) },
|
|
79
|
+
sourceUrl: `https://www.themoviedb.org/${type}/${result.id}`,
|
|
80
|
+
raw: result,
|
|
81
|
+
lastSyncedAt: new Date().toISOString(),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MetadataProvider } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
export const tmdbProvider: MetadataProvider = {
|
|
4
|
+
id: 'tmdb',
|
|
5
|
+
label: 'The Movie Database',
|
|
6
|
+
mediaTypes: ['movie', 'tv-show'],
|
|
7
|
+
|
|
8
|
+
async search({ query, mediaType, limit = 10 }) {
|
|
9
|
+
const res = await searchTmdb(query, limit)
|
|
10
|
+
return res.results
|
|
11
|
+
.filter((r) => {
|
|
12
|
+
if (r.media_type === 'person') return false
|
|
13
|
+
if (mediaType === 'movie') return r.media_type === 'movie'
|
|
14
|
+
if (mediaType === 'tv-show') return r.media_type === 'tv'
|
|
15
|
+
return true
|
|
16
|
+
})
|
|
17
|
+
.map(normaliseTmdbSearchResult)
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async lookup({ providerId, resourceType = 'movie' }) {
|
|
21
|
+
// Accept IMDB tt-prefixed IDs — resolve via TMDB's /find endpoint
|
|
22
|
+
if (providerId.startsWith('tt')) {
|
|
23
|
+
const found = await findByImdbId(providerId)
|
|
24
|
+
const movie = found.movie_results[0]
|
|
25
|
+
const tv = found.tv_results[0]
|
|
26
|
+
if (movie) return normaliseTmdbMovie(await fetchTmdbMovie(movie.id))
|
|
27
|
+
if (tv) return normaliseTmdbTV(await fetchTmdbTV(tv.id))
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
if (resourceType === 'tv-show') return normaliseTmdbTV(await fetchTmdbTV(providerId))
|
|
31
|
+
return normaliseTmdbMovie(await fetchTmdbMovie(providerId))
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async sync(input) {
|
|
35
|
+
return (await tmdbProvider.lookup!(input))!
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type TMDBExternalIds = {
|
|
2
|
+
imdb_id?: string
|
|
3
|
+
tvdb_id?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type TMDBCrewMember = {
|
|
7
|
+
id: number
|
|
8
|
+
name: string
|
|
9
|
+
job: string
|
|
10
|
+
department: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type TMDBCastMember = {
|
|
14
|
+
id: number
|
|
15
|
+
name: string
|
|
16
|
+
character: string
|
|
17
|
+
order: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TMDBCredits = {
|
|
21
|
+
cast: TMDBCastMember[]
|
|
22
|
+
crew: TMDBCrewMember[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TMDBCreatedBy = {
|
|
26
|
+
id: number
|
|
27
|
+
name: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TMDBMovie = {
|
|
31
|
+
id: number
|
|
32
|
+
title: string
|
|
33
|
+
overview: string
|
|
34
|
+
poster_path: string | null
|
|
35
|
+
backdrop_path: string | null
|
|
36
|
+
release_date: string
|
|
37
|
+
runtime?: number
|
|
38
|
+
genres?: Array<{ id: number; name: string }>
|
|
39
|
+
production_companies?: Array<{ id: number; name: string }>
|
|
40
|
+
status?: string
|
|
41
|
+
external_ids?: TMDBExternalIds
|
|
42
|
+
credits?: TMDBCredits
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TMDBTVShow = {
|
|
46
|
+
id: number
|
|
47
|
+
name: string
|
|
48
|
+
overview: string
|
|
49
|
+
poster_path: string | null
|
|
50
|
+
backdrop_path: string | null
|
|
51
|
+
first_air_date: string
|
|
52
|
+
number_of_seasons?: number
|
|
53
|
+
genres?: Array<{ id: number; name: string }>
|
|
54
|
+
networks?: Array<{ id: number; name: string }>
|
|
55
|
+
created_by?: TMDBCreatedBy[]
|
|
56
|
+
status?: string
|
|
57
|
+
external_ids?: TMDBExternalIds
|
|
58
|
+
credits?: TMDBCredits
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TMDBSearchMultiResult = {
|
|
62
|
+
id: number
|
|
63
|
+
media_type: 'movie' | 'tv' | 'person'
|
|
64
|
+
title?: string
|
|
65
|
+
name?: string
|
|
66
|
+
overview?: string
|
|
67
|
+
poster_path: string | null
|
|
68
|
+
release_date?: string
|
|
69
|
+
first_air_date?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TMDBSearchResponse = {
|
|
73
|
+
results: TMDBSearchMultiResult[]
|
|
74
|
+
total_results: number
|
|
75
|
+
page: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TMDBFindResponse = {
|
|
79
|
+
movie_results: TMDBMovie[]
|
|
80
|
+
tv_results: TMDBTVShow[]
|
|
81
|
+
}
|
|
@@ -13,13 +13,18 @@ export default defineEventHandler(async (event) => {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const provider = getProvider(providerName)
|
|
16
|
-
if (!provider)
|
|
16
|
+
if (!provider)
|
|
17
|
+
throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
|
|
17
18
|
|
|
18
19
|
const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
|
|
19
20
|
const cached = await getCacheRecord(cacheKey)
|
|
20
21
|
if (cached) return cached.normalised
|
|
21
22
|
|
|
22
|
-
const result = await provider.lookup({
|
|
23
|
+
const result = await provider.lookup({
|
|
24
|
+
provider: providerName,
|
|
25
|
+
providerId,
|
|
26
|
+
...(resourceType && { resourceType }),
|
|
27
|
+
})
|
|
23
28
|
if (!result) throw createError({ statusCode: 404, message: 'Not found' })
|
|
24
29
|
|
|
25
30
|
const now = new Date().toISOString()
|
|
@@ -9,9 +9,14 @@ export default defineEventHandler(async (event) => {
|
|
|
9
9
|
|
|
10
10
|
const mediaType = query.mediaType as MetadataMediaType | undefined
|
|
11
11
|
const providers = query.providers
|
|
12
|
-
? String(query.providers).split(',').filter(Boolean) as MetadataProviderId[]
|
|
12
|
+
? (String(query.providers).split(',').filter(Boolean) as MetadataProviderId[])
|
|
13
13
|
: undefined
|
|
14
14
|
const limit = query.limit ? Number(query.limit) : 10
|
|
15
15
|
|
|
16
|
-
return searchMetadata({
|
|
16
|
+
return searchMetadata({
|
|
17
|
+
query: q,
|
|
18
|
+
...(mediaType && { mediaType }),
|
|
19
|
+
...(providers && { providers }),
|
|
20
|
+
limit,
|
|
21
|
+
})
|
|
17
22
|
})
|