diff --git a/src/loader/date-range.ts b/src/loader/date-range.ts index 84339f7de00..b7a0badb9bb 100644 --- a/src/loader/date-range.ts +++ b/src/loader/date-range.ts @@ -1,4 +1,5 @@ import { AttrList } from '../utils/attr-list'; +import { parseDateTime } from '../utils/date-time'; import { logger } from '../utils/logger'; import type { MediaFragmentRef } from './fragment'; @@ -90,12 +91,14 @@ export class DateRange { this._endDate = dateRangeWithSameId._endDate; this._dateAtEnd = dateRangeWithSameId._dateAtEnd; } else { - this._startDate = new Date(dateRangeAttr[DateRangeAttribute.START_DATE]); + this._startDate = new Date( + parseDateTime(dateRangeAttr[DateRangeAttribute.START_DATE]), + ); } if (DateRangeAttribute.END_DATE in dateRangeAttr) { const endDate = dateRangeWithSameId?.endDate || - new Date(dateRangeAttr[DateRangeAttribute.END_DATE]); + new Date(parseDateTime(dateRangeAttr[DateRangeAttribute.END_DATE])); if (Number.isFinite(endDate.getTime())) { this._endDate = endDate; } diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index beddae0f4ae..d0bf111a3a2 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -1,6 +1,7 @@ import { buildAbsoluteURL } from 'url-toolkit'; import { LoadStats } from './load-stats'; import { PlaylistLevelType } from '../types/loader'; +import { parseDateTime } from '../utils/date-time'; import type { LevelKey } from './level-key'; import type { FragmentLoaderContext, @@ -337,7 +338,7 @@ export class Fragment extends BaseSegment { get programDateTime(): number | null { if (this._programDateTime === null && this.rawProgramDateTime) { - this.programDateTime = Date.parse(this.rawProgramDateTime); + this.programDateTime = parseDateTime(this.rawProgramDateTime); } return this._programDateTime; } diff --git a/src/utils/date-time.ts b/src/utils/date-time.ts new file mode 100644 index 00000000000..e9f52029ace --- /dev/null +++ b/src/utils/date-time.ts @@ -0,0 +1,75 @@ +import { logger } from './logger'; + +const enum CharCode { + Plus = 43, + Dash = 45, + Colon = 58, + T = 84, + Z = 90, + LowerT = 116, + LowerZ = 122, +} + +// `YYYY-MM-DDTHH:MM` judged by its separators; `Date.parse` validates the +// digits. Anything shorter or shaped differently (date-only values, RFC 2822 +// strings) is passed through untouched. +function isIsoDateTime(value: string): boolean { + const t = value.charCodeAt(10); + return ( + value.length >= 16 && + (t === CharCode.T || t === CharCode.LowerT) && + value.charCodeAt(4) === CharCode.Dash && + value.charCodeAt(7) === CharCode.Dash && + value.charCodeAt(13) === CharCode.Colon + ); +} + +function isSign(code: number): boolean { + return code === CharCode.Plus || code === CharCode.Dash; +} + +// A trailing `Z`, or a sign placed for `±HH:MM`, `±HHMM`, or `±HH`. With at +// least 16 characters the earliest position checked is index 10, past the +// date separators. +function hasTimeZone(value: string): boolean { + const last = value.charCodeAt(value.length - 1); + return ( + last === CharCode.Z || + last === CharCode.LowerZ || + isSign(value.charCodeAt(value.length - 6)) || + isSign(value.charCodeAt(value.length - 5)) || + isSign(value.charCodeAt(value.length - 3)) + ); +} + +let warnedZoneless = false; + +/** + * Parse an HLS playlist date/time value (`EXT-X-PROGRAM-DATE-TIME`, and the + * `START-DATE`/`END-DATE` attributes of `EXT-X-DATERANGE`) into a timestamp + * in milliseconds since the epoch. Returns NaN for values that cannot be + * parsed. Tolerates non-string values at runtime, matching `Date.parse` + * coercion. + * + * ISO 8601 date-times without a time zone represent local time, and + * `Date.parse` follows that. HLS supersedes it: clients SHOULD treat a + * date-time without a time zone as UTC (rfc8216bis-17 Section 4.4.4.6), + * matching Apple's clients. Playlists SHOULD indicate a time zone, so the + * first zone-less value parsed logs a warning. + */ +export function parseDateTime(value: string): number { + if ( + typeof value === 'string' && + isIsoDateTime(value) && + !hasTimeZone(value) + ) { + if (!warnedZoneless) { + warnedZoneless = true; + logger.warn( + `Date/time "${value}" has no time zone. Parsing as UTC (playlists SHOULD indicate a time zone).`, + ); + } + return Date.parse(value + 'Z'); + } + return Date.parse(value); +} diff --git a/tests/index.js b/tests/index.js index f1146e09915..02a84e7e520 100644 --- a/tests/index.js +++ b/tests/index.js @@ -49,6 +49,7 @@ import './unit/utils/binary-search'; import './unit/utils/buffer-helper'; import './unit/utils/cea-608-parser'; import './unit/utils/codecs'; +import './unit/utils/date-time'; import './unit/utils/error-helper'; import './unit/utils/fetch-loader'; import './unit/utils/discontinuities'; diff --git a/tests/unit/loader/date-range.ts b/tests/unit/loader/date-range.ts index e2e20892771..9a3930622a0 100644 --- a/tests/unit/loader/date-range.ts +++ b/tests/unit/loader/date-range.ts @@ -110,6 +110,20 @@ describe('DateRange class', function () { expect(dateRangeEndDate.duration).to.equal(60.001); }); + it('parses zone-less START-DATE and END-DATE as UTC', function () { + const zonelessDates = new AttrList( + 'ID="ad5",START-DATE="2020-01-02T21:55:44.000",END-DATE="2020-01-02T21:56:44.001"', + ); + const dateRange = new DateRange(zonelessDates); + expect(dateRange.isValid).to.be.true; + expect(dateRange.startDate.toISOString()).to.equal( + '2020-01-02T21:55:44.000Z', + ); + expect((dateRange.endDate as Date).toISOString()).to.equal( + '2020-01-02T21:56:44.001Z', + ); + }); + it('merges tags with matching ID attributes', function () { const scteOut = new DateRange(sctePlanned); const scteIn = new DateRange(scteDurationUpdate, scteOut); diff --git a/tests/unit/loader/m3u8-parser.ts b/tests/unit/loader/m3u8-parser.ts index dc73797111f..d5f7b24a409 100644 --- a/tests/unit/loader/m3u8-parser.ts +++ b/tests/unit/loader/m3u8-parser.ts @@ -1349,6 +1349,28 @@ Rollover38803/20160525T064049-01-69844069.ts expect(result.fragments[2].programDateTime).to.equal(1464366904000); }); + it('parses #EXT-X-PROGRAM-DATE-TIME without a time zone as UTC', function () { + const level = `#EXTM3U +#EXT-X-VERSION:2 +#EXT-X-TARGETDURATION:10 +#EXT-X-MEDIA-SEQUENCE:69844067 +#EXTINF:10, no desc +#EXT-X-PROGRAM-DATE-TIME:2016-05-27T16:34:44.000 +Rollover38803/20160525T064049-01-69844067.ts + `; + const result = M3U8Parser.parseLevelPlaylist( + level, + 'http://video.example.com/disc.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + expect(result.playlistParsingError).to.be.null; + expect(result.hasProgramDateTime).to.be.true; + expect(result.fragments[0].programDateTime).to.equal(1464366884000); + }); + it('parses delta playlists with one #EXT-X-PROGRAM-DATE-TIME after segments', function () { const level = `#EXTM3U #EXT-X-TARGETDURATION:6 diff --git a/tests/unit/utils/date-time.ts b/tests/unit/utils/date-time.ts new file mode 100644 index 00000000000..db0fd5d2754 --- /dev/null +++ b/tests/unit/utils/date-time.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import { parseDateTime } from '../../../src/utils/date-time'; + +describe('parseDateTime', function () { + it('parses date-times with a time zone as-is', function () { + expect(parseDateTime('2016-05-27T16:34:44Z')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T16:34:44.000Z')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T16:34:44z')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T19:34:44+03:00')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T13:34:44-03:00')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T19:34:44+0300')).to.equal(1464366884000); + }); + + it('parses zone-less date-times as UTC', function () { + expect(parseDateTime('2016-05-27T16:34:44')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T16:34:44.000')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T16:34:44.000123')).to.equal(1464366884000); + expect(parseDateTime('2016-05-27T16:34')).to.equal(1464366840000); + }); + + it('leaves date-only values alone (already UTC per ECMA-262)', function () { + expect(parseDateTime('2016-05-27')).to.equal(1464307200000); + }); + + it('leaves non-ISO date strings alone', function () { + const rfc2822 = 'Fri, 27 May 2016 16:34:44 GMT'; + expect(parseDateTime(rfc2822)).to.equal(Date.parse(rfc2822)); + }); + + it('returns NaN for invalid or missing input', function () { + expect(parseDateTime('not a date')).to.be.NaN; + expect(parseDateTime('2016-05-27T16:34:44 not a date')).to.be.NaN; + expect(parseDateTime(undefined as unknown as string)).to.be.NaN; + }); +});