Skip to content

Commit 4ef2477

Browse files
authored
refactor: add series contract alignment guidance and update type checks for anime and manga (#269)
* refactor: add series contract alignment guidance and update type checks for anime and manga * refactor: update type handling for series and transform tests to use string literals * feat: enhance seriesTransform to support manga type detection
1 parent 51f718a commit 4ef2477

File tree

15 files changed

+66
-35
lines changed

15 files changed

+66
-35
lines changed

docs/series-schema-alignment.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Series contract alignment guidance
2+
3+
This note documents the current gaps between the TypeScript contract exposed by `src/series/types.ts` in **on-the-edge** and the Python data schemas in `media/data/schemas.py` within the `AniTrend/anitrend` repository. Use it as a checklist when updating the Python layer so that both sides remain type-safe and compatible.
4+
5+
## 1. Nullability and required fields
6+
- Treat every property that is declared without `| null` in TypeScript as required in Python.
7+
- `SeriesId.shoboi` is **required** (`number`), whereas the Python schema currently makes it optional.
8+
- `SeriesScheduleEpisode` fields such as `name`, `overview`, `airDate`, `episodeNumber`, `productionCode`, `runtime`, `seasonNumber`, and `tmdbId` are required in TypeScript but marked optional in Python.
9+
- `SeriesSchedule.firstAirDate` and `SeriesSchedule.lastAirDate` are non-nullable `Instant` values in TypeScript and must be required integers in Python.
10+
- `SeriesNetwork.isPrimary` is required in TypeScript but optional in Python.
11+
- `SeriesImageAttributes.height`, `SeriesImageAttributes.width`, and `SeriesImageAttributes.type` must be required to match the TypeScript definition.
12+
- `Media.banner`, `Media.fanart`, `Media.description`, and other fields already allow `null` in TypeScript, so using `Optional[...]` in Python is correct.
13+
- For properties that are nullable (`| null`) in TypeScript, the Python schema should continue using `Optional[...]`. Avoid defaulting to an empty container when the TypeScript side may emit `null`.
14+
15+
## 2. Collection defaults
16+
- When TypeScript types specify `string[] | null` (for example `SeriesTitle.synonyms`), Python should allow `None` to be stored as-is instead of silently substituting an empty list. Prefer `Optional[List[str]]` with `default=None` unless there is a guaranteed non-null invariant.
17+
- Lists that are always present in TypeScript (e.g. `Media.images: SeriesImageAttributes[]`) should default to empty lists in Python to preserve parity with an empty array payload.
18+
19+
## 3. Enumerations and literal unions
20+
- Ensure Python `Literal[...]` declarations cover the exact value set:
21+
- `MediaKind``"ANIME" | "MANGA"`
22+
- `SeriesImageAttributes.type``"BACKDROP" | "POSTER" | "LOGO"`
23+
- `SeriesNetwork.category``"DISTRIBUTION" | "PRODUCTION"`
24+
- If additional enum members are added in TypeScript, replicate them immediately in Python to prevent validation drift.
25+
26+
## 4. Temporal fields (`Instant`)
27+
- `Instant` in TypeScript is defined as a numeric epoch (`number`). Mirror this using `int` (or `datetime` conversions) in Python. Do not coerce these to optional strings; treat them as required integers wherever `Instant` lacks `| null`.
28+
- If you need richer date handling on the Python side, perform conversions at the application boundary while keeping the raw contract aligned.
29+
30+
## 5. Media union structure
31+
- The TypeScript contract models media as a discriminated union: `Media` (base), `Media & AnimeMetadata`, and `Media & MangaMetadata`. The Python schema currently flattens everything into `MediaEntity` with many optional fields. Either:
32+
1. Split the Python models into `Media`, `AnimeMedia`, and `MangaMedia` dataclasses with the correct required metadata, or
33+
2. Keep a single dataclass but document that anime/manga metadata fields are only optional because the union collapses. If this option is chosen, add runtime validation to enforce that `AnimeMetadata` fields are present when `kind == "ANIME"` and vice versa.
34+
35+
## 6. Theme songs and trailers
36+
- `AnimeMetadata.themeSongs` is required and defaults to an empty array in TypeScript; match this by defaulting to an empty list (not `None`) in Python.
37+
- `SeriesTrailer.thumbnail` is optional (`?`), which aligns with `Optional[str]` in Python.
38+
39+
## 7. Future-proofing
40+
- Automate contract generation (e.g. generate JSON Schema from TypeScript or run Quicktype) so Python models stay synchronized. Add a CI check in both repositories to fail when schemas diverge.
41+
- Document any intentional deviations locally in both codebases so future contributors understand why a field differs.
42+
43+
Follow this checklist whenever `src/series/types.ts` changes or when updating the Python contract to keep the TypeScript and Python definitions in lockstep.

src/common/helpers/date.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,7 @@ export const toFuzzyDate = (date?: string | Date): FuzzyDate => {
3131
}
3232
};
3333

