Skip to content

Commit 0089561

Browse files
committed
Fixed Sorting by channel/time
1 parent dad2b56 commit 0089561

File tree

2 files changed

+93
-83
lines changed

2 files changed

+93
-83
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ I also *somewhat* maintain a version of the original in the [historical-perl bra
1010

1111
# Recent updates
1212

13+
# (2025-08-09)
14+
15+
* Fixed Sorting so output is listed by Channel ID (common station/gracenote id) then by date/time.
16+
1317
# (2025-08-07)
1418

1519
* Reordered Program fields to match original Perl script output

src/xmltv.ts

Lines changed: 89 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,28 @@ export function formatDate(dateStr: string): string {
2424

2525
const cli = new Command();
2626
cli
27-
.option('--appendAsterisk', 'Append * to titles with <new /> or <live />')
28-
.option('--mediaportal', 'Prioritize xmltv_ns episode-num tags')
29-
.option('--lineupId <lineupId>', 'Lineup ID')
30-
.option('--timespan <hours>', 'Timespan in hours (up to 360)', '6')
31-
.option('--pref <prefs>', 'User Preferences, e.g. m,p,h')
32-
.option('--country <code>', 'Country code', 'USA')
33-
.option('--postalCode <zip>', 'Postal code', '30309')
34-
.option('--userAgent <agent>', 'Custom user agent string')
35-
.option('--timezone <zone>', 'Timezone')
36-
.option('--outputFile <filename>', 'Output file name', 'xmltv.xml');
27+
.option("--appendAsterisk", "Append * to titles with <new /> or <live />")
28+
.option("--mediaportal", "Prioritize xmltv_ns episode-num tags")
29+
.option("--lineupId <lineupId>", "Lineup ID")
30+
.option("--timespan <hours>", "Timespan in hours (up to 360)", "6")
31+
.option("--pref <prefs>", "User Preferences, e.g. m,p,h")
32+
.option("--country <code>", "Country code", "USA")
33+
.option("--postalCode <zip>", "Postal code", "30309")
34+
.option("--userAgent <agent>", "Custom user agent string")
35+
.option("--timezone <zone>", "Timezone")
36+
.option("--outputFile <filename>", "Output file name", "xmltv.xml");
3737
cli.parse(process.argv);
3838
const options = cli.opts() as { [key: string]: any };
3939

4040
export function buildChannelsXml(data: GridApiResponse): string {
4141
let xml = "";
4242

43-
for (const channel of data.channels) {
43+
// Sort channels by channelId for deterministic <channel> order
44+
const sortedChannels = [...data.channels].sort((a, b) =>
45+
a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" })
46+
);
47+
48+
for (const channel of sortedChannels) {
4449
xml += ` <channel id="${escapeXml(channel.channelId)}">\n`;
4550
xml += ` <display-name>${escapeXml(channel.callSign)}</display-name>\n`;
4651

@@ -56,8 +61,8 @@ export function buildChannelsXml(data: GridApiResponse): string {
5661
let src = channel.thumbnail.startsWith("http")
5762
? channel.thumbnail
5863
: "https:" + channel.thumbnail;
59-
// This part removes the ?w=55 from the URL.
60-
const queryIndex = src.indexOf('?');
64+
// Strip any query string like ?w=55
65+
const queryIndex = src.indexOf("?");
6166
if (queryIndex !== -1) {
6267
src = src.substring(0, queryIndex);
6368
}
@@ -80,68 +85,68 @@ export function buildProgramsXml(data: GridApiResponse): string {
8085
return originalAirDate.replace(/-/g, "");
8186
};
8287

83-
for (const channel of data.channels) {
84-
for (const event of channel.events) {
88+
// Sort channels by channelId so <programme> blocks group by channel
89+
const sortedChannels = [...data.channels].sort((a, b) =>
90+
a.channelId.localeCompare(b.channelId, undefined, { numeric: true, sensitivity: "base" })
91+
);
92+
93+
for (const channel of sortedChannels) {
94+
// Sort events by startTime within each channel
95+
const sortedEvents = [...channel.events].sort(
96+
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
97+
);
98+
99+
for (const event of sortedEvents) {
85100
xml += ` <programme start="${formatDate(
86-
event.startTime,
87-
)}" stop="${formatDate(event.endTime)}" channel="${escapeXml(
88-
channel.channelId,
89-
)}">\n`;
101+
event.startTime
102+
)}" stop="${formatDate(event.endTime)}" channel="${escapeXml(channel.channelId)}">\n`;
90103

91104
const isNew = event.flag?.includes("New");
92105
const isLive = event.flag?.includes("Live");
93106
let title = event.program.title;
94-
if (options['appendAsterisk'] && (isNew || isLive)) {
107+
if (options["appendAsterisk"] && (isNew || isLive)) {
95108
title += " *";
96109
}
97110
xml += ` <title>${escapeXml(title)}</title>\n`;
98111

99112
if (event.program.episodeTitle) {
100-
xml += ` <sub-title>${escapeXml(
101-
event.program.episodeTitle,
102-
)}</sub-title>\n`;
113+
xml += ` <sub-title>${escapeXml(event.program.episodeTitle)}</sub-title>\n`;
103114
}
104115

105116
if (event.program.shortDesc) {
106117
xml += ` <desc>${escapeXml(event.program.shortDesc)}</desc>\n`;
107118
}
108119

109-
// Date logic: releaseYear first, else current date from startTime (America/New_York)
110-
if (event.program.releaseYear) {
111-
xml += ` <date>${escapeXml(event.program.releaseYear)}</date>
112-
`;
113-
} else {
114-
const nyFormatter = new Intl.DateTimeFormat("en-US", {
115-
timeZone: "America/New_York",
116-
year: "numeric",
117-
month: "2-digit",
118-
day: "2-digit"
119-
});
120-
const parts = nyFormatter.formatToParts(new Date(event.startTime));
121-
const year = parseInt(parts.find(p => p.type === "year")?.value || "1970", 10);
122-
const mm = parts.find(p => p.type === "month")?.value || "01";
123-
const dd = parts.find(p => p.type === "day")?.value || "01";
124-
xml += ` <date>${year}${mm}${dd}</date>
125-
`;
126-
}
127-
120+
// Date logic: releaseYear first, else current date from startTime (America/New_York)
121+
if (event.program.releaseYear) {
122+
xml += ` <date>${escapeXml(event.program.releaseYear)}</date>\n`;
123+
} else {
124+
const nyFormatter = new Intl.DateTimeFormat("en-US", {
125+
timeZone: "America/New_York",
126+
year: "numeric",
127+
month: "2-digit",
128+
day: "2-digit",
129+
});
130+
const parts = nyFormatter.formatToParts(new Date(event.startTime));
131+
const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10);
132+
const mm = parts.find((p) => p.type === "month")?.value || "01";
133+
const dd = parts.find((p) => p.type === "day")?.value || "01";
134+
xml += ` <date>${year}${mm}${dd}</date>\n`;
135+
}
128136

129-
const genreSet = new Set(event.program.genres?.map(g => g.toLowerCase()) || []);
137+
const genreSet = new Set(event.program.genres?.map((g) => g.toLowerCase()) || []);
130138

131139
if (event.program.genres && event.program.genres.length > 0) {
132140
const sortedGenres = [...event.program.genres].sort((a, b) => a.localeCompare(b));
133141
for (const genre of sortedGenres) {
134142
const capitalizedGenre = genre.charAt(0).toUpperCase() + genre.slice(1);
135-
xml += ` <category lang="en">${escapeXml(capitalizedGenre)}</category>
136-
`;
137-
143+
xml += ` <category lang="en">${escapeXml(capitalizedGenre)}</category>\n`;
138144
}
139145
}
140146

141-
// add <length> after categories
147+
// Add <length> after categories
142148
if (event.duration) {
143-
xml += ` <length units="minutes">${escapeXml(event.duration)}</length>
144-
`;
149+
xml += ` <length units="minutes">${escapeXml(event.duration)}</length>\n`;
145150
}
146151

147152
if (event.thumbnail) {
@@ -151,36 +156,34 @@ if (event.program.releaseYear) {
151156
xml += ` <icon src="${escapeXml(src)}" />\n`;
152157
}
153158

159+
// Optional series link
154160
if (event.program.seriesId && event.program.tmsId) {
155161
const encodedUrl = `https://tvlistings.gracenote.com//overview.html?programSeriesId=${event.program.seriesId}&amp;tmsId=${event.program.tmsId}`;
156162
xml += ` <url>${encodedUrl}</url>\n`;
157163
}
158164

159165
const skipXmltvNs = genreSet.has("movie") || genreSet.has("sports");
160-
const episodeNumTags = [];
166+
const episodeNumTags: string[] = [];
161167

162168
if (event.program.season && event.program.episode && !skipXmltvNs) {
163-
const onscreenTag = ` <episode-num system="onscreen">${escapeXml(
164-
`S${event.program.season.padStart(2, "0")}E${event.program.episode.padStart(2, "0")}`,
165-
)}</episode-num>\n`;
166-
episodeNumTags.push(onscreenTag);
167-
168-
const commonTag = ` <episode-num system="common">${escapeXml(
169-
`S${event.program.season.padStart(2, "0")}E${event.program.episode.padStart(2, "0")}`,
170-
)}</episode-num>\n`;
171-
episodeNumTags.push(commonTag);
169+
const onscreen = `S${event.program.season.padStart(2, "0")}E${event.program.episode.padStart(
170+
2,
171+
"0"
172+
)}`;
173+
episodeNumTags.push(` <episode-num system="onscreen">${escapeXml(onscreen)}</episode-num>\n`);
174+
episodeNumTags.push(` <episode-num system="common">${escapeXml(onscreen)}</episode-num>\n`);
172175

173176
if (/\.\d{8}\d{4}/.test(event.program.id)) {
174-
const ddProgIdTag = ` <episode-num system="dd_progid">${escapeXml(event.program.id)}</episode-num>\n`;
175-
episodeNumTags.push(ddProgIdTag);
177+
episodeNumTags.push(
178+
` <episode-num system="dd_progid">${escapeXml(event.program.id)}</episode-num>\n`
179+
);
176180
}
177181

178182
const seasonNum = parseInt(event.program.season, 10);
179183
const episodeNum = parseInt(event.program.episode, 10);
180-
181184
if (!isNaN(seasonNum) && !isNaN(episodeNum) && seasonNum >= 1 && episodeNum >= 1) {
182185
const xmltvNsTag = ` <episode-num system="xmltv_ns">${seasonNum - 1}.${episodeNum - 1}.</episode-num>\n`;
183-
if (options['mediaportal']) {
186+
if (options["mediaportal"]) {
184187
episodeNumTags.unshift(xmltvNsTag);
185188
} else {
186189
episodeNumTags.push(xmltvNsTag);
@@ -191,43 +194,43 @@ if (event.program.releaseYear) {
191194
timeZone: "America/New_York",
192195
year: "numeric",
193196
month: "2-digit",
194-
day: "2-digit"
197+
day: "2-digit",
195198
});
196199
const parts = nyFormatter.formatToParts(new Date(event.startTime));
197-
const year = parseInt(parts.find(p => p.type === "year")?.value || "1970", 10);
200+
const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10);
198201
const episodeIdx = parseInt(event.program.episode, 10);
199202
if (!isNaN(episodeIdx)) {
200203
const xmltvNsTag = ` <episode-num system="xmltv_ns">${year - 1}.${episodeIdx - 1}.0/1</episode-num>\n`;
201-
if (options['mediaportal']) {
204+
if (options["mediaportal"]) {
202205
episodeNumTags.unshift(xmltvNsTag);
203206
} else {
204207
episodeNumTags.push(xmltvNsTag);
205208
}
206209
}
207-
208210
} else if (!event.program.episode && event.program.id) {
209-
const match = event.program.id.match(/^(..\d{8})(\d{4})/);
211+
const match = event.program.id.match(/^(.\d{8})(\d{4})/);
210212
if (match) {
211-
const ddProgIdTag = ` <episode-num system="dd_progid">${match[1]}.${match[2]}</episode-num>\n`;
212-
episodeNumTags.push(ddProgIdTag);
213+
episodeNumTags.push(
214+
` <episode-num system="dd_progid">${match[1]}.${match[2]}</episode-num>\n`
215+
);
213216
}
214217

215218
const nyFormatter = new Intl.DateTimeFormat("en-US", {
216219
timeZone: "America/New_York",
217220
year: "numeric",
218221
month: "2-digit",
219-
day: "2-digit"
222+
day: "2-digit",
220223
});
221224
const parts = nyFormatter.formatToParts(new Date(event.startTime));
222-
const year = parseInt(parts.find(p => p.type === "year")?.value || "1970", 10);
223-
const mm = parts.find(p => p.type === "month")?.value || "01";
224-
const dd = parts.find(p => p.type === "day")?.value || "01";
225+
const year = parseInt(parts.find((p) => p.type === "year")?.value || "1970", 10);
226+
const mm = parts.find((p) => p.type === "month")?.value || "01";
227+
const dd = parts.find((p) => p.type === "day")?.value || "01";
225228

226229
if (!skipXmltvNs) {
227230
const mmddNum = parseInt(`${mm}${dd}`, 10);
228-
const mmddMinusOne = (mmddNum - 1).toString().padStart(4, '0');
231+
const mmddMinusOne = (mmddNum - 1).toString().padStart(4, "0");
229232
const xmltvNsTag = ` <episode-num system="xmltv_ns">${year - 1}.${mmddMinusOne}.</episode-num>\n`;
230-
if (options['mediaportal']) {
233+
if (options["mediaportal"]) {
231234
episodeNumTags.unshift(xmltvNsTag);
232235
} else {
233236
episodeNumTags.push(xmltvNsTag);
@@ -238,10 +241,14 @@ if (event.program.releaseYear) {
238241
xml += episodeNumTags.join("");
239242

240243
if (event.program.originalAirDate || event.program.episodeAirDate) {
241-
const airDate = new Date(event.program.episodeAirDate || event.program.originalAirDate || '');
244+
const airDate = new Date(
245+
event.program.episodeAirDate || event.program.originalAirDate || ""
246+
);
242247
if (!isNaN(airDate.getTime())) {
243-
244-
xml += ` <episode-num system="original-air-date">${airDate.toISOString().replace("T", " ").split(".")[0]}</episode-num>\n`;
248+
xml += ` <episode-num system="original-air-date">${airDate
249+
.toISOString()
250+
.replace("T", " ")
251+
.split(".")[0]}</episode-num>\n`;
245252
}
246253
}
247254

@@ -269,9 +276,7 @@ if (event.program.releaseYear) {
269276
}
270277

271278
if (event.rating) {
272-
xml += ` <rating system="MPAA"><value>${escapeXml(
273-
event.rating,
274-
)}</value></rating>\n`;
279+
xml += ` <rating system="MPAA"><value>${escapeXml(event.rating)}</value></rating>\n`;
275280
}
276281

277282
xml += " </programme>\n";
@@ -285,7 +290,8 @@ export function buildXmltv(data: GridApiResponse): string {
285290
console.log("Building XMLTV file");
286291

287292
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
288-
xml += '<tv generator-info-name="jef/zap2xml" generator-info-url="https://github.com/jef/zap2xml">\n';
293+
xml +=
294+
'<tv generator-info-name="jef/zap2xml" generator-info-url="https://github.com/jef/zap2xml">\n';
289295
xml += buildChannelsXml(data);
290296
xml += buildProgramsXml(data);
291297
xml += "</tv>\n";

0 commit comments

Comments
 (0)