diff --git a/api/episodes.js b/api/episodes.js index 39c7eb0..5831288 100644 --- a/api/episodes.js +++ b/api/episodes.js @@ -1,4 +1,4 @@ -import { getUpcomingEpisodes, toIcs } from '../lib/tvEpisodes.js'; +import { getEpisodes, toIcs } from '../lib/tvEpisodes.js'; function sendJson(res, statusCode, payload) { res.statusCode = statusCode; @@ -17,9 +17,10 @@ export default async function handler(req, res) { const query = requestUrl.searchParams.get('show') || requestUrl.searchParams.get('q'); const format = requestUrl.searchParams.get('format'); const timezone = requestUrl.searchParams.get('tz') || 'UTC'; + const since = requestUrl.searchParams.get('since'); try { - const result = await getUpcomingEpisodes({ query }); + const result = await getEpisodes({ query, since }); if (format === 'ics') { res.statusCode = 200; @@ -32,7 +33,7 @@ export default async function handler(req, res) { } catch (error) { const statusCode = error.statusCode || (error.message?.includes('404') ? 404 : 500); return sendJson(res, statusCode, { - error: statusCode === 404 ? 'Show not found.' : error.message || 'Unable to fetch upcoming episodes.' + error: statusCode === 404 ? 'Show not found.' : error.message || 'Unable to fetch episodes.' }); } } diff --git a/api/sports-events.js b/api/sports-events.js index 226bd39..58fdc69 100644 --- a/api/sports-events.js +++ b/api/sports-events.js @@ -1,4 +1,4 @@ -import { getUpcomingEvents, toIcs } from '../lib/sports.js'; +import { getEvents, toIcs } from '../lib/sports.js'; function sendJson(res, statusCode, payload) { res.statusCode = statusCode; @@ -17,13 +17,14 @@ export default async function handler(req, res) { const teamId = requestUrl.searchParams.get('teamId'); const format = requestUrl.searchParams.get('format'); const timezone = requestUrl.searchParams.get('tz') || 'UTC'; + const since = requestUrl.searchParams.get('since'); if (!teamId) { return sendJson(res, 400, { error: 'teamId is required.' }); } try { - const result = await getUpcomingEvents({ teamId }); + const result = await getEvents({ teamId, since }); if (format === 'ics') { res.statusCode = 200; diff --git a/lib/sports.js b/lib/sports.js index 1f8df82..b3e9bdf 100644 --- a/lib/sports.js +++ b/lib/sports.js @@ -246,7 +246,7 @@ async function loadSupplementalTeamEvents(teamId) { } } -export async function getUpcomingEvents({ teamId, fetchImpl = globalThis.fetch } = {}) { +export async function getEvents({ teamId, now = new Date(), since = null, fetchImpl = globalThis.fetch } = {}) { if (!teamId) { throw new Error('A team ID is required.'); } @@ -319,23 +319,26 @@ export async function getUpcomingEvents({ teamId, fetchImpl = globalThis.fetch } } } - // Filter for upcoming events (include ongoing events started in the last 3 hours) - const now = new Date(); - const threeHoursAgo = new Date(now.getTime() - 180 * 60 * 1000); - const upcomingEvents = uniqueEvents - .filter((event) => { - const eventDate = parseApiTimestamp(event.timestamp, event.date, event.time); - return eventDate >= threeHoursAgo; - }) + let sortedEvents = uniqueEvents .sort((a, b) => { const dateA = parseApiTimestamp(a.timestamp, a.date, a.time); const dateB = parseApiTimestamp(b.timestamp, b.date, b.time); return dateA - dateB; }); + if (since) { + const sinceTime = Date.parse(since); + if (!Number.isNaN(sinceTime)) { + sortedEvents = sortedEvents.filter((e) => { + const timestamp = parseApiTimestamp(e.timestamp, e.date, e.time); + return timestamp.getTime() >= sinceTime; + }); + } + } + return { team: normalizeTeamSuggestion(team), - events: upcomingEvents, + events: sortedEvents, generatedAt: now.toISOString() }; } @@ -354,7 +357,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', - 'PRODID:-//MakeICS//Sports Upcoming Events//EN', + 'PRODID:-//MakeICS//Sports Events//EN', 'CALSCALE:GREGORIAN', `X-PUBLISHED-TTL:${FEED_REFRESH_INTERVAL}`, `REFRESH-INTERVAL;VALUE=DURATION:${FEED_REFRESH_INTERVAL}` diff --git a/lib/tvEpisodes.js b/lib/tvEpisodes.js index 905596e..9ff35f3 100644 --- a/lib/tvEpisodes.js +++ b/lib/tvEpisodes.js @@ -97,11 +97,8 @@ function normalizeEpisode(episode, show) { }; } -function filterUpcomingEpisodes(episodes, show, now) { - const from = now.getTime(); - +function normalizeEpisodes(episodes, show) { return episodes - .filter((episode) => getEpisodeTimestamp(episode) >= from) .sort((left, right) => getEpisodeTimestamp(left) - getEpisodeTimestamp(right)) .map((episode) => normalizeEpisode(episode, show)); } @@ -320,7 +317,7 @@ async function fetchImdbDetails(imdbId, env, fetchImpl) { } } -export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl = globalThis.fetch, env = process.env } = {}) { +export async function getEpisodes({ query, now = new Date(), since = null, fetchImpl = globalThis.fetch, env = process.env } = {}) { const trimmedQuery = typeof query === 'string' ? query.trim() : ''; if (!trimmedQuery) { const error = new Error('A show name is required.'); @@ -334,7 +331,15 @@ export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl = const show = await searchShow(trimmedQuery, fetchImpl); const episodes = await fetchShowEpisodes(show.id, fetchImpl); - const upcomingEpisodes = filterUpcomingEpisodes(episodes, show, now); + let allEpisodes = normalizeEpisodes(episodes, show).filter((e) => e.airstamp || e.airdate); + + if (since) { + const sinceTime = Date.parse(since); + if (!Number.isNaN(sinceTime)) { + allEpisodes = allEpisodes.filter((e) => getEpisodeTimestamp(e) >= sinceTime); + } + } + const imdbId = show.externals?.imdb || null; const imdb = await fetchImdbDetails(imdbId, env, fetchImpl); @@ -361,7 +366,7 @@ export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl = network: show.network?.name || show.webChannel?.name || null }, imdb, - episodes: upcomingEpisodes + episodes: allEpisodes }; } @@ -383,7 +388,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) { details.push(`Time: ${episodeTime}.`); } - details.push(episode.summary || `Upcoming episode of ${episode.showName}`); + details.push(episode.summary || `Episode of ${episode.showName}`); return details.join(' '); }; @@ -394,7 +399,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', - 'PRODID:-//MakeICS//TV Upcoming Episodes//EN', + 'PRODID:-//MakeICS//TV Episodes//EN', 'CALSCALE:GREGORIAN', `X-PUBLISHED-TTL:${FEED_REFRESH_INTERVAL}`, `REFRESH-INTERVAL;VALUE=DURATION:${FEED_REFRESH_INTERVAL}` diff --git a/public/app.js b/public/app.js index adf12ef..afd4025 100644 --- a/public/app.js +++ b/public/app.js @@ -17,8 +17,8 @@ let suggestionAbortController; let activeSuggestionIndex = -1; const CATEGORY_HINTS = { - tv: 'Start typing to see TV show suggestions below the search bar. The all-time feed uses TVMaze for show and episode schedules.', - sports: 'Search for sports teams from TheSportsDB. Copy the ICS URL to track upcoming matches.' + tv: 'Start typing to see TV show suggestions below the search bar. The feed includes all episodes from today onwards once added.', + sports: 'Search for sports teams from TheSportsDB. Copy the ICS URL to track matches from today onwards once added.' }; // --- Tab Logic --- @@ -225,10 +225,11 @@ function formatAirDate(dateStr, timeStr, timestamp, includeZones = false, timezo function icsUrlForCurrent() { let path = ''; const tz = timezoneInput?.value || 'UTC'; + const since = new Date().toISOString().split('T')[0]; if (currentCategory === 'tv') { - path = `/api/episodes?show=${encodeURIComponent(showInput.value)}&format=ics&tz=${encodeURIComponent(tz)}`; + path = `/api/episodes?show=${encodeURIComponent(showInput.value)}&format=ics&tz=${encodeURIComponent(tz)}&since=${since}`; } else if (currentCategory === 'sports') { - path = `/api/sports-events?teamId=${encodeURIComponent(sportsInput.dataset.teamId)}&format=ics&tz=${encodeURIComponent(tz)}`; + path = `/api/sports-events?teamId=${encodeURIComponent(sportsInput.dataset.teamId)}&format=ics&tz=${encodeURIComponent(tz)}&since=${since}`; } return new URL(path, window.location.origin).href; } @@ -389,8 +390,8 @@ function renderList(items, type, context, timezone = 'UTC') { const count = document.createElement('p'); count.className = 'count'; count.textContent = items.length - ? `${items.length} upcoming event${items.length === 1 ? '' : 's'} found.` - : `No upcoming events found.`; + ? `${items.length} event${items.length === 1 ? '' : 's'} found.` + : `No events found.`; resultEl.append(count); const list = document.createElement('div'); @@ -494,9 +495,10 @@ form.addEventListener('submit', async (event) => { let label = ''; const tz = timezoneInput?.value || 'UTC'; + const since = new Date().toISOString().split('T')[0]; if (currentCategory === 'tv') { label = showInput.value; - url = `/api/episodes?show=${encodeURIComponent(label)}&tz=${encodeURIComponent(tz)}`; + url = `/api/episodes?show=${encodeURIComponent(label)}&tz=${encodeURIComponent(tz)}&since=${since}`; } else if (currentCategory === 'sports') { const teamId = sportsInput.dataset.teamId; if (!teamId || teamId === 'undefined') { @@ -504,7 +506,7 @@ form.addEventListener('submit', async (event) => { return; } label = sportsInput.value; - url = `/api/sports-events?teamId=${encodeURIComponent(teamId)}&tz=${encodeURIComponent(tz)}`; + url = `/api/sports-events?teamId=${encodeURIComponent(teamId)}&tz=${encodeURIComponent(tz)}&since=${since}`; } setStatus(`Fetching for ${label}...`); diff --git a/server_output.log b/server_output.log deleted file mode 100644 index fb7c8b7..0000000 --- a/server_output.log +++ /dev/null @@ -1 +0,0 @@ -MakeICS TV Episodes running at http://localhost:3000 diff --git a/test/sports.test.js b/test/sports.test.js index d1e1215..7ef83ca 100644 --- a/test/sports.test.js +++ b/test/sports.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { searchTeamSuggestions, getUpcomingEvents, toIcs } from '../lib/sports.js'; +import { searchTeamSuggestions, getEvents, toIcs } from '../lib/sports.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CACHE_DIR = path.join(__dirname, '../lib/data/sports'); @@ -128,19 +128,19 @@ test('searchTeamSuggestions returns team results', async () => { assert.equal(suggestions[0].name, 'Arsenal'); }); -test('getUpcomingEvents returns merged and deduplicated events for a team', (t) => { +test('getEvents returns merged and deduplicated events for a team, including past ones', (t) => { const clock = t.mock.timers; - clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') }); + clock.enable({ names: ['Date'], now: new Date('2027-01-01T00:00:00Z') }); return t.test('merging logic', async () => { - const result = await getUpcomingEvents({ + const result = await getEvents({ teamId: '133604', fetchImpl: createFetchMock() }); assert.equal(result.team.name, 'Arsenal'); // Should have: Arsenal vs Real Betis (4), Arsenal vs Everton (1), Chelsea vs Arsenal (3) - // Deduplicated and sorted by date + // Deduplicated and sorted by date, all are in the past now but should be included assert.equal(result.events.length, 3); assert.equal(result.events[0].id, '4'); assert.equal(result.events[1].id, '1'); @@ -148,7 +148,7 @@ test('getUpcomingEvents returns merged and deduplicated events for a team', (t) }); }); -test('getUpcomingEvents utilizes local cache if available', async (t) => { +test('getEvents utilizes local cache if available', async (t) => { const leagueId = '4328'; const cacheFilePath = path.join(CACHE_DIR, `${leagueId}.json`); const cachedData = { @@ -180,7 +180,7 @@ test('getUpcomingEvents utilizes local cache if available', async (t) => { const onCall = (url) => calls.push(url); try { - const result = await getUpcomingEvents({ + const result = await getEvents({ teamId: '133604', fetchImpl: createFetchMock(onCall) }); @@ -197,7 +197,7 @@ test('getUpcomingEvents utilizes local cache if available', async (t) => { } }); -test('getUpcomingEvents merges supplemental (scraped) data', async (t) => { +test('getEvents merges supplemental (scraped) data', async (t) => { const clock = t.mock.timers; clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') }); @@ -229,7 +229,7 @@ test('getUpcomingEvents merges supplemental (scraped) data', async (t) => { await fs.writeFile(supplementalFilePath, JSON.stringify(scrapedData)); try { - const result = await getUpcomingEvents({ + const result = await getEvents({ teamId, fetchImpl: createFetchMock() }); @@ -247,7 +247,7 @@ test('toIcs creates ICS for sports events', async (t) => { const clock = t.mock.timers; clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') }); - const result = await getUpcomingEvents({ + const result = await getEvents({ teamId: '133604', fetchImpl: createFetchMock() }); @@ -260,7 +260,7 @@ test('toIcs creates ICS for sports events', async (t) => { assert.match(ics, /LOCATION:Emirates Stadium/); }); -test('getUpcomingEvents handles strTimestamp with offset correctly', async (t) => { +test('getEvents handles strTimestamp with offset correctly', async (t) => { const clock = t.mock.timers; clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') }); @@ -284,12 +284,12 @@ test('getUpcomingEvents handles strTimestamp with offset correctly', async (t) = const fetchImpl = async (url) => { if (url.includes('lookupteam.php')) return Response.json(teamPayload); if (url.includes('eventsnext.php')) return Response.json(customEventsPayload); - // Return empty for other calls to avoid errors in getUpcomingEvents + // Return empty for other calls to avoid errors in getEvents if (url.includes('lookupleague.php')) return Response.json({ leagues: [] }); return Response.json({}); }; - const result = await getUpcomingEvents({ + const result = await getEvents({ teamId: '133604', fetchImpl }); diff --git a/test/tvEpisodes.test.js b/test/tvEpisodes.test.js index d3e6d8f..b014f38 100644 --- a/test/tvEpisodes.test.js +++ b/test/tvEpisodes.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import { getUpcomingEpisodes, searchShowSuggestions, toIcs } from '../lib/tvEpisodes.js'; +import { getEpisodes, searchShowSuggestions, toIcs } from '../lib/tvEpisodes.js'; const showPayload = { id: 1, @@ -129,10 +129,9 @@ test('searchShowSuggestions returns Google-style typeahead TV show results', asy assert.ok(requests.some((request) => request.url === 'https://api.tvmaze.com/search/shows?q=exa')); }); -test('getUpcomingEpisodes returns upcoming TVMaze episodes and custom IMDb enrichment', async () => { - const result = await getUpcomingEpisodes({ +test('getEpisodes returns TVMaze episodes and custom IMDb enrichment', async () => { + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl: createFetchMock(), env: { IMDB_API_URL: 'https://imdb.test/title/{imdbId}' } }); @@ -141,17 +140,16 @@ test('getUpcomingEpisodes returns upcoming TVMaze episodes and custom IMDb enric assert.equal(result.show.imdbId, 'tt1234567'); assert.equal(result.imdb.title, 'IMDb Example Show'); assert.equal(result.window.mode, 'all-time'); - assert.equal(result.episodes.length, 2); - assert.deepEqual(result.episodes.map((episode) => episode.name), ['The Future', 'Too Far Away']); - assert.equal(result.episodes[0].summary, 'Future episode.'); + assert.equal(result.episodes.length, 3); + assert.deepEqual(result.episodes.map((episode) => episode.name), ['Already Aired', 'The Future', 'Too Far Away']); + assert.equal(result.episodes[1].summary, 'Future episode.'); assert.equal(result.episodes[0].network, 'Test Network'); }); -test('getUpcomingEpisodes uses the free public IMDb endpoint without an API key by default', async () => { +test('getEpisodes uses the free public IMDb endpoint without an API key by default', async () => { const requests = []; - const result = await getUpcomingEpisodes({ + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl: createFetchMock(requests), env: {} }); @@ -163,7 +161,7 @@ test('getUpcomingEpisodes uses the free public IMDb endpoint without an API key assert.ok(requests.every((request) => !request.options.headers?.Authorization)); }); -test('getUpcomingEpisodes quietly skips unavailable default IMDb enrichment', async () => { +test('getEpisodes quietly skips unavailable default IMDb enrichment', async () => { const requests = []; const fetchImpl = async (url, options = {}) => { requests.push({ url: String(url), options }); @@ -179,9 +177,8 @@ test('getUpcomingEpisodes quietly skips unavailable default IMDb enrichment', as throw new Error(`Unexpected URL: ${url}`); }; - const result = await getUpcomingEpisodes({ + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl, env: {} }); @@ -190,11 +187,11 @@ test('getUpcomingEpisodes quietly skips unavailable default IMDb enrichment', as assert.equal(result.imdb.sourceConfigured, false); assert.equal(result.imdb.error, undefined); assert.match(result.imdb.warning, /HTTP 500/); - assert.equal(result.episodes.length, 2); + assert.equal(result.episodes.length, 3); assert.ok(requests.some((request) => request.url === 'https://imdb.iamidiotareyoutoo.com/search?tt=tt1234567')); }); -test('getUpcomingEpisodes quietly skips default IMDb network failures', async () => { +test('getEpisodes quietly skips default IMDb network failures', async () => { const fetchImpl = async (url) => { if (String(url).includes('/singlesearch/shows')) { return Response.json(showPayload); @@ -208,9 +205,8 @@ test('getUpcomingEpisodes quietly skips default IMDb network failures', async () throw new Error(`Unexpected URL: ${url}`); }; - const result = await getUpcomingEpisodes({ + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl, env: {} }); @@ -218,14 +214,13 @@ test('getUpcomingEpisodes quietly skips default IMDb network failures', async () assert.equal(result.imdb.sourceConfigured, false); assert.equal(result.imdb.error, undefined); assert.match(result.imdb.warning, /request failed/); - assert.equal(result.episodes.length, 2); + assert.equal(result.episodes.length, 3); }); -test('getUpcomingEpisodes uses FIRECRAWL_API_KEY for IMDb scraping when configured', async () => { +test('getEpisodes uses FIRECRAWL_API_KEY for IMDb scraping when configured', async () => { const requests = []; - const result = await getUpcomingEpisodes({ + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl: createFetchMock(requests), env: { FIRECRAWL_API_KEY: 'fc-test', FIRECRAWL_API_URL: 'https://firecrawl.test/v2/scrape' } }); @@ -239,17 +234,16 @@ test('getUpcomingEpisodes uses FIRECRAWL_API_KEY for IMDb scraping when configur assert.match(firecrawlRequest.options.body, /https:\/\/www\.imdb\.com\/title\/tt1234567\//); }); -test('getUpcomingEpisodes validates missing show names', async () => { +test('getEpisodes validates missing show names', async () => { await assert.rejects( - () => getUpcomingEpisodes({ query: ' ', fetchImpl: createFetchMock() }), + () => getEpisodes({ query: ' ', fetchImpl: createFetchMock() }), /show name is required/ ); }); -test('toIcs creates a daily-refreshing calendar event feed for each upcoming episode', async () => { - const result = await getUpcomingEpisodes({ +test('toIcs creates a daily-refreshing calendar event feed for episodes', async () => { + const result = await getEpisodes({ query: 'Example Show', - now: new Date('2026-05-29T00:00:00Z'), fetchImpl: createFetchMock(), env: {} }); @@ -258,6 +252,7 @@ test('toIcs creates a daily-refreshing calendar event feed for each upcoming epi assert.match(ics, /BEGIN:VCALENDAR/); assert.match(ics, /X-PUBLISHED-TTL:PT24H/); assert.match(ics, /REFRESH-INTERVAL;VALUE=DURATION:PT24H/); + assert.match(ics, /SUMMARY:Example Show S01E01 Already Aired/); assert.match(ics, /SUMMARY:Example Show S02E03 The Future/); assert.match(ics, /SUMMARY:Example Show S02E04 Too Far Away/); assert.match(ics, /DESCRIPTION:.*Time: 9:00 PM EDT \/ 6:00 PM PDT.*/);