normalize local movie data

main
Inga 🏳‍🌈 11 months ago
parent e73fcc1e73
commit 97455f5437
  1. 96
      src/integration/movies/internal/converters.spec.ts
  2. 54
      src/integration/movies/internal/converters.ts
  3. 59
      src/integration/movies/internal/index.spec.ts
  4. 28
      src/integration/movies/internal/index.ts
  5. 4
      src/integration/movies/internal/storage.spec.ts
  6. 2
      src/integration/movies/internal/types.ts
  7. 41
      src/integration/movies/omdb/converters.ts
  8. 2
      src/integration/movies/omdb/index.spec.ts
  9. 70
      src/integration/movies/types.ts
  10. 50
      src/utils/objectHelpers.ts

@ -0,0 +1,96 @@
import { describe, it, expect } from '@jest/globals';
import movie3532674 from '../../../resources/movies/3532674.json';
import movie5979300 from '../../../resources/movies/5979300.json';
import movie11043689 from '../../../resources/movies/11043689.json';
import movie11528860 from '../../../resources/movies/11528860.json';
import { normalizeRawInternalData } from './converters';
describe('normalizeRawInternalData', () => {
it('normalizes data correctly for 3532674', () => {
const result = normalizeRawInternalData(movie3532674);
expect(result).toEqual({
availableLanguages: ['de', 'en'],
duration: 119,
imdbId: 'tt0401792',
internalId: 3532674,
localDescription:
'Im Sündenpfuhl Sin City werden drei Geschichten parallel erzählt: Der Polizist Hartigan jagt einen Pädophilen, der Outlaw Dwight muss im Rotlichtbezirk untertauchen und dem Schläger Marv wird ein Mord angehängt. ',
localTitle: 'Sin City',
originalLanguage: 'en',
productionYear: 2005,
ratings: [
{
source: 'Internal',
value: '3.9/5',
},
],
studios: ['Studiocanal', 'Paramount'],
});
});
it('normalizes data correctly for 5979300', () => {
const result = normalizeRawInternalData(movie5979300);
expect(result).toEqual({
availableLanguages: ['de', 'en'],
duration: 127,
imdbId: 'tt0097576',
internalId: 5979300,
localDescription:
'1912: Der junge Indiana Jones will Grabräubern das Kreuz von Coronado abluchsen, um es in ein Museum zu bringen. Nach einem Zeitsprung ins Jahr 1938 kämpft Indy wieder um das Kreuz, diesmal erfolgreich. Anschließend soll er den Heiligen Gral suchen.',
localTitle: 'Indiana Jones und der letzte Kreuzzug',
originalLanguage: 'en',
productionYear: 1989,
ratings: [
{
source: 'Internal',
value: '4.2/5',
},
],
studios: ['Paramount'],
});
});
it('normalizes data correctly for 11043689', () => {
const result = normalizeRawInternalData(movie11043689);
expect(result).toEqual({
availableLanguages: ['de', 'en'],
duration: 120,
imdbId: 'tt0076759',
internalId: 11043689,
localDescription:
'Der im Exil lebende Jedi-Ritter Obi-Wan Kenobi nimmt sich einen Mann namens Luke Skywalker als Schüler. Zusammen helfen sie der Rebellion, die Pläne des bösen Imperiums und des Sith-Lords Darth Vader zu vereiteln. ',
localTitle: 'Star Wars: Eine neue Hoffnung',
originalLanguage: 'en',
productionYear: 1977,
ratings: [
{
source: 'Internal',
value: '4.5/5',
},
],
studios: ['FOX', 'Paramount'],
});
});
it('normalizes data correctly for 11528860', () => {
const result = normalizeRawInternalData(movie11528860);
expect(result).toEqual({
availableLanguages: ['de', 'en'],
duration: 75,
imdbId: 'tt0061852',
internalId: 11528860,
localDescription:
'Unter der Obhut des Panthers Baghira wächst das Findelkind Mogli bei einer Wolfsfamilie auf. Doch da erschüttert die Rückkehr des menschenfressenden Tigers Shir Khan den Dschungel. Die Sorge um Mogli zwingt Baghira zu der einzig möglichen Entscheidung.',
localTitle: 'Das Dschungelbuch',
originalLanguage: 'en',
productionYear: 1967,
ratings: [
{
source: 'Internal',
value: '4.4/5',
},
],
studios: ['Disney'],
});
});
});

