import groovy.json.JsonSlurper import org.apache.commons.io.IOUtils import org.apache.http.client.utils.URLEncodedUtils import org.apache.http.NameValuePair import org.apache.http.message.BasicNameValuePair import java.util.regex.Matcher import java.util.zip.GZIPInputStream import java.util.zip.InflaterInputStream import org.serviio.library.metadata.MediaFileType import org.serviio.library.online.ContentURLContainer import org.serviio.library.online.PreferredQuality import org.serviio.library.online.WebResourceContainer import org.serviio.library.online.WebResourceItem import org.serviio.library.online.WebResourceUrlExtractor class YetAnotherSeasonVar extends WebResourceUrlExtractor { static final String baseURL = "seasonvar.ru" private static final Map headers = [ "User-Agent" : "Mozilla/5.0 (iPad; CPU OS 9_1 like Mac OS X) \ AppleWebKit/601.1.46 (KHTML, like Gecko) \ Version/9.0 Mobile/13B143 Safari/601.1", "Accept" : "*/*", "Accept-Language": "ru-RU", ] private static final Map cookies = [ "playerHtml": "true", "svid": "1428617_bdaa2d32cd6d1f864cba566da5949672" ] private HTTP connection = new HTTP(headers, [(baseURL): cookies]) /** * Called once for the whole feed. * For each feed which needs a plugin Serviio tries to match all available plugins to the feed's URL by * calling this method and uses the first plugin that returns true. Use of regular expression is * recommended. * * @param url URL of the whole feed, as entered by the user * @return true if the feed's items can be processed by this plugin */ @Override boolean extractorMatches(URL url) { return url ==~ /^(?:http?:\\/\\/)?(?:www\.)?seasonvar\.ru\\/.*/ } /** * @return the version of this plugin. Defaults to “1” if the method is not implemented. */ @Override int getVersion() { return 4 } /** * @return the name of this plugin. Is mostly used for logging and reporting purposes. */ @Override String getExtractorName() { return "${this.getClass().getName()} v${getVersion()}" } private Map extractSeasonInfo(URL url, String preferredTranslation = null, String alreadyLoadedHTML = null) { String indexHTML = alreadyLoadedHTML ?: this.connection.GET(url).text Map seasonInfo = ["type": "html5"] seasonInfo["title"] = Parser.title(indexHTML) seasonInfo["seasonId"] = Parser.currentSeasonNumber(indexHTML) seasonInfo["id"] = Parser.id(indexHTML) seasonInfo["serial"] = Parser.serial(indexHTML) seasonInfo["secure"] = Parser.secure(indexHTML) seasonInfo["time"] = Parser.time(indexHTML) String playerHTML = this.connection.POST("http://seasonvar.ru/player.php", seasonInfo).text Map availableTranslations = Parser.translations(playerHTML) seasonInfo["playlist"] = availableTranslations.containsKey(preferredTranslation) ? availableTranslations.get(preferredTranslation) : Parser.playlist(playerHTML) // else default translation return seasonInfo } /** * Performs the extraction of basic information about the resource. * If the object cannot be constructed the method should return null or throw an exception. * * @param url URL of the resource to be extracted. The plugin will have to get the contents on the URL itself. * @param i Max. number of items the user prefers to get back or -1 for unlimited. It is * up to the plugin designer to decide how to limit the results (if at all). * @return an instance of org.serviio.library.online.WebResourceContainer. * These are the properties of the class: * • String title – title of the web resource; optional * • String thumbnailUrl – URL of the resource's thumbnail; optional * • List items – list of extracted content items * WebResourceItem represents basic information about an item and has these properties: * • String title – title of the content item; mandatory * • Date releaseDate – release date of the content; optional * • Map additionalInfo – a map of key – value pairs that can include * information needed to retrieve content URL of the item (see extractUrl() method) * • String cacheKey – if present, the URL extracted on Serviio startup will be cached, and * reused rather than re-extracted on subsequent feed refreshes, unless the item has explicitly * expired due to the related ContentURLContainer's expiresOn value or the feed is forcibly * refreshed by the user. */ @Override protected WebResourceContainer extractItems(URL url, int maxItems) { String userPreferredTranslation = null boolean loadNextSeasons if (url.getQuery()) { Map userParams = Utils.getQueryParams(url) url = new URL(url.protocol, url.host, url.path) // cleanup user's query if (userParams.containsKey("t")) userPreferredTranslation = userParams.get("t") if (userParams.containsKey("next")) loadNextSeasons = userParams.get("next") == "true" } String currentSeasonHTML = this.connection.GET(url).text if (loadNextSeasons) { List allSeasons = Parser.seasons(currentSeasonHTML) int currentSeasonNumber = allSeasons.findIndexOf {it.contains(url.path)} List currentAndNextSeasons = allSeasons[currentSeasonNumber..allSeasons.size() - 1] List> currentAndNextSeasonsInfo = currentAndNextSeasons.eachWithIndex{ String entry, int i -> } collect { seasonURL -> extractSeasonInfo(new URL(url.protocol, url.host, seasonURL), userPreferredTranslation) // TODO One unnecessary request } return new WebResourceContainer( title: "${currentAndNextSeasonsInfo[0]["title"]} \u0438 \u043f\u043e\u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435", thumbnailUrl: "http://cdn.seasonvar.ru/oblojka/large/${currentAndNextSeasonsInfo[0]["id"]}.jpg", items: currentAndNextSeasonsInfo.collect { season -> Object json = this.connection.GET(new URL(url.protocol, url.host, season["playlist"])).json List> series = (List>) json["playlist"] series.collect { item -> new WebResourceItem( title: /${season["seasonId"]} \u0441\u0435\u0437\u043e\u043d ${item["comment"].replace("
", " ")}/, additionalInfo: ['link': item["file"]] ) } }.flatten() as List // TODO Fix me please! ) } else { Map seasonInfo = extractSeasonInfo(url, userPreferredTranslation, currentSeasonHTML) Object json = this.connection.GET(new URL(url.protocol, url.host, seasonInfo["playlist"])).json List> series = (List>) json["playlist"] return new WebResourceContainer( title: seasonInfo["title"], thumbnailUrl: "http://cdn.seasonvar.ru/oblojka/large/${seasonInfo["id"]}.jpg", items: series.collect { item -> new WebResourceItem( title: item["comment"].replace("
", " "), additionalInfo: ['link': item["file"]] ) } ) } } /** * This method is called once for each item included in the created WebResourceContainer. * Performs the actual extraction of content information using the provided information. * If the object cannot be constructed the method should return null or throw an exception. * * @param webResourceItem an instance of org.serviio.library.online.WebResourceItem, as created in * extractItems() method. * @param preferredQuality includes value (HIGH, MEDIUM, LOW) of enumeration * org.serviio.library.online.PreferredQuality. It should be taken into consideration if the * online service offers multiple quality-based renditions of the content. * @return an instance of org.serviio.library.online.ContentURLContainer. * These are the properties of the class: * • String contentUrl – URL of the feed item's content; mandatory * • String thumbnailUrl – URL of the feed item's thumbnail; optional * • org.serviio.library.metadata.MediaFileType fileType – file type of the feed item; default is VIDEO * • Date expiresOn – a date the feed item expires on. It can mean the item itself expires or the * item's contentUrl expires; the whole feed will be parsed again on the earliest expiry date of * all feed items; optional * • boolean expiresImmediately – if true Serviio will extract the URL again when the play * request is received to get URL that is valid for the whole playback duration. Note this is * related to the content URL only, the feed item itself should still be valid and available; optional * • String cacheKey – a unique identifier of the content (i.e. this item with this quality) used * as a key to technical metadata cache; required if either expiresOn and/or expiresImmediately is provided * • boolean live – identifies the content as a live stream; optional (default is false) * • String userAgent – specifies a particular User-Agent HTTP header to use when retrieving the content */ @Override protected ContentURLContainer extractUrl(WebResourceItem webResourceItem, PreferredQuality preferredQuality) { return new ContentURLContainer( fileType: MediaFileType.VIDEO, thumbnailUrl: webResourceItem.additionalInfo.thumbnailUrl, contentUrl: webResourceItem.additionalInfo.link, userAgent: headers["User-Agent"] ) } } class Utils { static Map getQueryParams(URL url) { List params = URLEncodedUtils.parse(url.toURI(), "UTF-8") return params.collectEntries { String name = URLDecoder.decode(it.name, "UTF-8").toLowerCase() String value = URLDecoder.decode(it.value, "UTF-8").toLowerCase() if (name == "translation") name = "t" // Some legacy :) return [(name): value]} } } class Parser { static private Matcher match(String text, String pattern) { Matcher matcher = text =~ pattern matcher.find() return matcher } static String secure(String text) { Matcher matcher = match(text, /(?s)var data4play = .*?'secureMark': '([a-f0-9]+)'/) return matcher.group(1) } static String time(String text) { Matcher matcher = match(text, /(?s)var data4play = .*?'secureMark': '[a-f0-9]+',.*?'time': ([0-9]+).*?/) return matcher.group(1) } static String id(String text) { Matcher matcher = match(text, /data-id-season="(\d+)"/) return matcher.group(1) } static String serial(String text) { Matcher matcher = match(text, /data-id-serial="(\d+)"/) return matcher.group(1) } static Map translations(String text) { Map translationVariants = [:] try { String translationsList = match(text, /(?s)
    /).group(0) Matcher matcher = match(translationsList, /(?s)
  • [\u0410-\u044f]+\s+(.*?)\s+[\u0410-\u044f]+<\/h1>/) return matcher.group(1).replaceAll(/\s+/, " ") } static String currentSeasonNumber(String text) { try { Matcher matcher = match(text, /(?s)/) return matcher.group(1) } catch (IllegalStateException) { return "0" } } static List seasons(String text) { Matcher matcher = match(text,/(?s)

    .*?href="(\/serial-\d+-[^-.]+(?:-\d+-(?:sezon|season))?\.html)".*?/) return matcher.iterator().collect { matcher.group(1) }.unique() } } class HTTP { private Proxy proxy private List supportedEncodings = ["gzip", "deflate"] private Map headers private CookieStore cookieJar = new CustomCookieStore() private CookieManager cookieManager = new CookieManager(this.cookieJar, CookiePolicy.ACCEPT_ALL) HTTP(Map headers = [:], Map> cookies = [:], Proxy proxy = Proxy.NO_PROXY) { this.proxy = proxy this.headers = headers << ["Accept-Encoding" : supportedEncodings.join(", ")] CookieHandler.setDefault(this.cookieManager) cookies.each { domain, map -> map.each { key, value -> this.cookieJar.add(new URI(domain), new HttpCookie(key, value)) } } } private HttpURLConnection makeRequest(URL url) { HttpURLConnection connection = url.openConnection(this.proxy) as HttpURLConnection this.headers.each { key, value -> connection.setRequestProperty(key, value) } return connection } Response GET(URL url) { HttpURLConnection connection = this.makeRequest(url) connection.setRequestMethod("GET") connection.connect() return new Response(connection) } Response GET(String url) { return GET(new URL(url)) } Response POST(String url, Map params) { HttpURLConnection connection = this.makeRequest(new URL(url)) connection.with { setRequestMethod("POST") setRequestProperty("X-Requested-With", "XMLHttpRequest") setRequestProperty("Content-type", "application/x-www-form-urlencoded") doOutput = true } String data = URLEncodedUtils.format(params.collect{key, value -> new BasicNameValuePair(key, value)}, "UTF-8") /* String data = params.collect {key, value -> /${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}/} .join("&")*/ // TODO https://hc.apache.org/httpcomponents-client-ga/httpclient/apidocs/org/apache/http/client/utils/URLEncodedUtils.html#format(java.util.List,%20java.lang.String) OutputStreamWriter writer = new OutputStreamWriter(connection.outputStream) writer.with { write(data) flush() close() } connection.connect() return new Response(connection) } class CustomCookieStore implements CookieStore { /* https://stackoverflow.com/questions/45677375/add-httpcookie-to-cookiestore */ CookieStore store = new CookieManager().getCookieStore() void add(URI uri, HttpCookie cookie) { if (!cookie.domain) cookie.domain = uri.isAbsolute() ? ".$uri.host" : ".$uri.path" if (!cookie.path) cookie.path = uri.isAbsolute() ? uri.path : "/" if (cookie.version == 1) cookie.version = 0 // Compatibility with RFC 2109 this.store.add(uri, cookie) } List get(URI uri) { return this.store.get(uri) } List getCookies() { return this.store.getCookies() } List getURIs() { return this.store.getURIs() } boolean remove(URI uri, HttpCookie cookie) { return this.store.remove(uri, cookie) } boolean removeAll() { return this.store.removeAll() } } class Response { final String text final int responseCode final URL url def json Response(HttpURLConnection connection){ this.text = decodeInputStream(connection) this.responseCode = connection.getResponseCode() this.url = connection.getURL() } private def getJson() { if (!this.json) this.json = new JsonSlurper().parseText(this.text) return this.json } private static String decodeInputStream(HttpURLConnection connection) { String encoding = connection.getContentEncoding() switch (encoding) { case "gzip": return IOUtils.toString(new GZIPInputStream(connection.inputStream), "UTF-8") case "deflate": return IOUtils.toString(new InflaterInputStream(connection.inputStream), "UTF-8") case null: return IOUtils.toString(connection.inputStream, "UTF-8") default: throw new UnsupportedEncodingException("The server has returned an unsupported encoding: $encoding") } } } }