-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* docs: 📝 Mark todo item as done * feat(ads): ✨ initial hls-merging * fix: switch segments
- Loading branch information
Showing
11 changed files
with
437 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { HlsPlaylist, MediaSegment } from "./hls-types"; | ||
|
||
/** | ||
* Note: this replaces segments in the target playlist | ||
* @param target The playlist to be merged into | ||
* @param reference The playlist which provides live segments | ||
* @returns The "new" playlist | ||
*/ | ||
export function mergePlaylists(target: HlsPlaylist, reference: HlsPlaylist) { | ||
const findRef = (segment: MediaSegment) => { | ||
if (!segment.dateTime) return; | ||
return reference.segments.find(ref => ref.dateTime && ref.dateTime!.date >= segment.dateTime!.date); | ||
} | ||
|
||
for (const segment of target.segments) { | ||
if (segment.segmentInfo.title !== 'live') { | ||
const replacement = findRef(segment); | ||
if (replacement) { | ||
segment.uri = replacement.uri; | ||
segment.dateTime = replacement.dateTime; | ||
} | ||
} | ||
} | ||
|
||
return target; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import { ExtInfTag, ExtXDaterange, ExtXDiscontinuity, ExtXProgramDateTime, SegmentTag } from "./hls-segments-tags"; | ||
import { HlsPlaylist } from "./hls-types"; | ||
|
||
/** | ||
* Note: This isn't a full hls parser. It only parses media playlists. | ||
* | ||
* @param raw The playlist file | ||
*/ | ||
export function parseMediaPlaylist(raw: string): HlsPlaylist { | ||
const lines = raw | ||
.split('\n') | ||
// ignore empty lines and comments | ||
.filter(line => line && line.match(/^#EXT|[^#]/)); | ||
|
||
const isExtM3u = lines.shift() === '#EXTM3U'; | ||
if (!isExtM3u) throw new Error('Invalid playlist'); | ||
|
||
const playlist: HlsPlaylist = { | ||
version: 1, | ||
dateranges: [], | ||
tags: [], | ||
segments: [], | ||
twitchPrefech: [], | ||
}; | ||
|
||
let segmentAttributes: Array<SegmentTag<any>> | null = null; | ||
let segmentInfo: ExtInfTag | null = null; | ||
let segmentDateTime: ExtXProgramDateTime | null = null; | ||
|
||
const pushSegmentAttr = (attr: SegmentTag<any>) => (segmentAttributes ??= []).push(attr); | ||
const flushAttr = () => { | ||
const attr = segmentAttributes; | ||
segmentAttributes = null; | ||
return attr; | ||
}; | ||
|
||
for (const next of lines) { | ||
if (!next) continue; | ||
|
||
if (next?.startsWith('#')) { | ||
const match = next.match(/^#(?<tag>[\w-]+):?(?<attributes>.*)$/)?.groups; | ||
if (!match) { | ||
console.warn(`Invalid tag: ${next}`); | ||
continue; | ||
} | ||
const { tag, attributes } = match; | ||
|
||
switch (tag) { | ||
case 'EXT-X-VERSION': { | ||
playlist.version = Number(attributes); | ||
continue; | ||
} | ||
case 'EXTINF': { | ||
segmentInfo = new ExtInfTag(attributes); | ||
continue; | ||
} | ||
case 'EXT-X-PROGRAM-DATE-TIME': { | ||
segmentDateTime = new ExtXProgramDateTime(attributes); | ||
continue; | ||
} | ||
case 'EXT-X-DATERANGE': { | ||
playlist.dateranges.push(new ExtXDaterange(attributes)); | ||
continue; | ||
} | ||
case 'EXT-X-DISCONTINUITY': { | ||
pushSegmentAttr(new ExtXDiscontinuity(attributes)); | ||
continue; | ||
} | ||
case 'EXT-X-TWITCH-PREFETCH': { | ||
playlist.twitchPrefech.push(next); | ||
continue; | ||
} | ||
default: { | ||
playlist.tags.push(next); | ||
continue; | ||
} | ||
} | ||
} else { | ||
// a URI | ||
|
||
if (!segmentInfo) throw new Error('invalid playlist'); | ||
|
||
const attr = flushAttr(); | ||
playlist.segments.push({ uri: next, attributes: attr ?? [], segmentInfo, dateTime: segmentDateTime }); | ||
} | ||
} | ||
|
||
return playlist; | ||
} | ||
|
||
/** | ||
* | ||
* @param raw The attribute string - essentially: `'CLASS="twitch-stitched-ad",DURATION=15.187,...'` | ||
* @returns The object-version: `{ CLASS: 'twitch-stitched-ad', DURATION: 15.187 }` | ||
*/ | ||
export function parseAttributeList<T extends Record<string, any> = Record<string, any>>(raw: string): T { | ||
return Object.fromEntries( | ||
raw | ||
.split(/(?:^|,)([^=]*=(?:"[^"]*"|[^,]*))/) | ||
.filter(Boolean) | ||
.map(x => { | ||
const idx = x.indexOf('='); | ||
const key = x.substring(0, idx); | ||
const value = x.substring(idx + 1); | ||
const num = Number(value); | ||
return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] | ||
})) as T; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import { parseAttributeList } from "./hls-parser"; | ||
import { writeAttributes } from "./hls-writer"; | ||
|
||
export abstract class SegmentTag<Attr = undefined> { | ||
abstract readonly TAGNAME: string; | ||
|
||
protected readonly attributes: Attr; | ||
|
||
constructor(rawAttributes: string) { | ||
this.attributes = this.parseAttributes(rawAttributes); | ||
} | ||
|
||
parseAttributes(raw: string): Attr { | ||
// the implementation has to override it | ||
return undefined as any as Attr; | ||
} | ||
|
||
get attributeString(): string | null { | ||
return null; | ||
} | ||
|
||
[Symbol.toStringTag]() { | ||
const attr = this.attributeString; | ||
return `#${this.TAGNAME}${attr ? ':' + attr : ''}`; | ||
} | ||
} | ||
|
||
export class ExtInfTag extends SegmentTag<[number, string?]> { | ||
TAGNAME = 'EXTINF'; | ||
|
||
get duration() { | ||
return this.attributes[0]; | ||
} | ||
|
||
get title() { | ||
return this.attributes[1]; | ||
} | ||
|
||
get attributeString() { | ||
return `${this.duration.toFixed(3)}${this.title ? ',' + this.title : ''}`; | ||
} | ||
|
||
parseAttributes(raw: string): [number, string?] { | ||
const firstIdx = raw.indexOf(','); | ||
const duration = Number(raw.substring(0, firstIdx)); | ||
const title = raw.substring(firstIdx + 1); | ||
|
||
return [duration, title || undefined]; | ||
} | ||
} | ||
|
||
export class ExtXDiscontinuity extends SegmentTag { | ||
TAGNAME = 'EXT-X-DISCONTINUITY'; | ||
} | ||
|
||
export class ExtXProgramDateTime extends SegmentTag<Date> { | ||
TAGNAME = 'EXT-X-PROGRAM-DATE-TIME'; | ||
|
||
get attributeString() { | ||
return this.attributes.toISOString(); | ||
} | ||
|
||
get date() { | ||
return this.attributes; | ||
} | ||
|
||
parseAttributes(raw: string) { | ||
return new Date(raw); | ||
} | ||
} | ||
export type ExtXDaterangeAttributes = { | ||
ID: string; | ||
CLASS?: string; | ||
'START-DATE': string; | ||
'END-DATE'?: string; | ||
// DURATION?: number; -- not on twitch | ||
// 'PLANNED-DURATION'?: number; | ||
'END-ON-NEXT'?: 'YES' | ||
|
||
[x: string]: any | ||
} | ||
export class ExtXDaterange extends SegmentTag<ExtXDaterangeAttributes> { | ||
TAGNAME = 'EXT-X-DATERANGE'; | ||
|
||
get id() { | ||
return this.attributes.ID; | ||
} | ||
|
||
get class() { | ||
return this.attributes.CLASS; | ||
} | ||
|
||
get start() { | ||
return new Date(this.attributes["START-DATE"]); | ||
} | ||
|
||
get end() { | ||
return this.attributes["END-DATE"] ? new Date(this.attributes["END-DATE"]) : undefined; | ||
} | ||
|
||
get endOnNext() { | ||
return !!this.attributes["END-ON-NEXT"]; | ||
} | ||
|
||
get attributeString() { | ||
return writeAttributes(this.attributes); | ||
} | ||
|
||
parseAttributes(raw: string) { | ||
return parseAttributeList<ExtXDaterangeAttributes>(raw); | ||
} | ||
} |
Oops, something went wrong.