34-
export const toInstant = (date?: string | Date): Instant => {
35-
if (!date) {
36-
return -1;
37-
}
38-
34+
export const toInstant = (date: string | Date): Instant => {
3935
if (date instanceof Date) {
4036
return date.getTime() / 1000;
4137
} else {
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import { MalType } from '@scope/service/jikan';
22

3-
export const isManga = (type?: MalType): boolean => type == MalType.Manga;
3+
export const isManga = (type?: MalType): boolean => type === 'Manga';
4+
export const isAnime = (type?: MalType): boolean => type === 'TV';

src/series/repository/series.repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getTmdbShow } from '@scope/service/tmdb';
88
import { getTraktShow } from '@scope/service/trakt';
99
import { seriesTransform } from '../transformer/series.transformer.ts';
1010
import { MediaEntity } from '../types.ts';
11-
import { isManga } from './helpers/qualifier.ts';
11+
import { isAnime } from './helpers/qualifier.ts';
1212
import LocalSource from '../local/series.local.source.ts';
1313
import { Theme } from '@scope/service/theme';
1414
import { SkyhookShow } from '@scope/service/skyhook';
@@ -36,7 +36,7 @@ export default class SeriesRepository {
3636
skyhook: SkyhookShow | undefined,
3737
trakt: Show | undefined,
3838
tmdb: TmdbShow | undefined;
39-
if (!isManga(mal?.type)) {
39+
if (isAnime(mal?.type)) {
4040
[themes, skyhook] = await Promise.all([
4141
getThemesForAnime(relation?.myanimelist),
4242
getSkyhookShow(relation?.thetvdb),

src/series/transformer/series.anime.transformer.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { seriesTransform } from './series.transformer.ts';
44
import { SeriesRelationId } from '@scope/service/arm';
55
import { JikanAnime } from '@scope/service/jikan';
66
import { MediaUnion } from '../types.ts';
7-
import { MalType } from '@scope/service/jikan';
87

98
// Minimal anime fixture focusing on discriminated union behavior
109

@@ -39,7 +38,7 @@ describe('seriesTransform (anime union)', () => {
3938
title_english: 'Primary Anime Title EN',
4039
title_japanese: 'アニメ',
4140
title_synonyms: ['Alt Anime Title'],
42-
type: MalType.ANIME,
41+
type: 'TV',
4342
score: 0,
4443
scored_by: 0,
4544
rank: null,

src/series/transformer/series.manga.transformer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('seriesTransform (manga support)', () => {
3636
title_english: 'Primary Manga Title EN',
3737
title_japanese: 'マンガ',
3838
title_synonyms: ['Alt Title'],
39-
type: 1, // MalType.Manga
39+
type: 'Manga',
4040
score: 0,
4141
scored_by: 0,
4242
rank: null,

src/series/transformer/series.transformer.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { currentDate, toEpotch } from '@scope/common/core';
22
import { toInstant } from '@scope/common/helpers';
33
import { SeriesRelationId } from '@scope/service/arm';
4-
import { Jikan, JikanAnime, JikanManga, MalType } from '@scope/service/jikan';
4+
import { Jikan, JikanAnime, JikanManga } from '@scope/service/jikan';
55
import { NotifyAnime } from '@scope/service/notify';
66
import { SkyhookShow } from '@scope/service/skyhook';
77
import { AnimeTheme } from '@scope/service/theme';
@@ -27,6 +27,7 @@ import {
2727
SeriesTitle,
2828
SeriesTrailer,
2929
} from '../types.ts';
30+
import { isAnime, isManga } from '../repository/helpers/qualifier.ts';
3031

3132
const seriesId = (
3233
relation?: SeriesRelationId,
@@ -50,7 +51,7 @@ const seriesId = (
5051
tvMazeId: skyhook?.tvMazeId ?? null,
5152
tvrage: trakt?.mediaId?.tvrage ?? null,
5253
slug: relation?.animePlanet ?? trakt?.mediaId?.slug ?? skyhook?.slug ?? null,
53-
shoboi: Number(notify?.mediaId?.shoboi),
54+
shoboi: notify?.mediaId?.shoboi ? Number(notify.mediaId.shoboi) : null,
5455
trakt: trakt?.mediaId?.trakt ?? null,
5556
});
5657

@@ -102,8 +103,8 @@ const seriesSchedule = (
102103
if (!tmdb) return null;
103104

104105
return {
105-
firstAirDate: toInstant(tmdb?.first_air_date),
106-
lastAirDate: toInstant(tmdb?.last_air_date),
106+
firstAirDate: tmdb?.first_air_date ? toInstant(tmdb.first_air_date) : null,
107+
lastAirDate: tmdb?.last_air_date ? toInstant(tmdb.last_air_date) : null,
107108
lastAiredEpisode: seriesScheduleEpisode(tmdb?.last_episode_to_air),
108109
nextEpisodeToAir: seriesScheduleEpisode(tmdb?.next_episode_to_air),
109110
};
@@ -217,9 +218,7 @@ export const seriesTransform = (
217218
jikan?: Jikan,
218219
trakt?: TraktShow,
219220
): MediaUnion => {
220-
const isAnime = jikan?.type === MalType.ANIME;
221-
222-
const kind: MediaKind = isAnime ? 'ANIME' : 'MANGA';
221+
const kind: MediaKind = isAnime(jikan?.type) ? 'ANIME' : 'MANGA';
223222

224223
const base = {
225224
kind,
@@ -238,7 +237,7 @@ export const seriesTransform = (
238237
description: seriesDescription(skyhook, tmdb, notify, jikan, trakt),
239238
};
240239

241-
if (!isAnime) {
240+
if (isManga(jikan?.type)) {
242241
const jikanManga = jikan as JikanManga;
243242
const manga: MangaMetadata = {
244243
chapters: typeof jikanManga.chapters === 'number'
@@ -258,7 +257,7 @@ export const seriesTransform = (
258257
return { ...base, ...manga };
259258
}
260259

261-
if (isAnime) {
260+
if (isAnime(jikan?.type)) {
262261
const jikanAnime = jikan as JikanAnime;
263262
const anime: AnimeMetadata = {
264263
themeSongs: themes ?? [],

src/series/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type SeriesId = {
1717
tvMazeId: number | null;
1818
tvrage: string | null;
1919
slug: string | null;
20-
shoboi: number;
20+
shoboi: number | null;
2121
trakt: number | null;
2222
};
2323

@@ -44,8 +44,8 @@ export type SeriesScheduleEpisode = {
4444
};
4545

4646
export type SeriesSchedule = {
47-
firstAirDate: Instant;
48-
lastAirDate: Instant;
47+
firstAirDate: Instant | null;
48+
lastAirDate: Instant | null;
4949
lastAiredEpisode: SeriesScheduleEpisode | null;
5050
nextEpisodeToAir: SeriesScheduleEpisode | null;
5151
};

src/service/jikan/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
export * from './jikan.service.ts';
22
export * from './types.ts';
33
export * from './remote/types.ts';
4-
export * from './remote/enums.ts';

src/service/jikan/jikan.manga.transformer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('jikan manga transformer', () => {
1717
title: 'Manga Title',
1818
title_english: 'Manga English',
1919
title_japanese: 'マンガ',
20-
type: 1, // MalType.Manga
20+
type: 'Manga',
2121
score: 0,
2222
scored_by: 0,
2323
rank: 0,

0 commit comments

Comments
 (0)