@ -0,0 +1,54 @@
import { createTargetHelpers, identity } from '../../../utils/objectHelpers';
import { NormalizedInternalData } from '../types';
import { StoredData } from './types';
const { createRequiredField } = createTargetHelpers<NormalizedInternalData>([
null,
'',
]);
const internalRatingToRatings = (internalRating: StoredData['userrating']) => {
let totalStars = 0;
for (let i = 1; i <= 5; i++) {
totalStars += internalRating[`countStar${i as 1 | 2 | 3 | 4 | 5}`] * i;
}
const rating = totalStars / internalRating.countTotal;
return [
{
source: 'Internal',
value: `${rating.toFixed(1)}/5`,
},
];
};
export const normalizeRawInternalData = (
rawInternalData: StoredData,
): NormalizedInternalData => {
const {
description,
duration,
id,
imdbId,
languages,
originalLanguage,
productionYear,
studios,
title,
userrating,
} = rawInternalData;
return {
...createRequiredField('localDescription', description, identity),
...createRequiredField('duration', duration, identity),
...createRequiredField('internalId', id, identity<number>),
...createRequiredField('imdbId', imdbId, identity),
...createRequiredField('availableLanguages', languages, identity),
...createRequiredField('originalLanguage', originalLanguage, identity),
...createRequiredField('productionYear', productionYear, identity),
...createRequiredField('studios', studios, identity),
...createRequiredField('localTitle', title, identity),
...createRequiredField('ratings', userrating, internalRatingToRatings),
};
};

@ -0,0 +1,59 @@
import { describe, it, expect } from '@jest/globals';
import { createInternalProvider } from './index';
describe('createInternalProvider', () => {
const client = createInternalProvider();
it('returns correct data for 11528860', async () => {
const result = await client.getMetadataByInternalId(11528860);
expect(result).toMatchObject({
availableLanguages: expect.any(Array),
duration: 75,
internalId: 11528860,
imdbId: 'tt0061852',
localDescription: expect.any(String),
localTitle: 'Das Dschungelbuch',
originalLanguage: 'en',
productionYear: 1967,
studios: ['Disney'],
ratings: expect.arrayContaining([
{
source: expect.any(String),
value: expect.any(String),
},
]),
});
});
it('returns undefined for non-existent internal id', async () => {
const result = await client.getMetadataByInternalId(999999);
expect(result).toBeUndefined();
});
it('returns data for imdb id', async () => {
const result = await client.getMetadataByImdbId('tt0061852');
expect(result).toMatchObject({
availableLanguages: expect.any(Array),
duration: 75,
internalId: 11528860,
imdbId: 'tt0061852',
localDescription: expect.any(String),
localTitle: 'Das Dschungelbuch',
originalLanguage: 'en',
productionYear: 1967,
studios: ['Disney'],
ratings: expect.arrayContaining([
{
source: expect.any(String),
value: expect.any(String),
},
]),
});
});
it('returns undefined for non-existent imdb id', async () => {
const result = await client.getMetadataByImdbId('tt99999999999');
expect(result).toBeUndefined();
});
});

@ -0,0 +1,28 @@
import { InternalProvider } from '../types';
import { normalizeRawInternalData } from './converters';
import { createStorageClient } from './storage';
export const createInternalProvider = (): InternalProvider => {
const storageClient = createStorageClient();
return {
getMetadataByInternalId: async (internalId: number) => {
const rawInternalData =
await storageClient.getMetadataByInternalId(internalId);
if (!rawInternalData) {
return undefined;
}
return normalizeRawInternalData(rawInternalData);
},
getMetadataByImdbId: async (imdbId: string) => {
const rawInternalData =
await storageClient.getMetadataByImdbId(imdbId);
if (!rawInternalData) {
return undefined;
}
return normalizeRawInternalData(rawInternalData);
},
};
};

