diff --git a/StreamCinemaLib/API/FilterHits.cs b/StreamCinemaLib/API/FilterHits.cs index ebef87b..363aee3 100644 --- a/StreamCinemaLib/API/FilterHits.cs +++ b/StreamCinemaLib/API/FilterHits.cs @@ -5,6 +5,6 @@ namespace StreamCinemaLib.API; public class FilterHits { public FilterTotalHits? total {get;set;} - public double max_score {get;set;} + public double? max_score {get;set;} public List? hits {get; set;} } diff --git a/StreamCinemaLib/API/MediaInfo.cs b/StreamCinemaLib/API/MediaInfo.cs index d42057d..56a41a3 100644 --- a/StreamCinemaLib/API/MediaInfo.cs +++ b/StreamCinemaLib/API/MediaInfo.cs @@ -5,8 +5,8 @@ namespace StreamCinemaLib.API; public class MediaInfo { public string? _index { get; set; } - public string? _id { get; set; } - public double _score { get; set; } + public string _id { get; set; } + public double? _score { get; set; } //"_ignored": [ // "i18n_info_labels.plot.keyword" //], diff --git a/StreamCinemaLib/API/Metadata.cs b/StreamCinemaLib/API/Metadata.cs index 86cd79c..2ef90ac 100644 --- a/StreamCinemaLib/API/Metadata.cs +++ b/StreamCinemaLib/API/Metadata.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -14,21 +16,111 @@ public class Metadata private static readonly Uri ApiRoot = new Uri("https://plugin.sc2.zone/api/"); private static readonly Uri ApiFilter = new Uri(ApiRoot, "media/filter/v2/"); - public static Task SearchAsync(string expression, ItemOrder order = ItemOrder.Descending, FilterSortBy sort = FilterSortBy.Score, ItemType type = ItemType.All, int limit = 0, CancellationToken cancel = default) + /// + /// Searches for media using an expression. + /// + /// Expression like part of title. + /// Result ordering direction. + /// Result ordering column. + /// Allowed result type. + /// Item offset. + /// Maximum returned items count. + /// Asynchronous cancellation. + /// Response. + public static Task SearchAsync(string expression, ItemOrder order = ItemOrder.Descending, FilterSortBy sort = FilterSortBy.Score, ItemType type = ItemType.All, int offset = 0, int limit = 0, CancellationToken cancel = default) { if (expression == null) throw new ArgumentNullException(); - if (limit < 0) + if (limit < 0 || offset < 0) throw new ArgumentOutOfRangeException(); if (limit == 0 || limit > MaxPageLimit) limit = MaxPageLimit; UriBuilder uri = new UriBuilder(new Uri(ApiFilter, "search")); - uri.Query = $"?access_token={AccessToken}&value={Uri.EscapeDataString(expression)}&order={ToString(order)}&sort={ToString(sort)}&type={ToString(type)}&size={limit.ToString()}"; + uri.Query = $"?access_token={AccessToken}&value={Uri.EscapeDataString(expression)}&order={ToString(order)}&sort={ToString(sort)}&type={ToString(type)}&from={offset.ToString()}&size={limit.ToString()}"; - JsonSerializerOptions options = new JsonSerializerOptions(); - options.Converters.Add(new CustomDateTimeConverter()); - return Program._http.GetFromJsonAsync(uri.Uri, options, cancel); + return Program._http.GetFromJsonAsync(uri.Uri, CreateFilterJsonOptions(), cancel); + } + + /// + /// Search for child media. + /// + /// Identifier of the parent media. + /// Result ordering column. + /// Asynchronous cancellation. + /// Response. + public static Task ChildrenAsync(string parentId, FilterSortBy sort = FilterSortBy.Episode, CancellationToken cancel = default) + { + if (parentId == null) + throw new ArgumentNullException(); + + UriBuilder uri = new UriBuilder(new Uri(ApiFilter, "parent")); + uri.Query = $"?access_token={AccessToken}&value={parentId}&sort={ToString(sort)}&size={MaxPageLimit.ToString()}"; + + return Program._http.GetFromJsonAsync(uri.Uri, CreateFilterJsonOptions(), cancel); + } + + /// + /// Gets available streams for the given media. + /// + /// Media identifier. + /// Asynchronous cancellation. + /// Available streams for download. + public static Task StreamsAsync(string id, CancellationToken cancel = default) + { + if (id == null) + throw new ArgumentNullException(); + + UriBuilder uri = new UriBuilder(new Uri(ApiRoot, "media/" + id + "/streams")); + uri.Query = $"?access_token={AccessToken}"; + + return Program._http.GetFromJsonAsync(uri.Uri, CreateFilterJsonOptions(), cancel); + } + + /// + /// Tries to get a thumbnail image from a well-known image sources to speed-up loading. + /// + /// Image url with probably full-sized image. + /// Requested image width that may however get rounded or completely ignored. + /// Requested image height that may however get rounded or completely ignored. + /// On success the thumbnail address. + /// True if thumbnail url got calculated, false otherwise. + public static bool TryGetThumbnail(Uri imageUri, int suggestWidth, int suggestHeight, [NotNullWhen(true)] out Uri? thumbUri) { + if (imageUri == null) + throw new ArgumentNullException(); + + switch (imageUri.Host) { + case "image.tmdb.org": + const string TmdbOrigPrefix = "/t/p/original/"; + if (imageUri.AbsolutePath.StartsWith(TmdbOrigPrefix)) { + UriBuilder ub = new UriBuilder(imageUri); + ub.Path = "/t/p/w300_and_h450_bestv2/" + ub.Path.Substring(TmdbOrigPrefix.Length); + thumbUri = ub.Uri; + return true; + } + break; + + case "img.csfd.cz": + if (imageUri.AbsolutePath.StartsWith("/files/images/film/posters/")) { + // 140px, 280px, 420px + int w; + if (suggestWidth < 160) + w = 140; + else if (suggestWidth < 320) + w = 280; + else + w = 420; + UriBuilder ub = new UriBuilder(imageUri); + ub.Host = "image.pmgstatic.com"; + ub.Path = "/cache/resized/w" + w.ToString(CultureInfo.InvariantCulture) + ub.Path; + thumbUri = ub.Uri; + return true; + } + break; + } + + thumbUri = null; + return false; } private static string ToString(ItemOrder value) @@ -79,6 +171,12 @@ public class Metadata } } + private static JsonSerializerOptions CreateFilterJsonOptions() { + JsonSerializerOptions options = new JsonSerializerOptions(); + options.Converters.Add(new CustomDateTimeConverter()); + return options; + } + sealed class CustomDateTimeConverter : JsonConverter { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/StreamCinemaLib/API/Stream.cs b/StreamCinemaLib/API/Stream.cs new file mode 100644 index 0000000..ec62fbf --- /dev/null +++ b/StreamCinemaLib/API/Stream.cs @@ -0,0 +1,17 @@ +using System; + +namespace StreamCinemaLib.API; + +public class Stream +{ + public string _id {get; set;} + public string name {get;set;} + public string media {get;set;} + public string provider {get;set;} + public DateTime? date_added {get;set;} + public string ident {get;set;} + public long? size {get;set;} + public List? audio {get;set;} + public List? video {get;set;} + public List? subtitles {get;set;} +} diff --git a/StreamCinemaLib/API/StreamAudio.cs b/StreamCinemaLib/API/StreamAudio.cs new file mode 100644 index 0000000..5dd839d --- /dev/null +++ b/StreamCinemaLib/API/StreamAudio.cs @@ -0,0 +1,10 @@ +using System; + +namespace StreamCinemaLib.API; + +public class StreamAudio +{ + public string language { get; set; } // two letter ISO code + public string codec { get; set; } // ie. AAC + public int channels { get; set; } +} diff --git a/StreamCinemaLib/API/StreamSubtitle.cs b/StreamCinemaLib/API/StreamSubtitle.cs new file mode 100644 index 0000000..93e0cd4 --- /dev/null +++ b/StreamCinemaLib/API/StreamSubtitle.cs @@ -0,0 +1,9 @@ +using System; + +namespace StreamCinemaLib.API; + +public class StreamSubtitle +{ + public string language { get; set; } // two letter ISO code + public bool forced { get; set; } +} diff --git a/StreamCinemaLib/API/StreamVideo.cs b/StreamCinemaLib/API/StreamVideo.cs new file mode 100644 index 0000000..9d274e5 --- /dev/null +++ b/StreamCinemaLib/API/StreamVideo.cs @@ -0,0 +1,15 @@ +using System; +using System.Text.Json.Serialization; + +namespace StreamCinemaLib.API; + +public class StreamVideo +{ + public int width { get; set; } // horizontal resolution in pixels + public int height { get; set; } // vertical resolution in pixels + public string codec { get; set; } // ie. AVC + public double? aspect { get; set; } // aspect ratio, ie. 1.778 + [JsonPropertyName("3d")] + public bool is3d { get; set; } + public double? duration { get; set; } // in seconds +} diff --git a/StreamCinemaLib/Webshare/LinkGenerator.cs b/StreamCinemaLib/Webshare/LinkGenerator.cs index 0f8b09d..14663e6 100644 --- a/StreamCinemaLib/Webshare/LinkGenerator.cs +++ b/StreamCinemaLib/Webshare/LinkGenerator.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Xml; @@ -9,7 +10,7 @@ public class LinkGenerator { private static readonly BaseEncoding UnixMD5 = new BaseEncoding("./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", false); private static readonly Uri WebshareApiUri = new Uri("https://webshare.cz/api/"); - + /// /// Generates a download link for the given Webshare download. /// @@ -22,24 +23,38 @@ public class LinkGenerator // Obtain the download password salt // Example response: OKUX8y8Fpa30 Uri saltUri = new Uri(WebshareApiUri, "file_password_salt/"); - HttpResponseMessage saltRes = await Program._http.PostAsync(saltUri, new FormUrlEncodedContent(new [] { new KeyValuePair("ident", fileId) }), cancel); + HttpResponseMessage saltRes = await Program._http.PostAsync(saltUri, new FormUrlEncodedContent(new[] { new KeyValuePair("ident", fileId) }), cancel); if (!saltRes.IsSuccessStatusCode) throw new IOException("Failed to get password salt from Webshare API."); XmlReader saltR = XmlReader.Create(saltRes.Content.ReadAsStream()); saltR.ReadStartElement("response"); - ThrowIfStatusNotOK(saltR); - string salt = saltR.ReadElementContentAsString("salt", ""); - saltR.ReadElementContentAsString("app_version", ""); - saltR.ReadEndElement(); // response + Exception? e = GetExceptionIfStatusNotOK(saltR, out string? code); + string password; + if (e != null) + { + if (code == "FILE_PASSWORD_SALT_FATAL_2") + { + // No password is set + password = ""; + } + else + throw e; + } + else + { + // Calculate the download password + string salt = saltR.ReadElementContentAsString("salt", ""); + saltR.ReadElementContentAsString("app_version", ""); + saltR.ReadEndElement(); // response - // Calculate the download password - string password = CalculatePassword(fileId, fileName, salt); + password = CalculatePassword(fileId, fileName, salt); + } // Obtain the download link // Example response: OKhttps://free.19.dl.wsfiles.cz/9209/...30 Uri linkUri = new Uri(WebshareApiUri, "file_link/"); - HttpResponseMessage linkRes = await Program._http.PostAsync(linkUri, new FormUrlEncodedContent(new [] { + HttpResponseMessage linkRes = await Program._http.PostAsync(linkUri, new FormUrlEncodedContent(new[] { new KeyValuePair("ident", fileId), new KeyValuePair("download_type", "video_stream"), new KeyValuePair("device_uuid", Guid.NewGuid().ToString("X")), @@ -61,14 +76,24 @@ public class LinkGenerator return new Uri(link); } - private static void ThrowIfStatusNotOK(XmlReader r) { + private static Exception? GetExceptionIfStatusNotOK(XmlReader r, out string? code) + { string status = r.ReadElementContentAsString("status", ""); - if (status == "OK") - return; + if (status == "OK") { + code = null; + return null; + } - string code = r.ReadElementContentAsString("code", ""); + code = r.ReadElementContentAsString("code", ""); string message = r.ReadElementContentAsString("message", ""); - throw new IOException(code + ": " + message); + return new IOException(code + ": " + message); + } + + private static void ThrowIfStatusNotOK(XmlReader r) + { + Exception? e = GetExceptionIfStatusNotOK(r, out _); + if (e != null) + throw e; } /// diff --git a/StreamCinemaWeb/Layouts/BasicLayout.cs b/StreamCinemaWeb/Layouts/BasicLayout.cs index 4661cdd..777e137 100644 --- a/StreamCinemaWeb/Layouts/BasicLayout.cs +++ b/StreamCinemaWeb/Layouts/BasicLayout.cs @@ -40,7 +40,17 @@ public abstract class BasicLayout w.WriteLine(""); w.WriteLine("" + HttpUtility.HtmlEncode(title) + ""); w.WriteLine(""); w.WriteLine(""); } diff --git a/StreamCinemaWeb/Pages/EpisodesPage.cs b/StreamCinemaWeb/Pages/EpisodesPage.cs new file mode 100644 index 0000000..230fe89 --- /dev/null +++ b/StreamCinemaWeb/Pages/EpisodesPage.cs @@ -0,0 +1,99 @@ +using System; +using System.Web; +using StreamCinemaLib.API; +using StreamCinemaWeb.Layouts; + +namespace StreamCinemaWeb.Pages; + +public class EpisodesPage : BasicLayout +{ + private readonly string _parentId; + private readonly string _title; + + public EpisodesPage(string parentId, string title) + { + this._parentId = parentId; + this._title = title; + } + + protected override string Title => _title; + + protected override async Task RenderContentAsync(HttpRequest req, TextWriter w) + { + w.WriteLine("

" + HttpUtility.HtmlEncode(_title) + "

"); + + FilterResponse? res = await Metadata.ChildrenAsync(_parentId, sort: FilterSortBy.Episode, cancel: req.HttpContext.RequestAborted); + + if (res == null || res.hits == null || res.hits.hits == null || res.hits.hits.Count == 0) + { + w.WriteLine("Nic nenalezeno"); + } + else + { + if (res.hits.hits[0]._source?.children_count == 0) + { + // Just episodes + w.WriteLine("

Epizody

"); + RenderEpisodes(w, res.hits.hits, _title); + + } + else + { + // Seasons and episodes + foreach (MediaInfo i in res.hits.hits) + { + if (i._source == null) + continue; + InfoLabelI18n? label = i._source.i18n_info_labels?.Where(x => x.lang == "cs").FirstOrDefault(); + if (label == null) + label = i._source.i18n_info_labels?.FirstOrDefault(); + if (label == null) + continue; + + w.WriteLine("

Sezóna " + label.title + "

"); + + FilterResponse? resEp = await Metadata.ChildrenAsync(i._id, sort: FilterSortBy.Episode, cancel: req.HttpContext.RequestAborted); + if (resEp == null || resEp.hits == null || resEp.hits.hits == null || resEp.hits.hits.Count == 0) + { + w.WriteLine("Nic nenalezeno"); + } + else + { + RenderEpisodes(w, resEp.hits.hits, _title + " - " + label.title); + } + } + } + } + } + + private void RenderEpisodes(TextWriter w, IEnumerable episodes, string parentTitles) + { + w.WriteLine("
"); + foreach (MediaInfo i in episodes) + { + if (i._source == null) + continue; + InfoLabelI18n? label = i._source.i18n_info_labels?.Where(x => x.lang == "cs").FirstOrDefault(); + if (label == null) + label = i._source.i18n_info_labels?.FirstOrDefault(); + if (label == null) + continue; + + string? artUriS = label?.art?.poster ?? label?.art?.fanart; + Uri? artUri; + if (artUriS == null) + artUri = null; + else if (Metadata.TryGetThumbnail(artUri = new Uri(artUriS), 128, 128, out Uri? artThumbUri)) + artUri = artThumbUri; + + string streamsUri = "/streams/" + i._id + "/" + HttpUtility.UrlEncode(parentTitles + " - " + label?.title ?? "neznamy"); + + w.WriteLine("
"); + w.WriteLine("
"); + w.WriteLine(""); + w.WriteLine("
" + HttpUtility.HtmlEncode(label?.plot) + "
"); + w.WriteLine("
"); // media-info + } + w.WriteLine("
"); + } +} diff --git a/StreamCinemaWeb/Pages/LinkPage.cs b/StreamCinemaWeb/Pages/LinkPage.cs new file mode 100644 index 0000000..b9a1488 --- /dev/null +++ b/StreamCinemaWeb/Pages/LinkPage.cs @@ -0,0 +1,42 @@ +using System; +using System.Web; +using StreamCinema.Webshare; +using StreamCinemaLib.API; +using StreamCinemaWeb.Layouts; +using LinkGenerator = StreamCinema.Webshare.LinkGenerator; + +namespace StreamCinemaWeb.Pages; + +public class LinkPage : BasicLayout +{ + private readonly string _provider; + private readonly string _ident; + private readonly string _name; + private readonly string _title; + + public LinkPage(string provider, string ident, string name, string title) + { + this._provider = provider; + this._ident = ident; + this._name = name; + this._title = title; + } + + protected override string Title => _title; + + protected override async Task RenderContentAsync(HttpRequest req, TextWriter w) + { + w.WriteLine("

" + HttpUtility.HtmlEncode(_title) + "

"); + + Uri link; + switch (_provider) + { + case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(_ident, _name, req.HttpContext.RequestAborted); break; + default: + w.WriteLine("Provider '" + HttpUtility.HtmlEncode(_provider) + "' není aktuálně podporován."); + return; + } + + w.WriteLine(""); + } +} diff --git a/StreamCinemaWeb/Pages/MediaPage.cs b/StreamCinemaWeb/Pages/MediaPage.cs new file mode 100644 index 0000000..566dc81 --- /dev/null +++ b/StreamCinemaWeb/Pages/MediaPage.cs @@ -0,0 +1,24 @@ +using System; +using System.Web; +using StreamCinemaWeb.Layouts; + +namespace StreamCinemaWeb.Pages; + +public class MediaPage : BasicLayout +{ + private readonly string _id; + private readonly string _title; + + public MediaPage(string id, string title) + { + this._id = id; + this._title = title; + } + + protected override string Title => _title; + + protected override async Task RenderContentAsync(HttpRequest req, TextWriter w) + { + w.WriteLine("

" + HttpUtility.HtmlEncode(_title) + "

"); + } +} diff --git a/StreamCinemaWeb/Pages/SearchPage.cs b/StreamCinemaWeb/Pages/SearchPage.cs index f3da87e..74f54a1 100644 --- a/StreamCinemaWeb/Pages/SearchPage.cs +++ b/StreamCinemaWeb/Pages/SearchPage.cs @@ -1,24 +1,30 @@ using System; -using StreamCinemaWeb.Layouts; - -using StreamCinemaLib.API; +using System.Globalization; using System.Web; +using StreamCinemaWeb.Layouts; +using StreamCinemaLib.API; +using System.Runtime.InteropServices; + namespace StreamCinemaWeb.Pages; public class SearchPage : BasicLayout { + private const int PageSize = 30; + private const string ParamExpression = "expr"; + private const string ParamPageIdx = "page"; + protected override string Title => "Hledat filmy a seriály"; protected override async Task RenderContentAsync(HttpRequest req, TextWriter w) { - string expr; - if (req.Method != "POST" || (expr = req.Form["expr"]) == null || expr.Trim().Length == 0) + string? expr; + if ((expr = req.Query["expr"]) == null || expr.Trim().Length == 0) { // Show search w.WriteLine("

Hledat filmy a seriály

"); - w.WriteLine("
"); - w.WriteLine(""); + w.WriteLine(""); + w.WriteLine(""); w.WriteLine(""); w.WriteLine("
"); @@ -26,14 +32,18 @@ public class SearchPage : BasicLayout else { // Execute search - FilterResponse? res = await Metadata.SearchAsync(expr, cancel: req.HttpContext.RequestAborted); - w.WriteLine("

Hledání:" + HttpUtility.HtmlEncode(expr) + "

"); + string? pageS = req.Query[ParamPageIdx]; + int page = pageS != null && int.TryParse(pageS, out int value) ? value : 0; + FilterResponse? res = await Metadata.SearchAsync(expr, offset: page * PageSize, limit: PageSize, cancel: req.HttpContext.RequestAborted); + + w.WriteLine("

Hledání: " + HttpUtility.HtmlEncode(expr) + "

"); if (res == null || res.hits == null || res.hits.hits == null || res.hits.hits.Count == 0) { w.WriteLine("Nic nenalezeno"); } else { + w.WriteLine("
"); foreach (MediaInfo i in res.hits.hits) { if (i._source == null) @@ -44,12 +54,23 @@ public class SearchPage : BasicLayout if (label == null) continue; + string? artUriS = label?.art?.poster ?? label?.art?.fanart; + Uri? artUri; + if (artUriS == null) + artUri = null; + else if (Metadata.TryGetThumbnail(artUri = new Uri(artUriS), 300, 300, out Uri? artThumbUri)) + artUri = artThumbUri; + + string detailUri = (i._source.children_count == 0 ? "media" : "episodes") + "/" + i._id + "/" + HttpUtility.UrlEncode(label?.title ?? "neznamy"); + w.WriteLine("
"); - w.WriteLine("
"); - w.WriteLine("
" + HttpUtility.HtmlEncode(label?.title) + "" + HttpUtility.HtmlEncode(label?.plot) + "
"); + w.WriteLine(""); + w.WriteLine("
" + HttpUtility.HtmlEncode(label?.plot) + "
"); w.WriteLine("
"); // media-info } + w.WriteLine("
"); + w.WriteLine(""); } } } diff --git a/StreamCinemaWeb/Pages/StreamsPage.cs b/StreamCinemaWeb/Pages/StreamsPage.cs new file mode 100644 index 0000000..6b4c8ad --- /dev/null +++ b/StreamCinemaWeb/Pages/StreamsPage.cs @@ -0,0 +1,73 @@ +using System; +using System.Web; +using StreamCinemaLib.API; +using StreamCinemaWeb.Layouts; + +namespace StreamCinemaWeb.Pages; + +public class StreamsPage : BasicLayout +{ + private readonly string _id; + private readonly string _title; + + public StreamsPage(string id, string title) + { + this._id = id; + this._title = title; + } + + protected override string Title => _title; + + protected override async Task RenderContentAsync(HttpRequest req, TextWriter w) + { + w.WriteLine("

" + HttpUtility.HtmlEncode(_title) + "

"); + w.WriteLine("
Zvolte kvalitu
"); + + StreamCinemaLib.API.Stream[]? res = await Metadata.StreamsAsync(_id, cancel: req.HttpContext.RequestAborted); + + if (res == null || res.Length == 0) + { + w.WriteLine("Nic nenalezeno"); + } + else + { + w.WriteLine(""); + } + } +} diff --git a/StreamCinemaWeb/Program.cs b/StreamCinemaWeb/Program.cs index 13f02d3..9c9f2ca 100644 --- a/StreamCinemaWeb/Program.cs +++ b/StreamCinemaWeb/Program.cs @@ -1,9 +1,13 @@ +using System.Web; using StreamCinemaWeb.Pages; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", new SearchPage().ExecuteAsync); -app.MapPost("/", new SearchPage().ExecuteAsync); +app.MapGet("/episodes/{id}/{title}", (string id, string title, HttpRequest req) => new EpisodesPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req)); +app.MapGet("/streams/{id}/{title}", (string id, string title, HttpRequest req) => new StreamsPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req)); +app.MapGet("/media/{id}/{title}", (string id, string title, HttpRequest req) => new MediaPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req)); +app.MapGet("/link/{provider}/{ident}/{name}/{title}", (string provider, string ident, string name, string title, HttpRequest req) => new LinkPage(provider, HttpUtility.UrlDecode(ident), HttpUtility.UrlDecode(name), HttpUtility.UrlDecode(title)).ExecuteAsync(req)); app.Run();