kmcom-nuxt-layers 2.5.1 → 2.6.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.
Files changed (58) hide show
  1. package/layers/core/app/utils/browserInfo.test.ts +81 -0
  2. package/layers/core/app/utils/featureClasses.test.ts +103 -0
  3. package/layers/core/app/utils/regex.test.ts +31 -0
  4. package/layers/feeds/nuxt.config.ts +7 -10
  5. package/layers/feeds/server/utils/feed-author.test.ts +71 -0
  6. package/layers/feeds/server/utils/feed-config.test.ts +162 -0
  7. package/layers/forms/app/utils/contact-schema.test.ts +75 -0
  8. package/layers/forms/app/utils/fieldProps.test.ts +141 -0
  9. package/layers/layout/app/utils/gridPlacementStyle.test.ts +310 -0
  10. package/layers/metadata/providers/comicvine/nuxt.config.ts +1 -1
  11. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +13 -6
  12. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +201 -0
  13. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.ts +34 -15
  14. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +26 -9
  15. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/types.ts +1 -0
  16. package/layers/metadata/providers/comicvine/tsconfig.json +1 -1
  17. package/layers/metadata/providers/google-books/nuxt.config.ts +1 -1
  18. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +76 -0
  19. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +20 -2
  20. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +144 -0
  21. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts +21 -12
  22. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +11 -4
  23. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/types.ts +9 -1
  24. package/layers/metadata/providers/google-books/tsconfig.json +1 -1
  25. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +25 -0
  26. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +14 -6
  27. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +142 -0
  28. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts +30 -24
  29. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +5 -1
  30. package/layers/metadata/providers/openlibrary/tsconfig.json +1 -1
  31. package/layers/metadata/providers/themoviedb/nuxt.config.ts +36 -0
  32. package/layers/metadata/providers/themoviedb/package.json +12 -0
  33. package/layers/metadata/providers/themoviedb/server/plugins/tmdb-register.ts +3 -0
  34. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +31 -0
  35. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +60 -0
  36. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +162 -0
  37. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.ts +83 -0
  38. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +37 -0
  39. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/types.ts +81 -0
  40. package/layers/metadata/providers/themoviedb/tsconfig.json +5 -0
  41. package/layers/metadata/server/api/metadata/lookup.get.ts +7 -2
  42. package/layers/metadata/server/api/metadata/search.get.ts +7 -2
  43. package/layers/metadata/server/api/metadata/status.get.ts +24 -0
  44. package/layers/metadata/server/api/metadata/sync.post.ts +14 -4
  45. package/layers/metadata/server/utils/metadata/cache.test.ts +39 -0
  46. package/layers/metadata/server/utils/metadata/cache.ts +15 -3
  47. package/layers/metadata/server/utils/metadata/errors.ts +1 -1
  48. package/layers/metadata/server/utils/metadata/provider-registry.test.ts +54 -0
  49. package/layers/metadata/server/utils/metadata/search.test.ts +198 -0
  50. package/layers/metadata/server/utils/metadata/search.ts +9 -4
  51. package/layers/navigation/app/utils/site.test.ts +41 -0
  52. package/layers/seo/app/utils/seoConfig.test.ts +58 -0
  53. package/layers/theme/server/utils/accent-css.test.ts +56 -0
  54. package/layers/theme/server/utils/fouc-config.test.ts +74 -0
  55. package/layers/visual/app/utils/colorTokens.test.ts +51 -0
  56. package/layers/visual/app/utils/gradientStyle.test.ts +76 -0
  57. package/layers/visual/app/utils/responsiveSizes.test.ts +71 -0
  58. package/package.json +6 -2
@@ -1,15 +1,21 @@
1
- import type { MetadataRecord, MetadataCreator } from '#layers/metadata/shared/types'
2
- import type { OpenLibrarySearchDoc, OpenLibraryWork, OpenLibraryEdition } from './types'
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
- name,
8
- role: 'author',
9
- providerId: doc.author_key?.[i]?.replace('/authors/', '') ?? undefined,
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: creators.length ? creators : undefined,
22
- publisher: doc.publisher?.[0] ?? undefined,
23
- publishedAt: doc.first_publish_year ? String(doc.first_publish_year) : undefined,
24
- coverUrl: doc.cover_i ? openLibraryCoverUrl(doc.cover_i) : undefined,
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: doc.isbn?.find((i) => i.length === 10),
27
- isbn13: doc.isbn?.find((i) => i.length === 13),
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
- coverUrl: work.covers?.[0] ? openLibraryCoverUrl(work.covers[0]) : undefined,
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?.[0] ?? undefined,
70
- publishedAt: edition.publish_date,
71
- coverUrl: edition.covers?.[0] ? openLibraryCoverUrl(edition.covers[0]) : undefined,
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?.[0],
74
- isbn13: edition.isbn_13?.[0],
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!({ provider: 'openlibrary', providerId, resourceType }))!
23
+ return (await openLibraryProvider.lookup!({
24
+ provider: 'openlibrary',
25
+ providerId,
26
+ resourceType,
27
+ }))!
24
28
  },
25
29
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "../../../apps/playground/tsconfig.json",
2
+ "extends": "../../../../apps/playground/tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "strict": true
5
5
  },
@@ -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,3 @@
1
+ export default defineNitroPlugin(() => {
2
+ registerProvider(tmdbProvider)
3
+ })
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../../../apps/playground/tsconfig.json",
3
+ "compilerOptions": { "strict": true },
4
+ "exclude": ["node_modules"]
5
+ }
@@ -13,13 +13,18 @@ export default defineEventHandler(async (event) => {
13
13
  }
14
14
 
15
15
  const provider = getProvider(providerName)
16
- if (!provider) throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
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({ provider: providerName, providerId, resourceType })
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({ query: q, mediaType, providers, limit })
16
+ return searchMetadata({
17
+ query: q,
18
+ ...(mediaType && { mediaType }),
19
+ ...(providers && { providers }),
20
+ limit,
21
+ })
17
22
  })