Skip to content

Commit e9f3e0a

Browse files
Merge pull request #48 from justinhartman/develop
Merge develop into main
2 parents 094170d + 8f1b4e4 commit e9f3e0a

File tree

10 files changed

+161
-53
lines changed

10 files changed

+161
-53
lines changed

controllers/appController.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ describe('controllers/appController', () => {
3838
jest.clearAllMocks();
3939
(getSeriesDetail as jest.Mock).mockResolvedValue({
4040
totalSeasons: 1,
41-
totalEpisodes: 1,
42-
seasons: [{ season: 1, episodes: [{ episode: 1, title: 'E1' }] }],
41+
currentSeason: { season: 1, episodes: [{ episode: 1, title: 'E1' }] },
4342
});
4443
});
4544

@@ -126,7 +125,7 @@ describe('controllers/appController', () => {
126125
{ $set: { type: 'series', lastSeason: 1, lastEpisode: 2 } },
127126
{ upsert: true }
128127
);
129-
expect(getSeriesDetail).toHaveBeenCalledWith('tt');
128+
expect(getSeriesDetail).toHaveBeenCalledWith('tt', 1);
130129
expect(res.render).toHaveBeenCalledWith('view', expect.objectContaining({
131130
season: '1',
132131
episode: '2',

controllers/appController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const appController = {
161161
const iframeSrc = `https://${appConfig.VIDSRC_DOMAIN}/embed/tv?imdb=${id}&season=${season}&episode=${episode}`;
162162
const canonical = `${res.locals.APP_URL}/view/${id}/${type}/${season}/${episode}`;
163163
const data = await fetchOmdbData(id, false);
164-
const seriesDetail = await getSeriesDetail(id);
164+
const seriesDetail = await getSeriesDetail(id, Number(season));
165165
return res.render('view', {
166166
data,
167167
iframeSrc,

helpers/appHelper.spec.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ import appConfig from '../config/app';
2525
import * as helper from './appHelper';
2626

2727
describe('helpers/appHelper', () => {
28-
afterEach(() => {
28+
beforeEach(() => {
29+
helper.__clearCaches();
2930
jest.clearAllMocks();
3031
});
3132

33+
afterEach(() => {
34+
jest.useRealTimers();
35+
});
36+
3237
test('fetchOmdbData returns empty object when query missing', async () => {
3338
const result = await helper.fetchOmdbData('', true);
3439
expect(result).toEqual({});
@@ -63,6 +68,19 @@ describe('helpers/appHelper', () => {
6368
expect(data).toEqual({});
6469
});
6570

71+
test('fetchOmdbData caches results and expires', async () => {
72+
jest.useFakeTimers();
73+
(http.request as jest.Mock).mockResolvedValue({ data: { Title: 'Test' } });
74+
await helper.fetchOmdbData('tt123', false, 'movie');
75+
await helper.fetchOmdbData('tt123', false, 'movie');
76+
expect(http.request).toHaveBeenCalledTimes(1);
77+
jest.setSystemTime(Date.now() + helper.CACHE_TTL_MS + 1);
78+
(http.request as jest.Mock).mockResolvedValue({ data: { Title: 'Test2' } });
79+
await helper.fetchOmdbData('tt123', false, 'movie');
80+
expect(http.request).toHaveBeenCalledTimes(2);
81+
jest.useRealTimers();
82+
});
83+
6684
test('fetchAndUpdatePosters validates poster availability', async () => {
6785
const shows: any[] = [
6886
{ imdb_id: '1' },
@@ -95,36 +113,63 @@ describe('helpers/appHelper', () => {
95113
test('getSeriesDetail retrieves seasons and episodes', async () => {
96114
(http.request as jest.Mock)
97115
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E1' }] } })
98-
.mockResolvedValueOnce({ data: { Episodes: [{ Episode: '1', Title: 'E2' }, { Episode: '2', Title: 'E3' }] } });
99-
const detail = await helper.getSeriesDetail('tt1');
116+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E2' }, { Episode: '2', Title: 'E3' }] } });
117+
const detail = await helper.getSeriesDetail('tt1', 1);
100118
expect(http.request).toHaveBeenCalledTimes(2);
101119
expect(detail.totalSeasons).toBe(2);
102-
expect(detail.totalEpisodes).toBe(3);
103-
expect(detail.seasons[1].episodes[1].title).toBe('E3');
120+
expect(detail.currentSeason.episodes[0].title).toBe('E1');
121+
expect(detail.nextSeason?.episodes[1].title).toBe('E3');
104122
});
105123

106124
test('getSeriesDetail handles missing titles and episode arrays', async () => {
107125
(http.request as jest.Mock)
108126
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'N/A' }] } })
109-
.mockResolvedValueOnce({ data: { Episodes: 'N/A' } });
110-
const detail = await helper.getSeriesDetail('tt2');
111-
expect(detail.seasons[0].episodes[0].title).toBeUndefined();
112-
expect(detail.seasons[1].episodes).toEqual([]);
127+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: 'N/A' } });
128+
const detail = await helper.getSeriesDetail('tt2', 1);
129+
expect(detail.currentSeason.episodes[0].title).toBeUndefined();
130+
expect(detail.nextSeason?.episodes).toEqual([]);
113131
});
114132

115133
test('getSeriesDetail defaults when totalSeasons missing', async () => {
116134
(http.request as jest.Mock).mockResolvedValueOnce({ data: {} });
117-
const detail = await helper.getSeriesDetail('tt3');
135+
const detail = await helper.getSeriesDetail('tt3', 1);
118136
expect(detail.totalSeasons).toBe(0);
119-
expect(detail.seasons).toEqual([]);
137+
expect(detail.currentSeason.episodes).toEqual([]);
120138
});
121139

122140
test('getSeriesDetail returns empty when id missing', async () => {
123-
const detail = await helper.getSeriesDetail('');
124-
expect(detail).toEqual({ totalSeasons: 0, totalEpisodes: 0, seasons: [] });
141+
const detail = await helper.getSeriesDetail('', 1);
142+
expect(detail).toEqual({ totalSeasons: 0, currentSeason: { season: 0, episodes: [] } });
125143
expect(http.request).not.toHaveBeenCalled();
126144
});
127145

146+
test('getSeriesDetail caches seasons and expires', async () => {
147+
jest.useFakeTimers();
148+
(http.request as jest.Mock)
149+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E1' }] } })
150+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E2' }] } });
151+
await helper.getSeriesDetail('tt1', 1);
152+
await helper.getSeriesDetail('tt1', 1);
153+
expect(http.request).toHaveBeenCalledTimes(2);
154+
jest.setSystemTime(Date.now() + helper.CACHE_TTL_MS + 1);
155+
(http.request as jest.Mock)
156+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E1' }] } })
157+
.mockResolvedValueOnce({ data: { totalSeasons: '2', Episodes: [{ Episode: '1', Title: 'E2' }] } });
158+
await helper.getSeriesDetail('tt1', 1);
159+
expect(http.request).toHaveBeenCalledTimes(4);
160+
jest.useRealTimers();
161+
});
162+
163+
test('getSeriesDetail caches neighbour seasons', async () => {
164+
(http.request as jest.Mock)
165+
.mockResolvedValueOnce({ data: { totalSeasons: '3', Episodes: [{ Episode: '1', Title: 'A' }] } })
166+
.mockResolvedValueOnce({ data: { totalSeasons: '3', Episodes: [{ Episode: '1', Title: 'B' }] } })
167+
.mockResolvedValueOnce({ data: { totalSeasons: '3', Episodes: [{ Episode: '1', Title: 'C' }] } });
168+
await helper.getSeriesDetail('tt1', 1);
169+
await helper.getSeriesDetail('tt1', 2);
170+
expect(http.request).toHaveBeenCalledTimes(3);
171+
});
172+
128173
test('useAuth is false when no mongo uri', () => {
129174
expect(helper.useAuth).toBe(false);
130175
});

helpers/appHelper.ts

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ import http from './httpClient';
1313
import appConfig from '../config/app';
1414
import type { EpisodeInfo, SeasonDetail, SeriesDetail } from '../types/interfaces';
1515

16+
/**
17+
* TTL for cached OMDb responses in milliseconds (5 minutes).
18+
*/
19+
export const CACHE_TTL_MS = 1000 * 60 * 5;
20+
21+
const omdbCache = new Map<string, { data: any; expires: number }>();
22+
const seriesCache = new Map<string, { data: any; expires: number }>();
23+
24+
export const __clearCaches = (): void => {
25+
omdbCache.clear();
26+
seriesCache.clear();
27+
};
28+
1629
/**
1730
* Constructs parameters object for OMDB API requests.
1831
* @param query - Search term for title search or IMDB ID for specific item lookup
@@ -44,6 +57,12 @@ export const fetchOmdbData = async (
4457
type = ''
4558
): Promise<any> => {
4659
if (!query) return {};
60+
const key = `${query}:${search}:${type}`;
61+
const now = Date.now();
62+
const cached = omdbCache.get(key);
63+
if (cached && cached.expires > now) {
64+
return cached.data;
65+
}
4766
const params = constructOmdbParams(query, search, type);
4867
const options: AxiosRequestConfig = {
4968
method: 'GET',
@@ -54,7 +73,9 @@ export const fetchOmdbData = async (
5473
},
5574
};
5675
const response = await http.request(options);
57-
return response.data || {};
76+
const data = response.data || {};
77+
omdbCache.set(key, { data, expires: now + CACHE_TTL_MS });
78+
return data;
5879
};
5980

6081
/**
@@ -85,43 +106,69 @@ export const fetchAndUpdatePosters = async (show: any[]): Promise<void> => {
85106
};
86107

87108
/**
88-
* Retrieves detailed information for a series including seasons and episodes.
109+
* Retrieves detailed information for a specific season of a series.
110+
* Also optionally fetches neighbouring seasons for navigation.
89111
* @param id - IMDB ID of the series
90-
* @returns {Promise<SeriesDetail>} Object containing total seasons, total episodes and per-season breakdown
112+
* @param season - Season number to retrieve
113+
* @returns {Promise<SeriesDetail>} Object containing total seasons and cached season details
91114
*/
92-
export const getSeriesDetail = async (id: string): Promise<SeriesDetail> => {
93-
if (!id) return { totalSeasons: 0, totalEpisodes: 0, seasons: [] };
115+
export const getSeriesDetail = async (id: string, season: number): Promise<SeriesDetail> => {
116+
if (!id) return { totalSeasons: 0, currentSeason: { season: 0, episodes: [] } };
94117

95-
const buildOptions = (season: number): AxiosRequestConfig => ({
118+
const buildOptions = (s: number): AxiosRequestConfig => ({
96119
method: 'GET',
97120
url: appConfig.OMDB_API_URL,
98121
params: {
99122
apikey: appConfig.OMDB_API_KEY,
100123
i: id,
101-
Season: season,
124+
Season: s,
102125
},
103126
headers: { 'Content-Type': 'application/json' },
104127
});
105128

106-
const firstResponse = await http.request(buildOptions(1));
107-
const totalSeasons = Number(firstResponse.data.totalSeasons || 0);
108-
109-
const seasons: SeasonDetail[] = [];
110-
let totalEpisodes = 0;
111-
112-
for (let s = 1; s <= totalSeasons; s++) {
113-
const seasonData = s === 1 ? firstResponse.data : (await http.request(buildOptions(s))).data;
129+
const fetchSeason = async (s: number) => {
130+
const key = `${id}:${s}`;
131+
const now = Date.now();
132+
const cached = seriesCache.get(key);
133+
if (cached && cached.expires > now) return cached.data;
134+
const response = await http.request(buildOptions(s));
135+
const seasonData = response.data || {};
114136
const episodes: EpisodeInfo[] = Array.isArray(seasonData.Episodes)
115137
? seasonData.Episodes.map((ep: any) => ({
116138
episode: Number(ep.Episode),
117139
title: ep.Title !== 'N/A' ? ep.Title : undefined,
118140
}))
119141
: [];
120-
seasons.push({ season: s, episodes });
121-
totalEpisodes += episodes.length;
142+
const data = {
143+
season: s,
144+
episodes,
145+
totalSeasons: Number(seasonData.totalSeasons || 0),
146+
};
147+
seriesCache.set(key, { data, expires: now + CACHE_TTL_MS });
148+
return data;
149+
};
150+
151+
const current = await fetchSeason(season);
152+
const totalSeasons = current.totalSeasons;
153+
let prevSeason: SeasonDetail | undefined;
154+
let nextSeason: SeasonDetail | undefined;
155+
156+
if (season > 1) {
157+
const prev = await fetchSeason(season - 1);
158+
prevSeason = { season: prev.season, episodes: prev.episodes };
122159
}
123160

124-
return { totalSeasons, totalEpisodes, seasons };
161+
if (season < totalSeasons) {
162+
const next = await fetchSeason(season + 1);
163+
nextSeason = { season: next.season, episodes: next.episodes };
164+
}
165+
166+
return {
167+
totalSeasons,
168+
currentSeason: { season: current.season, episodes: current.episodes },
169+
...(prevSeason && { prevSeason }),
170+
...(nextSeason && { nextSeason }),
171+
};
125172
};
126173

127174
/**

helpers/cache.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { getLatest, setLatest, invalidateLatest } from './cache';
22

33
describe('helpers/cache', () => {
4-
afterEach(() => invalidateLatest());
4+
afterEach(() => {
5+
invalidateLatest();
6+
jest.useRealTimers();
7+
});
8+
9+
test('cache miss when empty', () => {
10+
expect(getLatest()).toBeUndefined();
11+
});
512

613
test('stores and retrieves latest content', () => {
714
const data = { movies: [1], series: [2] } as any;
@@ -14,4 +21,12 @@ describe('helpers/cache', () => {
1421
invalidateLatest();
1522
expect(getLatest()).toBeUndefined();
1623
});
24+
25+
test('cache entry expires after TTL', () => {
26+
jest.useFakeTimers();
27+
const data = { movies: [1], series: [2] } as any;
28+
setLatest(data);
29+
jest.setSystemTime(Date.now() + 60 * 60 * 24 * 1000 + 1);
30+
expect(getLatest()).toBeUndefined();
31+
});
1732
});

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"passport-local": "^1.0.0"
5151
},
5252
"devDependencies": {
53+
"@jest/globals": "^30.0.5",
54+
"@types/body-parser": "^1.19.6",
5355
"@types/ejs": "^3.1.5",
5456
"@types/express": "^5.0.3",
5557
"@types/jest": "^30.0.0",
@@ -62,6 +64,7 @@
6264
"grunt-git": "^1.1.1",
6365
"jest": "^30.0.5",
6466
"jest-junit": "^16.0.0",
67+
"jest-util": "^30.0.5",
6568
"migrate-mongo": "^11.0.0",
6669
"supertest": "^7.0.0",
6770
"ts-jest": "^29.4.1",

types/interfaces.d.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,15 @@ export interface SeasonDetail {
107107
* @interface SeriesDetail
108108
* @description Interface representing complete details about a TV series.
109109
* @property {number} totalSeasons - Total number of seasons in the series
110-
* @property {number} totalEpisodes - Total number of episodes across all seasons
111-
* @property {SeasonDetail[]} seasons - Array containing details of each season
110+
* @property {SeasonDetail} currentSeason - Details for the currently viewed season
111+
* @property {SeasonDetail} [prevSeason] - Optional details for the previous season
112+
* @property {SeasonDetail} [nextSeason] - Optional details for the next season
112113
*/
113114
export interface SeriesDetail {
114115
totalSeasons: number;
115-
totalEpisodes: number;
116-
seasons: SeasonDetail[];
116+
currentSeason: SeasonDetail;
117+
prevSeason?: SeasonDetail;
118+
nextSeason?: SeasonDetail;
117119
}
118120

119121
/**

views/partials/series-buttons.ejs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
<%
22
const seasonNum = Number(season);
33
const episodeNum = Number(episode);
4-
const totalSeasons = seriesDetail?.totalSeasons || 0;
5-
const currentSeason = seriesDetail?.seasons?.find(s => s.season === seasonNum) || { episodes: [] };
4+
const currentSeason = seriesDetail?.currentSeason || { episodes: [] };
65
const maxEpisodes = currentSeason.episodes.length;
76
let prevDisabled = false;
87
let nextDisabled = false;
98
let prevLink = `${APP_URL}/view/${id}/${type}/${seasonNum}/${episodeNum - 1}`;
109
let nextLink = `${APP_URL}/view/${id}/${type}/${seasonNum}/${episodeNum + 1}`;
1110
if (episodeNum <= 1) {
12-
if (seasonNum > 1) {
13-
const prevSeason = seasonNum - 1;
14-
const prevSeasonEpisodes = seriesDetail.seasons.find(s => s.season === prevSeason)?.episodes.length || 0;
15-
prevLink = `${APP_URL}/view/${id}/${type}/${prevSeason}/${prevSeasonEpisodes}`;
11+
if (seriesDetail?.prevSeason) {
12+
const prevSeasonEpisodes = seriesDetail.prevSeason.episodes.length || 0;
13+
prevLink = `${APP_URL}/view/${id}/${type}/${seriesDetail.prevSeason.season}/${prevSeasonEpisodes}`;
1614
} else {
1715
prevDisabled = true;
1816
prevLink = '#';
1917
}
2018
}
2119
if (episodeNum >= maxEpisodes) {
22-
if (seasonNum < totalSeasons) {
23-
const nextSeason = seasonNum + 1;
24-
nextLink = `${APP_URL}/view/${id}/${type}/${nextSeason}/1`;
20+
if (seriesDetail?.nextSeason) {
21+
nextLink = `${APP_URL}/view/${id}/${type}/${seriesDetail.nextSeason.season}/1`;
2522
} else {
2623
nextDisabled = true;
2724
nextLink = '#';

views/view.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
</div>
6060
<div class="series-episodes d-flex flex-wrap flex-md-column mt-2 mt-md-0">
6161
<%- include('./partials/series-buttons.ejs') -%>
62-
<% const currentSeason = seriesDetail.seasons.find(se => se.season === Number(season)) || { episodes: [] }; %>
62+
<% const currentSeason = seriesDetail.currentSeason || { episodes: [] }; %>
6363
<% currentSeason.episodes.forEach(ep => { %>
6464
<a href="<%= `${APP_URL}/view/${id}/${type}/${season}/${ep.episode}` %>" class="btn btn-outline-info btn-sm text-start m-1 <%= Number(episode) === ep.episode ? 'active' : '' %>"><%= ep.episode %>. <%= ep.title || `Episode ${ep.episode}` %></a>
6565
<% }) %>

0 commit comments

Comments
 (0)