@ -5,7 +5,7 @@ import { createStorageClient } from './storage';
describe('createStorageClient', () => {
const client = createStorageClient();
it('returns data for internal id', async () => {
it('returns data for 11528860', async () => {
const result = await client.getMetadataByInternalId(11528860);
expect(result).toMatchObject({
description: expect.any(String),
@ -33,7 +33,7 @@ describe('createStorageClient', () => {
expect(result).toBeUndefined();
});
it('returns data for imdb id', async () => {
it('returns data for tt0061852', async () => {
const result = await client.getMetadataByImdbId('tt0061852');
expect(result).toMatchObject({
description: expect.any(String),

@ -1,4 +1,4 @@
type StoredData = {
export type StoredData = {
/**
* Description in local language
*/

@ -1,7 +1,10 @@
import { createTargetHelpers, identity } from '../../../utils/objectHelpers';
import { NormalizedOmdbData } from '../types';
import { RawOmdbData } from './types';
const identity = <T>(value: T) => value;
const { createRequiredField, createOptionalField } =
createTargetHelpers<NormalizedOmdbData>([null, '', 'N/A']);
const stringToArray = (value: string) => value.split(', ');
const stringToNumber = (value: string) => parseInt(value, 10);
const stringToDuration = (value: string) => {
@ -17,42 +20,6 @@ const stringToDuration = (value: string) => {
const omdbRatingsToNormalizedRatings = (ratings: RawOmdbData['Ratings']) =>
ratings.map(({ Source, Value }) => ({ source: Source, value: Value }));
const createRequiredField = <TKey extends keyof NormalizedOmdbData, TRawValue>(
key: TKey,
value: TRawValue,
transformer: (value: TRawValue) => NormalizedOmdbData[TKey],
) => {
if (value === undefined) {
throw new Error(`Value for ${key} is not defined`);
}
if (value === 'N/A') {
throw new Error(`Value for ${key} is "N/A"`);
}
return {
[key]: transformer(value),
} as Record<TKey, NormalizedOmdbData[TKey]>;
};
const createOptionalField = <TKey extends keyof NormalizedOmdbData, TRawValue>(
key: TKey,
value: TRawValue,
transformer: (
value: Exclude<TRawValue, undefined>,
) => NormalizedOmdbData[TKey],
) => {
if (value === undefined || value === 'N/A') {
return undefined;
}
return createRequiredField(
key,
value as Exclude<TRawValue, undefined>,
transformer,
);
};
export const normalizeRawOmdbData = (
rawOmdbData: RawOmdbData,
): NormalizedOmdbData => {

@ -1,6 +1,6 @@
import { describe, it, expect } from '@jest/globals';
import { createOmdbProvider } from '.';
import { createOmdbProvider } from './index';
describe('createOmdbProvider', () => {
const client = createOmdbProvider('68fd98ab');

@ -1,38 +1,3 @@
type InternalMovieData = {
/**
* Description in local language
*/
description: string;
/**
* A whole number of minutes, presumably
*/
duration: number;
id: number;
imdbId: string;
/**
* Two-letter codes
*/
languages: string[];
/**
* Two-letter code
*/
originalLanguage: string;
productionYear: number;
studios: string[];
/**
* Title in local language
*/
title: string;
userrating: {
countStar1: number;
countStar2: number;
countStar3: number;
countStar4: number;
countStar5: number;
countTotal: number;
};
};
type MovieData = {
internalId?: number;
imdbId: string;
@ -74,7 +39,7 @@ type MovieData = {
}[];
};
export type StrictPartial<
type StrictPartial<
T,
TForbiddenKeys extends keyof T,
TOptionalKeys extends Exclude<keyof T, TForbiddenKeys>,
@ -95,13 +60,36 @@ export type NormalizedOmdbData = StrictPartial<
| 'website'
>;
export type InternalMoviesProvider = {
getMovieMetadataByInternalId(
export type NormalizedInternalData = StrictPartial<
MovieData,
| 'actors'
| 'awards'
| 'boxOffice'
| 'contentRating'
| 'description'
| 'directors'
| 'dvdReleaseDate'
| 'genres'
| 'posterUrl'
| 'productionCountries'
| 'releaseDate'
| 'title'
| 'totalSeasons'
| 'type'
| 'website'
| 'writers',
never
> & {
internalId: number;
};
export type InternalProvider = {
getMetadataByInternalId(
internalId: number,
): Promise<InternalMovieData | undefined>;
getMovieMetadataByImdbId(
): Promise<NormalizedInternalData | undefined>;
getMetadataByImdbId(
imdbId: string,
): Promise<InternalMovieData | undefined>;
): Promise<NormalizedInternalData | undefined>;
};
export type OmdbProvider = {

@ -0,0 +1,50 @@
export const identity = <T>(value: T) => value;
export const createTargetHelpers = <TTarget>(
missingValueIndicators: unknown[],
) => {
const createRequiredField = <
TKey extends string & keyof TTarget,
TRawValue,
>(
key: TKey,
value: TRawValue,
transformer: (value: TRawValue) => TTarget[TKey],
) => {
if (value === undefined) {
throw new Error(`Value for ${key} is not defined`);
}
if (missingValueIndicators.includes(value)) {
throw new Error(`Missing value for ${key}`);
}
return {
[key]: transformer(value),
} as Record<TKey, TTarget[TKey]>;
};
const createOptionalField = <
TKey extends string & keyof TTarget,
TRawValue,
>(
key: TKey,
value: TRawValue,
transformer: (value: Exclude<TRawValue, undefined>) => TTarget[TKey],
) => {
if (value === undefined || missingValueIndicators.includes(value)) {
return undefined;
}
return createRequiredField(
key,
value as Exclude<TRawValue, undefined>,
transformer,
);
};
return {
createRequiredField,
createOptionalField,
};
};
Loading…
Cancel
Save