Skip to content

Commit

Permalink
feat(ads): HLS-Merging (#8)
Browse files Browse the repository at this point in the history
* docs: 📝 Mark todo item as done

* feat(ads): ✨ initial hls-merging

* fix: switch segments
  • Loading branch information
Nerixyz authored Jun 30, 2021
1 parent 36974c8 commit 74cee65
Show file tree
Hide file tree
Showing 11 changed files with 437 additions and 26 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Downloads can be found on the [releases-page](https://github.com/Nerixyz/ttv-too
* Settings-Panel on the player
* Add tests for ad-skipping/-matching
* Provide better feedback
* Possibly parse playlist
* ~~Possibly parse playlist~~
* Investigate [`swc`/`spack`](https://swc.rs/) for faster compilation (blocking: [#1438](https://github.com/swc-project/swc/issues/1438))

# Set-Up
Expand Down
24 changes: 5 additions & 19 deletions src/background/ad.replacement.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TwitchStitchedAdData } from './twitch-m3u8.types';
import { getPlayerAccessTokenRequest, makeAdRequest } from './gql.requests';
import { getPlayerAccessTokenRequest, makeAdRequest, PlayerType } from './gql.requests';
import { eventHandler } from './utilities/messaging';
import { getUsherData } from './storage';

export async function onAdPod(stitchedAd: TwitchStitchedAdData, stream: string) {
const adPod = {
Expand All @@ -16,26 +17,11 @@ export async function onAdPod(stitchedAd: TwitchStitchedAdData, stream: string)
};
await makeAdRequest(adPod);

let {usherData} = await browser.storage.local.get('usherData');
if(!usherData) {
console.warn('No usherData pogo, replacing');
usherData = {
allow_source: "true",
fast_bread: "true",
player_backend: "mediaplayer",
playlist_include_framerate: "true",
reassignments_supported: "true",
supported_codecs: "avc1",
cdm: "wv",
player_version: "1.2.0"
}
}

eventHandler.emitContext('updateUrl',{url: await createM3U8Url({stream, usher: usherData}), stream});
eventHandler.emitContext('updateUrl',{url: await createM3U8Url({stream, usher: await getUsherData()}), stream});
}

async function createM3U8Url({usher, stream}: {usher: any, stream: string}) {
const {value: token, signature: sig} = await getPlayerAccessTokenRequest(stream);
export async function createM3U8Url({usher, stream}: {usher: any, stream: string}, playerType = PlayerType.Site) {
const {value: token, signature: sig} = await getPlayerAccessTokenRequest(stream, playerType);

const search = new URLSearchParams({
...usher,
Expand Down
11 changes: 7 additions & 4 deletions src/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { onAdPod, StreamTabs } from './ad.replacement';
import { TWITCH_USER_PAGE } from './utilities/request.utilities';
import { eventHandler } from './utilities/messaging';
import { TwitchStitchedAdData } from './twitch-m3u8.types';
import { parseMediaPlaylist } from './hls/hls-parser';
import { writePlaylist } from './hls/hls-writer';
import { mergePlayer } from './player-merging';

function onRequest(request: _OnBeforeRequestDetails) {
if (!request.url.includes('video-weaver')) return;
Expand All @@ -17,9 +20,9 @@ function onRequest(request: _OnBeforeRequestDetails) {

filter.ondata = async (event: { data: ArrayBuffer }) => {
const text = decoder.decode(event.data);
const adIdx = text.indexOf('#EXT-X-DATERANGE:ID="stitched-ad');
const hasAds = text.match(/^#EXTINF:\d+\.?\d*,[^live]+$/m);

if (adIdx === -1) {
if (!hasAds) {
filter.write(event.data);
return;
}
Expand All @@ -28,9 +31,9 @@ function onRequest(request: _OnBeforeRequestDetails) {
filter.write(encoder.encode(data));
filter.close();
};
extractAdData(text, request.documentUrl ?? '', request.tabId);

finalWrite(cleanupAllAdStuff(text));
extractAdData(text, request.documentUrl ?? '', request.tabId);
finalWrite(await mergePlayer(request.tabId, text));
};

filter.onstop = () => {
Expand Down
42 changes: 40 additions & 2 deletions src/background/gql.requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: I
}
`.replace(/[\n\r]/g, '');

export async function getPlayerAccessTokenRequest(login: string): Promise<{value: string, signature: string}> {
export async function getPlayerAccessTokenRequest(login: string, playerType = PlayerType.Site): Promise<{value: string, signature: string}> {
const res = await gqlRequest(makeRawGqlPacket('PlaybackAccessToken', PLAYBACK_ACCESS_TOKEN_QUERY, {
isLive: true,
isVod: false,
login,
playerType: 'site',
playerType,
vodID: ''
})).then(x => x.json());
const accessToken = res?.data?.streamPlaybackAccessToken;
Expand All @@ -113,3 +113,41 @@ export async function getPlayerAccessTokenRequest(login: string): Promise<{value
delete accessToken.__typename;
return accessToken;
}

export enum PlayerType {
AmazonLive = 'amazon_live',
AmazonProductPage = 'amazon_product_page',
AmazonVse = 'amazon_vse_test',
AnimatedThumbnails = 'animated_thumbnails',
ChannelTrailer = 'channel_trailer',
ClipsEditing = 'clips-editing',
ClipsEmbed = 'clips-embed',
ClipsViewing = 'clips-viewing',
ClipsWatchPage = 'clips-watch',
Creative = 'creative',
Curse = 'curse',
Dashboard = 'dashboard',
VideoProducerModal = 'video_producer_modal',
Embed = 'embed',
Facebook = 'facebook',
Feed = 'feed',
Frontpage = 'frontpage',
Highlighter = 'highlighter',
Imdb = 'imdb',
MultiviewPrimary = 'multiview-primary',
MultiviewSecondary = 'multiview-secondary',
Onboarding = 'onboarding',
PictureByPicture = 'picture-by-picture',
Popout = 'popout',
Pulse = 'pulse',
Site = 'site',
Thunderdome = 'thunderdome',
ChannelHomeCarousel = 'channel_home_carousel',
ChannelHomeLive = 'channel_home_live',
SiteMini = 'site_mini',
SquadPrimary = 'squad_primary',
SquadSecondary = 'squad_secondary',
TwitchEverywhere = 'twitch_everywhere',
WatchPartyHost = 'watch_party_host',
PopTart = 'pop_tart'
}
26 changes: 26 additions & 0 deletions src/background/hls/hls-merger.ts
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;
}
108 changes: 108 additions & 0 deletions src/background/hls/hls-parser.ts
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;
}
112 changes: 112 additions & 0 deletions src/background/hls/hls-segments-tags.ts
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);
}
}
Loading

0 comments on commit 74cee65

Please sign in to comment.