// ==UserScript== // @name MALstreaming // @namespace https://github.com/mattiadr/MALstreaming // @version 5.89 // @author https://github.com/mattiadr // @description Adds various anime and manga links to MAL // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JQAAgIMAAPn/AACA6QAAdTAAAOpgAAA6mAAAF2+SX8VGAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wQRDic4ysC1kQAAA+lJREFUWMPtlk1sVFUUx3/n3vvmvU6nnXbESkTCR9DYCCQSFqQiMdEY4zeJuiBhwUISAyaIHzHGaDTxKyzEr6ULNboiRonRhQrRCMhGiDFGA+WjhQ4NVKbtzJuP9969Lt4wlGnBxk03vZv3cu495/7u/5x7cmX1xk8dczjUXG4+DzAPMA8AYNoNIunXudnZ2+enrvkvn2kADkhiiwM8o6YEEuLE4pxDK0GakZUIoiCOHXFiW2uNEqyjZdNaIbMB0Ero7gwQ4OJEDa0VSoR6lNDT5eMZRaUa0YgSjFZU6zG1ekK+y6er00eJECWWchiRMYp8VwBAOYyw1l0dQIlQrcfcvKSHT968j+5chg+/OMoHnx9FCdwzsIRdz24gGxhe2v0Le74/htaKFYvzbNm4knWrF3J9IYtSQq0e8+C2r+jwDXvefYjEWja98B2DQyU6fINty8cVCigl9HYHiMCOzWs4/HuR4XNl3n5mPbmsB0DgGyYrDR69ewXvvXgXgW+oNxLOX6ySJJaebp/+ZQWOD5fIZT2cS5WddRGCw9oU5rVtA1SqEfmcTxRZPE8RxZbe7oBXnlpH4BtGx0Ke2PkNt624jte3DzBWqjF4ZhzP6GYBOtw1qtC07Y2I0IgTisUKtyztBaB4voLWQl8hS1iLuL2/j0V9OQC+/fkkx4ZK3L9hGQt6Oyj0BCiR1qZpwV5dgRn7gBLh1Y8OcmpkAoDndv3E6IUQgCRx9BWy6b91bH64n7P7tvL8lrU4l/pOi6dSRZWSaShmJgDPKIbPTfLy+wdYfEMXB46M0JXLNE8ElWoEQK0e8/fJi8SJpa+QZemi7hmiOSphxESlQRRb/IzGKMHNBOCaJwTI53wOHhnBM5pCPqDRSFIHrTh1drzls/2Nffx18h+efGwV7+y8kyi2l+O5VKW1KxeycEEn2Q6PPwfHKE3WMVpwrg1AAK1TkaxzBBlDEGiSxLXsgW84cWacE2fGWX5TnnsHlnB8qEQ2SG+J1qnM0lTLaMVbO+5AJL2ijzy9l7FSDaMV4FIAh0MpoRxGfL1vECRtHiK0Gsj+w8OcHpmkeKFCWIv54dAQWx9fxfo1N/Lxl38wVJzgx1+HCGsx1XoMwN79gy1VfU9zujjB2dFJfE9dLtKpb0JrHeUwzW8u66Gm3N9yGJEkls6sR5I4+pcX2PTArez+7DcmK+lcWIsRgc5mzyhXoivSq5W0+klL9fZH6SWpL9VCy64ERLDW4lyaorAaE2Q0xihE0kqnmfepsaZSJPYanXCmjVt265rnaAKJkM9lsM7hXLPg2nyvFuuaALMdjumn+T9jzh8k8wDzAPMAcw7wLz7iq04ifbsDAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE1LTA0LTE3VDE0OjM5OjU2LTA0OjAw6I0f5AAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNS0wNC0xN1QxNDozOTo1Ni0wNDowMJnQp1gAAAAASUVORK5CYII= // @run-at document-idle // @updateURL https://raw.githubusercontent.com/mattiadr/MALstreaming/master/MALstreaming.user.js // @downloadURL https://raw.githubusercontent.com/mattiadr/MALstreaming/master/MALstreaming.user.js // @supportURL https://github.com/mattiadr/MALstreaming/issues // @match https://myanimelist.net/animelist/* // @match https://myanimelist.net/ownlist/anime/*/edit* // @match https://myanimelist.net/ownlist/anime/add?selected_series_id=* // @match https://myanimelist.net/mangalist/* // @match https://myanimelist.net/ownlist/manga/*/edit* // @match https://myanimelist.net/ownlist/manga/add?selected_manga_id=* // @require https://code.jquery.com/jquery-3.7.1.min.js // @require https://cdn.rawgit.com/dcodeIO/protobuf.js/6.8.8/dist/protobuf.js // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant window.close // @connect * // ==/UserScript== /* generic */ /*******************************************************************************************************************************************************************/ // array of all streaming services const streamingServices = [ // anime { id: "erairaws", type: "anime", name: "Erai-raws", domain: "www.erai-raws.info" }, { id: "subsplease", type: "anime", name: "SubsPlease", domain: "subsplease.org" }, // manga { id: "mangadex", type: "manga", name: "MangaDex", domain: "mangadex.org" }, { id: "mangaplus", type: "manga", name: "MANGA Plus", domain: "mangaplus.shueisha.co.jp" }, ]; // contains variable properties for anime/manga modes let properties = {}; properties.anime = { mode: "anime", watching: ".list-unit.watching", colHeaderText: "Watch", commentsRegex: /Notes: ([\S ]+) /, iconAdd: ".icon-add-episode", findProgress: ".data.progress", findAiring: "span.content-status:contains('Airing')", latest: "Latest ep is #", notAired: "Not Yet Aired", ep: "Ep.", editPageBox: "#add_anime_comments", bulkTooltip: "Open %d episodes in bulk", }; properties.manga = { mode: "manga", watching: ".list-unit.reading", colHeaderText: "Read", commentsRegex: /Notes: ([\S ]+) /, iconAdd: ".icon-add-chapter", findProgress: ".data.chapter", findAiring: "span.content-status:contains('Publishing')", latest: "Latest ch is #", notAired: "Not Yet Published", ep: "Ch.", editPageBox: "#add_manga_comments", bulkTooltip: "Open %d chapters in bulk", }; // contains all functions to execute on page load const pageLoad = {}; // contains all functions to get the episodes list from the streaming services // must callback to putEpisodes(dataStream, episodes, timeMillis) const getEpisodes = {}; // contains queue settings for queuing requests to services (optional) // must contain `maxRequests` and `timout` const queueSettings = {}; queueSettings["default"] = { maxRequests: 1, timeout: 1000, } // contains all functions to get the episode list url from the partial url const getEplistUrl = {}; // contains all functions to execute the search on the streaming services // must callback to putResults(results) const searchSite = {}; // return an array that contains the streaming service and url relative to that service or false if comment is not valid function getUrlFromComment(comment) { let c = comment.split(" "); if (c.length < 2) return false; for (let i = 0; i < streamingServices.length; i++) { if (streamingServices[i].id == c[0]) return c; } return false; } // estimate time before next chapter as min of last n chapters function estimateTimeMillis(episodes, n) { if (episodes.length == 0) return undefined; let prev = null; let min = undefined; for (let i = episodes.length - 1; i > Math.max(0, episodes.length - 1 - n); i--) { if (!episodes[i]) continue; if (prev && episodes[i].timestamp != prev) { let diff = prev - episodes[i].timestamp; if (!min || diff < min && diff > 0) min = diff; } prev = episodes[i].timestamp; } return episodes[episodes.length - 1].timestamp + min; } // returns the domain for the streaming service or false if ss doesn't exist function getDomainById(id) { for (let i = 0; i < streamingServices.length; i++) { if (streamingServices[i].id == id) { return streamingServices[i].domain; } } return false; } // returns true if the result matches the title function matchResult(result, title) { // split title into tokens let split = title.split(/\W+/g); for (let i = 0; i < split.length; i++) { // result must contain all tokens if (!result.title.toLowerCase().includes(split[i].toLowerCase())) { return false; } } return true; } // stackexchange's string format utility String.prototype.formatUnicorn = function() { let e = this.toString(); if (!arguments.length) return e; let t = typeof arguments[0]; let n = "string" === t || "number" === t ? Array.prototype.slice.call(arguments) : arguments[0]; for (let i in n) { e = e.replace(new RegExp("\\{" + i + "\\}", "gi"), n[i]); } return e; } /* anilist */ /*******************************************************************************************************************************************************************/ const anilist = {}; anilist.api = "https://graphql.anilist.co"; anilist.query = `\ query ($idMal: Int) { Media(type: ANIME, idMal: $idMal) { airingSchedule(notYetAired: true, perPage: 1) { nodes { episode airingAt } } } }`; // request time until next episode for the specified anime id function requestTime(id) { // prepare data let data = { query: anilist.query, variables: { idMal: id } }; // do request GM_xmlhttpRequest({ method: "POST", url: anilist.api, headers: { "Content-Type": "application/json" }, data: JSON.stringify(data), onload: function(resp) { let res = JSON.parse(resp.response); let times = GM_getValue("anilistTimes", {}); // get data from response let sched = res.data.Media.airingSchedule.nodes[0]; // if there is no episode then it means the last episode just notYetAired if (!sched || !sched.episode) return; let ep = sched.episode; let timeMillis = sched.airingAt * 1000; // set time, ep is episode the timer is referring to times[id] = { ep: ep, timeMillis: timeMillis }; // put times in GM value GM_setValue("anilistTimes", times); } }); } // puts timeMillis into dataStream, then calls back function anilist_setTimeMillis(dataStream, canReload) { let listitem = dataStream.parents(".list-item"); let times = GM_getValue("anilistTimes", false); // get anime id let id = listitem.find(".data.title > .link").attr("href").split("/")[2]; let t = times ? times[id] : false; if (times && t && Date.now() < t.timeMillis) { // time doesn't need to update // set timeMillis, this is used to check if anilist timer is referring to next episode putTimeMillis(dataStream, t.timeMillis, false, t.ep); } else if (canReload) { // add value change listener let listenerId = GM_addValueChangeListener("anilistTimes", function(name, old_value, new_value, remote) { // reload anilist_setTimeMillis(dataStream, false); // remove listener GM_removeValueChangeListener(listenerId); }); // api request to anilist requestTime(id); } } /* cookies */ /*******************************************************************************************************************************************************************/ // array with services that require cookies to make requests const cookieServices = [ // anime // manga ]; // checks if i need/can load cookies and returns the cookieService function needsCookies(id, status) { for (let i = 0; i < cookieServices.length; i++) { if (cookieServices[i].id == id && cookieServices[i].status == status) return cookieServices[i]; } return false; } // load cookies for specified service, then calls back function loadCookies(cookieService, callback) { let lc = GM_getValue("loadCookies", {}); if (lc[cookieService.id] === undefined || lc[cookieService.id] + 30*1000 < Date.now()) { lc[cookieService.id] = Date.now(); GM_setValue("loadCookies", lc); GM_openInTab(cookieService.url, true); } if (callback) { setTimeout(function() { callback(); }, cookieService.timeout); } } // function to execute when script is run on website to load cookies from pageLoad["loadCookies"] = function(cookieService) { let lc = GM_getValue("loadCookies", {}); if (lc[cookieService.id] && cookieService.loaded()) { lc[cookieService.id] = false; GM_setValue("loadCookies", lc); window.close(); } } /* erai-raws */ /*******************************************************************************************************************************************************************/ const erairaws = {}; erairaws.base = "https://www.erai-raws.info/"; erairaws.anime = erairaws.base + "anime-list/"; erairaws.search = erairaws.base + "?s=" getEpisodes["erairaws"] = function(dataStream, url) { // request GM_xmlhttpRequest({ method: "POST", url: erairaws.anime + url, onload: function(resp) { if (resp.status == 200) { // OK let jqPage = $(resp.response); let episodes = []; jqPage.find("#menu0 > .table").each(function() { let tt = $(this).find(".tooltip2"); let type = tt.text(); let m = tt.next().text().match(/[\d\.]+/g); let release = $(this).find(".release-links").first(); let magnet = release.find(".load_more_links_buttons:contains(magnet)").attr("href"); if (type == "B") { // batch let first = parseInt(m[m.length - 2]); let last = parseInt(m[m.length - 1]); let obj = { text: `Batch ${first} ~ ${last}`, href: magnet, }; for (let i = first - 1; i < last; i++) { episodes[i] = obj; } } else if (type == "E" || type == "A" || type == "F") { // encoding || airing || final let ep = parseInt(m[m.length - 1]); let res = release.find("span").text().match(/^\w+/)[0]; if (!episodes[ep - 1]) { episodes[ep - 1] = { text: `Ep ${ep} (${res})`, href: magnet, } } } else { // unknown type return; } }); // callback putEpisodes(dataStream, episodes, undefined); } else { // error errorEpisodes(dataStream, "Erai-raws: " + resp.status); } } }); } getEplistUrl["erairaws"] = function(partialUrl) { return erairaws.anime + partialUrl; } searchSite["erairaws"] = function(id, title) { GM_xmlhttpRequest({ method: "GET", url: erairaws.search + title, onload: function(resp) { if (resp.status == 200) { // OK let jqPage = $(resp.response); let results = jqPage.find("#main .entry-title > a").map(function() { return { title: $(this).text().trim(), href: $(this).attr("href").split("/")[4], }; }); // callback putResults(id, results); } else { // error errorResults(id, "Erai-raws: " + resp.status); } } }); } /* subsplease */ /*******************************************************************************************************************************************************************/ const subsplease = {}; subsplease.base = "https://subsplease.org/"; subsplease.anime = subsplease.base + "shows/"; subsplease.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone subsplease.api = subsplease.base + "api/?f=show&tz=" + subsplease.timezone + "&sid="; subsplease.schedule = subsplease.base + "api/?f=schedule&h=true&tz=" + subsplease.timezone getEpisodes["subsplease"] = function(dataStream, url) { let ids = GM_getValue("subspleaseIDS", {}); if (ids[url]) { // found id, request episodes subsplease_getEpisodesFromAPI(dataStream, ids[url], url); } else { // id not found, request id then episodes GM_xmlhttpRequest({ method: "GET", url: subsplease.anime + url, onload: function(resp) { if (resp.status == 200) { // OK let jqPage = $(resp.response); // get id let id = jqPage.find("#show-release-table").attr("sid"); // save id in GM values ids[url] = id; GM_setValue("subspleaseIDS", ids); // get episodes subsplease_getEpisodesFromAPI(dataStream, id, url); } else { // error errorEpisodes(dataStream, "SubsPlease: " + resp.status); } } }); } } function subsplease_getEpisodesFromAPI(dataStream, id, url) { GM_xmlhttpRequest({ method: "GET", url: subsplease.api + id, onload: function(resp) { if (resp.status == 200) { // OK let res = JSON.parse(resp.response); let episodes = []; // loop through values Object.values(res.episode).forEach(ep => { let dwn = ep.downloads.pop(); episodes[parseInt(ep.episode) - 1] = { text: `Ep ${ep.episode} (${dwn.res}p)`, href: dwn.magnet }; }); // callback putEpisodes(dataStream, episodes, undefined); subsplease_getAirTime(dataStream, url); } else { // error errorEpisodes(dataStream, "SubsPlease: " + resp.status); } } }); } function subsplease_getAirTime(dataStream, url) { let lastTs = GM_getValue("subspleaseScheduleDate", 0); let now = +new Date(); // request at most once every 5 minutes if (now > lastTs + 5 * 60 * 1000) { // we request schedule, invalidate the cache and set the date immediately to avoid other dataStream requesting it too GM_deleteValue("subspleaseSchedule") GM_setValue("subspleaseScheduleDate", now); // and we start the request for the schedule GM_xmlhttpRequest({ method: "GET", url: subsplease.schedule, onload: function(resp) { let timeMillis = undefined; if (resp.status == 200) { // OK let res = JSON.parse(resp.response); let schedule = {}; res.schedule.forEach(s => { if (!s.aired) { let airTime = new Date(); let t = s.time.split(":"); airTime.setHours(t[0], t[1], 0, 0); schedule[s.page] = +airTime; } }); // set time let time = schedule[url]; if (time) { putTimeMillis(dataStream, time, true); } // save schedule GM_setValue("subspleaseSchedule", schedule); } else { // error, remove date so we may retry the request GM_deleteValue("subspleaseScheduleDate"); } } }); } else { let schedule = GM_getValue("subspleaseSchedule", {}); let time = schedule[url]; if (time) { // time is valid, just callback putTimeMillis(dataStream, time, true); } else { // time is not available, can happen if we already sent a request from another dataStream and we are waiting for results // or if the time is actually not available (usually because it's the wrong day of week) // we set the listener in case we are waiting on another request let listenerId = GM_addValueChangeListener("subspleaseSchedule", function(name, old_value, new_value, remote) { let time = new_value[url]; if (time) { putTimeMillis(dataStream, time, true); } // remove listener GM_removeValueChangeListener(listenerId); }); } } } getEplistUrl["subsplease"] = function(partialUrl) { return subsplease.anime + partialUrl; } searchSite["subsplease"] = function(id, title) { GM_xmlhttpRequest({ method: "GET", url: subsplease.anime, onload: function(resp) { if (resp.status == 200) { // OK let jqPage = $(resp.response); let results = []; // get all anime as list let list = jqPage.find("#post-wrapper > div > div > .all-shows > .all-shows-link > a"); // map and filter list to results list.each(function() { results.push({ title: $(this).text().trim(), href: $(this).attr("href").split("/")[2] }); }); results = results.filter(item => matchResult(item, title)); // callback putResults(id, results); } else { // error errorResults(id, "SubsPlease: " + resp.status); } } }); } /* mangadex */ /*******************************************************************************************************************************************************************/ const mangadex = {}; mangadex.base = "https://mangadex.org/"; mangadex.base_api = "https://api.mangadex.org/"; mangadex.manga = mangadex.base + "title/" mangadex.lang_code = "en"; mangadex.manga_api = mangadex.base_api + `manga/{0}/feed?limit=500&order[chapter]=asc&offset={1}&translatedLanguage[]=${mangadex.lang_code}`; mangadex.chapter = mangadex.base + "chapter/"; mangadex.search_api = mangadex.base_api + "manga?title="; getEpisodes["mangadex"] = function(dataStream, url, offset=0, episodes=[]) { GM_xmlhttpRequest({ method: "GET", url: mangadex.manga_api.formatUnicorn(url, offset), onload: function(resp) { if (resp.status == 200) { let res = JSON.parse(resp.response); if (res.result != "ok") { // error errorResults(id, "MangaDex: " + res.result); } // OK for (let i = 0; i < res.data.length; i++) { let chapter = res.data[i]; let n = chapter.attributes.chapter; let t = `Chapter ${n}`; if (chapter.attributes.title) t += `: ${chapter.attributes.title}`; episodes[n - 1] = { text: t, href: mangadex.chapter + chapter.id, timestamp: new Date(chapter.attributes.createdAt).getTime(), } } // check if we got all the episodes if (offset + 500 >= res.total) { // estimate timeMillis let timeMillis = estimateTimeMillis(episodes, 5); // callback putEpisodes(dataStream, episodes, timeMillis); } else { // request next 500 episodes getEpisodes["mangadex"](dataStream, url, offset + 500, episodes); } } else { // error errorEpisodes(dataStream, "MangaDex: " + resp.status); } } }); } getEplistUrl["mangadex"] = function(partialUrl) { return mangadex.manga + partialUrl; } searchSite["mangadex"] = function(id, title) { GM_xmlhttpRequest({ method: "GET", url: mangadex.search_api + encodeURI(title), onload: function(resp) { if (resp.status == 200) { let res = JSON.parse(resp.response); if (res.result != "ok") { // error errorResults(id, "MangaDex: " + res.result); } // OK let results = []; for (let i = 0; i < res.data.length; i++) { let manga = res.data[i]; results.push({ title: manga.attributes.title.en || manga.attributes.title.jp, href: manga.id, }); } // callback putResults(id, results); } else { // error errorResults(id, "MangaDex: " + resp.status); } } }); } /* manga plus */ /*******************************************************************************************************************************************************************/ const mangaplus = {} mangaplus.base = "https://mangaplus.shueisha.co.jp/"; mangaplus.manga = mangaplus.base + "titles/"; mangaplus.base_api = "https://jumpg-webapi.tokyo-cdn.com/api/"; mangaplus.manga_api = mangaplus.base_api + "title_detail?title_id="; mangaplus.chapter = mangaplus.base + "viewer/"; mangaplus.search = mangaplus.base_api + "title_list/all"; mangaplus.lang_table = { undefined: "english", 0: "english", 1: "spanish", 2: "french", 3: "indonesian", 4: "portuguese", 5: "russian", 6: "thai", } /* =============== *\ protobuf config \* =============== */ let Root = protobuf.Root; let Type = protobuf.Type; let Field = protobuf.Field; let Enum = protobuf.Enum; let OneOf = protobuf.OneOf; let Response = new Type("Response") .add(new OneOf("data") .add(new Field("success", 1, "SuccessResult")) .add(new Field("error", 2, "ErrorResult")) ); let ErrorResult = new Type("ErrorResult") .add(new Field("action", 1, "Action")) .add(new Field("englishPopup", 2, "Popup")) .add(new Field("spanishPopup", 3, "Popup")); let Action = new Enum("Action") .add("DEFAULT", 0) .add("UNAUTHORIZED", 1) .add("MAINTAINENCE", 2) .add("GEOIP_BLOCKING", 3); let Popup = new Type("Popup") .add(new Field("subject", 1, "string")) .add(new Field("body", 2, "string")); let SuccessResult = new Type("SuccessResult") .add(new Field("isFeaturedUpdated", 1, "bool")) .add(new OneOf("data") .add(new Field("allTitlesView", 5, "AllTitlesView")) .add(new Field("titleRankingView", 6, "TitleRankingView")) .add(new Field("titleDetailView", 8, "TitleDetailView")) .add(new Field("mangaViewer", 10, "MangaViewer")) .add(new Field("webHomeView", 11, "WebHomeView")) ); let TitleRankingView = new Type("TitleRankingView") .add(new Field("titles", 1, "Title", "repeated")); let AllTitlesView = new Type("AllTitlesView") .add(new Field("titles", 1, "Title", "repeated")); let WebHomeView = new Type("WebHomeView") .add(new Field("groups", 2, "UpdatedTitleGroup", "repeated")); let TitleDetailView = new Type("TitleDetailView") .add(new Field("title", 1, "Title")) .add(new Field("titleImageUrl", 2, "string")) .add(new Field("overview", 3, "string")) .add(new Field("backgroundImageUrl", 4, "string")) .add(new Field("nextTimeStamp", 5, "uint32")) .add(new Field("updateTiming", 6, "UpdateTiming")) .add(new Field("viewingPeriodDescription", 7, "string")) .add(new Field("firstChapterList", 9, "Chapter", "repeated")) .add(new Field("lastChapterList", 10, "Chapter", "repeated")) .add(new Field("isSimulReleased", 14, "bool")) .add(new Field("chaptersDescending", 17, "bool")); let UpdateTiming = new Enum("UpdateTiming") .add("NOT_REGULARLY", 0) .add("MONDAY", 1) .add("TUESDAY", 2) .add("WEDNESDAY", 3) .add("THURSDAY", 4) .add("FRIDAY", 5) .add("SATURDAY", 6) .add("SUNDAY", 7) .add("DAY", 8); let MangaViewer = new Type("MangaViewer") .add(new Field("pages", 1, "Page", "repeated")); let Title = new Type("Title") .add(new Field("titleId", 1, "uint32")) .add(new Field("name", 2, "string")) .add(new Field("author", 3, "string")) .add(new Field("portraitImageUrl", 4, "string")) .add(new Field("landscapeImageUrl", 5, "string")) .add(new Field("viewCount", 6, "uint32")) .add(new Field("language", 7, "Language", {"default": 0})); let Language = new Enum("Language") .add("ENGLISH", 0) .add("SPANISH", 1); let UpdatedTitleGroup = new Type("UpdatedTitleGroup") .add(new Field("groupName", 1, "string")) .add(new Field("titles", 2, "UpdatedTitle", "repeated")); let UpdatedTitle = new Type("UpdatedTitle") .add(new Field("title", 1, "Title")) .add(new Field("chapterId", 2, "uint32")) .add(new Field("chapterName", 3, "string")) .add(new Field("chapterSubtitle", 4, "string")); let Chapter = new Type("Chapter") .add(new Field("titleId", 1, "uint32")) .add(new Field("chapterId", 2, "uint32")) .add(new Field("name", 3, "string")) .add(new Field("subTitle", 4, "string", "optional")) .add(new Field("startTimeStamp", 6, "uint32")) .add(new Field("endTimeStamp", 7, "uint32")); let Page = new Type("Page") .add(new Field("page", 1, "MangaPage")); let MangaPage = new Type("MangaPage") .add(new Field("imageUrl", 1, "string")) .add(new Field("width", 2, "uint32")) .add(new Field("height", 3, "uint32")) .add(new Field("encryptionKey", 5, "string", "optional")); let root = new Root() .define("mangaplus") .add(Response) .add(ErrorResult) .add(Action) .add(Popup) .add(SuccessResult) .add(TitleRankingView) .add(AllTitlesView) .add(WebHomeView) .add(TitleDetailView) .add(UpdateTiming) .add(MangaViewer) .add(Title) .add(Language) .add(UpdatedTitleGroup) .add(UpdatedTitle) .add(Chapter) .add(Page) .add(MangaPage); /* =================== *\ protobuf config end \* =================== */ getEpisodes["mangaplus"] = function(dataStream, url) { GM_xmlhttpRequest({ method: "GET", url: mangaplus.manga_api + url, responseType: "arraybuffer", onload: function(resp) { if (resp.status == 200) { // OK // decode response let buf = resp.response; let message = Response.decode(new Uint8Array(buf)); let respJSON = Response.toObject(message); // check if response is valid if (!respJSON || !respJSON.success || !respJSON.success.titleDetailView) { // error errorEpisodes(dataStream, "MANGA Plus: Bad Response"); return; } let episodes = []; let titleDetailView = respJSON.success.titleDetailView; // insert episodes into list for (let i = 0; i < (titleDetailView.firstChapterList || []).length; i++) { let ch = titleDetailView.firstChapterList[i]; let n = parseInt(ch.name.slice(1) - 1); episodes[n] = { text: ch.subTitle, href: mangaplus.chapter + ch.chapterId, timestamp: ch.startTimeStamp * 1000, }; } for (let i = 0; i < (titleDetailView.lastChapterList || []).length; i++) { let ch = titleDetailView.lastChapterList[i]; let n = parseInt(ch.name.slice(1) - 1); episodes[n] = { text: ch.subTitle, href: mangaplus.chapter + ch.chapterId, timestamp: ch.startTimeStamp * 1000, }; } // get time of next episode let time = titleDetailView.nextTimeStamp * 1000; // callback putEpisodes(dataStream, episodes, time); } else { // error errorEpisodes(dataStream, "MANGA Plus: " + resp.status); } } }); } getEplistUrl["mangaplus"] = function(partialUrl) { return mangaplus.manga + partialUrl; } searchSite["mangaplus"] = function(id, title) { GM_xmlhttpRequest({ method: "GET", url: mangaplus.search, responseType: "arraybuffer", onload: function(resp) { if (resp.status == 200) { // OK // decode response let buf = resp.response; let message = Response.decode(new Uint8Array(buf)); let respJSON = Response.toObject(message); // check if response is valid if (!respJSON || !respJSON.success || !respJSON.success.allTitlesView) { // error return; } let titles = respJSON.success.allTitlesView.titles; let list = []; // insert results into list for (let i = 0; i < titles.length; i++) { let lang = mangaplus.lang_table[titles[i].language]; list.push({ title: titles[i].name + " (" + lang + ")", href: titles[i].titleId, }); } // filter results let results = list.filter(item => matchResult(item, title)); // callback putResults(id, results); } else { // error errorResults(id, "MANGA Plus: " + resp.status); } } }); } /* MAL list */ /*******************************************************************************************************************************************************************/ const mal = {}; mal.timerRate = 15000; mal.recheckInterval = 4; // as a multiple of timerRate mal.loadRows = 25; mal.epStrLen = 14; mal.genericErrorRequest = "Error while performing request"; mal.userId = null; mal.CSRFToken = null; let onScrollQueue = []; let requestsQueues = {}; let timerEventCounter = 0; pageLoad["list"] = function() { // own list if ($(".header-menu.other").length !== 0) return; if ($(properties.watching).length !== 1) return; // add col header to table let colHeader = $(`