@@ -24,23 +24,28 @@ export function formatDate(dateStr: string): string {
24
24
25
25
const cli = new Command ( ) ;
26
26
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" ) ;
37
37
cli . parse ( process . argv ) ;
38
38
const options = cli . opts ( ) as { [ key : string ] : any } ;
39
39
40
40
export function buildChannelsXml ( data : GridApiResponse ) : string {
41
41
let xml = "" ;
42
42
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 ) {
44
49
xml += ` <channel id="${ escapeXml ( channel . channelId ) } ">\n` ;
45
50
xml += ` <display-name>${ escapeXml ( channel . callSign ) } </display-name>\n` ;
46
51
@@ -56,8 +61,8 @@ export function buildChannelsXml(data: GridApiResponse): string {
56
61
let src = channel . thumbnail . startsWith ( "http" )
57
62
? channel . thumbnail
58
63
: "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 ( "?" ) ;
61
66
if ( queryIndex !== - 1 ) {
62
67
src = src . substring ( 0 , queryIndex ) ;
63
68
}
@@ -80,68 +85,68 @@ export function buildProgramsXml(data: GridApiResponse): string {
80
85
return originalAirDate . replace ( / - / g, "" ) ;
81
86
} ;
82
87
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 ) {
85
100
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`;
90
103
91
104
const isNew = event . flag ?. includes ( "New" ) ;
92
105
const isLive = event . flag ?. includes ( "Live" ) ;
93
106
let title = event . program . title ;
94
- if ( options [ ' appendAsterisk' ] && ( isNew || isLive ) ) {
107
+ if ( options [ " appendAsterisk" ] && ( isNew || isLive ) ) {
95
108
title += " *" ;
96
109
}
97
110
xml += ` <title>${ escapeXml ( title ) } </title>\n` ;
98
111
99
112
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` ;
103
114
}
104
115
105
116
if ( event . program . shortDesc ) {
106
117
xml += ` <desc>${ escapeXml ( event . program . shortDesc ) } </desc>\n` ;
107
118
}
108
119
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
+ }
128
136
129
- const genreSet = new Set ( event . program . genres ?. map ( g => g . toLowerCase ( ) ) || [ ] ) ;
137
+ const genreSet = new Set ( event . program . genres ?. map ( ( g ) => g . toLowerCase ( ) ) || [ ] ) ;
130
138
131
139
if ( event . program . genres && event . program . genres . length > 0 ) {
132
140
const sortedGenres = [ ...event . program . genres ] . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
133
141
for ( const genre of sortedGenres ) {
134
142
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` ;
138
144
}
139
145
}
140
146
141
- // add <length> after categories
147
+ // Add <length> after categories
142
148
if ( event . duration ) {
143
- xml += ` <length units="minutes">${ escapeXml ( event . duration ) } </length>
144
- ` ;
149
+ xml += ` <length units="minutes">${ escapeXml ( event . duration ) } </length>\n` ;
145
150
}
146
151
147
152
if ( event . thumbnail ) {
@@ -151,36 +156,34 @@ if (event.program.releaseYear) {
151
156
xml += ` <icon src="${ escapeXml ( src ) } " />\n` ;
152
157
}
153
158
159
+ // Optional series link
154
160
if ( event . program . seriesId && event . program . tmsId ) {
155
161
const encodedUrl = `https://tvlistings.gracenote.com//overview.html?programSeriesId=${ event . program . seriesId } &tmsId=${ event . program . tmsId } ` ;
156
162
xml += ` <url>${ encodedUrl } </url>\n` ;
157
163
}
158
164
159
165
const skipXmltvNs = genreSet . has ( "movie" ) || genreSet . has ( "sports" ) ;
160
- const episodeNumTags = [ ] ;
166
+ const episodeNumTags : string [ ] = [ ] ;
161
167
162
168
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` ) ;
172
175
173
176
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
+ ) ;
176
180
}
177
181
178
182
const seasonNum = parseInt ( event . program . season , 10 ) ;
179
183
const episodeNum = parseInt ( event . program . episode , 10 ) ;
180
-
181
184
if ( ! isNaN ( seasonNum ) && ! isNaN ( episodeNum ) && seasonNum >= 1 && episodeNum >= 1 ) {
182
185
const xmltvNsTag = ` <episode-num system="xmltv_ns">${ seasonNum - 1 } .${ episodeNum - 1 } .</episode-num>\n` ;
183
- if ( options [ ' mediaportal' ] ) {
186
+ if ( options [ " mediaportal" ] ) {
184
187
episodeNumTags . unshift ( xmltvNsTag ) ;
185
188
} else {
186
189
episodeNumTags . push ( xmltvNsTag ) ;
@@ -191,43 +194,43 @@ if (event.program.releaseYear) {
191
194
timeZone : "America/New_York" ,
192
195
year : "numeric" ,
193
196
month : "2-digit" ,
194
- day : "2-digit"
197
+ day : "2-digit" ,
195
198
} ) ;
196
199
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 ) ;
198
201
const episodeIdx = parseInt ( event . program . episode , 10 ) ;
199
202
if ( ! isNaN ( episodeIdx ) ) {
200
203
const xmltvNsTag = ` <episode-num system="xmltv_ns">${ year - 1 } .${ episodeIdx - 1 } .0/1</episode-num>\n` ;
201
- if ( options [ ' mediaportal' ] ) {
204
+ if ( options [ " mediaportal" ] ) {
202
205
episodeNumTags . unshift ( xmltvNsTag ) ;
203
206
} else {
204
207
episodeNumTags . push ( xmltvNsTag ) ;
205
208
}
206
209
}
207
-
208
210
} 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 } ) / ) ;
210
212
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
+ ) ;
213
216
}
214
217
215
218
const nyFormatter = new Intl . DateTimeFormat ( "en-US" , {
216
219
timeZone : "America/New_York" ,
217
220
year : "numeric" ,
218
221
month : "2-digit" ,
219
- day : "2-digit"
222
+ day : "2-digit" ,
220
223
} ) ;
221
224
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" ;
225
228
226
229
if ( ! skipXmltvNs ) {
227
230
const mmddNum = parseInt ( `${ mm } ${ dd } ` , 10 ) ;
228
- const mmddMinusOne = ( mmddNum - 1 ) . toString ( ) . padStart ( 4 , '0' ) ;
231
+ const mmddMinusOne = ( mmddNum - 1 ) . toString ( ) . padStart ( 4 , "0" ) ;
229
232
const xmltvNsTag = ` <episode-num system="xmltv_ns">${ year - 1 } .${ mmddMinusOne } .</episode-num>\n` ;
230
- if ( options [ ' mediaportal' ] ) {
233
+ if ( options [ " mediaportal" ] ) {
231
234
episodeNumTags . unshift ( xmltvNsTag ) ;
232
235
} else {
233
236
episodeNumTags . push ( xmltvNsTag ) ;
@@ -238,10 +241,14 @@ if (event.program.releaseYear) {
238
241
xml += episodeNumTags . join ( "" ) ;
239
242
240
243
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
+ ) ;
242
247
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`;
245
252
}
246
253
}
247
254
@@ -269,9 +276,7 @@ if (event.program.releaseYear) {
269
276
}
270
277
271
278
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` ;
275
280
}
276
281
277
282
xml += " </programme>\n" ;
@@ -285,7 +290,8 @@ export function buildXmltv(data: GridApiResponse): string {
285
290
console . log ( "Building XMLTV file" ) ;
286
291
287
292
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' ;
289
295
xml += buildChannelsXml ( data ) ;
290
296
xml += buildProgramsXml ( data ) ;
291
297
xml += "</tv>\n" ;
0 commit comments