using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; using System.Runtime.Serialization; using System.Reflection; using System.Threading; using System.Net; using System.IO; using System.Diagnostics; namespace TwitchAdUtils { class Program { static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko";//ilfexgv3nnljz3isbm257gzwrzr7bi - Xtra for Twitch static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"; static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"; static string UserAgent = UserAgentChrome; static bool UseOldAccessToken = false; static bool UseAccessTokenTemplate = false; static bool ShouldNotifyAdWatched = false; static bool ShouldNotifyAdWatchedMin = true; static bool ShouldDenyAd = false; static bool UseFastBread = true;// fast_bread (EXT-X-TWITCH-PREFETCH) static string PlayerTypeNormal = "site";//embed squad_secondary squad_primary static string PlayerTypeMiniNoAd = "picture-by-picture";//"thunderdome"; static string PlayerTypeEmbed = "embed"; static string Platform = "web"; static string PlayerBackend = "mediaplayer"; static string MainM3U8AdditionalParams = ""; static string AdSignifier = "stitched-ad"; static string ProxyUrl = ""; static int TargetResolution = 480; static TimeSpan LoopDelay = TimeSpan.FromSeconds(1); enum RunnerMode { Normal, MiniNoAd, Proxy, Embed } static void Main(string[] args) { ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; ServicePointManager.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true; if (args.Length >= 1 && args[0] == "build_scripts") { // This takes "base.user.js" and updates all of the other scripts based on the cfg values BuildScripts(); return; } if (args.Length >= 1 && args[0] == "m3u8") { // Tests modifications of m3u8 files Console.WriteLine("Starting local server (http://localhost)"); TwitchTestServer testServer = new TwitchTestServer(); testServer.Start(80); Console.ReadLine(); return; } Console.Write("Enter channel name: "); string channel = Console.ReadLine().ToLower(); Console.WriteLine("Fetching channel '" + channel + "'"); RunImpl(RunnerMode.Normal, channel); //RunImpl(RunnerMode.Embed, channel); //RunImpl(RunnerMode.MiniNoAd, channel); } static void BuildScripts() { string[] deprecated = { }; string baseScriptName = "base"; string suffixConfg = ".cfg"; string suffixUserscript = ".user.js"; string suffixUblock = "-ublock-origin.js"; string baseFile = Path.Combine(baseScriptName, baseScriptName + ".user.js"); if (File.Exists(baseFile)) { foreach (string dir in Directory.GetDirectories(Environment.CurrentDirectory)) { DirectoryInfo dirInfo = new DirectoryInfo(dir); if (dirInfo.Name != baseScriptName && !deprecated.Contains(dirInfo.Name)) { string cfgFile = Path.Combine(dir, dirInfo.Name + suffixConfg); string userscriptFile = Path.Combine(dir, dirInfo.Name + suffixUserscript); string ublockFile = Path.Combine(dir, dirInfo.Name + suffixUblock); if (File.Exists(userscriptFile) && File.Exists(ublockFile) && File.Exists(cfgFile)) { Dictionary cfgValues = new Dictionary(); string[] cfgLines = File.ReadAllLines(cfgFile); for (int i = 0; i < cfgLines.Length; i++) { string line = cfgLines[i]; if (!string.IsNullOrEmpty(line)) { int spaceIndex = line.IndexOf(' '); if (spaceIndex > 0) { cfgValues["scope." + line.Substring(0, spaceIndex).Trim() + " "] = line.Substring(spaceIndex + 1).Trim(); } } } Console.WriteLine(dir); foreach (KeyValuePair val in cfgValues) { Console.WriteLine(val.Key + "= " + val.Value); } Console.WriteLine("============================="); StringBuilder sbUserscript = new StringBuilder(); StringBuilder sbUblock = new StringBuilder(); string[] lines = File.ReadAllLines(baseFile); bool modifiedOptions = false; bool foundUserScriptEnd = false; for (int i = 0; i < lines.Length; i++) { string line = lines[i]; string lineTrimmed = line.Trim(); if (lineTrimmed.StartsWith("// Modify options based on mode")) { modifiedOptions = true; } if (lineTrimmed.StartsWith("// @name ")) { line = line += " (" + dirInfo.Name + ")"; } if (lineTrimmed.StartsWith("// @description")) { string url = "https://github.com/pixeltris/TwitchAdSolutions/raw/master/" + dirInfo.Name + "/" + dirInfo.Name + suffixUserscript; sbUserscript.AppendLine("// @updateURL " + url); sbUserscript.AppendLine("// @downloadURL " + url); line = line += " (" + dirInfo.Name + ")"; } if (!modifiedOptions) { if (!foundUserScriptEnd) { sbUserscript.AppendLine(line); if (line.Contains("/UserScript")) { sbUblock.AppendLine("twitch-videoad.js application/javascript"); foundUserScriptEnd = true; } } else if (lineTrimmed.StartsWith("'use strict'")) { sbUserscript.AppendLine(line); sbUblock.AppendLine(" if ( /(^|\\.)twitch\\.tv$/.test(document.location.hostname) === false ) { return; }"); } else { foreach (KeyValuePair val in cfgValues) { if (line.Contains(val.Key)) { line = line.Substring(0, line.IndexOf(val.Key) + val.Key.Length) + "= " + val.Value + ";"; break; } } sbUserscript.AppendLine(line); sbUblock.AppendLine(line); } } else { sbUserscript.AppendLine(line); sbUblock.AppendLine(line); } } File.WriteAllText(userscriptFile, sbUserscript.ToString()); File.WriteAllText(ublockFile, sbUblock.ToString()); } } } } using (WebClient wc = new WebClient()) { string response = null, token = null, sig = null; wc.Proxy = null; string code = wc.DownloadString("https://raw.githubusercontent.com/cleanlock/VideoAdBlockForTwitch/master/chrome/remove_video_ads.js"); List lines = code.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).ToList(); for (int i = lines.Count - 1; i >= 0; i--) { if (string.IsNullOrWhiteSpace(lines[i])) { lines.RemoveAt(i); } else { lines[i] = " " + lines[i]; } } string manifestStr = wc.DownloadString("https://raw.githubusercontent.com/cleanlock/VideoAdBlockForTwitch/master/chrome/manifest.json"); ChromeExtensionManifest manifest = JSONSerializer.DeSerialize(manifestStr); Console.WriteLine("vaft: " + manifest.version); string comment = "// This code is directly copied from https://github.com/cleanlock/VideoAdBlockForTwitch (only change is whitespace is removed for the ublock origin script - also indented)"; StringBuilder sbUserscript = new StringBuilder(); sbUserscript.AppendLine("// ==UserScript=="); sbUserscript.AppendLine("// @name TwitchAdSolutions (vaft)"); sbUserscript.AppendLine("// @namespace https://github.com/pixeltris/TwitchAdSolutions"); sbUserscript.AppendLine("// @version " + manifest.version); sbUserscript.AppendLine("// @description Multiple solutions for blocking Twitch ads (vaft)"); sbUserscript.AppendLine("// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js"); sbUserscript.AppendLine("// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js"); sbUserscript.AppendLine("// @author https://github.com/cleanlock/VideoAdBlockForTwitch#credits"); sbUserscript.AppendLine("// @match *://*.twitch.tv/*"); sbUserscript.AppendLine("// @run-at document-start"); sbUserscript.AppendLine("// @grant none"); sbUserscript.AppendLine("// ==/UserScript=="); sbUserscript.AppendLine(comment); sbUserscript.AppendLine("(function() {"); sbUserscript.AppendLine(" 'use strict';"); StringBuilder sbUblock = new StringBuilder(); sbUblock.AppendLine(comment); sbUblock.AppendLine("twitch-videoad.js application/javascript"); sbUblock.AppendLine("(function() {"); sbUblock.AppendLine(" if ( /(^|\\.)twitch\\.tv$/.test(document.location.hostname) === false ) { return; }"); foreach (string line in lines) { sbUserscript.AppendLine(line); sbUblock.AppendLine(line); } sbUserscript.AppendLine("})();"); sbUblock.AppendLine("})();"); File.WriteAllText(Path.Combine("vaft", "vaft.user.js"), sbUserscript.ToString()); File.WriteAllText(Path.Combine("vaft", "vaft-ublock-origin.js"), sbUblock.ToString()); } } static void Run(RunnerMode mode, string channel) { Thread thread = new Thread(delegate() { RunImpl(mode, channel); }); thread.IsBackground = true; thread.Start(); } static string RunImpl(RunnerMode mode, string channel, bool isFetchingM3U8 = false, bool forceSkipAd = false) { string playerType = PlayerTypeNormal; switch (mode) { case RunnerMode.MiniNoAd: playerType = PlayerTypeMiniNoAd; break; case RunnerMode.Embed: playerType = PlayerTypeEmbed; break; } string cookies = null; string uniqueId = null; int cycle = 0; while (true) { if (string.IsNullOrEmpty(cookies)) { using (CookieAwareWebClient wc = new CookieAwareWebClient()) { wc.Proxy = null; wc.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"; wc.DownloadString("https://www.twitch.tv/" + channel); cookies = ProcessCookies(wc.Cookies, out uniqueId); //Console.WriteLine("unique_id: " + uniqueId); } } if (string.IsNullOrEmpty(uniqueId)) { Console.WriteLine("unique_id is null"); return null; } using (WebClient wc = new WebClient()) { string response = null, token = null, sig = null; wc.Proxy = null; if (mode != RunnerMode.Proxy) { if (UseOldAccessToken) { wc.Headers.Clear(); wc.Headers["client-id"] = ClientID; wc.Headers["accept"] = "application/vnd.twitchtv.v5+json; charset=UTF-8"; wc.Headers["accept-encoding"] = "gzip, deflate, br"; wc.Headers["accept-language"] = "en-us"; wc.Headers["content-type"] = "application/json; charset=UTF-8"; wc.Headers["origin"] = "https://www.twitch.tv"; wc.Headers["referer"] = "https://www.twitch.tv/"; wc.Headers["user-agent"] = UserAgent; wc.Headers["x-requested-with"] = "XMLHttpRequest"; wc.Headers["cookie"] = cookies; response = wc.DownloadString("https://api.twitch.tv/api/channels/" + channel + "/access_token?oauth_token=undefined&need_https=true&platform=" + Platform + "&player_type=" + playerType + "&player_backend=" + PlayerBackend); if (!string.IsNullOrEmpty(response)) { TwitchAccessTokenOld tokenInfo = JSONSerializer.DeSerialize(response); if (tokenInfo != null && !string.IsNullOrEmpty(tokenInfo.token) && !string.IsNullOrEmpty(tokenInfo.sig)) { token = tokenInfo.token; sig = tokenInfo.sig; } } } else { wc.Headers.Clear(); wc.Headers["client-id"] = ClientID; wc.Headers["Device-ID"] = uniqueId; wc.Headers["accept"] = "*/*"; wc.Headers["accept-encoding"] = "gzip, deflate, br"; wc.Headers["accept-language"] = "en-us"; wc.Headers["content-type"] = "text/plain; charset=UTF-8"; wc.Headers["origin"] = "https://www.twitch.tv"; wc.Headers["referer"] = "https://www.twitch.tv/"; wc.Headers["user-agent"] = UserAgent; if (UseAccessTokenTemplate) { response = wc.UploadString("https://gql.twitch.tv/gql", @"{""operationName"":""PlaybackAccessToken_Template"",""query"":""query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \""" + Platform + @"\"", playerBackend: \""" + PlayerBackend + @"\"", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}"",""variables"":{""isLive"":true,""login"":""" + channel + @""",""isVod"":false,""vodID"":"""",""playerType"":""" + playerType + @"""}}"); } else { response = wc.UploadString("https://gql.twitch.tv/gql", @"{""operationName"":""PlaybackAccessToken"",""variables"":{""isLive"":true,""login"":""" + channel + @""",""isVod"":false,""vodID"":"""",""playerType"":""" + playerType + @"""},""extensions"":{""persistedQuery"":{""version"":1,""sha256Hash"":""0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712""}}}"); } if (!string.IsNullOrEmpty(response)) { TwitchAccessToken tokenInfo = JSONSerializer.DeSerialize(response); if (tokenInfo != null && tokenInfo.data != null && tokenInfo.data.streamPlaybackAccessToken != null && !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.value) && !string.IsNullOrEmpty(tokenInfo.data.streamPlaybackAccessToken.signature)) { token = tokenInfo.data.streamPlaybackAccessToken.value; sig = tokenInfo.data.streamPlaybackAccessToken.signature; } } } } if (mode == RunnerMode.Proxy || !string.IsNullOrEmpty(token)) { string url = null; if (mode == RunnerMode.Proxy) { url = ProxyUrl + channel; } else { string additionalParams = ""; if (UseFastBread) { additionalParams += "&fast_bread=true"; } url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true" + additionalParams + "&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token) + MainM3U8AdditionalParams; } if (isFetchingM3U8) { if (!forceSkipAd || cycle > 0) { return url; } } wc.Headers.Clear(); wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain"; wc.Headers["host"] = "usher.ttvnw.net"; wc.Headers["cookie"] = "DNT=1;"; wc.Headers["DNT"] = "1"; wc.Headers["user-agent"] = UserAgent; string encodingsM3u8 = wc.DownloadString(url); if (!string.IsNullOrEmpty(encodingsM3u8)) { string[] lines = encodingsM3u8.Split('\n'); string info = lines.FirstOrDefault(x => x.Contains("EXT-X-TWITCH-INFO")); bool isFuture = false; if (info != null) { Dictionary attr = ParseAttributes(info); string futureStr; if (attr.TryGetValue("FUTURE", out futureStr)) { isFuture = bool.Parse(futureStr); } } string streamM3u8Url = lines.FirstOrDefault(x => x.EndsWith(".m3u8")); if (!string.IsNullOrEmpty(streamM3u8Url)) { bool foundAd = true; while (foundAd) { string streamM3u8 = wc.DownloadString(streamM3u8Url); if (!string.IsNullOrEmpty(streamM3u8Url)) { if (streamM3u8.Contains(AdSignifier)) { Console.WriteLine("has ad " + DateTime.Now.TimeOfDay + " - " + mode + " - future:" + isFuture); if (ShouldDenyAd) { DeclineAd(uniqueId, streamM3u8, sig, token, true); DeclineAd(uniqueId, streamM3u8, sig, token, false); } } else { Console.WriteLine("no ad " + DateTime.Now.TimeOfDay + " - " + mode + " - future:" + isFuture); } if ((streamM3u8.Contains(AdSignifier) || forceSkipAd) && (!UseOldAccessToken && (ShouldNotifyAdWatched || forceSkipAd))) { NotifyWatchedAd(uniqueId, streamM3u8); } } else { Console.WriteLine("Failed to fetch streamM3u8Url"); } if (!ShouldDenyAd) { break; } else { Thread.Sleep(LoopDelay); } } } else { Console.WriteLine("Failed to find streamM3u8Url"); } } else { Console.WriteLine("Failed to fetch encodingsM3u8"); } } else { Console.WriteLine("Failed to get stream token mode:" + mode); } } Thread.Sleep(LoopDelay); cycle++; } } static Dictionary ParseAttributes(string tag) { string tagName; return ParseAttributes(tag, out tagName); } static Dictionary ParseAttributes(string tag, out string tagName) { // TODO: Improve this Dictionary result = new Dictionary(); tagName = null; int tagDataSplitIndex = tag.IndexOf(':'); if (tagDataSplitIndex > 0) { tagName = tag.Substring(0, tagDataSplitIndex); tag = tag.Substring(tagDataSplitIndex + 1); string[] splitted = tag.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); foreach (string str in splitted) { int index = str.IndexOf('='); if (index > 0) { result[str.Substring(0, index)] = str.Substring(index + 1).Trim('\"'); } } } return result; } static TValue GetOrDefault(Dictionary dict, TKey key, TValue defaultValue = default(TValue)) { TValue result; if (dict.TryGetValue(key, out result)) { return result; } return defaultValue; } static void DeclineAd(string uniqueId, string streamM3u8, string sig, string token, bool first) { string[] lines = streamM3u8.Split('\n'); for (int i = 0; i < lines.Length; i++) { if (lines[i].Contains(AdSignifier)) { Dictionary attr = ParseAttributes(lines[i]); Dictionary vals = new Dictionary(); vals["TARG_adSessionID"] = GetOrDefault(attr, "X-TV-TWITCH-AD-AD-SESSION-ID"); vals["TARG_sig"] = sig; vals["TARG_token"] = token.Replace("\"", "\\\""); string str = null; //string str = @"[{""operationName"":""VideoAdRequestDecline"",""variables"":{""context"":{""adSessionID"":""TARG_adSessionID"",""clientContext"":""{\""isAudioOnly\"":false,\""isMiniTheater\"":false,\""isPIP\"":true,\""isUsingExternalPlayback\"":false}"",""isAudioOnly"":false,""isMiniTheater"":false,""isPIP"":false,""isUsingExternalPlayback"":false,""duration"":30,""isVLM"":false,""rollType"":""PREROLL""}},""extensions"":{""persistedQuery"":{""version"":1,""sha256Hash"":""6f5d9fdc36a3c879cca7debdbe21c62d5cac4ad5b30b635263eff68335b96a71""}}}]"; if (first) str = @"[{""operationName"":""VideoAdRequestDecline"",""variables"":{""context"":{""adSessionID"":""TARG_adSessionID"",""clientContext"":{""isAudioOnly"":false,""isMiniTheater"":false,""isPIP"":false,""isUsingExternalPlayback"":false},""duration"":30,""playerContext"":{""contentType"":""LIVE"",""isAutoPlay"":true,""nauthSig"":""TARG_sig"",""nauthToken"":""TARG_token""},""rollType"":""PREROLL"",""isVLM"":false,""commercialID"":""""}},""extensions"":{""persistedQuery"":{""version"":1,""sha256Hash"":""6f5d9fdc36a3c879cca7debdbe21c62d5cac4ad5b30b635263eff68335b96a71""}}}]"; else { vals["TARG_ad_session_id"] = GetOrDefault(attr, "X-TV-TWITCH-AD-AD-SESSION-ID"); vals["TARG_radToken"] = GetOrDefault(attr, "X-TV-TWITCH-AD-RADS-TOKEN"); str = @"[{""operationName"":""ClientSideAdEventHandling_RecordAdEvent"",""variables"":{""input"":{""eventName"":""video_ad_request_declined"",""eventPayload"":""{\""reason_channeladfree\"":false,\""reason_channelsub\"":false,\""reason_vod_ads_disabled\"":false,\""reason_bounty\"":false,\""reason_vod_midroll\"":false,\""reason_stream_broadcaster\"":false,\""reason_embed_promo\"":false,\""reason_p4m\"":false,\""reason_lt\"":false,\""reason_raid\"":false,\""reason_midroll_during_preroll\"":false,\""reason_ratelimit\"":false,\""reason_short_vod\"":false,\""reason_turbo\"":false,\""reason_vod_creator\"":false,\""reason_wp\"":false,\""reason_zagd\"":false,\""reason_zagu\"":false,\""reason_midlimit\"":false,\""reason_amazon_product_page\"":false,\""reason_animated_thumbnails\"":false,\""reason_creative_player\"":false,\""reason_dashboard\"":false,\""reason_facebook\"":false,\""reason_frontpage\"":false,\""reason_highlighter\"":false,\""reason_onboarding\"":false,\""reason_pbyp\"":false,\""reason_squad_stream_secondary_player\"":false,\""reason_thunderdome\"":true,\""reason_embed\"":false,\""twitch_correlator\"":\""\"",\""ad_session_id\"":\""TARG_ad_session_id\"",\""roll_type\"":\""preroll\"",\""time_break\"":30}"",""radToken"":""TARG_radToken""}},""extensions"":{""persistedQuery"":{""version"":1,""sha256Hash"":""7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b""}}}]"; } foreach (KeyValuePair val in vals) { str = str.Replace(val.Key, val.Value); } //Console.WriteLine(str); using (WebClient wc = new WebClient()) { wc.Proxy = null; wc.Headers["Client-Id"] = ClientID; wc.Headers["X-Device-Id"] = uniqueId; wc.Headers["accept"] = "*/*"; wc.Headers["accept-encoding"] = "gzip, deflate, br"; wc.Headers["accept-language"] = "en-us"; wc.Headers["content-type"] = "text/plain; charset=UTF-8"; wc.Headers["origin"] = "https://www.twitch.tv"; wc.Headers["referer"] = "https://www.twitch.tv/"; wc.Headers["user-agent"] = UserAgent; string st2 = wc.UploadString("https://gql.twitch.tv/gql", str); Console.WriteLine(st2); } return; } } } static void SendGqlAdEvent(WebClient wc, string eventName, bool includeAdInfo, int adQuartile, int adPos, Dictionary vals) { // TARG_eventName TARG_roll_type TARG_radToken TARG_adInfo // TARG_ad_id TARG_ad_position TARG_duration TARG_creative_id TARG_total_ads TARG_order_id TARG_line_item_id TARG_quartile string str = @"[{""operationName"":""ClientSideAdEventHandling_RecordAdEvent"",""variables"":{""input"":{""eventName"":""TARG_eventName"",""eventPayload"":""{\""player_mute\"":false,\""player_volume\"":0.5,\""visible\"":true,\""roll_type\"":\""TARG_roll_type\"",\""stitched\"":trueTARG_adInfo}"",""radToken"":""TARG_radToken""}},""extensions"":{""persistedQuery"":{""version"":1,""sha256Hash"":""7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b""}}}]"; string strAdInfo = @",\""ad_id\"":\""TARG_ad_id\"",\""ad_position\"":TARG_ad_position,\""duration\"":TARG_duration,\""creative_id\"":\""TARG_creative_id\"",\""total_ads\"":TARG_total_ads,\""order_id\"":\""TARG_order_id\"",\""line_item_id\"":\""TARG_line_item_id\""TARG_quartile"; vals["TARG_eventName"] = eventName; vals["TARG_quartile"] = adQuartile > 0 ? (@",\""quartile\"":" + adQuartile) : string.Empty; if (includeAdInfo) { foreach (KeyValuePair val in vals) { strAdInfo = strAdInfo.Replace(val.Key, val.Value); } vals["TARG_adInfo"] = strAdInfo; } else { vals["TARG_adInfo"] = ""; } foreach (KeyValuePair val in vals) { str = str.Replace(val.Key, val.Value); } //Console.WriteLine(str); Console.WriteLine("SendGqlAdEvent " + eventName + " adinfo: " + includeAdInfo + " quartile: " + adQuartile + " adPos: " + adPos); wc.UploadString("https://gql.twitch.tv/gql", str); } static void NotifyWatchedAd(string uniqueId, string streamM3u8) { string[] lines = streamM3u8.Split('\n'); for (int i = 0; i < lines.Length; i++) { if (lines[i].Contains(AdSignifier)) { Dictionary attr = ParseAttributes(lines[i]); Dictionary vals = new Dictionary(); vals["TARG_roll_type"] = GetOrDefault(attr, "X-TV-TWITCH-AD-ROLL-TYPE", "preroll").ToLower(); vals["TARG_radToken"] = GetOrDefault(attr, "X-TV-TWITCH-AD-RADS-TOKEN"); vals["TARG_ad_id"] = GetOrDefault(attr, "X-TV-TWITCH-AD-ADVERTISER-ID"); vals["TARG_duration"] = "30"; vals["TARG_creative_id"] = GetOrDefault(attr, "X-TV-TWITCH-AD-CREATIVE-ID"); vals["TARG_total_ads"] = GetOrDefault(attr, "X-TV-TWITCH-AD-POD-LENGTH", "1"); vals["TARG_order_id"] = GetOrDefault(attr, "X-TV-TWITCH-AD-ORDER-ID"); vals["TARG_line_item_id"] = GetOrDefault(attr, "X-TV-TWITCH-AD-LINE-ITEM-ID"); using (WebClient wc = new WebClient()) { wc.Proxy = null; wc.Headers["Client-Id"] = ClientID; wc.Headers["X-Device-Id"] = uniqueId; wc.Headers["accept"] = "*/*"; wc.Headers["accept-encoding"] = "gzip, deflate, br"; wc.Headers["accept-language"] = "en-us"; wc.Headers["content-type"] = "text/plain; charset=UTF-8"; wc.Headers["origin"] = "https://www.twitch.tv"; wc.Headers["referer"] = "https://www.twitch.tv/"; wc.Headers["user-agent"] = UserAgent; if (ShouldNotifyAdWatchedMin) { SendGqlAdEvent(wc, "video_ad_pod_complete", false, 0, 0, vals); } else { int totalAds = int.Parse(vals["TARG_total_ads"]); for (int adPos = 0; adPos < totalAds; adPos++) { vals["TARG_ad_position"] = adPos.ToString(); SendGqlAdEvent(wc, "video_ad_impression", true, 0, adPos, vals); for (int quartile = 1; quartile <= 4; quartile++) { SendGqlAdEvent(wc, "video_ad_quartile_complete", true, quartile, adPos, vals); } SendGqlAdEvent(wc, "video_ad_pod_complete", false, 0, adPos, vals); } } } break; } } //Console.WriteLine(streamM3u8); } static string ProcessCookies(string str) { string uniqueId; return ProcessCookies(str, out uniqueId); } static string ProcessCookies(string str, out string uniqueId) { uniqueId = null; string result = string.Empty; string[] cookies = str.Split(','); foreach (string cookie in cookies) { if (cookie.Split(';')[0].Contains('=')) { string[] splitted = cookie.Split(';')[0].Split('='); if (splitted.Length >= 2 && splitted[0] == "unique_id") { uniqueId = splitted[1]; } result += cookie.Split(';')[0] + ";"; } } return result; } [DataContract] public class TwitchAccessTokenOld { [DataMember] public string token { get; set; } [DataMember] public string sig { get; set; } } [DataContract] public class TwitchAccessToken { [DataMember] public TwitchAccessToken_data data { get; set; } } [DataContract] public class TwitchAccessToken_data { [DataMember] public TwitchAccessToken_streamPlaybackAccessToken streamPlaybackAccessToken { get; set; } } [DataContract] public class TwitchAccessToken_streamPlaybackAccessToken { [DataMember] public string value { get; set; } [DataMember] public string signature { get; set; } } [DataContract] public class ChromeExtensionManifest { [DataMember] public string version { get; set; } } class CookieAwareWebClient : WebClient { public CookieContainer CookieContainer { get; set; } public Uri Uri { get; set; } public string Cookies { get; private set; } public CookieAwareWebClient() : this(new CookieContainer()) { } public CookieAwareWebClient(CookieContainer cookies) { this.CookieContainer = new CookieContainer(); } protected override WebResponse GetWebResponse(WebRequest request) { WebResponse response = base.GetWebResponse(request); string setCookieHeader = response.Headers.Get("Set-Cookie"); Cookies = setCookieHeader; return response; } } static class JSONSerializer where TType : class { public static TType DeSerialize(string json) { return TinyJson.JSONParser.FromJson(json); } } class TwitchTestServer { const string RecordDir = "recordings"; Dictionary states = new Dictionary(); class State { public bool IsReplay = false; public string RecordingName = null; public string RecordingPath = null; public string ChannelName = null; public string UrlChRecName { get { return ChannelName + "|" + RecordingName; } } public string M3U8Normal = null; public string M3U8Mini = null; public string M3U8Alt = null; public Dictionary> M3U8Map = new Dictionary>(); public Stopwatch Stopwatch = new Stopwatch(); public State(string channelName, string name) { ChannelName = channelName; RecordingName = name; RecordingPath = Path.GetFullPath(Path.Combine(RecordDir, name)); try { if (!Directory.Exists(RecordingPath)) { Directory.CreateDirectory(RecordingPath); } } catch { } } public void Clear() { M3U8Map.Clear(); Stopwatch.Restart(); try { while (Directory.Exists(RecordingPath)) { Directory.Delete(RecordingPath, true); } } catch { } try { if (!Directory.Exists(RecordingPath)) { Directory.CreateDirectory(RecordingPath); } } catch { } } public void Load() { M3U8Map.Clear(); Stopwatch.Restart(); IsReplay = true; } } private Thread thread; private HttpListener listener; public void Start(int port) { Stop(); thread = new Thread(delegate() { listener = new HttpListener(); listener.Prefixes.Add("http://*:" + port + "/"); listener.Start(); while (listener != null) { try { HttpListenerContext context = listener.GetContext(); Process(context); } catch { } } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); } public void Stop() { if (listener != null) { try { listener.Stop(); } catch { } listener = null; } if (thread != null) { try { thread.Abort(); } catch { } thread = null; } } private void Process(HttpListenerContext context) { try { string url = context.Request.Url.OriginalString; //Console.WriteLine("req " + DateTime.Now.TimeOfDay + " - " + url); string response = string.Empty; string contentType = "text/html"; if (url.Contains("favicon.ico")) { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.OutputStream.Close(); return; } byte[] responseBuffer = null; if (context.Request.Url.Segments.Length == 1 && context.Request.Url.Segments[0] == "/") { response = ""; } else if (context.Request.Url.Segments.Length == 2 && context.Request.Url.Segments[1] == "utils.js") { response = File.ReadAllText("utils.js"); } else if (context.Request.Url.Segments.Length >= 3) { string[] reqTypeSplitted = context.Request.Url.Segments[1].Trim('/').Split('_'); string reqType = reqTypeSplitted[0].ToLower(); string reqStreamType = reqTypeSplitted.Length > 1 ? reqTypeSplitted[1] : null; string[] splitted = context.Request.Url.Segments[2].Trim('/').ToLower().Replace("%7c", "|").Split('|'); string channelName = splitted[0]; string recordingName = splitted[1]; if (!string.IsNullOrEmpty(channelName) && !string.IsNullOrEmpty(recordingName)) { State state; if (!states.TryGetValue(recordingName, out state)) { states[recordingName] = state = new State(channelName, recordingName); } switch (reqType) { case "record-begin": { state.Clear(); string normal = RunImpl(RunnerMode.Normal, channelName, true); if (!string.IsNullOrEmpty(normal)) { string mini = RunImpl(RunnerMode.MiniNoAd, channelName, true); if (!string.IsNullOrEmpty(mini)) { //string alt = RunImpl(RunnerMode.Proxy, channelName, true); //string alt = RunImpl(RunnerMode.Normal, channelName, true, true); string alt = RunImpl(RunnerMode.Embed, channelName, true); state.M3U8Normal = normal; state.M3U8Mini = mini; state.M3U8Alt = alt; response = "ok"; } } } break; case "replay-begin": { DirectoryInfo dir = new DirectoryInfo(Path.Combine(RecordDir, recordingName)); if (dir.Exists && dir.GetFiles().Length > 0) { state.Load(); response = "ok"; } } break; case "m3u8": { string type = reqTypeSplitted[1].ToLower(); string m3u8Url = null; switch (type) { case "normal": case "output": m3u8Url = state.M3U8Normal; break; case "mini": m3u8Url = state.M3U8Mini; break; case "alt": m3u8Url = state.M3U8Alt; break; } if (!string.IsNullOrEmpty(m3u8Url)) { response = GetM3U8(state, m3u8Url, reqStreamType, true); } } break; case "m3u8-sub": { string type = reqTypeSplitted[1].ToLower(); string m3u8Url = null; if (!state.IsReplay) { m3u8Url = GetM3U8Url(state, reqStreamType); } if (!string.IsNullOrEmpty(m3u8Url) || state.IsReplay) { response = GetM3U8(state, m3u8Url, reqStreamType, false); } } break; case "m3u8-seg": { // TODO: Load segment, return as binary file } break; default: Console.WriteLine("Unhandled request '" + reqType + "'"); break; } } } if (responseBuffer == null) { responseBuffer = Encoding.UTF8.GetBytes(response == null ? string.Empty : response.ToString()); } context.Response.ContentType = contentType; context.Response.ContentEncoding = Encoding.UTF8; context.Response.ContentLength64 = responseBuffer.Length; context.Response.OutputStream.Write(responseBuffer, 0, responseBuffer.Length); context.Response.OutputStream.Flush(); context.Response.StatusCode = (int)HttpStatusCode.OK; } catch (Exception e) { Console.WriteLine(e); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; } context.Response.OutputStream.Close(); } private string DownloadM3U8(string url) { try { using (WebClient wc = new WebClient()) { wc.Proxy = null; wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain"; wc.Headers["host"] = "usher.ttvnw.net"; wc.Headers["cookie"] = "DNT=1;"; wc.Headers["DNT"] = "1"; wc.Headers["user-agent"] = UserAgent; return wc.DownloadString(url); } } catch (Exception e) { //Console.WriteLine(url); Console.WriteLine(e); return null; } } private string GetM3U8Url(State state, string reqStreamType) { Dictionary m3u8Map; if (state.M3U8Map.TryGetValue(reqStreamType, out m3u8Map) && m3u8Map.Count > 0) { string resUrl = null; int res = int.MaxValue; string backupUrl = null; foreach (KeyValuePair mappedUrl in m3u8Map) { if (mappedUrl.Key.Contains("x")) { int val; if (int.TryParse(mappedUrl.Key.Split('x')[1], out val)) { if (backupUrl == null) { backupUrl = mappedUrl.Value; } if (val < res && val >= TargetResolution) { res = val; resUrl = mappedUrl.Value; } } } } if (string.IsNullOrEmpty(resUrl)) { resUrl = backupUrl; } return resUrl; } return null; } private string GetM3U8(State state, string url, string reqStreamType, bool isMain) { string m3u8 = null; string backupUrl = null; if (state.IsReplay) { // TODO: Load replay m3u8 } else { m3u8 = DownloadM3U8(url); if (reqStreamType == "output") { backupUrl = GetM3U8Url(state, "mini"); } } if (string.IsNullOrEmpty(m3u8)) { return null; } if (!state.M3U8Map.ContainsKey(reqStreamType)) { state.M3U8Map[reqStreamType] = new Dictionary(); } m3u8 = m3u8.Replace("\r", string.Empty); string prevRes = null; string[] lines = m3u8.Split('\n'); string mainM3U8Name = "m3u8-sub_" + reqStreamType; for (int i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); if (line.StartsWith("#")) { string tagName; Dictionary attr = ParseAttributes(line, out tagName); if (tagName == "#EXT-X-STREAM-INF") { attr.TryGetValue("RESOLUTION", out prevRes); } } else if (line.EndsWith(".m3u8")) { if (!string.IsNullOrEmpty(prevRes) && !state.M3U8Map[reqStreamType].ContainsKey(prevRes)) { state.M3U8Map[reqStreamType][prevRes] = line; } lines[i] = "/" + mainM3U8Name + "/" + state.UrlChRecName; } else if (line.EndsWith(".ts")) { // TODO: Save seg } } if (!isMain && m3u8.Contains("stitched-ad") && !string.IsNullOrEmpty(backupUrl)) { string m3u8Backup = DownloadM3U8(backupUrl); if (!string.IsNullOrEmpty(m3u8Backup)) { m3u8Backup = m3u8Backup.Replace("\r", string.Empty); string[] backupLines = m3u8Backup.Split('\n'); Dictionary segmentMap = new Dictionary(); Dictionary segTimes = GetSegmentTimes(lines); Dictionary backupSegTimes = GetSegmentTimes(backupLines); foreach (KeyValuePair seg in segTimes) { //segmentMap[seg.Value] = backupSegTimes.Last().Value; long closestTime = long.MaxValue; long matchingBackupTime = long.MaxValue; foreach (KeyValuePair backupSeg in backupSegTimes) { long timeDiff = Math.Abs(seg.Key - backupSeg.Key); if (timeDiff < closestTime) { closestTime = timeDiff; matchingBackupTime = backupSeg.Key; segmentMap[seg.Value] = backupSeg.Value; } } if (closestTime != long.MaxValue) { backupSegTimes.Remove(matchingBackupTime); } } for (int i = 0; i < lines.Length; i++) { string line = lines[i]; if (line.Contains("stitched-ad")) { line = ""; } if (line.StartsWith("#EXTINF:") && !line.Contains(",live")) { lines[i] = line.Substring(0, line.IndexOf(',')) + ",live"; string backupSegment; segmentMap.TryGetValue(lines[i + 1], out backupSegment); lines[i + 1] = backupSegment != null ? backupSegment : ""; } } } } if (isMain) { File.WriteAllText(Path.Combine(state.RecordingPath, mainM3U8Name + "-original"), m3u8); File.WriteAllLines(Path.Combine(state.RecordingPath, mainM3U8Name), lines); } // TODO: Save m3u8 return string.Join(Environment.NewLine, lines); } private Dictionary GetSegmentTimes(string[] lines) { Dictionary result = new Dictionary(); long lastDate = 0; for (int i = 0; i < lines.Length; i++) { string line = lines[i]; if (line.StartsWith("#EXT-X-PROGRAM-DATE-TIME:")) { lastDate = DateTime.Parse(line.Substring(line.IndexOf(":") + 1)).Ticks; } else if (line.StartsWith("http")) { result[lastDate] = line; } } return result; } } } } namespace TinyJson { // Really simple JSON parser in ~300 lines // - Attempts to parse JSON files with minimal GC allocation // - Nice and simple "[1,2,3]".FromJson>() API // - Classes and structs can be parsed too! // class Foo { public int Value; } // "{\"Value\":10}".FromJson() // - Can parse JSON without type information into Dictionary and List e.g. // "[1,2,3]".FromJson().GetType() == typeof(List) // "{\"Value\":10}".FromJson().GetType() == typeof(Dictionary) // - No JIT Emit support to support AOT compilation on iOS // - Attempts are made to NOT throw an exception if the JSON is corrupted or invalid: returns null instead. // - Only public fields and property setters on classes/structs will be written to // // Limitations: // - No JIT Emit support to parse structures quickly // - Limited to parsing <2GB JSON files (due to int.MaxValue) // - Parsing of abstract classes or interfaces is NOT supported and will throw an exception. public static class JSONParser { [ThreadStatic] static Stack> splitArrayPool; [ThreadStatic] static StringBuilder stringBuilder; [ThreadStatic] static Dictionary> fieldInfoCache; [ThreadStatic] static Dictionary> propertyInfoCache; public static T FromJson(this string json) { // Initialize, if needed, the ThreadStatic variables if (propertyInfoCache == null) propertyInfoCache = new Dictionary>(); if (fieldInfoCache == null) fieldInfoCache = new Dictionary>(); if (stringBuilder == null) stringBuilder = new StringBuilder(); if (splitArrayPool == null) splitArrayPool = new Stack>(); //Remove all whitespace not within strings to make parsing simpler stringBuilder.Length = 0; for (int i = 0; i < json.Length; i++) { char c = json[i]; if (c == '"') { i = AppendUntilStringEnd(true, i, json); continue; } if (char.IsWhiteSpace(c)) continue; stringBuilder.Append(c); } //Parse the thing! return (T)ParseValue(typeof(T), stringBuilder.ToString()); } static int AppendUntilStringEnd(bool appendEscapeCharacter, int startIdx, string json) { stringBuilder.Append(json[startIdx]); for (int i = startIdx + 1; i < json.Length; i++) { if (json[i] == '\\') { if (appendEscapeCharacter) stringBuilder.Append(json[i]); stringBuilder.Append(json[i + 1]); i++;//Skip next character as it is escaped } else if (json[i] == '"') { stringBuilder.Append(json[i]); return i; } else stringBuilder.Append(json[i]); } return json.Length - 1; } //Splits { :, : } and [ , ] into a list of strings static List Split(string json) { List splitArray = splitArrayPool.Count > 0 ? splitArrayPool.Pop() : new List(); splitArray.Clear(); if (json.Length == 2) return splitArray; int parseDepth = 0; stringBuilder.Length = 0; for (int i = 1; i < json.Length - 1; i++) { switch (json[i]) { case '[': case '{': parseDepth++; break; case ']': case '}': parseDepth--; break; case '"': i = AppendUntilStringEnd(true, i, json); continue; case ',': case ':': if (parseDepth == 0) { splitArray.Add(stringBuilder.ToString()); stringBuilder.Length = 0; continue; } break; } stringBuilder.Append(json[i]); } splitArray.Add(stringBuilder.ToString()); return splitArray; } internal static object ParseValue(Type type, string json) { if (type == typeof(string)) { if (json.Length <= 2) return string.Empty; StringBuilder parseStringBuilder = new StringBuilder(json.Length); for (int i = 1; i < json.Length - 1; ++i) { if (json[i] == '\\' && i + 1 < json.Length - 1) { int j = "\"\\nrtbf/".IndexOf(json[i + 1]); if (j >= 0) { parseStringBuilder.Append("\"\\\n\r\t\b\f/"[j]); ++i; continue; } if (json[i + 1] == 'u' && i + 5 < json.Length - 1) { UInt32 c = 0; if (UInt32.TryParse(json.Substring(i + 2, 4), System.Globalization.NumberStyles.AllowHexSpecifier, null, out c)) { parseStringBuilder.Append((char)c); i += 5; continue; } } } parseStringBuilder.Append(json[i]); } return parseStringBuilder.ToString(); } if (type.IsPrimitive) { var result = Convert.ChangeType(json, type, System.Globalization.CultureInfo.InvariantCulture); return result; } if (type == typeof(decimal)) { decimal result; decimal.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); return result; } if (json == "null") { return null; } if (type.IsEnum) { if (json[0] == '"') json = json.Substring(1, json.Length - 2); try { return Enum.Parse(type, json, false); } catch { return 0; } } if (type.IsArray) { Type arrayType = type.GetElementType(); if (json[0] != '[' || json[json.Length - 1] != ']') return null; List elems = Split(json); Array newArray = Array.CreateInstance(arrayType, elems.Count); for (int i = 0; i < elems.Count; i++) newArray.SetValue(ParseValue(arrayType, elems[i]), i); splitArrayPool.Push(elems); return newArray; } if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) { Type listType = type.GetGenericArguments()[0]; if (json[0] != '[' || json[json.Length - 1] != ']') return null; List elems = Split(json); var list = (IList)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count }); for (int i = 0; i < elems.Count; i++) list.Add(ParseValue(listType, elems[i])); splitArrayPool.Push(elems); return list; } if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) { Type keyType, valueType; { Type[] args = type.GetGenericArguments(); keyType = args[0]; valueType = args[1]; } //Refuse to parse dictionary keys that aren't of type string if (keyType != typeof(string)) return null; //Must be a valid dictionary element if (json[0] != '{' || json[json.Length - 1] != '}') return null; //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON List elems = Split(json); if (elems.Count % 2 != 0) return null; var dictionary = (IDictionary)type.GetConstructor(new Type[] { typeof(int) }).Invoke(new object[] { elems.Count / 2 }); for (int i = 0; i < elems.Count; i += 2) { if (elems[i].Length <= 2) continue; string keyValue = elems[i].Substring(1, elems[i].Length - 2); object val = ParseValue(valueType, elems[i + 1]); dictionary[keyValue] = val; } return dictionary; } if (type == typeof(object)) { return ParseAnonymousValue(json); } if (json[0] == '{' && json[json.Length - 1] == '}') { return ParseObject(type, json); } return null; } static object ParseAnonymousValue(string json) { if (json.Length == 0) return null; if (json[0] == '{' && json[json.Length - 1] == '}') { List elems = Split(json); if (elems.Count % 2 != 0) return null; var dict = new Dictionary(elems.Count / 2); for (int i = 0; i < elems.Count; i += 2) dict[elems[i].Substring(1, elems[i].Length - 2)] = ParseAnonymousValue(elems[i + 1]); return dict; } if (json[0] == '[' && json[json.Length - 1] == ']') { List items = Split(json); var finalList = new List(items.Count); for (int i = 0; i < items.Count; i++) finalList.Add(ParseAnonymousValue(items[i])); return finalList; } if (json[0] == '"' && json[json.Length - 1] == '"') { string str = json.Substring(1, json.Length - 2); return str.Replace("\\", string.Empty); } if (char.IsDigit(json[0]) || json[0] == '-') { if (json.Contains(".")) { double result; double.TryParse(json, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); return result; } else { int result; int.TryParse(json, out result); return result; } } if (json == "true") return true; if (json == "false") return false; // handles json == "null" as well as invalid JSON return null; } static Dictionary CreateMemberNameDictionary(T[] members) where T : MemberInfo { Dictionary nameToMember = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < members.Length; i++) { T member = members[i]; if (member.IsDefined(typeof(IgnoreDataMemberAttribute), true)) continue; string name = member.Name; if (member.IsDefined(typeof(DataMemberAttribute), true)) { DataMemberAttribute dataMemberAttribute = (DataMemberAttribute)Attribute.GetCustomAttribute(member, typeof(DataMemberAttribute), true); if (!string.IsNullOrEmpty(dataMemberAttribute.Name)) name = dataMemberAttribute.Name; } nameToMember.Add(name, member); } return nameToMember; } static object ParseObject(Type type, string json) { object instance = FormatterServices.GetUninitializedObject(type); //The list is split into key/value pairs only, this means the split must be divisible by 2 to be valid JSON List elems = Split(json); if (elems.Count % 2 != 0) return instance; Dictionary nameToField; Dictionary nameToProperty; if (!fieldInfoCache.TryGetValue(type, out nameToField)) { nameToField = CreateMemberNameDictionary(type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); fieldInfoCache.Add(type, nameToField); } if (!propertyInfoCache.TryGetValue(type, out nameToProperty)) { nameToProperty = CreateMemberNameDictionary(type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)); propertyInfoCache.Add(type, nameToProperty); } for (int i = 0; i < elems.Count; i += 2) { if (elems[i].Length <= 2) continue; string key = elems[i].Substring(1, elems[i].Length - 2); string value = elems[i + 1]; FieldInfo fieldInfo; PropertyInfo propertyInfo; if (nameToField.TryGetValue(key, out fieldInfo)) fieldInfo.SetValue(instance, ParseValue(fieldInfo.FieldType, value)); else if (nameToProperty.TryGetValue(key, out propertyInfo)) propertyInfo.SetValue(instance, ParseValue(propertyInfo.PropertyType, value), null); } return instance; } } }