diff --git a/.gitignore b/.gitignore index 26e7760..fe9176c 100644 --- a/.gitignore +++ b/.gitignore @@ -364,6 +364,7 @@ FodyWeavers.xsd # MediathekArr specifics config.json +mediathekarr.json *.mp4 *.mkv ffmpeg/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0ad0fc4..39059c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN apt-get update && apt-get install -y \ gosu \ wget \ gnupg \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # Add MKVToolNix repository and install diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 0000000..0846f8f --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,53 @@ +FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/sdk:9.0 AS build-env +WORKDIR /app + +# Copy and restore dependencies +COPY . ./ +RUN dotnet restore + +# Publish MediathekArrServer +WORKDIR /app/MediathekArrServer +RUN dotnet publish -c Release -o /app/out/MediathekArrServer + +# Publish MediathekArrDownloader +WORKDIR /app/MediathekArr +RUN dotnet publish -c Release -o /app/out/MediathekArrDownloader + +# Final runtime image +FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/aspnet:9.0 + +# Install required packages +RUN apt-get update && apt-get install -y \ + tar \ + xz-utils \ + gosu \ + wget \ + gnupg \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Add MKVToolNix repository and install +RUN wget -O /etc/apt/keyrings/gpg-pub-moritzbunkus.gpg https://mkvtoolnix.download/gpg-pub-moritzbunkus.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/gpg-pub-moritzbunkus.gpg] https://mkvtoolnix.download/debian/ bookworm main" > /etc/apt/sources.list.d/mkvtoolnix.download.list && \ + apt-get update && apt-get install -y \ + mkvtoolnix + +# Set working directory +WORKDIR /app + +# Copy the built apps from the build environment +COPY --from=build-env /app/out/MediathekArrServer /app/MediathekArrServer +COPY --from=build-env /app/out/MediathekArrDownloader /app/MediathekArrDownloader + +# Copy the startup script +COPY docker_start.sh /app/docker_start.sh +RUN chmod +x /app/docker_start.sh + +# Create required directories +RUN mkdir -p /data/mediathek/incomplete /data/mediathek/complete + +ENV ASPNETCORE_ENVIRONMENT=Production +ENV CONFIG_PATH=/app/config + +# Use the shell script to start both processes +ENTRYPOINT ["/app/docker_start.sh"] diff --git a/MediathekArr/Controllers/ApiProxyControllerBase.cs b/MediathekArr/Controllers/ApiProxyControllerBase.cs new file mode 100644 index 0000000..777764a --- /dev/null +++ b/MediathekArr/Controllers/ApiProxyControllerBase.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Net; + +namespace MediathekArr.Controllers; + +public abstract class ApiProxyControllerBase : ControllerBase +{ + protected readonly IHttpClientFactory HttpClientFactory; + + protected ApiProxyControllerBase(IHttpClientFactory httpClientFactory) + { + HttpClientFactory = httpClientFactory; + } + + protected IActionResult? ValidateRequiredParameters(string apiKey, string host, string hostName = "Host") + { + if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(host)) + { + return BadRequest($"{hostName} and API key are required."); + } + return null; + } + + protected HttpClient CreateHttpClient(string apiKey) + { + var httpClient = HttpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); + return httpClient; + } + + protected async Task ReadRequestBodyAsync() + { + using var reader = new StreamReader(Request.Body); + return await reader.ReadToEndAsync(); + } + + protected IActionResult HandleErrorResponse(HttpStatusCode statusCode, string responseContent, string errorContext) + { + string message = $"Error {errorContext}"; + + if (!string.IsNullOrWhiteSpace(responseContent)) + { + try + { + // Try to parse as array of validation errors + var errorArray = JsonConvert.DeserializeObject>(responseContent); + if (errorArray?.Count > 0) + { + var errorMessages = errorArray + .Select(err => (string)err["errorMessage"]) + .Where(msg => !string.IsNullOrEmpty(msg)) + .ToList(); + + if (errorMessages.Count != 0) + { + message = string.Join(", ", errorMessages); + var propertyName = errorArray.Select(err => (string)err["propertyName"]).FirstOrDefault(); + var attemptedValue = errorArray.Select(err => (string)err["attemptedValue"]).FirstOrDefault(); + if (propertyName is not null) + { + message += $":{Environment.NewLine}Property Name: \"{propertyName}\"{Environment.NewLine}Attempted Value: \"{attemptedValue}\""; + } + } + } + else + { + // Try to parse as a single error object with errorMessage property + var errorObj = JsonConvert.DeserializeObject(responseContent); + if (errorObj?["errorMessage"] != null) + { + message = errorObj["errorMessage"].ToString(); + } + } + } + catch + { + // If parsing fails, fall back to raw response content + message = responseContent; + } + } + + var errorResult = new + { + error = new + { + message + } + }; + + return StatusCode((int)statusCode, errorResult); + } + + + protected async Task ExecuteApiRequest( + string apiKey, + string host, + string endpoint, + HttpMethod method, + string errorContext, + Func processSuccessResponse = null, + string hostName = "Host", + bool isProwlarr = false) + { + var validationResult = ValidateRequiredParameters(apiKey, host, hostName); + if (validationResult != null) + return validationResult; + + var cleanedHostName = host.TrimEnd('/'); + var httpClient = CreateHttpClient(apiKey); + + try + { + StringContent content = null; + + if (method != HttpMethod.Get) + { + var rawBody = await ReadRequestBodyAsync(); + content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response; + + if (method == HttpMethod.Get) + { + response = await httpClient.GetAsync($"{cleanedHostName}/{endpoint}"); + } + else if (method == HttpMethod.Post) + { + response = await httpClient.PostAsync($"{cleanedHostName}/{endpoint}", content); + } + else // PUT + { + response = await httpClient.PutAsync($"{cleanedHostName}/{endpoint}", content); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return HandleErrorResponse(response.StatusCode, responseContent, errorContext); + } + + if (processSuccessResponse != null) + { + return processSuccessResponse(responseContent); + } + + return Content(responseContent, "application/json"); + } + catch (Exception ex) + { + return StatusCode(500, $"Error {errorContext}: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/MediathekArr/Controllers/DownloadController.Config.cs b/MediathekArr/Controllers/DownloadController.Config.cs index 9d8e356..a23c303 100644 --- a/MediathekArr/Controllers/DownloadController.Config.cs +++ b/MediathekArr/Controllers/DownloadController.Config.cs @@ -1,7 +1,7 @@ -using MediathekArrDownloader.Models; +using MediathekArr.Models; using Microsoft.AspNetCore.Mvc; -namespace MediathekArrDownloader.Controllers; +namespace MediathekArr.Controllers; public partial class DownloadController { @@ -10,14 +10,27 @@ public IActionResult GetConfig() { var incompleteEnv = Environment.GetEnvironmentVariable("DOWNLOAD_INCOMPLETE_PATH"); var completeEnv = Environment.GetEnvironmentVariable("DOWNLOAD_COMPLETE_PATH"); - + var categoriesEnv = Environment.GetEnvironmentVariable("CATEGORIES"); + var maxParallelDownloadsEnv = Environment.GetEnvironmentVariable("MAX_PARALLEL_DOWNLOADS"); + + // Parse categories from environment variable if present + if (!string.IsNullOrEmpty(categoriesEnv)) + { + _config.Categories = categoriesEnv.Split(',') + .Select(c => c.Trim()) + .Where(c => !string.IsNullOrEmpty(c)) + .ToArray(); + } + var configDetails = new { config = _config, overrides = new { IncompletePath = !string.IsNullOrEmpty(incompleteEnv), - CompletePath = !string.IsNullOrEmpty(completeEnv) + CompletePath = !string.IsNullOrEmpty(completeEnv), + Categories = !string.IsNullOrEmpty(categoriesEnv), + MaxParallelDownloads = !string.IsNullOrEmpty(maxParallelDownloadsEnv) } }; @@ -29,6 +42,10 @@ public IActionResult UpdateConfig([FromBody] Config newConfig) { var incompleteEnv = Environment.GetEnvironmentVariable("DOWNLOAD_INCOMPLETE_PATH"); var completeEnv = Environment.GetEnvironmentVariable("DOWNLOAD_COMPLETE_PATH"); + var categoriesEnv = Environment.GetEnvironmentVariable("CATEGORIES"); + var maxParallelDownloadsEnv = Environment.GetEnvironmentVariable("MAX_PARALLEL_DOWNLOADS"); + + bool requiresRestart = false; // Prevent updates to fields overridden by environment variables if (string.IsNullOrEmpty(incompleteEnv)) @@ -41,6 +58,27 @@ public IActionResult UpdateConfig([FromBody] Config newConfig) _config.CompletePath = newConfig.CompletePath; } + // Only update categories if not overridden by environment variable + if (string.IsNullOrEmpty(categoriesEnv)) + { + if (_config.Categories != newConfig.Categories) + { + _config.Categories = newConfig.Categories; + + // Create directories for each category + _downloadService.InitializeCompleteDirectories(); + } + } + + if (string.IsNullOrEmpty(maxParallelDownloadsEnv)) + { + if (_config.MaxParallelDownloads != newConfig.MaxParallelDownloads) + { + _config.MaxParallelDownloads = Math.Clamp(newConfig.MaxParallelDownloads, 1, 10); + requiresRestart = true; + } + } + // Persist updated config to file var configPathEnv = Environment.GetEnvironmentVariable("CONFIG_PATH"); @@ -59,9 +97,10 @@ public IActionResult UpdateConfig([FromBody] Config newConfig) } System.IO.File.WriteAllText(configFilePath, System.Text.Json.JsonSerializer.Serialize(_config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); - return Ok(new { status = "success" }); + return Ok(new { status = "success", requiresRestart }); } + [HttpGet("browse")] public IActionResult BrowsePath([FromQuery] string path = "") { diff --git a/MediathekArr/Controllers/DownloadController.cs b/MediathekArr/Controllers/DownloadController.cs index 522f694..00d6314 100644 --- a/MediathekArr/Controllers/DownloadController.cs +++ b/MediathekArr/Controllers/DownloadController.cs @@ -1,10 +1,12 @@ -using MediathekArrDownloader.Models; -using MediathekArrDownloader.Models.SABnzbd; -using MediathekArrDownloader.Services; +using MediathekArr.Models; +using MediathekArr.Models.SABnzbd; +using MediathekArr.Services; using Microsoft.AspNetCore.Mvc; +using System.Text; using System.Text.RegularExpressions; +using System.Web; -namespace MediathekArrDownloader.Controllers; +namespace MediathekArr.Controllers; [ApiController] [Route("[controller]")] @@ -21,6 +23,7 @@ public IActionResult GetVersion([FromQuery] string mode, [FromQuery] string? nam "version" => Ok(new { version = "4.3.3" }), "get_config" => Content(ConfigResponse, "application/json"), "fullstatus" => Content(FullStatusResponse, "application/json"), + "translate" => (value == "ping") ? Ok(new { value = "pong" }) : Ok(new { value = value }), "queue" => Ok(GetQueue()), "history" => (name == "delete" && !string.IsNullOrEmpty(value)) ? DeleteHistoryItem(value, del_files.GetValueOrDefault() == 1) @@ -41,31 +44,99 @@ private IActionResult DeleteHistoryItem(string nzoId, bool delFiles) } [HttpPost("api")] - public async Task AddFile([FromQuery] string mode, [FromQuery] string cat) + public async Task AddFileEndpoint([FromQuery] string mode, [FromQuery] string cat, [FromQuery] string? name) { - if (mode != "addfile") + if (!_config.Categories.Contains(cat)) + { + return BadRequest(new { error = "Invalid category" }); + } + + if (mode == "addfile") + { + return await AddFileByNzb(cat); + } + else if(mode == "addurl" && !string.IsNullOrWhiteSpace(name)) + { + return await AddFileByUrl(cat, name); + } + else { return BadRequest(new { error = "Invalid mode" }); } + } + + private async Task AddFileByUrl(string cat, string name) + { + var uri = new Uri(name); + var query = HttpUtility.ParseQueryString(uri.Query); + string? encodedVideoUrl = query["encodedVideoUrl"]; + if (string.IsNullOrEmpty(encodedVideoUrl)) + { + return BadRequest(new { error = "Missing encodedVideoUrl parameter" }); + } + + string encodedTitle = query["encodedTitle"]?.Trim() ?? string.Empty; + string encodedSubtitleUrl = query["encodedSubtitleUrl"] ?? string.Empty; + var base64EncodedBytesVideoUrl = Convert.FromBase64String(encodedVideoUrl); + string decodedVideoUrl = Encoding.UTF8.GetString(base64EncodedBytesVideoUrl); + var base64EncodedBytesTitle = Convert.FromBase64String(encodedTitle); + string decodedTitle = Encoding.UTF8.GetString(base64EncodedBytesTitle); + string decodedSubtitleUrl; + if (string.IsNullOrEmpty(encodedSubtitleUrl)) + { + decodedSubtitleUrl = string.Empty; + } + else + { + var base64EncodedBytesSubtitleUrl = Convert.FromBase64String(encodedSubtitleUrl); + decodedSubtitleUrl = Encoding.UTF8.GetString(base64EncodedBytesSubtitleUrl); + } + + if (!decodedVideoUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !decodedVideoUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { error = "Invalid video URL scheme" }); + } + + if (!string.IsNullOrEmpty(decodedSubtitleUrl) && + !decodedSubtitleUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !decodedSubtitleUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { error = "Invalid subtitle URL scheme" }); + } + + var queueItem = _downloadService.AddToQueue(decodedVideoUrl, decodedSubtitleUrl, decodedTitle, cat); + + return Ok(new + { + status = true, + nzo_ids = new[] { queueItem.Id } + }); + } + + private async Task AddFileByNzb(string cat) + { // Read the fake NZB file from the request body using var reader = new StreamReader(Request.Body); var requestBody = await reader.ReadToEndAsync(); string[] lines = requestBody.Split(Environment.NewLine); - var filenameMatch = FileNameRegex().Match(lines[6]); - var videoUrlMatch = UrlRegex().Match(lines[7]); - var subtitleUrlMatch = UrlRegex().Match(lines[8]); + var filenameMatch = CommentRegex().Match(lines[6]); + var videoUrlMatch = CommentRegex().Match(lines[7]); + var subtitleUrlMatch = CommentRegex().Match(lines[8]); if (!filenameMatch.Success || !videoUrlMatch.Success) { return BadRequest(new { error = "Invalid NZB format" }); } - var fileName = $"{filenameMatch.Groups[1].Value.Trim()}"; - var videoDownloadUrl = videoUrlMatch.Groups[1].Value; - var subtitleDownloadUrl = subtitleUrlMatch.Groups[1].Value; + var fileName = Encoding.UTF8.GetString(Convert.FromBase64String(filenameMatch.Groups[1].Value.Trim())); + var videoDownloadUrl = Encoding.UTF8.GetString(Convert.FromBase64String(videoUrlMatch.Groups[1].Value.Trim())); + var subtitleDownloadUrl = subtitleUrlMatch.Success + ? Encoding.UTF8.GetString(Convert.FromBase64String(subtitleUrlMatch.Groups[1].Value.Trim())) + : string.Empty; // Add to the download queue using DownloadService and capture the created queue item var queueItem = _downloadService.AddToQueue(videoDownloadUrl, subtitleDownloadUrl, fileName, cat); @@ -74,7 +145,7 @@ public async Task AddFile([FromQuery] string mode, [FromQuery] st return Ok(new { status = true, - nzo_ids = new[] { queueItem.Id} + nzo_ids = new[] { queueItem.Id } }); } @@ -82,7 +153,7 @@ private QueueWrapper GetQueue() { var queueItems = _downloadService.GetQueue(); - var queue = new SabnzbdQueue + var queue = new Queue { Items = queueItems.ToList() }; @@ -95,11 +166,11 @@ private QueueWrapper GetQueue() private HistoryWrapper GetHistory() { - var historytems = _downloadService.GetHistory(); + var historyItems = _downloadService.GetHistory(); - var history = new SabnzbdHistory + var history = new History { - Items = historytems.ToList() + Items = historyItems.ToList() }; return new HistoryWrapper @@ -115,73 +186,46 @@ private HistoryWrapper GetHistory() }}"; - private string ConfigResponse => @$"{{ - ""config"": {{ - ""misc"": {{ - ""complete_dir"": ""{_config.CompletePath.Replace('\\', '/')}"", - ""enable_tv_sorting"": false, - ""enable_movie_sorting"": false, - ""pre_check"": false, - ""history_retention"": """", - ""history_retention_option"": ""all"" - }}, - ""categories"": [ - {{ - ""name"": ""mediathek"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": ""{_config.CompletePath.Replace('\\', '/')}"", - ""priority"": -100 - }}, - {{ - ""name"": ""sonarr"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": """", - ""priority"": -100 - }}, - {{ - ""name"": ""tv"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": """", - ""priority"": -100 - }}, - {{ - ""name"": ""radarr"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": """", - ""priority"": -100 - }}, - {{ - ""name"": ""movies"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": """", - ""priority"": -100 - }}, - {{ - ""name"": ""sonarr_blackhole"", - ""pp"": """", - ""script"": ""Default"", - ""dir"": """", - ""priority"": -100 - }}, - {{ - ""name"": ""radarr_blackhole"", + private string ConfigResponse + { + get + { + string completePathFixed = _config.CompletePath.Replace('\\', '/'); + + var categoryEntries = new List(); + foreach (var category in _config.Categories) + { + string dirPath = completePathFixed + "/" + category; + categoryEntries.Add($@"{{ + ""name"": ""{category}"", ""pp"": """", ""script"": ""Default"", - ""dir"": """", + ""dir"": ""{dirPath}"", ""priority"": -100 + }}"); + } + + string categoriesJson = string.Join(",\n", categoryEntries); + + return $@"{{ + ""config"": {{ + ""misc"": {{ + ""complete_dir"": ""{completePathFixed}"", + ""enable_tv_sorting"": false, + ""enable_movie_sorting"": false, + ""pre_check"": false, + ""history_retention"": """", + ""history_retention_option"": ""all"" + }}, + ""categories"": [ + {categoriesJson} + ], + ""sorters"": [] }} - ], - ""sorters"": [] - }} - }}"; + }}"; + } + } [GeneratedRegex(@"")] - private static partial Regex FileNameRegex(); - [GeneratedRegex(@"")] - private static partial Regex UrlRegex(); + private static partial Regex CommentRegex(); } diff --git a/MediathekArr/Controllers/TProxyController.cs b/MediathekArr/Controllers/TProxyController.cs index 059282c..86df72c 100644 --- a/MediathekArr/Controllers/TProxyController.cs +++ b/MediathekArr/Controllers/TProxyController.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc; -namespace MediathekArrDownloader.Controllers; +namespace MediathekArr.Controllers; [ApiController] [Route("api")] diff --git a/MediathekArr/Controllers/WizardController.cs b/MediathekArr/Controllers/WizardController.cs index 975c590..6e41ebd 100644 --- a/MediathekArr/Controllers/WizardController.cs +++ b/MediathekArr/Controllers/WizardController.cs @@ -2,361 +2,192 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace MediathekArrDownloader.Controllers; +namespace MediathekArr.Controllers; [ApiController] [Route("[controller]")] -public class WizardController(IHttpClientFactory httpClientFactory) : ControllerBase +public class WizardController : ApiProxyControllerBase { + public WizardController(IHttpClientFactory httpClientFactory) + : base(httpClientFactory) + { + } + [HttpGet("downloadclients")] public async Task GetDownloadClients([FromQuery] string apiKey, [FromQuery] string sonarrHost) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(sonarrHost)) - { - return BadRequest("Sonarr host and API key are required."); - } - - var cleanedHostName = sonarrHost.TrimEnd('/'); - - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - var response = await httpClient.GetAsync($"{cleanedHostName}/api/v3/downloadclient"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - var clients = JsonConvert.DeserializeObject>(content); - - var filteredClients = clients - .Where(client => - (string)client["implementation"] == "Sabnzbd" && - client["fields"].Any(field => (string)field["name"] == "urlBase" && (string)field["value"] == "download")) - .Select(client => new - { - Id = (int)client["id"], - Name = (string)client["name"], - Priority = (string)client["priority"], - Host = client["fields"].FirstOrDefault(field => (string)field["name"] == "host")?["value"]?.ToString(), - Port = client["fields"].FirstOrDefault(field => (string)field["name"] == "port")?["value"]?.ToString(), - Category = client["fields"].FirstOrDefault(field => (string)field["name"] == "tvCategory")?["value"]?.ToString(), - Enable = (bool)client["enable"] - }) - .ToList(); + return await ExecuteApiRequest( + apiKey, + sonarrHost, + "api/v3/downloadclient", + HttpMethod.Get, + "fetching download clients", + responseContent => + { + var clients = JsonConvert.DeserializeObject>(responseContent); - return Ok(filteredClients); - } - catch (Exception ex) - { - return StatusCode(500, $"Error fetching clients: {ex.Message}"); - } + var filteredClients = clients + .Where(client => + (string)client["implementation"] == "Sabnzbd" && + client["fields"].Any(field => (string)field["name"] == "urlBase" && (string)field["value"] == "download")) + .Select(client => new + { + Id = (int)client["id"], + Name = (string)client["name"], + Priority = (string)client["priority"], + Host = client["fields"].FirstOrDefault(field => (string)field["name"] == "host")?["value"]?.ToString(), + Port = client["fields"].FirstOrDefault(field => (string)field["name"] == "port")?["value"]?.ToString(), + Category = client["fields"].FirstOrDefault(field => (string)field["name"] == "tvCategory")?["value"]?.ToString(), + Enable = (bool)client["enable"] + }) + .ToList(); + + return Ok(filteredClients); + }, + "Sonarr host"); } + [HttpPost("downloadclient")] public async Task AddDownloadClient([FromQuery] string apiKey, [FromQuery] string sonarrHost) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(sonarrHost)) - { - return BadRequest("Sonarr host and API key are required."); - } - - var cleanedHostName = sonarrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{cleanedHostName}/api/v3/downloadclient", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error adding client: {ex.Message}"); - } + return await ExecuteApiRequest( + apiKey, + sonarrHost, + "api/v3/downloadclient", + HttpMethod.Post, + "adding download client", + hostName: "Sonarr host"); } [HttpPut("downloadclient/{id}")] public async Task UpdateDownloadClient([FromQuery] string apiKey, [FromQuery] string sonarrHost, [FromRoute] int id) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(sonarrHost)) - { - return BadRequest("Sonarr host and API key are required."); - } - - var cleanedHostName = sonarrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PutAsync($"{cleanedHostName}/api/v3/downloadclient/{id}", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error updating client: {ex.Message}"); - } + return await ExecuteApiRequest( + apiKey, + sonarrHost, + $"api/v3/downloadclient/{id}", + HttpMethod.Put, + "updating download client", + hostName: "Sonarr host"); } - [HttpGet("indexers")] - public async Task GetIndexers([FromQuery] string apiKey, [FromQuery] string arrHost, [FromQuery] bool prowlarr = false) + public async Task GetIndexers([FromQuery] string apiKey, [FromQuery] string arrHost, [FromQuery] int portFilter = 5007, [FromQuery] bool prowlarr = false) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(arrHost)) - { - return BadRequest("Host and API key are required."); - } - - var cleanedHostName = arrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - // Adjust API version for Prowlarr - var apiVersion = prowlarr ? "v1" : "v3"; - var response = await httpClient.GetAsync($"{cleanedHostName}/api/{apiVersion}/indexer"); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - var indexers = JsonConvert.DeserializeObject>(content); + string apiVersion = prowlarr ? "v1" : "v3"; + + return await ExecuteApiRequest( + apiKey, + arrHost, + $"api/{apiVersion}/indexer", + HttpMethod.Get, + "fetching indexers", + responseContent => + { + var indexers = JsonConvert.DeserializeObject>(responseContent); - var filteredIndexers = indexers - .Where(indexer => - { - if (prowlarr) + var filteredIndexers = indexers + .Where(indexer => { - // Check indexerUrls for Prowlarr - var indexerUrls = indexer["indexerUrls"]?.ToObject>(); - return indexerUrls != null && indexerUrls.Any(url => url.Contains(":5007")) && - (string)indexer["protocol"] == "usenet"; - } - else + if (prowlarr) + { + var indexerUrls = indexer["indexerUrls"]?.ToObject>(); + return indexerUrls != null && indexerUrls.Any(url => url.Contains($":{portFilter}")) && + (string)indexer["protocol"] == "usenet"; + } + else + { + return (string)indexer["protocol"] == "usenet" && + indexer["fields"].Any(field => + (string)field["name"] == "baseUrl" && ((string)field["value"]).Contains($":{portFilter}")); + } + }) + .Select(indexer => { - // Default (Sonarr/Radarr) logic - return (string)indexer["protocol"] == "usenet" && - indexer["fields"].Any(field => - (string)field["name"] == "baseUrl" && ((string)field["value"]).Contains(":5007")); - } - }) - .Select(indexer => - { - // Adapt fields for Prowlarr - var baseUrl = prowlarr ? - indexer["indexerUrls"]?.ToObject>().FirstOrDefault(url => url.Contains(":5007")) : - indexer["fields"]?.FirstOrDefault(field => (string)field["name"] == "baseUrl")?["value"]?.ToString(); - - return new - { - Id = (int)indexer["id"], - Name = (string)indexer["name"], - Priority = (int)indexer["priority"], - DownloadClientId = (int)indexer["downloadClientId"], - BaseUrl = baseUrl, - ApiPath = indexer["fields"]?.FirstOrDefault(field => (string)field["name"] == "apiPath")?["value"]?.ToString(), - EnableRss = prowlarr ? (bool)indexer["supportsRss"] : (bool)indexer["enableRss"], - EnableAutomaticSearch = prowlarr ? (bool)indexer["supportsSearch"] : (bool)indexer["enableAutomaticSearch"], - EnableInteractiveSearch = prowlarr ? true : (bool)indexer["enableInteractiveSearch"] - }; - }) - .ToList(); - - return Ok(filteredIndexers); - } - catch (Exception ex) - { - return StatusCode(500, $"Error fetching indexers: {ex.Message}"); - } + var baseUrl = prowlarr ? + indexer["indexerUrls"]?.ToObject>().FirstOrDefault(url => url.Contains($":{portFilter}")) : + indexer["fields"]?.FirstOrDefault(field => (string)field["name"] == "baseUrl")?["value"]?.ToString(); + + return new + { + Id = (int)indexer["id"], + Name = (string)indexer["name"], + Priority = (int)indexer["priority"], + DownloadClientId = (int)indexer["downloadClientId"], + BaseUrl = baseUrl, + ApiPath = indexer["fields"]?.FirstOrDefault(field => (string)field["name"] == "apiPath")?["value"]?.ToString(), + EnableRss = prowlarr ? (bool)indexer["supportsRss"] : (bool)indexer["enableRss"], + EnableAutomaticSearch = prowlarr ? (bool)indexer["supportsSearch"] : (bool)indexer["enableAutomaticSearch"], + EnableInteractiveSearch = prowlarr ? true : (bool)indexer["enableInteractiveSearch"] + }; + }) + .ToList(); + + return Ok(filteredIndexers); + }, + "Host"); } + [HttpPost("indexer")] public async Task AddIndexer([FromQuery] string apiKey, [FromQuery] string arrHost, [FromQuery] bool prowlarr = false) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(arrHost)) - { - return BadRequest("Host and API key are required."); - } - - var cleanedHostName = arrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var apiVersion = prowlarr ? "v1" : "v3"; - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{cleanedHostName}/api/{apiVersion}/indexer", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error adding indexer: {ex.Message}"); - } + string apiVersion = prowlarr ? "v1" : "v3"; + + return await ExecuteApiRequest( + apiKey, + arrHost, + $"api/{apiVersion}/indexer", + HttpMethod.Post, + "adding indexer"); } [HttpPut("indexer/{id}")] public async Task UpdateIndexer([FromQuery] string apiKey, [FromQuery] string arrHost, [FromRoute] int id, [FromQuery] bool prowlarr = false) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(arrHost)) - { - return BadRequest("Host and API key are required."); - } - - var cleanedHostName = arrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var apiVersion = prowlarr ? "v1" : "v3"; - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PutAsync($"{cleanedHostName}/api/{apiVersion}/indexer/{id}", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error updating indexer: {ex.Message}"); - } + string apiVersion = prowlarr ? "v1" : "v3"; + + return await ExecuteApiRequest( + apiKey, + arrHost, + $"api/{apiVersion}/indexer/{id}", + HttpMethod.Put, + "updating indexer"); } [HttpPost("indexer/test")] public async Task TestIndexerSettings([FromQuery] string apiKey, [FromQuery] string arrHost, [FromQuery] bool prowlarr = false) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(arrHost)) - { - return BadRequest("Host and API key are required."); - } - - var cleanedHostName = arrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var apiVersion = prowlarr ? "v1" : "v3"; - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{cleanedHostName}/api/{apiVersion}/indexer/test", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error testing indexer: {ex.Message}"); - } + string apiVersion = prowlarr ? "v1" : "v3"; + + return await ExecuteApiRequest( + apiKey, + arrHost, + $"api/{apiVersion}/indexer/test", + HttpMethod.Post, + "testing indexer"); } [HttpPost("downloadclient/test")] public async Task TestDownloadClientSettings([FromQuery] string apiKey, [FromQuery] string sonarrHost) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(sonarrHost)) - { - return BadRequest("Sonarr host and API key are required."); - } - - var cleanedHostName = sonarrHost.TrimEnd('/'); - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - using var reader = new StreamReader(Request.Body); - var rawBody = await reader.ReadToEndAsync(); - - var content = new StringContent(rawBody, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync($"{cleanedHostName}/api/v3/downloadclient/test", content); - var responseContent = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, responseContent); - } - - return Content(responseContent, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error testing download client settings: {ex.Message}"); - } + return await ExecuteApiRequest( + apiKey, + sonarrHost, + "api/v3/downloadclient/test", + HttpMethod.Post, + "testing download client", + hostName: "Sonarr host"); } [HttpGet("appprofiles")] public async Task GetAppProfiles([FromQuery] string apiKey, [FromQuery] string prowlarrHost) { - if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(prowlarrHost)) - { - return BadRequest("Prowlarr host, and API key are required."); - } - - var cleanedHostName = $"{prowlarrHost.TrimEnd('/')}"; - var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); - - try - { - var response = await httpClient.GetAsync($"{cleanedHostName}/api/v1/appprofile"); - var content = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, content); - } - - return Content(content, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, $"Error fetching app profiles: {ex.Message}"); - } + return await ExecuteApiRequest( + apiKey, + prowlarrHost, + "api/v1/appprofile", + HttpMethod.Get, + "fetching app profiles", + hostName: "Prowlarr host"); } - -} +} \ No newline at end of file diff --git a/MediathekArr/MediathekArrDownloader.csproj b/MediathekArr/MediathekArrDownloader.csproj index 482c6e6..89e9543 100644 --- a/MediathekArr/MediathekArrDownloader.csproj +++ b/MediathekArr/MediathekArrDownloader.csproj @@ -8,6 +8,7 @@ True mcr.microsoft.com/dotnet/aspnet:9.0 c655a1a3-0f6d-45f1-9615-dc576c4c0b84 + MediathekArr @@ -40,7 +41,7 @@ PreserveNewest - PreserveNewest + Always PreserveNewest diff --git a/MediathekArr/Models/Config.cs b/MediathekArr/Models/Config.cs index 9a4fea4..42310ff 100644 --- a/MediathekArr/Models/Config.cs +++ b/MediathekArr/Models/Config.cs @@ -1,7 +1,9 @@ -namespace MediathekArrDownloader.Models; +namespace MediathekArr.Models; public record Config { public string IncompletePath { get; set; } = string.Empty; public string CompletePath { get; set; } = string.Empty; + public string[] Categories { get; set; } = ["tv", "movies", "mediathek"]; + public int MaxParallelDownloads { get; set; } = 2; } diff --git a/MediathekArr/Models/SABnzbd/SabnzbdDownloadStatus.cs b/MediathekArr/Models/SABnzbd/SabnzbdDownloadStatus.cs deleted file mode 100644 index f872607..0000000 --- a/MediathekArr/Models/SABnzbd/SabnzbdDownloadStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MediathekArrDownloader.Models.SABnzbd; - -public enum SabnzbdDownloadStatus -{ - Completed, - Failed, - Downloading, - Queued, - Extracting -} diff --git a/MediathekArr/Models/SABnzbd/SabnzbdHistory.cs b/MediathekArr/Models/SABnzbd/SabnzbdHistory.cs deleted file mode 100644 index 709c72f..0000000 --- a/MediathekArr/Models/SABnzbd/SabnzbdHistory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MediathekArrDownloader.Models.SABnzbd; - -public class SabnzbdHistory -{ - [JsonPropertyName("slots")] - public List Items { get; set; } -} diff --git a/MediathekArr/Models/SABnzbd/SabnzbdQueue.cs b/MediathekArr/Models/SABnzbd/SabnzbdQueue.cs deleted file mode 100644 index 4cad3cf..0000000 --- a/MediathekArr/Models/SABnzbd/SabnzbdQueue.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MediathekArrDownloader.Models.SABnzbd; - -public class SabnzbdQueue -{ - [JsonPropertyName("paused")] - public bool Paused => false; - - [JsonPropertyName("slots")] - public List Items { get; set; } -} \ No newline at end of file diff --git a/MediathekArr/Program.cs b/MediathekArr/Program.cs index 54c37c5..f878c00 100644 --- a/MediathekArr/Program.cs +++ b/MediathekArr/Program.cs @@ -1,7 +1,7 @@ -using MediathekArrLib.Utilities; +using MediathekArr.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; -using MediathekArrDownloader.Services; -using MediathekArrDownloader.Models; +using MediathekArr.Services; +using MediathekArr.Models; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -27,9 +27,11 @@ builder.Services.AddMemoryCache(); builder.Services.AddHttpClient("MediathekClient", client => { - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0"); client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip"); - client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + client.DefaultRequestHeaders.Accept.ParseAdd("text/plain"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { @@ -100,6 +102,7 @@ static Config ConfigureAppConfig(IConfiguration configuration, ILogger logger) // Override from environment variables var incompletePath = Environment.GetEnvironmentVariable("DOWNLOAD_INCOMPLETE_PATH"); var completePath = Environment.GetEnvironmentVariable("DOWNLOAD_COMPLETE_PATH"); + var categoriesRaw = Environment.GetEnvironmentVariable("CATEGORIES"); if (!string.IsNullOrEmpty(incompletePath)) { @@ -121,10 +124,23 @@ static Config ConfigureAppConfig(IConfiguration configuration, ILogger logger) config.CompletePath = GetDefaultPath(AppContext.BaseDirectory, "complete", logger); } - if (!existingConfig && (string.IsNullOrEmpty(incompletePath) || string.IsNullOrEmpty(completePath))) + if (!string.IsNullOrEmpty(categoriesRaw)) + { + logger.LogInformation("Overriding categories from environment variable: {categoriesRaw}", categoriesRaw); + config.Categories = categoriesRaw.Split(','); + } + + var maxParallelDownloadsRaw = Environment.GetEnvironmentVariable("MAX_PARALLEL_DOWNLOADS"); + if (!string.IsNullOrEmpty(maxParallelDownloadsRaw) && int.TryParse(maxParallelDownloadsRaw, out var maxParallelDownloads) && maxParallelDownloads >= 1) + { + logger.LogInformation("Overriding max parallel downloads from environment variable: {MaxParallelDownloads}", maxParallelDownloads); + config.MaxParallelDownloads = maxParallelDownloads; + } + + if (!existingConfig && (string.IsNullOrEmpty(incompletePath) || string.IsNullOrEmpty(completePath) || string.IsNullOrEmpty(categoriesRaw))) { logger.LogWarning("Attention!"); - logger.LogWarning("Configuration file was not found. Please visit http://localhost:5007/ to setup MediathekArr."); + logger.LogWarning("Configuration file was not found or is incomplete. Please visit http://localhost:5007/ to setup MediathekArr."); logger.LogWarning("Alternatively use environment variables (see https://github.com/PCJones/MediathekArr)."); logger.LogWarning("MediathekArr will use default values:"); } diff --git a/MediathekArr/Services/DownloadService.M3u8.cs b/MediathekArr/Services/DownloadService.M3u8.cs new file mode 100644 index 0000000..db08077 --- /dev/null +++ b/MediathekArr/Services/DownloadService.M3u8.cs @@ -0,0 +1,56 @@ +using MediathekArr.Models; +using MediathekArr.Models.SABnzbd; +using MediathekArr.Utilities; + +namespace MediathekArr.Services; + +public partial class DownloadService +{ + private async Task DownloadM3u8FileAsync(string url, QueueItem queueItem) + { + try + { + var tsPath = Path.Combine(_config.IncompletePath, queueItem.Title + ".ts"); + + _logger.LogInformation("Starting M3U8 download for {Title} to path: {Path}", queueItem.Title, tsPath); + queueItem.Status = DownloadStatus.Downloading; + + var (success, exitCode, errorOutput) = await FfmpegUtils.StartFfmpegDownloadAsync( + _ffmpegPath, url, tsPath, queueItem.Title, _logger); + + if (success && File.Exists(tsPath)) + { + var fileInfo = new FileInfo(tsPath); + var sizeInMB = fileInfo.Length / (1024.0 * 1024.0); + queueItem.Size = $"{sizeInMB:F2}"; + + queueItem.Timeleft = "00:00:00"; + _logger.LogInformation("M3U8 download completed for {Title}. File saved to {Path}", queueItem.Title, tsPath); + } + else + { + queueItem.Status = DownloadStatus.Failed; + _logger.LogError("FFmpeg M3U8 download failed for {Title}. Exit code: {ExitCode}. Error: {ErrorOutput}", + queueItem.Title, exitCode, errorOutput); + } + } + catch (Exception ex) + { + queueItem.Status = DownloadStatus.Failed; + _logger.LogError(ex, "Download failed for {Title}. Adding to download history as failed.", queueItem.Title); + + _downloadHistory.Add(new HistoryItem + { + Title = queueItem.Title, + NzbName = queueItem.Title, + Category = queueItem.Category, + Size = 0, + DownloadTime = 0, + Storage = null, + Status = DownloadStatus.Failed, + Completed = DateTimeOffset.Now.ToUnixTimeSeconds(), + Id = queueItem.Id + }); + } + } +} diff --git a/MediathekArr/Services/DownloadService.cs b/MediathekArr/Services/DownloadService.cs index 85d2652..f545f33 100644 --- a/MediathekArr/Services/DownloadService.cs +++ b/MediathekArr/Services/DownloadService.cs @@ -1,7 +1,6 @@ -using MediathekArrDownloader.Models; -using MediathekArrDownloader.Models.SABnzbd; -using MediathekArrDownloader.Utilities; -using MediathekArrLib.Utilities; +using MediathekArr.Models; +using MediathekArr.Models.SABnzbd; +using MediathekArr.Utilities; using System.Collections.Concurrent; using System.Diagnostics; using System.IO.Compression; @@ -9,34 +8,69 @@ using System.Runtime.InteropServices; using System.Text; -namespace MediathekArrDownloader.Services; +namespace MediathekArr.Services; public partial class DownloadService { private readonly ILogger _logger; private readonly Config _config; - private readonly ConcurrentQueue _downloadQueue = new(); - private readonly List _downloadHistory = []; - private static readonly HttpClient _httpClient = new(); - private static readonly SemaphoreSlim _semaphore = new(2); // Limit concurrent downloads to 2 + private readonly ConcurrentQueue _downloadQueue = new(); + private readonly List _downloadHistory = []; + private readonly HttpClient _httpClient; + private readonly SemaphoreSlim _semaphore; private readonly string _mkvMergePath; + private readonly string _ffmpegPath; private readonly bool _isWindows; - public DownloadService(ILogger logger, Config config) + public DownloadService(ILogger logger, Config config, IHttpClientFactory httpClientFactory) { _logger = logger; _config = config; + _httpClient = httpClientFactory.CreateClient("MediathekClient"); _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var maxParallelDownloads = Math.Clamp(config.MaxParallelDownloads, 1, 10); + _semaphore = new SemaphoreSlim(maxParallelDownloads); + _logger.LogInformation("Download service initialized with {MaxParallelDownloads} parallel downloads", maxParallelDownloads); + // Set complete_dir based on the application's startup path var startupPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; _mkvMergePath = _isWindows ? Path.Combine(startupPath, "mkvtoolnix", "mkvmerge.exe") : "mkvmerge"; + _ffmpegPath = Environment.GetEnvironmentVariable("FFMPEG_PATH") ?? "ffmpeg"; InitializeIncompleteDirectory(); + InitializeCompleteDirectories(); CleanupAbandondedFilesInCompleteDirectory(); - // Ensure Mkvmerge is available - Task.Run(() => MkvMergeUtils.EnsureMkvMergeExistsAsync(_mkvMergePath, _logger, _httpClient)).Wait(); + // Ensure Mkvmerge and Ffmpeg are available + Task.WhenAll( + Task.Run(() => MkvMergeUtils.EnsureMkvMergeExistsAsync(_mkvMergePath, _logger, _httpClient)), + Task.Run(() => FfmpegUtils.EnsureFfmpegExistsAsync(_ffmpegPath, _logger)) + ).Wait(); + } + + public void InitializeCompleteDirectories() + { + // Ensure complete directory exists + if (!Directory.Exists(_config.CompletePath)) + { + _logger.LogInformation("Complete directory doesn't exist yet, creating directory: {completeDir}", _config.CompletePath); + Directory.CreateDirectory(_config.IncompletePath); + } + else + { + // Create category directories if they don't exist + foreach (var category in _config.Categories) + { + var categoryDir = Path.Combine(_config.CompletePath, category); + if (!Directory.Exists(categoryDir)) + { + _logger.LogInformation("Creating category directory: {categoryDir}", categoryDir); + Directory.CreateDirectory(categoryDir); + } + } + } + } private void CleanupAbandondedFilesInCompleteDirectory() @@ -48,21 +82,29 @@ private void CleanupAbandondedFilesInCompleteDirectory() return; } - var files = Directory.GetFiles(_config.CompletePath); - foreach (var file in files) + foreach (var category in _config.Categories) { - var fileInfo = new FileInfo(file); - if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(48)) + var categoryDir = Path.Combine(_config.CompletePath, category); + if (!Directory.Exists(categoryDir)) { - _logger.LogInformation("Deleting abandoned file in complete directory: {file}", file); - try - { - File.Delete(file); - } - catch (Exception ex) + continue; + } + var files = Directory.GetFiles(categoryDir); + foreach (var file in files) + { + var fileInfo = new FileInfo(file); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(48)) { - _logger.LogError(ex, "Error deleting file: {file}", file); - continue; + _logger.LogInformation("Deleting abandoned file in complete directory: {file}", file); + try + { + File.Delete(file); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file: {file}", file); + continue; + } } } } @@ -73,7 +115,7 @@ private void InitializeIncompleteDirectory() // Ensure incomplete directory exists if (!Directory.Exists(_config.IncompletePath)) { - _logger.LogInformation("Ensuring incomplete doesn't exist, creating directory: {incompleteDir}", _config.IncompletePath); + _logger.LogInformation("Incomplete directory doesn't exist yet, creating directory: {incompleteDir}", _config.IncompletePath); Directory.CreateDirectory(_config.IncompletePath); } else @@ -95,18 +137,24 @@ private void InitializeIncompleteDirectory() } } - public IEnumerable GetQueue() => [.. _downloadQueue]; - public IEnumerable GetHistory() => _downloadHistory; + public IEnumerable GetQueue() => [.. _downloadQueue]; + public IEnumerable GetHistory() => _downloadHistory; - public SabnzbdQueueItem AddToQueue(string videoUrl, string subtitleUrl, string fileName, string category) + public QueueItem AddToQueue(string videoUrl, string subtitleUrl, string fileName, string category) { - var queueItem = new SabnzbdQueueItem + var sanitizedFileName = Path.GetFileName(fileName); + if (string.IsNullOrWhiteSpace(sanitizedFileName)) + { + throw new ArgumentException("Invalid file name.", nameof(fileName)); + } + + var queueItem = new QueueItem { - Status = SabnzbdDownloadStatus.Queued, + Status = DownloadStatus.Queued, Index = _downloadQueue.Count, Timeleft = "10:00:00", Size = "0", - Title = fileName, + Title = sanitizedFileName, Category = category, Sizeleft = "0", Percentage = "0" @@ -119,7 +167,7 @@ public SabnzbdQueueItem AddToQueue(string videoUrl, string subtitleUrl, string f return queueItem; } - private async Task StartDownloadAsync(string videoUrl, string subtitleUrl, SabnzbdQueueItem queueItem) + private async Task StartDownloadAsync(string videoUrl, string subtitleUrl, QueueItem queueItem) { await _semaphore.WaitAsync(); @@ -129,19 +177,21 @@ private async Task StartDownloadAsync(string videoUrl, string subtitleUrl, Sabnz { _logger.LogInformation("Starting download for {Title} from URL: {URL}", queueItem.Title, videoUrl); - var downloadVideoTask = DownloadFileAsync(videoUrl, queueItem); + var isM3u8 = videoUrl.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase); + var inputExtension = isM3u8 ? ".ts" : ".mp4"; + + var downloadVideoTask = isM3u8 + ? DownloadM3u8FileAsync(videoUrl, queueItem) + : DownloadFileAsync(videoUrl, queueItem); var downloadSubtitlesTask = DownloadSubtitlesAsync(subtitleUrl, queueItem); + await Task.WhenAll(downloadVideoTask, downloadSubtitlesTask); var subtitlesAvailable = downloadSubtitlesTask.Result; - if (queueItem.Status != SabnzbdDownloadStatus.Failed) + if (queueItem.Status != DownloadStatus.Failed) { _logger.LogInformation("Download complete for {Title}. Starting conversion to MKV.", queueItem.Title); - await ConvertMp4ToMkvAsync(queueItem, stopwatch, subtitlesAvailable); - } - else - { - _logger.LogWarning("Download failed for {Title}, skipping conversion.", queueItem.Title); + await ConvertToMkvAsync(queueItem, stopwatch, subtitlesAvailable, inputExtension); } } catch (Exception ex) @@ -156,7 +206,7 @@ private async Task StartDownloadAsync(string videoUrl, string subtitleUrl, Sabnz } } - private async Task DownloadSubtitlesAsync(string subtitleUrl, SabnzbdQueueItem queueItem) + private async Task DownloadSubtitlesAsync(string subtitleUrl, QueueItem queueItem) { string? xmlFilePath = null; try @@ -172,7 +222,7 @@ private async Task DownloadSubtitlesAsync(string subtitleUrl, SabnzbdQueue // Download XML subtitle file _logger.LogInformation("Starting download of subtitle XML for {Title} to path: {Path}", queueItem.Title, xmlFilePath); - var response = await _httpClient.GetAsync(subtitleUrl, HttpCompletionOption.ResponseHeadersRead); + var response = await _httpClient.GetAsync(subtitleUrl); response.EnsureSuccessStatusCode(); await using (var contentStream = await response.Content.ReadAsStreamAsync()) @@ -185,7 +235,7 @@ private async Task DownloadSubtitlesAsync(string subtitleUrl, SabnzbdQueue // Convert XML to SRT var xmlContent = await File.ReadAllTextAsync(xmlFilePath, Encoding.UTF8); - var srtContent = SubtitleConverter.ConvertXmlToSrt(xmlContent); + var srtContent = SubtitleConverter.ConvertToSrt(xmlContent); if (string.IsNullOrEmpty(srtContent)) { @@ -221,7 +271,7 @@ private async Task DownloadSubtitlesAsync(string subtitleUrl, SabnzbdQueue return false; } - private async Task DownloadFileAsync(string url, SabnzbdQueueItem queueItem) + private async Task DownloadFileAsync(string url, QueueItem queueItem) { try { @@ -234,7 +284,7 @@ private async Task DownloadFileAsync(string url, SabnzbdQueueItem queueItem) } _logger.LogInformation("Starting download of file to path: {Path} with extension {Extension}", filePath, fileExtension); - queueItem.Status = SabnzbdDownloadStatus.Downloading; + queueItem.Status = DownloadStatus.Downloading; var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); var totalSize = response.Content.Headers.ContentLength ?? 0; @@ -267,10 +317,10 @@ private async Task DownloadFileAsync(string url, SabnzbdQueueItem queueItem) } catch (Exception ex) { - queueItem.Status = SabnzbdDownloadStatus.Failed; + queueItem.Status = DownloadStatus.Failed; _logger.LogError(ex, "Download failed for {Title}. Adding to download history as failed.", queueItem.Title); - _downloadHistory.Add(new SabnzbdHistoryItem + _downloadHistory.Add(new HistoryItem { Title = queueItem.Title, NzbName = queueItem.Title, @@ -278,7 +328,8 @@ private async Task DownloadFileAsync(string url, SabnzbdQueueItem queueItem) Size = 0, DownloadTime = 0, Storage = null, - Status = SabnzbdDownloadStatus.Failed, + Status = DownloadStatus.Failed, + Completed = DateTimeOffset.Now.ToUnixTimeSeconds(), Id = queueItem.Id }); } @@ -310,20 +361,20 @@ public bool DeleteHistoryItem(string nzoId, bool delFiles) return true; } - private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch stopwatch, bool subtitlesAvailable) + private async Task ConvertToMkvAsync(QueueItem queueItem, Stopwatch stopwatch, bool subtitlesAvailable, string inputExtension) { - var completeCategoryDir = _config.CompletePath; - _logger.LogInformation("Ensuring directory exists for category {Category} at path: {Path}", queueItem.Category, completeCategoryDir); + var completeCategoryDir = Path.Combine(_config.CompletePath, queueItem.Category); + _logger.LogInformation("Ensuring complete directory exists at path: {Path}", completeCategoryDir); Directory.CreateDirectory(completeCategoryDir); - var mp4Path = Path.Combine(_config.IncompletePath, queueItem.Title + ".mp4"); + var videoPath = Path.Combine(_config.IncompletePath, queueItem.Title + inputExtension); var subtitlePath = Path.Combine(_config.IncompletePath, queueItem.Title + ".srt"); var mkvPath = Path.Combine(completeCategoryDir, queueItem.Title + ".mkv"); - if (!File.Exists(mp4Path)) + if (!File.Exists(videoPath)) { - queueItem.Status = SabnzbdDownloadStatus.Failed; - _logger.LogWarning("MP4 file not found for conversion. Path: {Mp4Path}. Marking as failed.", mp4Path); + queueItem.Status = DownloadStatus.Failed; + _logger.LogWarning("Video file not found for conversion. Path: {VideoPath}. Marking as failed.", videoPath); return; } @@ -334,22 +385,22 @@ private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch st } // Temporarily remove umlauts as mkvmerge can't handle them on linux - var mp4PathWithoutUmlauts = mp4Path.RemoveUmlauts(); + var videoPathWithoutUmlauts = videoPath.RemoveUmlauts(); var subtitlePathWithoutUmlauts = subtitlePath.RemoveUmlauts(); var mkvPathWithoutUmlauts = mkvPath.RemoveUmlauts(); - if (mp4PathWithoutUmlauts != mp4Path) + if (videoPathWithoutUmlauts != videoPath) { - File.Move(mp4Path, mp4PathWithoutUmlauts); + File.Move(videoPath, videoPathWithoutUmlauts); } - if (subtitlePathWithoutUmlauts != subtitlePath) + if (subtitlesAvailable && subtitlePathWithoutUmlauts != subtitlePath) { File.Move(subtitlePath, subtitlePathWithoutUmlauts); } - queueItem.Status = SabnzbdDownloadStatus.Extracting; - _logger.LogInformation("Starting conversion of {Title} from MP4 to MKV. MP4 Path: {Mp4Path}, MKV Path: {MkvPath}", queueItem.Title, mp4Path, mkvPathWithoutUmlauts); + queueItem.Status = DownloadStatus.Extracting; + _logger.LogInformation("Starting conversion of {Title} to MKV. Video Path: {VideoPath}, MKV Path: {MkvPath}", queueItem.Title, videoPath, mkvPathWithoutUmlauts); - var (success, exitCode, errorOutput) = await MkvMergeUtils.StartMkvmergeProcessAsync(_mkvMergePath, mp4PathWithoutUmlauts, subtitlePathWithoutUmlauts, mkvPathWithoutUmlauts, subtitlesAvailable, queueItem.Title, _logger); + var (success, exitCode, errorOutput) = await MkvMergeUtils.StartMkvmergeProcessAsync(_mkvMergePath, videoPathWithoutUmlauts, subtitlePathWithoutUmlauts, mkvPathWithoutUmlauts, subtitlesAvailable, queueItem.Title, _logger); // Restore umlauts so *arrs correctly identify the show if (mkvPathWithoutUmlauts != mkvPath) @@ -359,16 +410,16 @@ private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch st if (success) { - queueItem.Status = SabnzbdDownloadStatus.Completed; + queueItem.Status = DownloadStatus.Completed; _logger.LogInformation("Conversion completed successfully for {Title}. Output path: {MkvPath}", queueItem.Title, mkvPath); } else { - queueItem.Status = SabnzbdDownloadStatus.Failed; + queueItem.Status = DownloadStatus.Failed; _logger.LogError("Mkvmerge conversion failed for {Title}. Exit code: {ExitCode}. Error output: {ErrorOutput}", queueItem.Title, exitCode, errorOutput); } - DeleteTemporaryFiles(mp4Path, subtitlePath, subtitlesAvailable); + DeleteTemporaryFiles(videoPathWithoutUmlauts, subtitlePathWithoutUmlauts, subtitlesAvailable); double sizeInMB = 0; if (double.TryParse(queueItem.Size.Replace("GB", "").Replace("MB", "").Trim(), out double size)) @@ -377,7 +428,7 @@ private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch st } // Move completed download to history - var historyItem = new SabnzbdHistoryItem + var historyItem = new HistoryItem { Title = $"{queueItem.Title}.mkv", NzbName = queueItem.Title, @@ -386,6 +437,7 @@ private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch st DownloadTime = (int)stopwatch.Elapsed.TotalSeconds, Storage = mkvPath, Status = queueItem.Status, + Completed = DateTimeOffset.Now.ToUnixTimeSeconds(), Id = queueItem.Id }; _downloadHistory.Add(historyItem); @@ -394,11 +446,11 @@ private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch st queueItem.Title, queueItem.Status, historyItem.DownloadTime, historyItem.Size); } - public void DeleteTemporaryFiles(string mp4Path, string subtitlePath, bool subtitlesAvailable) + public void DeleteTemporaryFiles(string videoPath, string subtitlePath, bool subtitlesAvailable) { try { - File.Delete(mp4Path); + File.Delete(videoPath); if (subtitlesAvailable) { File.Delete(subtitlePath); diff --git a/MediathekArr/Utilities/FfmpegUtils.cs b/MediathekArr/Utilities/FfmpegUtils.cs new file mode 100644 index 0000000..f59a916 --- /dev/null +++ b/MediathekArr/Utilities/FfmpegUtils.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace MediathekArr.Utilities; + +public static class FfmpegUtils +{ + public static async Task<(bool Success, int ExitCode, string ErrorOutput)> StartFfmpegDownloadAsync( + string ffmpegPath, string url, string tsPath, string title, ILogger logger) + { + var startInfo = new ProcessStartInfo + { + FileName = ffmpegPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-i"); + startInfo.ArgumentList.Add(url); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("copy"); + startInfo.ArgumentList.Add(tsPath); + + using var process = new Process { StartInfo = startInfo }; + + try + { + logger.LogDebug("ffmpeg path: {FfmpegPath}", ffmpegPath); + logger.LogDebug("Arguments: {Arguments}", string.Join(" ", startInfo.ArgumentList)); + + process.Start(); + logger.LogInformation("FFmpeg download process started for {Title} with arguments: {Arguments}", title, string.Join(" ", startInfo.ArgumentList)); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + string ffmpegOutput = await standardOutputTask; + string ffmpegError = await standardErrorTask; + + logger.LogInformation("FFmpeg process completed for {Title}. Exit code: {ExitCode}", title, process.ExitCode); + + if (!string.IsNullOrWhiteSpace(ffmpegOutput)) + { + logger.LogDebug("FFmpeg process standard output: {Output}", ffmpegOutput); + } + + if (!string.IsNullOrWhiteSpace(ffmpegError)) + { + logger.LogDebug("FFmpeg process error output: {Error}", ffmpegError); + } + + if (process.ExitCode != 0) + { + logger.LogError("FFmpeg download failed for {Title}. Exit code: {ExitCode}.", title, process.ExitCode); + return (false, process.ExitCode, string.IsNullOrWhiteSpace(ffmpegError) ? "Unknown error" : ffmpegError); + } + + return (true, process.ExitCode, ffmpegError); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during the FFmpeg download for {Title}.", title); + return (false, -1, ex.Message); + } + } + + public static async Task EnsureFfmpegExistsAsync(string ffmpegPath, ILogger logger) + { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + if (Path.IsPathRooted(ffmpegPath)) + { + if (File.Exists(ffmpegPath)) + { + logger.LogInformation("ffmpeg found at {FfmpegPath}.", ffmpegPath); + return; + } + } + + var checkCommand = isWindows ? "where" : "which"; + var fileName = isWindows ? "ffmpeg.exe" : "ffmpeg"; + + var searchName = Path.GetFileName(ffmpegPath); + if (string.IsNullOrEmpty(searchName)) searchName = fileName; + + var startInfo = new ProcessStartInfo + { + FileName = checkCommand, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(searchName); + + using var process = new Process { StartInfo = startInfo }; + + try + { + process.Start(); + string? foundPath = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(foundPath)) + { + logger.LogInformation("ffmpeg found in PATH at {FfmpegInPath}.", foundPath.Trim().Split(Environment.NewLine).FirstOrDefault()); + } + else + { + logger.LogError("ffmpeg is not found at the specified path {FfmpegPath} and is not available in PATH.", ffmpegPath); + logger.LogError("Please ensure ffmpeg is installed and accessible via PATH or located in the application directory."); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error while checking for ffmpeg existence."); + } + } +} diff --git a/MediathekArr/Utilities/MkvMergeUtils.cs b/MediathekArr/Utilities/MkvMergeUtils.cs index 0148a63..6bac2c2 100644 --- a/MediathekArr/Utilities/MkvMergeUtils.cs +++ b/MediathekArr/Utilities/MkvMergeUtils.cs @@ -1,43 +1,47 @@ using System.Diagnostics; using System.Runtime.InteropServices; -namespace MediathekArrDownloader.Utilities; +namespace MediathekArr.Utilities; public static class MkvMergeUtils { - private static string GetMkvMergeArguments(string mp4Path, string subtitlePath, string mkvPath, bool subtitlesAvailable) - { - return subtitlesAvailable - ? $"-o \"{mkvPath}\" --language 0:ger --language 1:ger \"{mp4Path}\" --language 0:ger --default-track 0:0 \"{subtitlePath}\"" - : $"-o \"{mkvPath}\" --language 0:ger --language 1:ger \"{mp4Path}\""; - } - public static async Task<(bool Success, int ExitCode, string ErrorOutput)> StartMkvmergeProcessAsync - (string mkvmergePath, string mp4Path, string subtitlePath, string mkvPath, bool subtitlesAvailable, string title, ILogger logger) + (string mkvmergePath, string videoPath, string subtitlePath, string mkvPath, bool subtitlesAvailable, string title, ILogger logger) { - var mkvmergeArgs = GetMkvMergeArguments(mp4Path, subtitlePath, mkvPath, subtitlesAvailable); - - using var process = new Process + var startInfo = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - FileName = mkvmergePath, - Arguments = mkvmergeArgs, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } + FileName = mkvmergePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true }; + startInfo.ArgumentList.Add("-o"); + startInfo.ArgumentList.Add(mkvPath); + startInfo.ArgumentList.Add("--language"); + startInfo.ArgumentList.Add("0:ger"); + startInfo.ArgumentList.Add("--language"); + startInfo.ArgumentList.Add("1:ger"); + startInfo.ArgumentList.Add(videoPath); + if (subtitlesAvailable) + { + startInfo.ArgumentList.Add("--language"); + startInfo.ArgumentList.Add("0:ger"); + startInfo.ArgumentList.Add("--default-track"); + startInfo.ArgumentList.Add("0:0"); + startInfo.ArgumentList.Add(subtitlePath); + } + + using var process = new Process { StartInfo = startInfo }; try { logger.LogDebug("mkvmerge path: {MkvmergePath}", mkvmergePath); - logger.LogDebug("Arguments: {Arguments}", mkvmergeArgs); - logger.LogDebug("MP4 Path: {Mp4Path}, Subtitle Path: {SubtitlePath}, MKV Path: {MkvPath}, Subtitles Available: {SubtitlesAvailable}", mp4Path, subtitlePath, mkvPath, subtitlesAvailable); + logger.LogDebug("Arguments: {Arguments}", string.Join(" ", startInfo.ArgumentList)); + logger.LogDebug("Video Path: {VideoPath}, Subtitle Path: {SubtitlePath}, MKV Path: {MkvPath}, Subtitles Available: {SubtitlesAvailable}", videoPath, subtitlePath, mkvPath, subtitlesAvailable); process.Start(); - logger.LogInformation("mkvmerge process started for {Title} with arguments: {Arguments}", title, mkvmergeArgs); + logger.LogInformation("mkvmerge process started for {Title} with arguments: {Arguments}", title, string.Join(" ", startInfo.ArgumentList)); var standardOutputTask = process.StandardOutput.ReadToEndAsync(); var standardErrorTask = process.StandardError.ReadToEndAsync(); @@ -58,7 +62,12 @@ private static string GetMkvMergeArguments(string mp4Path, string subtitlePath, logger.LogError("mkvmerge process error output: {Error}", mkvmergeError); } - if (process.ExitCode != 0) + if (process.ExitCode == 1) + { + logger.LogWarning("mkvmerge had warnings for {Title}: Stdout: {mkvmergeOutput}", title, mkvmergeOutput); + return (true, process.ExitCode, mkvmergeError); + } + else if (process.ExitCode != 0) { logger.LogError("mkvmerge conversion failed for {Title}. Exit code: {ExitCode}.", title, process.ExitCode); return (false, process.ExitCode, string.IsNullOrWhiteSpace(mkvmergeError) ? "Unknown error" : mkvmergeError); @@ -73,8 +82,6 @@ private static string GetMkvMergeArguments(string mp4Path, string subtitlePath, } } - - public static async Task EnsureMkvMergeExistsAsync(string mkvmergePath, ILogger logger, HttpClient httpClient) { bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); diff --git a/MediathekArr/Utilities/SubtitleConverter.cs b/MediathekArr/Utilities/SubtitleConverter.cs index 7dd71f4..715ca4a 100644 --- a/MediathekArr/Utilities/SubtitleConverter.cs +++ b/MediathekArr/Utilities/SubtitleConverter.cs @@ -1,68 +1,202 @@ -using System.Text; +using System.Globalization; +using System.Text; using System.Text.RegularExpressions; -using System.Web; +using System.Xml.Linq; -namespace MediathekArrDownloader.Utilities; +namespace MediathekArr.Utilities; public partial class SubtitleConverter { - public static string? ConvertXmlToSrt(string xmlContent) + private static readonly XNamespace TtmlNamespace = "http://www.w3.org/ns/ttml"; + + public static string? ConvertToSrt(string content) + { + if (content.TrimStart().StartsWith("WEBVTT")) + return ConvertVttToSrt(content); + + return ConvertTtmlToSrt(content); + } + + private static string? ConvertTtmlToSrt(string xmlContent) + { + var doc = XDocument.Parse(xmlContent); + var paragraphs = doc.Descendants(TtmlNamespace + "p") + .Where(p => p.Attribute("begin") != null && p.Attribute("end") != null) + .ToList(); + + if (paragraphs.Count == 0) + { + return null; + } + + var srtBuilder = new StringBuilder(); + var index = 1; + + foreach (var paragraph in paragraphs) + { + var startTime = FormatTtmlTimestamp(paragraph.Attribute("begin")!.Value); + var endTime = FormatTtmlTimestamp(paragraph.Attribute("end")!.Value); + var text = NormalizeText(ExtractText(paragraph)); + + if (string.IsNullOrWhiteSpace(text)) continue; + + srtBuilder.AppendLine(index.ToString()) + .AppendLine($"{startTime} --> {endTime}") + .AppendLine(text) + .AppendLine(); + index++; + } + + return srtBuilder.ToString().Trim(); + } + + private static string? ConvertVttToSrt(string vttContent) { - try + var lines = vttContent.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + var srtBuilder = new StringBuilder(); + var index = 1; + var lineIndex = 0; + + // Skip WEBVTT header and any metadata lines until first empty line or cue + while (lineIndex < lines.Length && !IsVttTimestampLine(lines[lineIndex])) { - // Extract subtitle blocks - var subtitleMatches = XMLSubtitleLineRegex().Matches(xmlContent); + lineIndex++; + } - var srtBuilder = new StringBuilder(); - int index = 1; + while (lineIndex < lines.Length) + { + if (string.IsNullOrWhiteSpace(lines[lineIndex]) || !IsVttTimestampLine(lines[lineIndex])) + { + lineIndex++; + continue; + } - if (subtitleMatches.Count == 0) + var timestampLine = lines[lineIndex]; + var convertedTimestamp = ConvertVttTimestampLine(timestampLine); + if (convertedTimestamp == null) { - return null; + lineIndex++; + continue; } - foreach (Match match in subtitleMatches) + lineIndex++; + + var textLines = new List(); + while (lineIndex < lines.Length && !string.IsNullOrWhiteSpace(lines[lineIndex])) { - if (match.Groups.Count != 4) continue; + var cleanedLine = CleanVttTags(lines[lineIndex]); + if (!string.IsNullOrWhiteSpace(cleanedLine)) + { + textLines.Add(cleanedLine); + } + lineIndex++; + } - var startTime = match.Groups[1].Value.Replace('.', ','); - var endTime = match.Groups[2].Value.Replace('.', ','); - var text = CleanText(match.Groups[3].Value); + if (textLines.Count == 0) continue; - if (string.IsNullOrWhiteSpace(text)) continue; + srtBuilder.AppendLine(index.ToString()) + .AppendLine(convertedTimestamp) + .AppendLine(string.Join("\n", textLines)) + .AppendLine(); + index++; + } - srtBuilder.AppendLine(index.ToString()) - .AppendLine($"{startTime} --> {endTime}") - .AppendLine(text) - .AppendLine(); - index++; - } + if (index == 1) return null; + + return srtBuilder.ToString().Trim(); + } + + private static bool IsVttTimestampLine(string line) + { + return line.Contains("-->"); + } + + private static string? ConvertVttTimestampLine(string line) + { + // "00:00:01.000 --> 00:00:04.000 align:middle" or "00:01.000 --> 00:04.000" + var arrowIndex = line.IndexOf("-->", StringComparison.Ordinal); + if (arrowIndex < 0) return null; + + var startPart = line[..arrowIndex].Trim(); + var afterArrow = line[(arrowIndex + 3)..].Trim(); - return srtBuilder.ToString().Trim(); + var endPart = afterArrow.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + + var srtStart = ConvertVttTimestamp(startPart); + var srtEnd = ConvertVttTimestamp(endPart); + + return $"{srtStart} --> {srtEnd}"; + } + + private static string ConvertVttTimestamp(string timestamp) + { + // VTT allows "MM:SS.mmm" (no hours) or "HH:MM:SS.mmm" + // SRT requires "HH:MM:SS,mmm" + var colonCount = timestamp.Count(c => c == ':'); + if (colonCount == 1) + { + // MM:SS.mmm → 00:MM:SS,mmm + timestamp = "00:" + timestamp; } - catch (Exception ex) + + return timestamp.Replace('.', ','); + } + + private static string CleanVttTags(string text) + { + return CleanVttTagsRegex().Replace(text, "").Trim(); + } + + private static string ExtractText(XElement element) + { + var sb = new StringBuilder(); + + foreach (var node in element.Nodes()) { - throw new InvalidOperationException("Failed to convert XML subtitles to SRT format.", ex); + switch (node) + { + case XText textNode: + sb.Append(textNode.Value.Replace("
", "\n")); + break; + case XElement child when child.Name.LocalName == "br": + sb.Append('\n'); + break; + case XElement child: + sb.Append(ExtractText(child)); + break; + } } + + return sb.ToString(); } - private static string CleanText(string text) + /// + /// Formats an offset-time format (e.g., "14.40s") to clock-time format (e.g., "00:00:14.400") + /// + /// + /// + private static string FormatTtmlTimestamp(string timestamp) { - // Remove unnecessary tags and decode special characters - text = Regex.Replace(text, @"]*>", string.Empty, RegexOptions.Singleline); - text = Regex.Replace(text, @"", string.Empty, RegexOptions.Singleline); - text = Regex.Replace(text, @"", "\n", RegexOptions.Singleline); - text = Regex.Replace(text, @"<.*?>", string.Empty, RegexOptions.Singleline); + if (timestamp.EndsWith('s')) + { + var totalSeconds = decimal.Parse(timestamp.TrimEnd('s'), CultureInfo.InvariantCulture); + var totalMilliseconds = (long)(totalSeconds * 1000m); + var timeSpan = TimeSpan.FromMilliseconds(totalMilliseconds); + return $"{(int)timeSpan.TotalHours:D2}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2},{timeSpan.Milliseconds:D3}"; + } - // Decode special characters - text = HttpUtility.HtmlDecode(text); + return timestamp.Replace('.', ','); + } - // Normalize line breaks and trim whitespace - text = Regex.Replace(text, @"\s*\n\s*", "\n").Trim(); + private static string NormalizeText(string text) + { + var lines = text.Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0); - return text; + return string.Join("\n", lines); } - [GeneratedRegex(@"(.*?)", RegexOptions.Singleline)] - private static partial Regex XMLSubtitleLineRegex(); + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex CleanVttTagsRegex(); } diff --git a/MediathekArr/static/download/index.html b/MediathekArr/static/download/index.html index 0160ffe..3fb17d2 100644 --- a/MediathekArr/static/download/index.html +++ b/MediathekArr/static/download/index.html @@ -46,6 +46,23 @@

Configuration

+
+ +
+ +
+
+ + +
+ +
+
+ + + + Changes require restart to take effect +
diff --git a/MediathekArr/static/download/js/queue.js b/MediathekArr/static/download/js/queue.js index 1486770..d501cdd 100644 --- a/MediathekArr/static/download/js/queue.js +++ b/MediathekArr/static/download/js/queue.js @@ -1,6 +1,11 @@ let currentInputId = null; let initialIncompletePath = ''; let initialCompletePath = ''; +let categories = []; +let initialCategories = []; +let categoriesOverridden = false; +let initialMaxParallelDownloads = 2; +let maxParallelDownloadsOverridden = false; async function fetchQueue() { try { @@ -50,40 +55,125 @@ async function openConfigModal() { initialIncompletePath = config.incompletePath.replace(/\\/g, '/'); initialCompletePath = config.completePath.replace(/\\/g, '/'); + // Handle categories + categories = config.categories || []; + initialCategories = [...categories]; + categoriesOverridden = overrides.categories; + renderCategories(); + + // Handle max parallel downloads + document.getElementById('maxParallelDownloads').value = config.maxParallelDownloads || 2; + initialMaxParallelDownloads = config.maxParallelDownloads || 2; + maxParallelDownloadsOverridden = overrides.maxParallelDownloads; + const incompleteInput = document.getElementById('incompletePath'); const completeInput = document.getElementById('completePath'); const incompleteBrowseButton = document.getElementById('browseIncomplete'); const completeBrowseButton = document.getElementById('browseComplete'); const incompleteWarning = document.getElementById('incompletePathWarning'); const completeWarning = document.getElementById('completePathWarning'); + const newCategoryInput = document.getElementById('newCategory'); + const categoriesAddButton = document.querySelector('button[onclick="addCategory()"]'); + const categoriesWarning = document.getElementById('categoriesWarning'); // Handle Incomplete Path if (overrides.incompletePath) { incompleteInput.disabled = true; - incompleteBrowseButton.disabled = true; // Disable the "Browse" button + incompleteBrowseButton.disabled = true; incompleteWarning.style.display = 'block'; } else { incompleteInput.disabled = false; - incompleteBrowseButton.disabled = false; // Enable the "Browse" button + incompleteBrowseButton.disabled = false; incompleteWarning.style.display = 'none'; } // Handle Complete Path if (overrides.completePath) { completeInput.disabled = true; - completeBrowseButton.disabled = true; // Disable the "Browse" button + completeBrowseButton.disabled = true; completeWarning.style.display = 'block'; } else { completeInput.disabled = false; - completeBrowseButton.disabled = false; // Enable the "Browse" button + completeBrowseButton.disabled = false; completeWarning.style.display = 'none'; } + // Handle Categories + if (categoriesOverridden) { + newCategoryInput.disabled = true; + categoriesAddButton.disabled = true; + categoriesWarning.style.display = 'block'; + } else { + newCategoryInput.disabled = false; + categoriesAddButton.disabled = false; + categoriesWarning.style.display = 'none'; + } + + // Handle Max Parallel Downloads + const maxParallelInput = document.getElementById('maxParallelDownloads'); + const maxParallelWarning = document.getElementById('maxParallelWarning'); + if (maxParallelDownloadsOverridden) { + maxParallelInput.disabled = true; + maxParallelWarning.style.display = 'block'; + } else { + maxParallelInput.disabled = false; + maxParallelWarning.style.display = 'none'; + } + // Show the modal document.getElementById('configModal').style.display = 'block'; document.getElementById('pathCheckModal').style.display = 'none'; } +function renderCategories() { + const categoriesList = document.getElementById('categoriesList'); + categoriesList.innerHTML = ''; + + if (categories.length === 0) { + categoriesList.innerHTML = '
No categories defined
'; + return; + } + + categories.forEach((category, index) => { + const categoryDiv = document.createElement('div'); + categoryDiv.className = 'category-item'; + categoryDiv.style.display = 'flex'; + categoryDiv.style.justifyContent = 'space-between'; + categoryDiv.style.alignItems = 'center'; + categoryDiv.style.marginBottom = '5px'; + categoryDiv.style.padding = '5px'; + categoryDiv.style.backgroundColor = '#f4f4f4'; + categoryDiv.style.borderRadius = '3px'; + + categoryDiv.innerHTML = ` + ${category} + + `; + categoriesList.appendChild(categoryDiv); + }); +} +function addCategory() { + const newCategoryInput = document.getElementById('newCategory'); + const newCategory = newCategoryInput.value.trim(); + + if (newCategory && !categories.includes(newCategory)) { + categories.push(newCategory); + renderCategories(); + newCategoryInput.value = ''; + } else if (categories.includes(newCategory)) { + alert('This category already exists!'); + } +} + +function removeCategory(index) { + categories.splice(index, 1); + renderCategories(); +} + async function browsePath(inputId) { currentInputId = inputId; @@ -194,13 +284,14 @@ function closeFileBrowser() { function closeConfigModal(promptUser = true) { if (promptUser) { - const incompletePath = document.getElementById('incompletePath').value; - const completePath = document.getElementById('completePath').value; - - if (incompletePath !== initialIncompletePath || completePath !== initialCompletePath) { - if (!confirm('You didn\'t save your changes - are you sure you want to close?')) { - return; - } + const configChanged = + document.getElementById('incompletePath').value !== initialIncompletePath || + document.getElementById('completePath').value !== initialCompletePath || + JSON.stringify(categories) !== JSON.stringify(initialCategories) || + parseInt(document.getElementById('maxParallelDownloads').value) !== initialMaxParallelDownloads; + + if (configChanged && !confirm('You didn\'t save your changes - are you sure you want to close?')) { + return; } } document.getElementById('configModal').style.display = 'none'; @@ -209,6 +300,7 @@ function closeConfigModal(promptUser = true) { async function saveConfig() { const incompletePath = document.getElementById('incompletePath').value; const completePath = document.getElementById('completePath').value; + const maxParallelDownloads = parseInt(document.getElementById('maxParallelDownloads').value) || 2; const response = await fetch('/download/config', { method: 'POST', @@ -217,12 +309,18 @@ async function saveConfig() { }, body: JSON.stringify({ incompletePath, - completePath + completePath, + categories, + maxParallelDownloads }), }); if (response.ok) { + const result = await response.json(); closeConfigModal(false); + if (result.requiresRestart) { + alert('Configuration saved. Please restart the application for the parallel downloads setting to take effect.'); + } } else { alert('Failed to save configuration.'); } diff --git a/MediathekArr/static/download/js/wizard.js b/MediathekArr/static/download/js/wizard.js index 7439b59..a6677a8 100644 --- a/MediathekArr/static/download/js/wizard.js +++ b/MediathekArr/static/download/js/wizard.js @@ -92,6 +92,7 @@ async function updateOrCreateIndexer() { } else { payload.appProfileId = selectedAppProfileId; payload.enable = true; + payload.redirect = true; if (!selectedIndexerDetails) { payload.added = new Date().toISOString(); } @@ -147,9 +148,11 @@ function showSuccessScreen() { async function fetchIndexers() { updateStatus('Fetching existing Indexers...'); + const mediathekArrIndexerPort = document.getElementById('indexerBaseUrl').textContent.split(':').pop(); + const url = useProwlarr - ? `/wizard/indexers?arrHost=${encodeURIComponent(prowlarrHost)}&apiKey=${encodeURIComponent(prowlarrApiKey)}&prowlarr=true` - : `/wizard/indexers?arrHost=${encodeURIComponent(sonarrHost)}&apiKey=${encodeURIComponent(apiKey)}`; + ? `/wizard/indexers?arrHost=${encodeURIComponent(prowlarrHost)}&apiKey=${encodeURIComponent(prowlarrApiKey)}&portFilter=${mediathekArrIndexerPort}&prowlarr=true` + : `/wizard/indexers?arrHost=${encodeURIComponent(sonarrHost)}&portFilter=${mediathekArrIndexerPort}&apiKey=${encodeURIComponent(apiKey)}`; const headers = {}; const indexers = await fetchWithStatus( @@ -242,12 +245,8 @@ async function proceedWithSelectedIndexer() { async function retryIndexerSettings() { document.getElementById('indexerBaseUrl').textContent = document.getElementById('editIndexerBaseUrl').value; const newBaseUrl = document.getElementById('editIndexerBaseUrl').value; - const newPort = document.getElementById('editIndexerPort').value; - - updateStatus(`Retrying with updated base URL (${newBaseUrl}) and port (${newPort})...`); - document.getElementById('editIndexerBaseUrl').textContent = newBaseUrl; - document.getElementById('editIndexerPort').textContent = newPort; + updateStatus(`Retrying with updated base URL (${newBaseUrl})...`); document.getElementById('editIndexerSettings').style.display = "none"; await testIndexerSettings(); @@ -269,6 +268,7 @@ async function testIndexerSettings() { enableRss: true, enableAutomaticSearch: true, enableInteractiveSearch: true, + redirect: true, protocol: "usenet", priority: 25, ...(selectedIndexerId ? { id: selectedIndexerId } : {}), @@ -339,8 +339,8 @@ async function fetchWithStatus(url, options, successMessage, errorMessage) { try { const response = await fetch(url, options); if (!response.ok) { - const error = await response.json(); - updateStatus(`${errorMessage}: ${error.message || 'Unknown error'}`); + const errorResponse = await response.json(); + updateStatus(`${errorMessage}: ${errorResponse.error.message || 'Unknown error'}`); return null; } updateStatus(successMessage); @@ -424,11 +424,11 @@ async function proceedWithSelectedClient() { updateStatus('Testing the selected client...'); - if (selectedClientDetails.category !== 'mediathek') { + if (selectedClientDetails.category !== 'tv') { document.getElementById('categoryChangeAlert').style.display = 'block'; - document.getElementById('categoryChangeAlert').innerText = `The selected client category is "${selectedClientDetails.category}" and not "mediathek". It will be changed to "mediathek" for migration to 1.0.`; + document.getElementById('categoryChangeAlert').innerText = `The selected client category is "${selectedClientDetails.category}" and not "tv". It will be changed to "tv" for migration to 1.0.`; updateStatus(document.getElementById('categoryChangeAlert').innerText) - selectedClientDetails.category = 'mediathek'; + selectedClientDetails.category = 'tv'; } if (selectedClientDetails.priority !== 50 && selectedClientDetails.priority !== "50") { @@ -480,7 +480,7 @@ async function testClientSettings(tryAlternate = true) { { name: "apiKey", value: "x" }, { name: "username" }, { name: "password" }, - { name: "tvCategory", value: "mediathek" }, + { name: "tvCategory", value: "tv" }, { name: "recentTvPriority", value: -100 }, { name: "olderTvPriority", value: -100 } ], @@ -527,17 +527,14 @@ async function testClientSettings(tryAlternate = true) { } async function setProwlarrIndexerDownloadClient(indexerId) { - updateStatus('Waiting for Prowlarr to sync with Sonarr...'); - await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for 5 seconds + updateStatus('Waiting 15 seconds for Prowlarr to sync with Sonarr...'); + await new Promise(resolve => setTimeout(resolve, 15000)); + const prowlarrPort = new URL(prowlarrHost).port || 9696; updateStatus('Fetching Sonarr indexers...'); const sonarrIndexers = await fetchWithStatus( - `${sonarrHost}/api/v3/indexer`, - { - headers: { - "X-Api-Key": apiKey - } - }, + `/wizard/indexers?apiKey=${encodeURIComponent(apiKey)}&arrHost=${encodeURIComponent(sonarrHost)}&portFilter=${prowlarrPort}`, + {}, 'Sonarr indexers fetched successfully.', 'Failed to fetch Sonarr indexers' ); @@ -546,8 +543,7 @@ async function setProwlarrIndexerDownloadClient(indexerId) { if (sonarrIndexers) { const matchingIndexers = sonarrIndexers.filter(indexer => - indexer.protocol === 'usenet' && - indexer.fields.some(field => field.name === 'baseUrl' && field.value.endsWith(`/${indexerId}/`)) + indexer.baseUrl && indexer.baseUrl.endsWith(`/${indexerId}/`) ); if (matchingIndexers.length !== 1) { @@ -557,23 +553,47 @@ async function setProwlarrIndexerDownloadClient(indexerId) { } const foundIndexer = matchingIndexers[0]; - foundIndexer.downloadClientId = selectedClientId; + + // Create update payload + const updatePayload = { + id: foundIndexer.id, + configContract: "NewznabSettings", + implementation: "Newznab", + implementationName: "Newznab", + enableRss: foundIndexer ? foundIndexer.enableRss : true, + enableAutomaticSearch: foundIndexer ? foundIndexer.enableAutomaticSearch : true, + enableInteractiveSearch: foundIndexer ? foundIndexer.enableInteractiveSearch : true, + protocol: "usenet", + priority: parseInt(foundIndexer.priority), + fields: [ + { name: "baseUrl", value: foundIndexer.baseUrl }, + { name: "apiPath", value: foundIndexer.apiPath }, + { name: "apiKey", value: "********" }, + { name: "categories", value: [5030, 5040] }, + { name: "animeCategories", value: [] } + ], + name: foundIndexer.name, + downloadClientId: selectedClientId + }; + + console.log(updatePayload); const updateResponse = await fetchWithStatus( - `${sonarrHost}/api/v3/indexer/${foundIndexer.id}`, + `/wizard/indexer/${foundIndexer.id}?apiKey=${apiKey}&arrHost=${sonarrHost}`, { method: "PUT", headers: { - "Content-Type": "application/json", - "X-Api-Key": apiKey + "Content-Type": "application/json" }, - body: JSON.stringify(foundIndexer) + body: JSON.stringify(updatePayload) }, 'Indexer updated with download client ID successfully.', 'Failed to update indexer with download client ID' ); - console.log('xxxxxxxxx') - console.log(updateResponse) + + console.log('xxxxxxxxx'); + console.log(updateResponse); + if (!updateResponse) { alert('Failed to update the indexer with the download client ID.'); return false; @@ -587,6 +607,7 @@ async function setProwlarrIndexerDownloadClient(indexerId) { return false; } + async function updateOrCreateDownloadClient() { const payload = { enable: true, @@ -603,7 +624,7 @@ async function updateOrCreateDownloadClient() { { name: "apiKey", value: "x" }, { name: "username" }, { name: "password" }, - { name: "tvCategory", value: "mediathek" }, + { name: "tvCategory", value: "tv" }, { name: "recentTvPriority", value: -100 }, { name: "olderTvPriority", value: -100 } ], @@ -651,7 +672,7 @@ async function tryAlternateHost(testPayload) { testPayload.fields.find(field => field.name === "host").value = alternateHost; const response = await fetchWithStatus( - `${sonarrHost}/api/v3/downloadclient/test`, + `/wizard/downloadclient/test?sonarrHost=${encodeURIComponent(sonarrHost)}&apiKey=${encodeURIComponent(apiKey)}`, { method: "POST", headers: { diff --git a/MediathekArr/static/download/wizard.html b/MediathekArr/static/download/wizard.html index 42780f5..0494c3d 100644 --- a/MediathekArr/static/download/wizard.html +++ b/MediathekArr/static/download/wizard.html @@ -143,13 +143,9 @@

Review and Test Indexer Settings

diff --git a/MediathekArrLib/MediathekArrLib.csproj b/MediathekArrLib/MediathekArrLib.csproj index 0fc41c3..5dfc1bf 100644 --- a/MediathekArrLib/MediathekArrLib.csproj +++ b/MediathekArrLib/MediathekArrLib.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + MediathekArr diff --git a/MediathekArrLib/Models/ApiResultItem.cs b/MediathekArrLib/Models/ApiResultItem.cs index 7620714..5d26836 100644 --- a/MediathekArrLib/Models/ApiResultItem.cs +++ b/MediathekArrLib/Models/ApiResultItem.cs @@ -1,10 +1,22 @@ -using MediathekArrLib.Utilities; +using MediathekArr.Utilities; using System.Text.Json.Serialization; -namespace MediathekArrLib.Models; +namespace MediathekArr.Models; public class ApiResultItem { + [JsonIgnore] + private readonly Dictionary LanguageIdentifiers = new() + { + { "(originalversion", "ORIGINAL" }, + { "(englisch", "ENGLISH" }, + { "(english version)", "ENGLISH" }, + { "(english)", "ENGLISH" }, + }; + + [JsonIgnore] + private readonly string[] BurnedInSubtitleIdentifiers = ["originalversion mit untertitel"]; + [JsonPropertyName("channel")] public string Channel { get; set; } @@ -45,5 +57,11 @@ public class ApiResultItem [JsonPropertyName("url_subtitle")] public string UrlSubtitle { get; set; } [JsonIgnore] - public string Language => Title?.Contains("(Englisch)") ?? false ? "ENGLISH" : "GERMAN"; + public string Language => HasBurnedInSubtitles + ? "GERMAN.SUBBED.HC" + : LanguageIdentifiers.FirstOrDefault(x => Title.Contains(x.Key, StringComparison.CurrentCultureIgnoreCase)).Value + ?? "GERMAN"; + + [JsonIgnore] + private bool HasBurnedInSubtitles => BurnedInSubtitleIdentifiers.Any(Title.ToLower().Contains) && string.IsNullOrEmpty(UrlSubtitle); } diff --git a/MediathekArrLib/Models/MediathekApiResponse.cs b/MediathekArrLib/Models/MediathekApiResponse.cs index a85766d..4340c78 100644 --- a/MediathekArrLib/Models/MediathekApiResponse.cs +++ b/MediathekArrLib/Models/MediathekApiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models; +namespace MediathekArr.Models; public class MediathekApiResponse { diff --git a/MediathekArrLib/Models/MediathekApiResult.cs b/MediathekArrLib/Models/MediathekApiResult.cs index c9a1dbd..9864998 100644 --- a/MediathekArrLib/Models/MediathekApiResult.cs +++ b/MediathekArrLib/Models/MediathekApiResult.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models; +namespace MediathekArr.Models; public class MediathekApiResult { diff --git a/MediathekArrLib/Models/Newznab/Attribute.cs b/MediathekArrLib/Models/Newznab/Attribute.cs index e57cc2e..ba6e944 100644 --- a/MediathekArrLib/Models/Newznab/Attribute.cs +++ b/MediathekArrLib/Models/Newznab/Attribute.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Attribute { diff --git a/MediathekArrLib/Models/Newznab/Channel.cs b/MediathekArrLib/Models/Newznab/Channel.cs index b104183..13ffabf 100644 --- a/MediathekArrLib/Models/Newznab/Channel.cs +++ b/MediathekArrLib/Models/Newznab/Channel.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Channel { diff --git a/MediathekArrLib/Models/Newznab/Enclosure.cs b/MediathekArrLib/Models/Newznab/Enclosure.cs index 085db95..39434d3 100644 --- a/MediathekArrLib/Models/Newznab/Enclosure.cs +++ b/MediathekArrLib/Models/Newznab/Enclosure.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Enclosure { diff --git a/MediathekArrLib/Models/Newznab/Guid.cs b/MediathekArrLib/Models/Newznab/Guid.cs index ac5f187..cdfa13d 100644 --- a/MediathekArrLib/Models/Newznab/Guid.cs +++ b/MediathekArrLib/Models/Newznab/Guid.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Guid { diff --git a/MediathekArrLib/Models/Newznab/Item.cs b/MediathekArrLib/Models/Newznab/Item.cs index 717e533..437990e 100644 --- a/MediathekArrLib/Models/Newznab/Item.cs +++ b/MediathekArrLib/Models/Newznab/Item.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Item { diff --git a/MediathekArrLib/Models/Newznab/Response.cs b/MediathekArrLib/Models/Newznab/Response.cs index f5e3edb..cb25741 100644 --- a/MediathekArrLib/Models/Newznab/Response.cs +++ b/MediathekArrLib/Models/Newznab/Response.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; public class Response { diff --git a/MediathekArrLib/Models/Newznab/Rss.cs b/MediathekArrLib/Models/Newznab/Rss.cs index 196b496..e1c5c81 100644 --- a/MediathekArrLib/Models/Newznab/Rss.cs +++ b/MediathekArrLib/Models/Newznab/Rss.cs @@ -1,7 +1,7 @@ using System.Xml; using System.Xml.Serialization; -namespace MediathekArrLib.Models.Newznab; +namespace MediathekArr.Models.Newznab; [XmlRoot("rss")] public class Rss diff --git a/MediathekArrLib/Models/QueryInfo.cs b/MediathekArrLib/Models/QueryInfo.cs index d4b609f..b4fbb56 100644 --- a/MediathekArrLib/Models/QueryInfo.cs +++ b/MediathekArrLib/Models/QueryInfo.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models; +namespace MediathekArr.Models; public class QueryInfo { diff --git a/MediathekArrLib/Models/Rulesets/EpisodeType.cs b/MediathekArrLib/Models/Rulesets/EpisodeType.cs index 6627c1d..4f49c5b 100644 --- a/MediathekArrLib/Models/Rulesets/EpisodeType.cs +++ b/MediathekArrLib/Models/Rulesets/EpisodeType.cs @@ -1,4 +1,4 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public enum EpisodeType { diff --git a/MediathekArrLib/Models/Rulesets/Filter.cs b/MediathekArrLib/Models/Rulesets/Filter.cs index 5fa93dc..1cea6c2 100644 --- a/MediathekArrLib/Models/Rulesets/Filter.cs +++ b/MediathekArrLib/Models/Rulesets/Filter.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class Filter { diff --git a/MediathekArrLib/Models/Rulesets/IdentificationResult.cs b/MediathekArrLib/Models/Rulesets/IdentificationResult.cs index accbdb7..928d73e 100644 --- a/MediathekArrLib/Models/Rulesets/IdentificationResult.cs +++ b/MediathekArrLib/Models/Rulesets/IdentificationResult.cs @@ -1,3 +1,3 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; -public record IdentificationResult(string UsedRuleset, string Name, string GermanName, int? SeasonNumber, int? EpisodeNumber, string ItemTitle, TvdbEpisode MatchedEpisode); \ No newline at end of file +public record IdentificationResult(string UsedRuleset, string Name, string GermanName, int? SeasonNumber, int? EpisodeNumber, string ItemTitle, Tvdb.Episode MatchedEpisode); \ No newline at end of file diff --git a/MediathekArrLib/Models/Rulesets/MatchType.cs b/MediathekArrLib/Models/Rulesets/MatchType.cs index b93dc22..a45c6ce 100644 --- a/MediathekArrLib/Models/Rulesets/MatchType.cs +++ b/MediathekArrLib/Models/Rulesets/MatchType.cs @@ -1,4 +1,4 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public enum MatchType { @@ -6,5 +6,5 @@ public enum MatchType Contains, Regex, GreaterThan, - LessThan + LowerThan } diff --git a/MediathekArrLib/Models/Rulesets/MatchedEpisodeInfo.cs b/MediathekArrLib/Models/Rulesets/MatchedEpisodeInfo.cs index 38fdbbe..41e0b18 100644 --- a/MediathekArrLib/Models/Rulesets/MatchedEpisodeInfo.cs +++ b/MediathekArrLib/Models/Rulesets/MatchedEpisodeInfo.cs @@ -1,3 +1,3 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; -public record MatchedEpisodeInfo(TvdbEpisode Episode, ApiResultItem Item, string ShowName, string MatchedTitle); +public record MatchedEpisodeInfo(Tvdb.Episode Episode, ApiResultItem Item, string ShowName, string MatchedTitle); diff --git a/MediathekArrLib/Models/Rulesets/MatchingStrategy.cs b/MediathekArrLib/Models/Rulesets/MatchingStrategy.cs index 83a9d4d..d0c8230 100644 --- a/MediathekArrLib/Models/Rulesets/MatchingStrategy.cs +++ b/MediathekArrLib/Models/Rulesets/MatchingStrategy.cs @@ -1,9 +1,10 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public enum MatchingStrategy { SeasonAndEpisodeNumber, // Use season + episode number for matching - ByAbsoluteEpisodeNumber, // Use absolute episode number for matching // TODO rename this to AbsoluteEpisodeNumber once API is c# + ByAbsoluteEpisodeNumber, // TODO remove this in a later version + AbsoluteEpisodeNumber, // Use absolute episode number for matching ItemTitleIncludes, // Match episodes where the tvdb episode name contains this title ItemTitleExact, // Match episodes with an exact itemTitle ItemTitleEqualsAirdate diff --git a/MediathekArrLib/Models/Rulesets/Media.cs b/MediathekArrLib/Models/Rulesets/Media.cs index 4429e9d..f5db88d 100644 --- a/MediathekArrLib/Models/Rulesets/Media.cs +++ b/MediathekArrLib/Models/Rulesets/Media.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class Media { diff --git a/MediathekArrLib/Models/Rulesets/Pagination.cs b/MediathekArrLib/Models/Rulesets/Pagination.cs index 2b0b231..7547d4c 100644 --- a/MediathekArrLib/Models/Rulesets/Pagination.cs +++ b/MediathekArrLib/Models/Rulesets/Pagination.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class Pagination { diff --git a/MediathekArrLib/Models/Rulesets/RegexRule.cs b/MediathekArrLib/Models/Rulesets/RegexRule.cs index a7bd9ff..6e96e2d 100644 --- a/MediathekArrLib/Models/Rulesets/RegexRule.cs +++ b/MediathekArrLib/Models/Rulesets/RegexRule.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class RegexRule { diff --git a/MediathekArrLib/Models/Rulesets/Ruleset.cs b/MediathekArrLib/Models/Rulesets/Ruleset.cs index a001a8c..a605885 100644 --- a/MediathekArrLib/Models/Rulesets/Ruleset.cs +++ b/MediathekArrLib/Models/Rulesets/Ruleset.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class Ruleset { @@ -19,8 +19,7 @@ public List Topics { get { - // Split the Topic string by commas and return as a list - return Topic.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + return Topic.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } } diff --git a/MediathekArrLib/Models/Rulesets/RulesetApiResponse.cs b/MediathekArrLib/Models/Rulesets/RulesetApiResponse.cs index 5403eff..576c376 100644 --- a/MediathekArrLib/Models/Rulesets/RulesetApiResponse.cs +++ b/MediathekArrLib/Models/Rulesets/RulesetApiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class RulesetApiResponse { diff --git a/MediathekArrLib/Models/Rulesets/TitleRegexRule.cs b/MediathekArrLib/Models/Rulesets/TitleRegexRule.cs index 7cded37..8f958ac 100644 --- a/MediathekArrLib/Models/Rulesets/TitleRegexRule.cs +++ b/MediathekArrLib/Models/Rulesets/TitleRegexRule.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; +using System.Text.Json.Serialization; -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public class TitleRegexRule { diff --git a/MediathekArrLib/Models/Rulesets/TitleRegexRuleType.cs b/MediathekArrLib/Models/Rulesets/TitleRegexRuleType.cs index fc500d1..55491e9 100644 --- a/MediathekArrLib/Models/Rulesets/TitleRegexRuleType.cs +++ b/MediathekArrLib/Models/Rulesets/TitleRegexRuleType.cs @@ -1,4 +1,4 @@ -namespace MediathekArrLib.Models.Rulesets; +namespace MediathekArr.Models.Rulesets; public enum TitleRegexRuleType { diff --git a/MediathekArrLib/Models/SABnzbd/DownloadStatus.cs b/MediathekArrLib/Models/SABnzbd/DownloadStatus.cs new file mode 100644 index 0000000..be22fc0 --- /dev/null +++ b/MediathekArrLib/Models/SABnzbd/DownloadStatus.cs @@ -0,0 +1,10 @@ +namespace MediathekArr.Models.SABnzbd; + +public enum DownloadStatus +{ + Completed, + Failed, + Downloading, + Queued, + Extracting +} diff --git a/MediathekArrLib/Models/SABnzbd/History.cs b/MediathekArrLib/Models/SABnzbd/History.cs new file mode 100644 index 0000000..1c9fc97 --- /dev/null +++ b/MediathekArrLib/Models/SABnzbd/History.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace MediathekArr.Models.SABnzbd; + +public class History +{ + [JsonPropertyName("slots")] + public List Items { get; set; } +} diff --git a/MediathekArr/Models/SABnzbd/SabnzbdHistoryItem.cs b/MediathekArrLib/Models/SABnzbd/HistoryItem.cs similarity index 73% rename from MediathekArr/Models/SABnzbd/SabnzbdHistoryItem.cs rename to MediathekArrLib/Models/SABnzbd/HistoryItem.cs index 371ab08..8e80526 100644 --- a/MediathekArr/Models/SABnzbd/SabnzbdHistoryItem.cs +++ b/MediathekArrLib/Models/SABnzbd/HistoryItem.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -namespace MediathekArrDownloader.Models.SABnzbd; +namespace MediathekArr.Models.SABnzbd; -public class SabnzbdHistoryItem +public class HistoryItem { [JsonPropertyName("fail_message")] public string FailMessage { get; set; } @@ -24,11 +24,17 @@ public class SabnzbdHistoryItem [JsonPropertyName("status")] [JsonConverter(typeof(JsonStringEnumConverter))] - public SabnzbdDownloadStatus Status { get; set; } + public DownloadStatus Status { get; set; } + + [JsonPropertyName("completed")] + public long Completed { get; set; } [JsonPropertyName("nzo_id")] public string Id { get; set; } + [JsonPropertyName("postproc_time")] + public int PostprocTime => 0; + [JsonPropertyName("name")] public string Title { get; set; } } diff --git a/MediathekArr/Models/SABnzbd/HistoryWrapper.cs b/MediathekArrLib/Models/SABnzbd/HistoryWrapper.cs similarity index 52% rename from MediathekArr/Models/SABnzbd/HistoryWrapper.cs rename to MediathekArrLib/Models/SABnzbd/HistoryWrapper.cs index 8a0a5d5..b7b6ebb 100644 --- a/MediathekArr/Models/SABnzbd/HistoryWrapper.cs +++ b/MediathekArrLib/Models/SABnzbd/HistoryWrapper.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace MediathekArrDownloader.Models.SABnzbd; +namespace MediathekArr.Models.SABnzbd; public class HistoryWrapper { [JsonPropertyName("history")] - public SabnzbdHistory History { get; set; } + public History History { get; set; } } diff --git a/MediathekArrLib/Models/SABnzbd/Queue.cs b/MediathekArrLib/Models/SABnzbd/Queue.cs new file mode 100644 index 0000000..31e13aa --- /dev/null +++ b/MediathekArrLib/Models/SABnzbd/Queue.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace MediathekArr.Models.SABnzbd; + +public class Queue +{ + [JsonPropertyName("paused")] + public bool Paused => false; + + [JsonPropertyName("kbpersec")] + public string KbPerSec => "0"; + + [JsonPropertyName("slots")] + public List Items { get; set; } +} \ No newline at end of file diff --git a/MediathekArr/Models/SABnzbd/SabnzbdQueueItem.cs b/MediathekArrLib/Models/SABnzbd/QueueItem.cs similarity index 86% rename from MediathekArr/Models/SABnzbd/SabnzbdQueueItem.cs rename to MediathekArrLib/Models/SABnzbd/QueueItem.cs index 0cc26e5..cb643b6 100644 --- a/MediathekArr/Models/SABnzbd/SabnzbdQueueItem.cs +++ b/MediathekArrLib/Models/SABnzbd/QueueItem.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace MediathekArrDownloader.Models.SABnzbd; +namespace MediathekArr.Models.SABnzbd; -public class SabnzbdQueueItem +public class QueueItem { [JsonPropertyName("status")] [JsonConverter(typeof(JsonStringEnumConverter))] - public SabnzbdDownloadStatus Status { get; set; } + public DownloadStatus Status { get; set; } [JsonPropertyName("index")] public int Index { get; set; } diff --git a/MediathekArr/Models/SABnzbd/QueueWrapper.cs b/MediathekArrLib/Models/SABnzbd/QueueWrapper.cs similarity index 53% rename from MediathekArr/Models/SABnzbd/QueueWrapper.cs rename to MediathekArrLib/Models/SABnzbd/QueueWrapper.cs index 6c12f1a..3de1039 100644 --- a/MediathekArr/Models/SABnzbd/QueueWrapper.cs +++ b/MediathekArrLib/Models/SABnzbd/QueueWrapper.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace MediathekArrDownloader.Models.SABnzbd; +namespace MediathekArr.Models.SABnzbd; public class QueueWrapper { [JsonPropertyName("queue")] - public SabnzbdQueue Queue { get; set; } + public Queue Queue { get; set; } } diff --git a/MediathekArrLib/Models/Tvdb/Alias.cs b/MediathekArrLib/Models/Tvdb/Alias.cs new file mode 100644 index 0000000..0f80df6 --- /dev/null +++ b/MediathekArrLib/Models/Tvdb/Alias.cs @@ -0,0 +1,3 @@ +namespace MediathekArr.Models.Tvdb; + +public record Alias(string Language, string Name); diff --git a/MediathekArrLib/Models/TvdbData.cs b/MediathekArrLib/Models/Tvdb/Data.cs similarity index 81% rename from MediathekArrLib/Models/TvdbData.cs rename to MediathekArrLib/Models/Tvdb/Data.cs index f59a5ad..c33d07c 100644 --- a/MediathekArrLib/Models/TvdbData.cs +++ b/MediathekArrLib/Models/Tvdb/Data.cs @@ -1,15 +1,15 @@ using System.Text.Json.Serialization; -namespace MediathekArrLib.Models; +namespace MediathekArr.Models.Tvdb; -public record TvdbData(int Id, string Name, [property: JsonPropertyName("german_name")] string GermanName, List Aliases, List Episodes) +public record Data(int Id, string Name, [property: JsonPropertyName("german_name")] string GermanName, List Aliases, List Episodes) { /// /// Finds an episode by its air date. /// /// The air date to search for. /// The TvdbEpisode if found, or null if not found. - public TvdbEpisode? FindEpisodeByAirDate(DateTime airDate) + public Episode? FindEpisodeByAirDate(DateTime airDate) { return Episodes?.FirstOrDefault(episode => episode.Aired?.Date == airDate.Date); } @@ -20,7 +20,7 @@ public record TvdbData(int Id, string Name, [property: JsonPropertyName("german_ /// The year of the episodes to search for. /// The month of the episodes to search for. /// A list of TvdbEpisode objects that aired in the specified year and month. - public List? FindEpisodeByAirMonth(int year, int month) + public List? FindEpisodeByAirMonth(int year, int month) { return Episodes? .Where(episode => episode.Aired.HasValue && @@ -34,7 +34,7 @@ public record TvdbData(int Id, string Name, [property: JsonPropertyName("german_ /// /// The year to search for. /// A list of TvdbEpisode objects aired in the specified year, or an empty list if none are found. - public List FindEpisodesByAirYear(int year) + public List FindEpisodesByAirYear(int year) { return Episodes? .Where(episode => episode.Aired?.Year == year) @@ -46,7 +46,7 @@ public List FindEpisodesByAirYear(int year) /// /// The season number to search for. /// A list of TvdbEpisode objects in the specified season, or an empty list if none are found. - public List FindEpisodesBySeason(int seasonNumber) + public List FindEpisodesBySeason(int seasonNumber) { return Episodes?.Where(episode => episode.SeasonNumber == seasonNumber).ToList() ?? []; } @@ -56,7 +56,7 @@ public List FindEpisodesBySeason(int seasonNumber) /// /// The season number to search for. /// A list of TvdbEpisode objects in the specified season, or an empty list if none are found. - public List FindEpisodesBySeason(string? seasonNumber) + public List FindEpisodesBySeason(string? seasonNumber) { if (int.TryParse(seasonNumber, out int parsedSeason)) { @@ -72,7 +72,7 @@ public List FindEpisodesBySeason(string? seasonNumber) /// The season number of the episode. /// The episode number within the season. /// The TvdbEpisode if found, or null if not found. - public TvdbEpisode? FindEpisodeBySeasonAndNumber(int seasonNumber, int episodeNumber) + public Episode? FindEpisodeBySeasonAndNumber(int seasonNumber, int episodeNumber) { return Episodes?.FirstOrDefault(episode => episode.SeasonNumber == seasonNumber && episode.EpisodeNumber == episodeNumber); @@ -84,7 +84,7 @@ public List FindEpisodesBySeason(string? seasonNumber) /// The season number of the episode. /// The episode number within the season. /// The TvdbEpisode if found, or null if not found. - public TvdbEpisode? FindEpisodeBySeasonAndNumber(string? seasonNumber, string? episodeNumber) + public Episode? FindEpisodeBySeasonAndNumber(string? seasonNumber, string? episodeNumber) { if (int.TryParse(seasonNumber, out int parsedSeason) && int.TryParse(episodeNumber, out int parsedEpisode)) @@ -96,11 +96,11 @@ public List FindEpisodesBySeason(string? seasonNumber) } /// - /// Finds a specific episode by absolute episode number. Dangerous, unless the show only has one season or absolute season numbers + /// Finds a specific episode by absolute episode number. Dangerous, unless the show only has one season or absolute episode numbers /// /// The absolute episode number of the episode. /// The TvdbEpisode if found, or null if not found. - public TvdbEpisode? FindEpisodeByAbsoluteEpisodeNumber(int absoluteEpisodeNumber) + public Episode? FindEpisodeByAbsoluteEpisodeNumber(int absoluteEpisodeNumber) { if (absoluteEpisodeNumber == 0) { diff --git a/MediathekArrLib/Models/Tvdb/Episode.cs b/MediathekArrLib/Models/Tvdb/Episode.cs new file mode 100644 index 0000000..33bba22 --- /dev/null +++ b/MediathekArrLib/Models/Tvdb/Episode.cs @@ -0,0 +1,7 @@ +namespace MediathekArr.Models.Tvdb; + +public record Episode(string? Name, DateTime? Aired, int? Runtime, int SeasonNumber, int EpisodeNumber) +{ + public string PaddedSeason => SeasonNumber.ToString("D2"); + public string PaddedEpisode => EpisodeNumber.ToString("D2"); +}; diff --git a/MediathekArrLib/Models/Tvdb/InfoResponse.cs b/MediathekArrLib/Models/Tvdb/InfoResponse.cs new file mode 100644 index 0000000..2a4b79b --- /dev/null +++ b/MediathekArrLib/Models/Tvdb/InfoResponse.cs @@ -0,0 +1,3 @@ +namespace MediathekArr.Models.Tvdb; + +public record InfoResponse(string Status, Data Data); diff --git a/MediathekArrLib/Models/TvdbAlias.cs b/MediathekArrLib/Models/TvdbAlias.cs deleted file mode 100644 index 784e641..0000000 --- a/MediathekArrLib/Models/TvdbAlias.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MediathekArrLib.Models; - -public record TvdbAlias(string Language, string Name); diff --git a/MediathekArrLib/Models/TvdbEpisode.cs b/MediathekArrLib/Models/TvdbEpisode.cs deleted file mode 100644 index 18fee80..0000000 --- a/MediathekArrLib/Models/TvdbEpisode.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MediathekArrLib.Models; - -public record TvdbEpisode(string Name, DateTime? Aired, int? Runtime, int SeasonNumber, int EpisodeNumber) -{ - public string PaddedSeason => SeasonNumber.ToString("D2"); - public string PaddedEpisode => EpisodeNumber.ToString("D2"); -}; diff --git a/MediathekArrLib/Models/TvdbInfoResponse.cs b/MediathekArrLib/Models/TvdbInfoResponse.cs deleted file mode 100644 index 2082c4b..0000000 --- a/MediathekArrLib/Models/TvdbInfoResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MediathekArrLib.Models; - -public record TvdbInfoResponse(string Status, TvdbData Data); diff --git a/MediathekArrLib/Utilities/JsonConverter.cs b/MediathekArrLib/Utilities/JsonConverter.cs index 897b679..5c7abe5 100644 --- a/MediathekArrLib/Utilities/JsonConverter.cs +++ b/MediathekArrLib/Utilities/JsonConverter.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using System.Text.Json; -namespace MediathekArrLib.Utilities; +namespace MediathekArr.Utilities; public class NumberOrEmptyConverter : JsonConverter where T : struct, IConvertible diff --git a/MediathekArrLib/Utilities/LoggerExtensions.cs b/MediathekArrLib/Utilities/LoggerExtensions.cs index 05f1068..467e725 100644 --- a/MediathekArrLib/Utilities/LoggerExtensions.cs +++ b/MediathekArrLib/Utilities/LoggerExtensions.cs @@ -8,7 +8,7 @@ using System.Xml.Linq; using Microsoft.Extensions.Logging; -namespace MediathekArrLib.Utilities; +namespace MediathekArr.Utilities; /// /// Generic Logger for MediathekArr @@ -36,7 +36,16 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except var originalTextColour = Console.ForegroundColor; Console.ForegroundColor = _config.LogLevelTextColourMapping.TryGetValue(logLevel, out ConsoleColor loggingTextColour) ? loggingTextColour : originalTextColour; string logLevelString = _config.LogLevelAbbreviationMapping.TryGetValue(logLevel, out string logLevelAbbreviation) ? logLevelAbbreviation : logLevel.ToString(); - Console.WriteLine($"[{assemblyName}] {logLevelString}: {_name} - {formatter(state, exception)}"); + + string message = formatter(state, exception); + + // Append exception details if an exception exists + if (exception != null) + { + message += Environment.NewLine + exception.ToString(); + } + + Console.WriteLine($"[{assemblyName}] {logLevelString}: {_name} - {message}"); Console.ForegroundColor = originalTextColour; } } diff --git a/MediathekArrLib/Utilities/NewznabUtils.cs b/MediathekArrLib/Utilities/NewznabUtils.cs index eda736c..dd9e9d3 100644 --- a/MediathekArrLib/Utilities/NewznabUtils.cs +++ b/MediathekArrLib/Utilities/NewznabUtils.cs @@ -1,10 +1,10 @@ -using MediathekArrLib.Models; -using MediathekArrLib.Models.Newznab; -using MediathekArrLib.Models.Rulesets; +using MediathekArr.Models; +using MediathekArr.Models.Newznab; +using MediathekArr.Models.Rulesets; using System.Xml.Serialization; -using Attribute = MediathekArrLib.Models.Newznab.Attribute; +using Attribute = MediathekArr.Models.Newznab.Attribute; -namespace MediathekArrLib.Utilities; +namespace MediathekArr.Utilities; public static class NewznabUtils { public static List GenerateAttributes(ApiResultItem item, string? season, string? episode, string[] categoryValues, EpisodeType episodeType, DateTime? airDate = null) diff --git a/MediathekArrLib/Utilities/StringExtensions.cs b/MediathekArrLib/Utilities/StringExtensions.cs index 597802c..2ea2532 100644 --- a/MediathekArrLib/Utilities/StringExtensions.cs +++ b/MediathekArrLib/Utilities/StringExtensions.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text; -namespace MediathekArrLib.Utilities; +namespace MediathekArr.Utilities; public static class StringExtensions { public static string RemoveAccentButKeepGermanUmlauts(this string text) @@ -24,7 +24,7 @@ public static string RemoveAccentButKeepGermanUmlauts(this string text) public static string RemoveUmlauts(this string text) { - var normalizedString = text.Normalize(NormalizationForm.FormD); + var normalizedString = text.Replace("ß", "ss").Normalize(NormalizationForm.FormD); var stringBuilder = new StringBuilder(); foreach (var c in normalizedString) { diff --git a/MediathekArrServer/Controllers/TController.cs b/MediathekArrServer/Controllers/TController.cs index 0e1c54c..7c28077 100644 --- a/MediathekArrServer/Controllers/TController.cs +++ b/MediathekArrServer/Controllers/TController.cs @@ -1,8 +1,8 @@ -using MediathekArrServer.Services; +using MediathekArr.Services; using Microsoft.AspNetCore.Mvc; using System.Text; -namespace MediathekArrServer.Controllers; +namespace MediathekArr.Controllers; [ApiController] [Route("api")] @@ -86,36 +86,11 @@ public async Task GetCapsXml([FromQuery] string t) [HttpGet("fake_nzb_download")] public IActionResult FakeNzbDownload([FromQuery] string encodedVideoUrl, [FromQuery] string? encodedSubtitleUrl, [FromQuery] string encodedTitle) { - string decodedVideoUrl; - string decodedTitle; - string decodedSubtitleUrl; - try - { - var base64EncodedBytesVideoUrl = Convert.FromBase64String(encodedVideoUrl); - decodedVideoUrl = Encoding.UTF8.GetString(base64EncodedBytesVideoUrl); - var base64EncodedBytesTitle = Convert.FromBase64String(encodedTitle); - decodedTitle = Encoding.UTF8.GetString(base64EncodedBytesTitle); - if (string.IsNullOrEmpty(encodedSubtitleUrl)) - { - decodedSubtitleUrl = string.Empty; - } - else - { - var base64EncodedBytesSubtitleUrl = Convert.FromBase64String(encodedSubtitleUrl); - decodedSubtitleUrl = Encoding.UTF8.GetString(base64EncodedBytesSubtitleUrl); - } - } - catch (FormatException) - { - return BadRequest("Invalid base64 string."); - } - - // Define a basic NZB XML structure with the comment and encoded URL. var nzbContent = $@" - - - + + + diff --git a/MediathekArrServer/MediathekArrServer.csproj b/MediathekArrServer/MediathekArrServer.csproj index 1d65e08..02072fa 100644 --- a/MediathekArrServer/MediathekArrServer.csproj +++ b/MediathekArrServer/MediathekArrServer.csproj @@ -8,6 +8,7 @@ True mcr.microsoft.com/dotnet/aspnet:9.0 6f9f5643-8dc2-4efe-8c30-608b5c2bb8c5 + MediathekArr diff --git a/MediathekArrServer/Program.cs b/MediathekArrServer/Program.cs index b47c47b..de42d48 100644 --- a/MediathekArrServer/Program.cs +++ b/MediathekArrServer/Program.cs @@ -1,5 +1,5 @@ -using MediathekArrLib.Utilities; -using MediathekArrServer.Services; +using MediathekArr.Utilities; +using MediathekArr.Services; using Microsoft.Extensions.DependencyInjection.Extensions; using Scalar.AspNetCore; @@ -11,7 +11,7 @@ builder.Services.AddMemoryCache(); builder.Services.AddHttpClient("MediathekClient", client => { - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0"); client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip"); client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); }) diff --git a/MediathekArrServer/Services/ItemLookupService.cs b/MediathekArrServer/Services/ItemLookupService.cs index 8d194cf..d46ca7d 100644 --- a/MediathekArrServer/Services/ItemLookupService.cs +++ b/MediathekArrServer/Services/ItemLookupService.cs @@ -1,14 +1,14 @@ -using MediathekArrLib.Models; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Memory; using System.Text.Json; -namespace MediathekArrServer.Services; +namespace MediathekArr.Services; -public class ItemLookupService(IHttpClientFactory httpClientFactory, IConfiguration configuration, IMemoryCache memoryCache) +public class ItemLookupService(IHttpClientFactory httpClientFactory, IConfiguration configuration, IMemoryCache memoryCache, ILogger logger) { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); private readonly string _apiBaseUrl = configuration["MEDIATHEKARR_API_BASE_URL"] ?? "https://mediathekarr.pcjones.de/api/v1"; private readonly IMemoryCache _memoryCache = memoryCache; + private readonly ILogger _logger = logger; private static JsonSerializerOptions GetJsonSerializerOptions() { @@ -18,7 +18,7 @@ private static JsonSerializerOptions GetJsonSerializerOptions() }; } - public async Task GetShowInfoByTvdbId(int? tvdbid) + public async Task GetShowInfoByTvdbId(int? tvdbid) { if (tvdbid == null) { @@ -26,12 +26,9 @@ private static JsonSerializerOptions GetJsonSerializerOptions() } var cacheKey = $"TvdbInfo_{tvdbid}"; - if (_memoryCache.TryGetValue(cacheKey, out TvdbData? cachedTvdbInfo)) + if (_memoryCache.TryGetValue(cacheKey, out Models.Tvdb.Data? cachedTvdbInfo)) { - if (cachedTvdbInfo != null) - { - return cachedTvdbInfo; - } + return cachedTvdbInfo; } var requestUrl = $"{_apiBaseUrl}/get_show.php?tvdbid={tvdbid}"; @@ -45,12 +42,18 @@ private static JsonSerializerOptions GetJsonSerializerOptions() } var jsonResponse = await response.Content.ReadAsStringAsync(); - var tvdbInfo = JsonSerializer.Deserialize(jsonResponse, GetJsonSerializerOptions()); + var tvdbInfo = JsonSerializer.Deserialize(jsonResponse, GetJsonSerializerOptions()); + + if (tvdbInfo?.Status == "error") + { + _logger.LogError("Error fetching TVDB data: {Status}", tvdbInfo.Status); + _memoryCache.Set(cacheKey, (Models.Tvdb.Data?)null, TimeSpan.FromHours(12)); + return null; + } if (tvdbInfo == null || tvdbInfo.Status != "success" || tvdbInfo.Data == null) { - throw new HttpRequestException($"Failed to fetch TVDB data. Response: {jsonResponse}"); - // TODO log and return null + throw new HttpRequestException($"Failed to fetch TVDB data for tvdbid {tvdbid}. Response: {jsonResponse}"); } _memoryCache.Set(cacheKey, tvdbInfo.Data, TimeSpan.FromHours(12)); diff --git a/MediathekArrServer/Services/MediathekSearchFallbackHandler.cs b/MediathekArrServer/Services/MediathekSearchFallbackHandler.cs index 948739c..d9413d8 100644 --- a/MediathekArrServer/Services/MediathekSearchFallbackHandler.cs +++ b/MediathekArrServer/Services/MediathekSearchFallbackHandler.cs @@ -1,28 +1,28 @@ -using MediathekArrLib.Models; -using MediathekArrLib.Models.Newznab; -using MediathekArrLib.Models.Rulesets; -using MediathekArrLib.Utilities; +using MediathekArr.Models; +using MediathekArr.Models.Newznab; +using MediathekArr.Models.Rulesets; +using MediathekArr.Utilities; using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; -using Guid = MediathekArrLib.Models.Newznab.Guid; +using Guid = MediathekArr.Models.Newznab.Guid; -namespace MediathekArrServer.Services; +namespace MediathekArr.Services; public partial class MediathekSearchFallbackHandler { - public static List GetFallbackSearchResultItemsById(string? apiResponse, TvdbEpisode episode, TvdbData tvdbData) + public static List GetFallbackSearchResultItemsById(List results, Models.Tvdb.Episode episode, Models.Tvdb.Data tvdbData, ILogger logger) { - if (string.IsNullOrWhiteSpace(apiResponse)) + if (results.Count == 0 || tvdbData.Name.Length <= 3) { return []; } - var filteredResponse = ApplyFilters(apiResponse, episode); + var filteredResults = ApplyFilters(results, episode, logger); var seasonNumber = episode.SeasonNumber.ToString(); var episodeNumber = episode.EpisodeNumber.ToString(); - return filteredResponse?.Result.Results.SelectMany(item => GenerateRssItems(item, seasonNumber, episodeNumber, tvdbData)).ToList() ?? []; + return filteredResults.SelectMany(item => GenerateRssItems(item, seasonNumber, episodeNumber, tvdbData)).ToList(); } public static List GetFallbackSearchResultItemsByString(List? unmatchedFilteredResultItems, string? season) @@ -36,7 +36,7 @@ public static List GetFallbackSearchResultItemsByString(List GenerateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData = null) + private static List GenerateRssItems(ApiResultItem item, string? season, string? episode, Models.Tvdb.Data? tvdbData = null) { var items = new List(); @@ -61,7 +61,7 @@ private static List GenerateRssItems(ApiResultItem item, string? season, s return items; } - private static List CreateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url) + private static List CreateRssItems(ApiResultItem item, string? season, string? episode, Models.Tvdb.Data? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url) { var items = new List(); @@ -83,9 +83,34 @@ private static List CreateRssItems(ApiResultItem item, string? season, str return items; } - private static Item CreateRssItem(ApiResultItem item, string? yearSeason, string? season, string? episode, TvdbData? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url, string? formattedDate = null) + private static Item CreateRssItem(ApiResultItem item, string? yearSeason, string? season, string? episode, Models.Tvdb.Data? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url, string? formattedDate = null) { var adjustedSize = (long)(item.Size * sizeMultiplier); + + // Enforce m3u8 minimum size to keep Sonarr happy (dependent on quality and duration) + if (url.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase)) + { + long bitrate = 600000; + switch (quality) + { + case "1080p": + bitrate = 750000; + break; + case "720p": + bitrate = 450000; + break; + case "480p": + bitrate = 250000; + break; + } + + long estimatedMinSize = (long)item.Duration * bitrate; + if (adjustedSize < estimatedMinSize) + { + adjustedSize = estimatedMinSize; + } + } + if (!string.IsNullOrEmpty(item.UrlSubtitle)) { adjustedSize += 15000000; // Add 15MB to size if subs are available @@ -125,10 +150,10 @@ private static Item CreateRssItem(ApiResultItem item, string? yearSeason, string // TODO refactor and make this look good, It's too late right now:D // TODO now it's even worse :D oh god - private static string GenerateTitle(TvdbData? tvdbData, string topic, string title, string quality, string? formattedDate, string? seasonOverride, string? episodeOverride) + private static string GenerateTitle(Models.Tvdb.Data? tvdbData, string topic, string title, string quality, string? formattedDate, string? seasonOverride, string? episodeOverride) { var showName = tvdbData?.Name ?? topic; - var language = title.Contains("(Englisch)") ? "ENGLISH" : "GERMAN"; + var language = title.Contains("(Englisch)") ? "ENGLISH" : title.Contains("(Originalversion") ? "ORIGINAL" : "GERMAN"; if (!string.IsNullOrEmpty(formattedDate)) { @@ -194,23 +219,15 @@ private static string FormatTitle(string title) title = title.Replace("–", "-"); title = title.RemoveAccentButKeepGermanUmlauts(); title = TitleRegexUnd().Replace(title, "und"); - title = TitleRegexSymbols().Replace(title, ""); // Remove various symbols + title = TitleRegexInvalidChars().Replace(title, ""); // Remove invalid characters title = TitleRegexWhitespace().Replace(title, ".").Replace("..", "."); return title; } - private static MediathekApiResponse? ApplyFilters(string apiResponse, TvdbEpisode episode) + private static List ApplyFilters(List results, Models.Tvdb.Episode episode, ILogger logger) { - var responseObject = JsonSerializer.Deserialize(apiResponse); - - if (responseObject?.Result?.Results == null) - { - return null; - } - - var initialResults = responseObject.Result.Results; - var resultsFilteredByRuntime = FilterByRuntime(initialResults, episode.Runtime); + var resultsFilteredByRuntime = FilterByRuntime(results, episode.Runtime); var resultsByTitleDate = FilterByTitleDate(resultsFilteredByRuntime, episode.Aired).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); var resultsByDescriptionDate = FilterByDescriptionDate(resultsFilteredByRuntime, episode.Aired).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); var resultsByEpisodeTitleMatch = FilterByEpisodeTitleMatch(resultsFilteredByRuntime, episode.Name).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); @@ -218,6 +235,7 @@ private static string FormatTitle(string title) // if more than 3 results we assume episode title match wasn't correct if (resultsByEpisodeTitleMatch.Count > 3) { + logger.LogInformation("clearing resultsByEpisodeTitleMatch because it has more than 3 items."); resultsByEpisodeTitleMatch.Clear(); } @@ -226,24 +244,13 @@ private static string FormatTitle(string title) // Only trust Mediathek season/episode if no other match (aside from airedDate): resultsBySeasonEpisodeMatch = FilterBySeasonEpisodeMatch(resultsFilteredByRuntime, episode.SeasonNumber.ToString(), episode.EpisodeNumber.ToString()) - .Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); ; + .Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); } // HashSet to remove duplicates - HashSet filteredResults = [ .. resultsByTitleDate, .. resultsByDescriptionDate, .. resultsByEpisodeTitleMatch, .. resultsBySeasonEpisodeMatch]; + HashSet filteredResults = [.. resultsByTitleDate, .. resultsByDescriptionDate, .. resultsByEpisodeTitleMatch, .. resultsBySeasonEpisodeMatch]; - // Create a filtered API response - var filteredApiResponse = new MediathekApiResponse - { - Result = new MediathekApiResult - { - Results = [.. filteredResults], - QueryInfo = responseObject.Result.QueryInfo - }, - Err = responseObject.Err - }; - - return filteredApiResponse; + return [.. filteredResults]; } private static List FilterByRuntime(List results, int? runtime) @@ -351,8 +358,18 @@ private static string ExtractDate(string title) year += 2000; } - DateTime date = new(year, month, day); - return date.ToString("yyyy-MM-dd"); + if (DateTime.TryParseExact($"{day}.{month}.{year}", + "d.M.yyyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime date)) + { + return date.ToString("yyyy-MM-dd"); + } + else + { + return string.Empty; + } } var longMonthMatch = Regex.Match(title, germanMonthPattern); @@ -388,8 +405,8 @@ private static DateTime ConvertToBerlinTimezone(DateTime utcDateTime) [GeneratedRegex(@"[&]")] private static partial Regex TitleRegexUnd(); - [GeneratedRegex(@"[/:;""'@#?$%^*+=!<>,()|]")] - private static partial Regex TitleRegexSymbols(); + [GeneratedRegex(@"[/:;,""„""''‚'@#?$%^*+=!|<>,()|·]")] + private static partial Regex TitleRegexInvalidChars(); [GeneratedRegex(@"\s+")] private static partial Regex TitleRegexWhitespace(); [GeneratedRegex(@"Folge\s*\d+:\s*")] diff --git a/MediathekArrServer/Services/MediathekSearchService.cs b/MediathekArrServer/Services/MediathekSearchService.cs index e1e394c..61b8a66 100644 --- a/MediathekArrServer/Services/MediathekSearchService.cs +++ b/MediathekArrServer/Services/MediathekSearchService.cs @@ -3,929 +3,1017 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; -using MediathekArrLib.Models; -using MediathekArrLib.Models.Newznab; -using MediathekArrLib.Models.Rulesets; -using MediathekArrLib.Utilities; +using MediathekArr.Models; +using MediathekArr.Models.Newznab; +using MediathekArr.Models.Rulesets; +using MediathekArr.Utilities; using Microsoft.Extensions.Caching.Memory; -using Guid = MediathekArrLib.Models.Newznab.Guid; -using MatchType = MediathekArrLib.Models.Rulesets.MatchType; +using Guid = MediathekArr.Models.Newznab.Guid; +using MatchType = MediathekArr.Models.Rulesets.MatchType; -namespace MediathekArrServer.Services; +namespace MediathekArr.Services; -public partial class MediathekSearchService(IHttpClientFactory httpClientFactory, IMemoryCache cache, ItemLookupService itemLookupService) +public partial class MediathekSearchService(IHttpClientFactory httpClientFactory, IMemoryCache cache, ItemLookupService itemLookupService, ILogger logger) { - private readonly IMemoryCache _cache = cache; - private readonly ItemLookupService _itemLookupService = itemLookupService; - private readonly HttpClient _httpClient = httpClientFactory.CreateClient("MediathekClient"); - private readonly TimeSpan _cacheTimeSpan = TimeSpan.FromMinutes(55); - private static readonly string[] _skipTitleKeywords = ["Audiodeskription", "Hörfassung", "(klare Sprache)", "Gebärdensprache", "Trailer", "Outtakes:"]; - private static readonly string[] _skipUrlKeywords = ["YXVkaW9kZXNrcmlwdGlvbg"]; // base64 for ARD, YXVkaW9kZXNrcmlwdGlvbg = audiodeskription - private static readonly string[] _queryFields = ["topic", "title"]; - private readonly ConcurrentDictionary> _rulesetsByTopic = new(); - - public async Task UpdateRulesetsAsync() - { - var allRulesets = new List(); - int currentPage = 1; - - while (true && currentPage < 100) - { - var response = await _httpClient.GetAsync($"https://mediathekarr.pcjones.de/metadata/api/rulesets.php?page={currentPage++}"); - if (response.IsSuccessStatusCode) - { - var responseContent = await response.Content.ReadAsStringAsync(); - var rulesetResponse = JsonSerializer.Deserialize(responseContent); - - if (rulesetResponse?.Rulesets != null) - { - allRulesets.AddRange(rulesetResponse.Rulesets); - } - - if (rulesetResponse?.Pagination?.CurrentPage >= rulesetResponse?.Pagination.TotalPages) - { - break; - } - } - else - { - // Exit if the request fails - Console.WriteLine("Failed to fetch rulesets from the API."); - break; - } - } - - _rulesetsByTopic.Clear(); - foreach (var ruleset in allRulesets) - { - foreach (var topic in ruleset.Topics) // Iterate over all topics for the ruleset - { - if (!_rulesetsByTopic.TryGetValue(topic, out List? value)) - { - value = []; - _rulesetsByTopic[topic] = value; - } - - value.Add(ruleset); - } - } - - // Sort each topic group by priority - foreach (var topic in _rulesetsByTopic.Keys.ToList()) - { - _rulesetsByTopic[topic] = [.. _rulesetsByTopic[topic].OrderBy(ruleset => ruleset.Priority)]; - } - } - - private async Task FetchMediathekViewApiResponseAsync(List queries, int size) - { - var requestBody = new - { - queries, - sortBy = "filmlisteTimestamp", - sortOrder = "desc", - future = true, - offset = 0, - size - }; - - var requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8); - var response = await _httpClient.PostAsync("https://mediathekviewweb.de/api/query", requestContent); - - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadAsStringAsync(); - } - - return string.Empty; - } - - public async Task FetchSearchResultsFromApiById(TvdbData tvdbData, string? season, string? episodeNumber, int limit, int offset) - { - var cacheKey = $"tvdb_{tvdbData.Id}_{season ?? "null"}_{episodeNumber ?? "null"}_{limit}_{offset}"; - - if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) - { - return cachedResponse ?? ""; - } - - List? desiredEpisodes = GetDesiredEpisodes(tvdbData, season, episodeNumber); - if (season != null && desiredEpisodes?.Count == 0) - { - var response = NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); - _cache.Set(cacheKey, response, _cacheTimeSpan); - return response; - } - - var mediathekViewRequestCacheKey = $"mediathekapi_{tvdbData.Id}"; - string apiResponse = string.Empty; - if (_cache.TryGetValue(mediathekViewRequestCacheKey, out string? cachedApiResponse)) - { - apiResponse = cachedApiResponse ?? string.Empty; - } - else - { - // TODO make this look nicer, this is a quick fix - for (int i = 0; i < 2; i++) - { - if (!string.IsNullOrEmpty(apiResponse) && !apiResponse.Contains("results\":[]")) - { - break; - } - var searchQuery = tvdbData.GermanName.Replace(" & ", " ") ?? tvdbData.Name.Replace(" & ", " "); - if (i == 1 && searchQuery.Contains('(')) // if title contains year - { - searchQuery = searchQuery.Split('(')[0]; - } - else if(i == 1) - { - break; - } - var queries = new List - { - new { fields = _queryFields, query = searchQuery } - }; - - apiResponse = await FetchMediathekViewApiResponseAsync(queries, 10000); - } - - if (string.IsNullOrEmpty(apiResponse)) - { - return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); - } - - _cache.Set(mediathekViewRequestCacheKey, apiResponse, _cacheTimeSpan); - } - - var results = JsonSerializer.Deserialize(apiResponse)?.Result.Results ?? []; - var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results, tvdbData); - var matchedDesiredEpisodes = ApplyDesiredEpisodeFilter(matchedEpisodes, desiredEpisodes); - - List? newznabItems; - if (matchedDesiredEpisodes.Count == 0 && desiredEpisodes?.Count > 0) - { - // Fallback to best effort matching - newznabItems = desiredEpisodes - .SelectMany(episode => MediathekSearchFallbackHandler.GetFallbackSearchResultItemsById(apiResponse, episode, tvdbData)) - .ToList(); - } - else - { - newznabItems = matchedDesiredEpisodes.SelectMany(GenerateRssItems).ToList(); - } - - var newznabRssResponse = ConvertNewznabItemsToRss(newznabItems, limit, offset); - - _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); - - return newznabRssResponse; - } - - private static List? GetDesiredEpisodes(TvdbData tvdbData, string? season, string? episodeNumber) - { - List? desiredEpisodes; - if (season != null) - { - desiredEpisodes = []; - if (episodeNumber is null) - { - desiredEpisodes.AddRange(tvdbData.FindEpisodesBySeason(season)); - if (season.Length == 4 && int.TryParse(season, out var year)) - { - if (year >= 1900 && year <= 2100) - { - desiredEpisodes.AddRange(tvdbData.FindEpisodesByAirYear(year)); - desiredEpisodes = desiredEpisodes.Distinct().ToList(); - } - } - } - else - { - TvdbEpisode? desiredEpisode; - if (season?.Length == 4 && episodeNumber.Contains('/')) - { - var episodeNumberSplitted = episodeNumber?.Split('/'); - if (episodeNumberSplitted?.Length == 2 && DateTime.TryParse($"{season}-{episodeNumberSplitted[0]}-{episodeNumberSplitted[1]}", out DateTime searchAirDate)) - { - desiredEpisode = tvdbData.FindEpisodeByAirDate(searchAirDate); - } - else - { - desiredEpisode = null; - } - } - else - { - desiredEpisode = tvdbData.FindEpisodeBySeasonAndNumber(season, episodeNumber); - } - - if (desiredEpisode != null) - { - desiredEpisodes.Add(desiredEpisode); - } - } - } - else - { - desiredEpisodes = null; - } - - return desiredEpisodes; - } - - private static string ConvertNewznabItemsToRss(List items, int limit, int offset) - { - if (items == null || items.Count == 0) - { - return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); - } - - var paginatedItems = items.Skip(offset).Take(limit).ToList(); - - var rss = new Rss - { - Channel = new Channel - { - Title = "MediathekArr", - Description = "MediathekArr API results", - Response = new Response - { - Offset = offset, - Total = items.Count - }, - Items = paginatedItems, - } - }; - - return NewznabUtils.SerializeRss(rss); - } - - private static List ApplyDesiredEpisodeFilter(List matchedEpisodes, List? desiredEpisodes) - { - if (desiredEpisodes is null) - { - return matchedEpisodes; - } - - return matchedEpisodes.Where(matched => - desiredEpisodes.Any(desiredEpisode => - desiredEpisode.SeasonNumber == matched.Episode.SeasonNumber && - desiredEpisode.EpisodeNumber == matched.Episode.EpisodeNumber - ) - ).ToList(); - } - - private async Task MatchesSeasonAndEpisode(ApiResultItem item, Ruleset ruleset) - { - // Fetch TVDB episode information - var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); - - if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) - { - return null; - } - - // Use generated title if available, otherwise read from mediathek response - string? title = ruleset.TitleRegexRules.Count != 0 ? - BuildTitleFromRegexRules(item, ruleset.TitleRegexRules) : - GetFieldValue(item, "title"); - - // Extract season and episode from the item using the ruleset - string? season; - if (ruleset.SeasonRegex != null && StaticSeasonRegex().IsMatch(ruleset.SeasonRegex)) - { - // If SeasonRegex is in the format "S0nnn", use it directly - season = ruleset.SeasonRegex[1..]; - } - else - { - // Otherwise, attempt to extract the value using the regex - season = ExtractValueUsingRegex(title, ruleset.SeasonRegex); - } - - string? episode; - if (ruleset.SeasonRegex != null && StaticEpisodeRegex().IsMatch(ruleset.EpisodeRegex)) - { - // If EpisodeRegex is in the format "E0nnn", use it directly - episode = ruleset.EpisodeRegex[1..]; - } - else - { - // Otherwise, attempt to extract the value using the regex - episode = ExtractValueUsingRegex(title, ruleset.EpisodeRegex); - } - - if (string.IsNullOrEmpty(season) || string.IsNullOrEmpty(episode)) - { - return null; - } - - if (!int.TryParse(season, out var seasonNumber) || !int.TryParse(episode, out var episodeNumber)) - { - return null; // Invalid season or episode format - } - - // Find the matching episode in the TVDB data - var matchedEpisode = tvdbData.FindEpisodeBySeasonAndNumber(seasonNumber, episodeNumber); - - if (matchedEpisode == null) - { - return null; // No matching episode found - } - - return new MatchedEpisodeInfo( - Episode: matchedEpisode, - Item: item, - ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, - MatchedTitle: $"S{season}E{episode}" - ); - } - - private async Task MatchesAbsoluteEpisodeNumber(ApiResultItem item, Ruleset ruleset) - { - // Fetch TVDB episode information - var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); - - if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) - { - return null; - } - - // Use generated title if available, otherwise read from mediathek response - string? title = ruleset.TitleRegexRules.Count != 0 ? - BuildTitleFromRegexRules(item, ruleset.TitleRegexRules) : - GetFieldValue(item, "title"); - - // Extract absolute episode number from the item using the ruleset - - string? absoluteEpisode = ExtractValueUsingRegex(title, ruleset.EpisodeRegex); - - if (string.IsNullOrEmpty(absoluteEpisode)) - { - return null; - } - - if (!int.TryParse(absoluteEpisode, out var absoluteEpisodeNumber)) - { - return null; // Invalid season or episode format - } - - // Find the matching episode in the TVDB data - var matchedEpisode = tvdbData.FindEpisodeByAbsoluteEpisodeNumber(absoluteEpisodeNumber); - - if (matchedEpisode == null) - { - return null; // No matching episode found - } - - return new MatchedEpisodeInfo( - Episode: matchedEpisode, - Item: item, - ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, - MatchedTitle: $"E{absoluteEpisode}" - ); - } - - /// - /// Extracts a value from the item using the specified regex rule. - /// - /// The API result item. - /// The regex rule. - /// The extracted value, or null if not found. - private static string? ExtractValueUsingRegex(string? source, string? pattern) - { - if (string.IsNullOrEmpty(pattern)) - { - return null; - } - - if (string.IsNullOrEmpty(source)) - { - return null; - } - - var match = Regex.Match(source, pattern); - - return match.Success && match.Groups.Count > 1 ? match.Groups[1].Value : null; - } - - private async Task MatchesItemTitleIncludes(ApiResultItem item, Ruleset ruleset) - { - // Fetch TVDB episode information - var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); - - if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) - { - return null; - } - - // Construct the title based on ruleset - var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); - - if (string.IsNullOrEmpty(constructedTitle)) - { - return null; - } - - // Check if the constructed title is included in any episode title - var matchedEpisode = - tvdbData.Episodes - .FirstOrDefault(episode => FormatTitle(episode.Name) - .Contains(FormatTitle(constructedTitle), StringComparison.OrdinalIgnoreCase)); - - if (matchedEpisode is null) - { - return null; - } - - return new MatchedEpisodeInfo( - Episode: matchedEpisode, - Item: item, - ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, - MatchedTitle: constructedTitle - ); - } - - private async Task MatchesItemTitleExact(ApiResultItem item, Ruleset ruleset) - { - // Fetch TVDB episode information - var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); - - if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) - { - return null; - } - - // Construct the title based on ruleset - var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); - - if (string.IsNullOrEmpty(constructedTitle)) - { - return null; - } - - var formattedConstructedTitle = FormatTitle(constructedTitle); - - // Check if the constructed title matches any episode title exactly - var matchedEpisodes = - tvdbData.Episodes - .Where(episode => FormatTitle(episode.Name) - .Equals(formattedConstructedTitle, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - TvdbEpisode? matchedEpisode = GuessCorrectMatch(item, matchedEpisodes); - - if (matchedEpisode != null) - { - return new MatchedEpisodeInfo( - Episode: matchedEpisode, - Item: item, - ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, - MatchedTitle: constructedTitle - ); - } - - return null; - } - - private static TvdbEpisode? GuessCorrectMatch(ApiResultItem item, TvdbEpisode[] matchedEpisodes) - { - if (matchedEpisodes.Length == 1) - { - return matchedEpisodes[0]; - } - else // multiple matched episodes found, we try to guess which one is the best - { - // Try to match by aired date - var matchedEpisodeByAirDate = matchedEpisodes.FirstOrDefault(episode => episode.Aired == DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).UtcDateTime.Date); - if (matchedEpisodeByAirDate != null) - { - return matchedEpisodeByAirDate; - } - // chose the newest one - return matchedEpisodes.OrderByDescending(episode => episode.Aired).FirstOrDefault(); - } - } - - private async Task MatchesItemTitleEqualsAirdate(ApiResultItem item, Ruleset ruleset) - { - // Fetch TVDB episode information - var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); - - if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) - { - return null; - } - - // Construct the title based on ruleset - var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); - - if (string.IsNullOrEmpty(constructedTitle)) - { - return null; - } - - if (TryParseDate(constructedTitle, out var parsedDate)) - { - // Find the episode by airdate - var matchedEpisode = tvdbData.FindEpisodeByAirDate(parsedDate); - - if (matchedEpisode != null) - { - return new MatchedEpisodeInfo( - Episode: matchedEpisode, - Item: item, - ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, - MatchedTitle: constructedTitle - ); - } - } - - return null; - } - - private static bool TryParseDate(string dateString, out DateTime date) - { - // Attempt parsing with various formats - var formats = new[] - { - "d. MMMM yyyy", // e.g., "7. Juni 2024" + private readonly IMemoryCache _cache = cache; + private readonly ItemLookupService _itemLookupService = itemLookupService; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient("MediathekClient"); + private readonly ILogger _logger = logger; + private readonly TimeSpan _cacheTimeSpan = TimeSpan.FromMinutes(55); + private static readonly string[] _skipTitleKeywords = ["Audiodeskription", "Hörfassung", "(klare Sprache)", "Gebärdensprache", "Trailer", "Outtakes:"]; + private static readonly string[] _skipUrlKeywords = ["YXVkaW9kZXNrcmlwdGlvbg"]; // base64 for ARD, YXVkaW9kZXNrcmlwdGlvbg = audiodeskription + private static readonly string[] _queryFields = ["topic", "title"]; + private readonly ConcurrentDictionary> _rulesetsByTopic = new(); + + public async Task UpdateRulesetsAsync() + { + var allRulesets = new List(); + int currentPage = 1; + + while (true && currentPage < 100) + { + var response = await _httpClient.GetAsync($"https://mediathekarr.pcjones.de/metadata/api/rulesets.php?page={currentPage++}"); + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + var rulesetResponse = JsonSerializer.Deserialize(responseContent); + + if (rulesetResponse?.Rulesets != null) + { + allRulesets.AddRange(rulesetResponse.Rulesets); + } + + if (rulesetResponse?.Pagination?.CurrentPage >= rulesetResponse?.Pagination.TotalPages) + { + break; + } + } + else + { + // Exit if the request fails + Console.WriteLine("Failed to fetch rulesets from the API."); + break; + } + } + + _rulesetsByTopic.Clear(); + foreach (var ruleset in allRulesets) + { + foreach (var topic in ruleset.Topics) // Iterate over all topics for the ruleset + { + if (!_rulesetsByTopic.TryGetValue(topic, out List? value)) + { + value = []; + _rulesetsByTopic[topic] = value; + } + + value.Add(ruleset); + } + } + + // Sort each topic group by priority + foreach (var topic in _rulesetsByTopic.Keys.ToList()) + { + _rulesetsByTopic[topic] = [.. _rulesetsByTopic[topic].OrderBy(ruleset => ruleset.Priority)]; + } + } + + private const int MediathekViewApiMaxPageSize = 1000; + + private async Task> FetchMediathekViewApiResponseAsync(List queries, int maxSize) + { + var allResults = new List(); + var offset = 0; + + while (allResults.Count < maxSize) + { + var pageSize = Math.Min(MediathekViewApiMaxPageSize, maxSize - allResults.Count); + var requestBody = new + { + queries, + sortBy = "filmlisteTimestamp", + sortOrder = "desc", + future = true, + offset, + size = pageSize + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8); + var response = await _httpClient.PostAsync("https://mediathekviewweb.de/api/query", requestContent); + + if (!response.IsSuccessStatusCode) + { + break; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var pageResults = JsonSerializer.Deserialize(responseContent)?.Result.Results ?? []; + + allResults.AddRange(pageResults); + + if (pageResults.Count < pageSize) + { + break; + } + + offset += pageSize; + } + + return allResults; + } + + private async Task> FetchCachedApiResponseForTvdbId(Models.Tvdb.Data tvdbData) + { + var cacheKey = $"mediathekapi_{tvdbData.Id}"; + + if (_cache.TryGetValue(cacheKey, out List? cachedResults)) + { + return cachedResults ?? []; + } + + var searchQuery = tvdbData.GermanName.Replace(" & ", " ") ?? tvdbData.Name.Replace(" & ", " "); + if (searchQuery.Contains('(')) + { + // if title contains year or other information in brackets like (DE), remove it + searchQuery = searchQuery.Split('(')[0]; + } + searchQuery = searchQuery.Trim(); + + var rulesetTopics = GetTopicsForTvdbId(tvdbData.Id); + var additionalTopics = rulesetTopics + .Where(t => !t.Equals(searchQuery, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var allQueries = new List> + { + new() { new { fields = _queryFields, query = searchQuery } } + }; + foreach (var topic in additionalTopics) + { + allQueries.Add([new { fields = new[] { "topic" }, query = topic }]); + } + + var tasks = allQueries.Select(q => FetchMediathekViewApiResponseAsync(q, 1000)); + var responses = await Task.WhenAll(tasks); + + var allResults = responses + .SelectMany(r => r) + .DistinctBy(item => item.UrlVideo) + .ToList(); + + _cache.Set(cacheKey, allResults, _cacheTimeSpan); + return allResults; + } + + public async Task FetchSearchResultsFromApiById(Models.Tvdb.Data tvdbData, string? season, string? episodeNumber, int limit, int offset) + { + var cacheKey = $"tvdb_{tvdbData.Id}_{season ?? "null"}_{episodeNumber ?? "null"}_{limit}_{offset}"; + + if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) + { + return cachedResponse ?? ""; + } + + List? desiredEpisodes = GetDesiredEpisodes(tvdbData, season, episodeNumber); + if (season != null && desiredEpisodes?.Count == 0) + { + var response = NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); + _cache.Set(cacheKey, response, _cacheTimeSpan); + return response; + } + + var results = await FetchCachedApiResponseForTvdbId(tvdbData); + if (results.Count == 0) + { + return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); + } + + var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results, tvdbData); + var matchedDesiredEpisodes = ApplyDesiredEpisodeFilter(matchedEpisodes, desiredEpisodes); + + List? newznabItems; + if (matchedDesiredEpisodes.Count == 0 && desiredEpisodes?.Count > 0) + { + // Fallback to best effort matching + newznabItems = desiredEpisodes + .SelectMany(episode => MediathekSearchFallbackHandler.GetFallbackSearchResultItemsById(results, episode, tvdbData, _logger)) + .ToList(); + } + else + { + newznabItems = matchedDesiredEpisodes.SelectMany(GenerateRssItems).ToList(); + } + + var newznabRssResponse = ConvertNewznabItemsToRss(newznabItems, limit, offset); + + _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); + + return newznabRssResponse; + } + + private static List? GetDesiredEpisodes(Models.Tvdb.Data tvdbData, string? season, string? episodeNumber) + { + List? desiredEpisodes; + if (season != null) + { + desiredEpisodes = []; + if (episodeNumber is null) + { + desiredEpisodes.AddRange(tvdbData.FindEpisodesBySeason(season)); + if (season.Length == 4 && int.TryParse(season, out var year)) + { + if (year >= 1900 && year <= 2100) + { + desiredEpisodes.AddRange(tvdbData.FindEpisodesByAirYear(year)); + desiredEpisodes = desiredEpisodes.Distinct().ToList(); + } + } + } + else + { + Models.Tvdb.Episode? desiredEpisode; + if (season?.Length == 4 && episodeNumber.Contains('/')) + { + var episodeNumberSplitted = episodeNumber?.Split('/'); + if (episodeNumberSplitted?.Length == 2 && DateTime.TryParse($"{season}-{episodeNumberSplitted[0]}-{episodeNumberSplitted[1]}", out DateTime searchAirDate)) + { + desiredEpisode = tvdbData.FindEpisodeByAirDate(searchAirDate); + } + else + { + desiredEpisode = null; + } + } + else + { + desiredEpisode = tvdbData.FindEpisodeBySeasonAndNumber(season, episodeNumber); + } + + if (desiredEpisode != null) + { + desiredEpisodes.Add(desiredEpisode); + } + } + } + else + { + desiredEpisodes = null; + } + + return desiredEpisodes; + } + + private static string ConvertNewznabItemsToRss(List items, int limit, int offset) + { + if (items == null || items.Count == 0) + { + return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); + } + + var paginatedItems = items.Skip(offset).Take(limit).ToList(); + + var rss = new Rss + { + Channel = new Channel + { + Title = "MediathekArr", + Description = "MediathekArr API results", + Response = new Response + { + Offset = offset, + Total = items.Count + }, + Items = paginatedItems, + } + }; + + return NewznabUtils.SerializeRss(rss); + } + + private static List ApplyDesiredEpisodeFilter(List matchedEpisodes, List? desiredEpisodes) + { + if (desiredEpisodes is null) + { + return matchedEpisodes; + } + + return matchedEpisodes.Where(matched => + desiredEpisodes.Any(desiredEpisode => + desiredEpisode.SeasonNumber == matched.Episode.SeasonNumber && + desiredEpisode.EpisodeNumber == matched.Episode.EpisodeNumber + ) + ).ToList(); + } + + private async Task MatchesSeasonAndEpisode(ApiResultItem item, Ruleset ruleset) + { + // Fetch TVDB episode information + var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); + + if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) + { + return null; + } + + // Use generated title if available, otherwise read from mediathek response + string? title = ruleset.TitleRegexRules.Count != 0 ? + BuildTitleFromRegexRules(item, ruleset.TitleRegexRules) : + GetFieldValue(item, "title"); + + // Extract season and episode from the item using the ruleset + string? season; + if (ruleset.SeasonRegex != null && StaticSeasonRegex().IsMatch(ruleset.SeasonRegex)) + { + // If SeasonRegex is in the format "S0nnn", use it directly + season = ruleset.SeasonRegex[1..]; + } + else + { + // Otherwise, attempt to extract the value using the regex + season = ExtractValueUsingRegex(title, ruleset.SeasonRegex); + } + + string? episode; + if (ruleset.EpisodeRegex != null && StaticEpisodeRegex().IsMatch(ruleset.EpisodeRegex)) + { + // If EpisodeRegex is in the format "E0nnn", use it directly + episode = ruleset.EpisodeRegex[1..]; + } + else + { + // Otherwise, attempt to extract the value using the regex + episode = ExtractValueUsingRegex(title, ruleset.EpisodeRegex); + } + + if (string.IsNullOrEmpty(season) || string.IsNullOrEmpty(episode)) + { + return null; + } + + if (!int.TryParse(season, out var seasonNumber) || !int.TryParse(episode, out var episodeNumber)) + { + return null; // Invalid season or episode format + } + + // Find the matching episode in the TVDB data + var matchedEpisode = tvdbData.FindEpisodeBySeasonAndNumber(seasonNumber, episodeNumber); + + if (matchedEpisode == null) + { + return null; // No matching episode found + } + + return new MatchedEpisodeInfo( + Episode: matchedEpisode, + Item: item, + ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, + MatchedTitle: $"S{season}E{episode}" + ); + } + + private async Task MatchesAbsoluteEpisodeNumber(ApiResultItem item, Ruleset ruleset) + { + // Fetch TVDB episode information + var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); + + if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) + { + return null; + } + + // Use generated title if available, otherwise read from mediathek response + string? title = ruleset.TitleRegexRules.Count != 0 ? + BuildTitleFromRegexRules(item, ruleset.TitleRegexRules) : + GetFieldValue(item, "title"); + + // Extract absolute episode number from the item using the ruleset + + string? absoluteEpisode = ExtractValueUsingRegex(title, ruleset.EpisodeRegex); + + if (string.IsNullOrEmpty(absoluteEpisode)) + { + return null; + } + + if (!int.TryParse(absoluteEpisode, out var absoluteEpisodeNumber)) + { + return null; // Invalid season or episode format + } + + // Find the matching episode in the TVDB data + var matchedEpisode = tvdbData.FindEpisodeByAbsoluteEpisodeNumber(absoluteEpisodeNumber); + + if (matchedEpisode == null) + { + return null; // No matching episode found + } + + return new MatchedEpisodeInfo( + Episode: matchedEpisode, + Item: item, + ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, + MatchedTitle: $"E{absoluteEpisode}" + ); + } + + /// + /// Extracts a value from the item using the specified regex rule. + /// + /// The API result item. + /// The regex rule. + /// The extracted value, or null if not found. + private static string? ExtractValueUsingRegex(string? source, string? pattern) + { + if (string.IsNullOrEmpty(pattern)) + { + return null; + } + + if (string.IsNullOrEmpty(source)) + { + return null; + } + + var match = Regex.Match(source, pattern); + + return match.Success && match.Groups.Count > 1 ? match.Groups[1].Value : null; + } + + private async Task MatchesItemTitleIncludes(ApiResultItem item, Ruleset ruleset) + { + // Fetch TVDB episode information + var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); + + if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) + { + return null; + } + + // Construct the title based on ruleset + var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); + + if (string.IsNullOrEmpty(constructedTitle)) + { + return null; + } + + // Check if the constructed title is included in any episode title + var matchedEpisode = + tvdbData.Episodes + .FirstOrDefault(episode => FormatTitle(episode.Name) + .Contains(FormatTitle(constructedTitle), StringComparison.OrdinalIgnoreCase)); + + if (matchedEpisode is null) + { + return null; + } + + return new MatchedEpisodeInfo( + Episode: matchedEpisode, + Item: item, + ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, + MatchedTitle: constructedTitle + ); + } + + private async Task MatchesItemTitleExact(ApiResultItem item, Ruleset ruleset) + { + // Fetch TVDB episode information + var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); + + if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) + { + return null; + } + + // Construct the title based on ruleset + var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); + + if (string.IsNullOrEmpty(constructedTitle)) + { + return null; + } + + var formattedConstructedTitle = FormatTitle(constructedTitle); + + // Check if the constructed title matches any episode title exactly + var matchedEpisodes = + tvdbData.Episodes + .Where(episode => FormatTitle(episode.Name) + .Equals(formattedConstructedTitle, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + Models.Tvdb.Episode? matchedEpisode = GuessCorrectMatch(item, matchedEpisodes); + + if (matchedEpisode != null) + { + return new MatchedEpisodeInfo( + Episode: matchedEpisode, + Item: item, + ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, + MatchedTitle: constructedTitle + ); + } + + return null; + } + + private static Models.Tvdb.Episode? GuessCorrectMatch(ApiResultItem item, Models.Tvdb.Episode[] matchedEpisodes) + { + if (matchedEpisodes.Length == 1) + { + return matchedEpisodes[0]; + } + else // multiple matched episodes found, we try to guess which one is the best + { + // Try to match by aired date + var matchedEpisodeByAirDate = matchedEpisodes.FirstOrDefault(episode => episode.Aired == DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).UtcDateTime.Date); + if (matchedEpisodeByAirDate != null) + { + return matchedEpisodeByAirDate; + } + // chose the newest one + return matchedEpisodes.OrderByDescending(episode => episode.Aired).FirstOrDefault(); + } + } + + private async Task MatchesItemTitleEqualsAirdate(ApiResultItem item, Ruleset ruleset) + { + // Fetch TVDB episode information + var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId); + + if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0) + { + return null; + } + + // Construct the title based on ruleset + var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules); + + if (string.IsNullOrEmpty(constructedTitle)) + { + return null; + } + + if (TryParseDate(constructedTitle, out var parsedDate)) + { + // Find the episode by airdate + var matchedEpisode = tvdbData.FindEpisodeByAirDate(parsedDate); + + if (matchedEpisode != null) + { + return new MatchedEpisodeInfo( + Episode: matchedEpisode, + Item: item, + ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name, + MatchedTitle: constructedTitle + ); + } + } + + return null; + } + + private static bool TryParseDate(string dateString, out DateTime date) + { + // Attempt parsing with various formats + var formats = new[] + { + "d. MMMM yyyy", // e.g., "7. Juni 2024" "dd.MM.yyyy", // e.g., "31.12.2017" "yyyy-MM-dd", // e.g., "2017-12-01" "yyyyMMdd", // e.g., "20171201" "dd. MMMM yyyy", // e.g., "07. Juni 2024" }; - return DateTime.TryParseExact( - dateString, - formats, - CultureInfo.GetCultureInfo("de-DE"), - DateTimeStyles.None, - out date - ); - } - - private static string? BuildTitleFromRegexRules(ApiResultItem item, List titleRegexRules) - { - var stringBuilder = new StringBuilder(); - - foreach (var rule in titleRegexRules) - { - switch (rule.Type) - { - case TitleRegexRuleType.Static: - // Append the static value directly - if (!string.IsNullOrEmpty(rule.Value)) - { - stringBuilder.Append(rule.Value); - } - break; - - case TitleRegexRuleType.Regex: - // Extract substring using the regex pattern from the specified field - if (!string.IsNullOrEmpty(rule.Pattern) && !string.IsNullOrEmpty(rule.Field)) - { - var fieldValue = GetFieldValue(item, rule.Field); - if (!string.IsNullOrEmpty(fieldValue)) - { - var match = Regex.Match(fieldValue, rule.Pattern); - if (match.Success && match.Groups[^1].Length > 0) - { - // Use the last group - stringBuilder.Append(match.Groups[^1].Value); - } - else - { - // abort if regex match failed - return null; - } - } - } - break; - } - } - - return stringBuilder.ToString(); - } - - private static string GetFieldValue(ApiResultItem item, string fieldName) - { - return fieldName switch - { - "channel" => item.Channel, - "topic" => item.Topic, - "title" => item.Title, - "description" => item.Description, - "timestamp" => item.Timestamp.ToString(), - "duration" => item.Duration.ToString(), - "size" => item.Size.ToString(), - "url_website" => item.UrlWebsite, - "url_video" => item.UrlVideo, - "url_video_low" => item.UrlVideoLow, - "url_video_hd" => item.UrlVideoHd, - _ => string.Empty - }; - } - - - private static bool FilterMatches(ApiResultItem item, Filter filter) - { - string? attributeValue = GetFieldValue(item, filter.Attribute); - - return filter.Type switch - { - MatchType.ExactMatch => attributeValue.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase), - MatchType.Contains => attributeValue.Contains(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase), - MatchType.Regex => Regex.IsMatch(attributeValue, filter.Value.ToString()), - MatchType.GreaterThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue > filterValue * 60, - MatchType.LessThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue < filterValue * 60, - _ => false, - }; - } - - private List GetRulesetsForTopic(string topic) - { - return _rulesetsByTopic.TryGetValue(topic, out var rulesets) ? rulesets : []; - } - - private async Task<(List matchedEpisodes, List unmatchedFilteredResultItems)> ApplyRulesetFilters(List results, TvdbData? tvdbData = null) - { - var matchedFilteredResults = new List(); - var unmatchedFilteredResults = new List(results); - - foreach (var item in results) - { - if (ShouldSkipItem(item)) - { - unmatchedFilteredResults.Remove(item); - continue; - } - - // Get applicable rulesets for the topic or specific TVDB data - var rulesets = tvdbData is null - ? GetRulesetsForTopic(item.Topic) - : GetRulesetsForTopic(item.Topic).Where(r => r.Media?.TvdbId == tvdbData.Id).ToList(); - - foreach (var ruleset in rulesets) - { - if (!ruleset.Filters.All(filter => FilterMatches(item, filter))) - { - unmatchedFilteredResults.Remove(item); - continue; // Skip this ruleset if any filter fails - } - - MatchedEpisodeInfo? matchInfo = null; - - switch (ruleset.MatchingStrategy) - { - case MatchingStrategy.SeasonAndEpisodeNumber: - matchInfo = await MatchesSeasonAndEpisode(item, ruleset); - break; - case MatchingStrategy.ItemTitleIncludes: - matchInfo = await MatchesItemTitleIncludes(item, ruleset); - break; - case MatchingStrategy.ItemTitleExact: - matchInfo = await MatchesItemTitleExact(item, ruleset); - break; - case MatchingStrategy.ItemTitleEqualsAirdate: - matchInfo = await MatchesItemTitleEqualsAirdate(item, ruleset); - break; - case MatchingStrategy.ByAbsoluteEpisodeNumber: - matchInfo = await MatchesAbsoluteEpisodeNumber(item, ruleset); - break; - } - - if (matchInfo != null) - { - matchedFilteredResults.Add(matchInfo); - break; - } - else - { - unmatchedFilteredResults.Remove(item); - } - } - } - - return (matchedFilteredResults, unmatchedFilteredResults); - } - - public async Task FetchSearchResultsForRssSync(int limit, int offset) - { - var cacheKey = $"rss_{limit}_{offset}"; - - // Return cached response if it exists - if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) - { - return cachedResponse ?? ""; - } - - var mediathekViewRequestCacheKey = "rss_mediathekview_results"; - List results; - if (_cache.TryGetValue(mediathekViewRequestCacheKey, out List? cachedResults)) - { - results = cachedResults ?? []; - } - else - { - var queries = new List(); - var apiResponse = await FetchMediathekViewApiResponseAsync(queries, 6000); - - if (string.IsNullOrEmpty(apiResponse)) - { - return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); - } - - results = JsonSerializer.Deserialize(apiResponse)?.Result.Results ?? []; - _cache.Set(mediathekViewRequestCacheKey, results, TimeSpan.FromMinutes(20)); - } - - // Deserialize the API response and apply ruleset filters - var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results); - - List? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList(); - List? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, null); - - // Combine the results from ruleset matching and fallback handler - var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset); - - // Cache the response and return it - _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); - return newznabRssResponse; - } - - public async Task FetchSearchResultsFromApiByString(string? q, string? season, int limit, int offset) - { - var cacheKey = $"q_{q ?? "null"}_{season ?? "null"}_{limit}_{offset}"; - - // Return cached response if it exists - if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) - { - return cachedResponse ?? ""; - } - - var mediathekViewRequestCacheKey = $"mediathekapi_{q ?? "null"}_{season ?? "null"}"; - string apiResponse; - if (_cache.TryGetValue(mediathekViewRequestCacheKey, out string? cachedApiResponse)) - { - apiResponse = cachedApiResponse ?? string.Empty; - } - else - { - var queries = new List(); - if (q != null) - { - queries.Add(new { fields = _queryFields, query = q }); - } - - if (!string.IsNullOrEmpty(season)) - { - var zeroBasedSeason = season.Length >= 2 ? season : $"0{season}"; - queries.Add(new { fields = new[] { "title" }, query = $"S{zeroBasedSeason}" }); - } - - apiResponse = await FetchMediathekViewApiResponseAsync(queries, 1500); - if (string.IsNullOrEmpty(apiResponse)) - { - return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); - } - - _cache.Set(mediathekViewRequestCacheKey, apiResponse, _cacheTimeSpan); - } - // Deserialize the API response and apply ruleset filters - var results = JsonSerializer.Deserialize(apiResponse)?.Result.Results ?? []; - var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results); - - List? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList(); - List? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, season); - - // Combine the results from ruleset matching and fallback handler - var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset); - - // Cache the response and return it - _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); - return newznabRssResponse; - } - - private List GenerateRssItems(MatchedEpisodeInfo matchedEpisodeInfo) - { - var items = new List(); - - string[] categories = ["5000", "2000"]; - - if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoHd)) - { - items.AddRange(CreateRssItems(matchedEpisodeInfo, "1080p", 1.75, "TV > HD", [.. categories, "5040", "2040"], matchedEpisodeInfo.Item.UrlVideoHd)); - } - - if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideo)) - { - items.AddRange(CreateRssItems(matchedEpisodeInfo, "720p", 1.0, "TV > HD", [.. categories, "5040", "2040"], matchedEpisodeInfo.Item.UrlVideo)); - } - - if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoLow)) - { - items.AddRange(CreateRssItems(matchedEpisodeInfo, "480p", 0.4, "TV > SD", [.. categories, "5030", "2030"], matchedEpisodeInfo.Item.UrlVideoLow)); - - } - - return items; - } - - private static List CreateRssItems(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url) - { - var items = new List - { - CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Standard) - }; - - // also create daily type if season is a year - if (matchedEpisodeInfo.Episode.SeasonNumber > 1950) - { - items.Add(CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Daily)); - } - - return items; - } - - private static string FormatTitle(string title) - { - // Remove unwanted characters - title = title.Replace("–", "-"); - title = title.RemoveAccentButKeepGermanUmlauts(); - title = TitleRegexUnd().Replace(title, "and"); - title = TitleRegexSymbols().Replace(title, ""); // Remove various symbols - title = TitleRegexWhitespace().Replace(title, ".").Replace("..", "."); - - return title; - } - - - private static Item CreateRssItem(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url, EpisodeType episodeType) - { - var item = matchedEpisodeInfo.Item; - var adjustedSize = (long)(matchedEpisodeInfo.Item.Size * sizeMultiplier); - if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlSubtitle)) - { - adjustedSize += 15000000; // Add 15MB to size if subs are available - } - var parsedTitle = GenerateTitle(matchedEpisodeInfo, quality, episodeType); - var formattedTitle = FormatTitle(parsedTitle); - var translatedTitle = formattedTitle; - var encodedTitle = Convert.ToBase64String(Encoding.UTF8.GetBytes(translatedTitle)); - var encodedVideoUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(url)); - var encodedSubtitleUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(item.UrlSubtitle)); - - // Generate the full URL for the fake_nzb_download endpoint - var fakeDownloadUrl = $"/api/fake_nzb_download?encodedVideoUrl={encodedVideoUrl}&encodedSubtitleUrl={encodedSubtitleUrl}&encodedTitle={encodedTitle}"; - - return new Item - { - Title = translatedTitle, - Guid = new Guid - { - IsPermaLink = true, - Value = $"{item.UrlWebsite}#{quality}{(episodeType == EpisodeType.Daily ? "" : "-d")}-{item.Language}", - }, - Link = url, - Comments = item.UrlWebsite, - PubDate = DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString("R"), - Category = category, - Description = item.Description, - Enclosure = new Enclosure - { - Url = fakeDownloadUrl, - Length = adjustedSize, - Type = "application/x-nzb" - }, - Attributes = NewznabUtils.GenerateAttributes(matchedEpisodeInfo, categoryValues, episodeType) - }; - } - - private static string GenerateTitle(MatchedEpisodeInfo matchedEpisodeInfo, string quality, EpisodeType episodeType) - { - var episode = matchedEpisodeInfo.Episode; - - if (episodeType == EpisodeType.Daily) - { - return $"{matchedEpisodeInfo.ShowName}.{episode.Aired:yyyy-MM-dd}.{episode.Name}.{matchedEpisodeInfo.Item.Language}.{quality}.WEB.h264-MEDiATHEK".Replace(" ", "."); - } - return $"{matchedEpisodeInfo.ShowName}.S{episode.PaddedSeason}E{episode.PaddedEpisode}.{episode.Name}.{matchedEpisodeInfo.Item.Language}.{quality}.WEB.h264-MEDiATHEK".Replace(" ", "."); - } - - public static bool ShouldSkipItem(ApiResultItem item) - { - return item.UrlVideo.EndsWith(".m3u8") || _skipTitleKeywords.Any(item.Title.Contains) || _skipUrlKeywords.Any(item.UrlWebsite.Contains); - } - - [GeneratedRegex(@"[&]")] - private static partial Regex TitleRegexUnd(); - [GeneratedRegex(@"[/:;,""'’@#?$%^*+=!|<>,()|]")] - private static partial Regex TitleRegexSymbols(); - [GeneratedRegex(@"\s+")] - private static partial Regex TitleRegexWhitespace(); - [GeneratedRegex(@"^S\d{1,4}$")] - private static partial Regex StaticSeasonRegex(); - [GeneratedRegex(@"^E\d{1,4}$")] - private static partial Regex StaticEpisodeRegex(); + return DateTime.TryParseExact( + dateString, + formats, + CultureInfo.GetCultureInfo("de-DE"), + DateTimeStyles.None, + out date + ); + } + + private static string? BuildTitleFromRegexRules(ApiResultItem item, List titleRegexRules) + { + var stringBuilder = new StringBuilder(); + + foreach (var rule in titleRegexRules) + { + switch (rule.Type) + { + case TitleRegexRuleType.Static: + // Append the static value directly + if (!string.IsNullOrEmpty(rule.Value)) + { + stringBuilder.Append(rule.Value); + } + break; + + case TitleRegexRuleType.Regex: + // Extract substring using the regex pattern from the specified field + if (!string.IsNullOrEmpty(rule.Pattern) && !string.IsNullOrEmpty(rule.Field)) + { + var fieldValue = GetFieldValue(item, rule.Field); + if (!string.IsNullOrEmpty(fieldValue)) + { + var match = Regex.Match(fieldValue, rule.Pattern); + if (match.Success && match.Groups[^1].Length > 0) + { + // Use the last group + stringBuilder.Append(match.Groups[^1].Value); + } + else + { + // abort if regex match failed + return null; + } + } + } + break; + } + } + + return stringBuilder.ToString(); + } + + private static string GetFieldValue(ApiResultItem item, string fieldName) + { + return fieldName switch + { + "channel" => item.Channel, + "topic" => item.Topic, + "title" => item.Title, + "description" => item.Description, + "timestamp" => item.Timestamp.ToString(), + "duration" => item.Duration.ToString(), + "size" => item.Size.ToString(), + "url_website" => item.UrlWebsite, + "url_video" => item.UrlVideo, + "url_video_low" => item.UrlVideoLow, + "url_video_hd" => item.UrlVideoHd, + "timestamp_date" => DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString("yyyyMMdd"), + _ => string.Empty + }; + } + + + private static bool FilterMatches(ApiResultItem item, Filter filter) + { + string? attributeValue = GetFieldValue(item, filter.Attribute); + + return filter.Type switch + { + MatchType.ExactMatch => attributeValue.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase), + MatchType.Contains => attributeValue.Contains(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase), + MatchType.Regex => Regex.IsMatch(attributeValue, filter.Value.ToString()), + MatchType.GreaterThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue > filterValue * 60, + MatchType.LowerThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue < filterValue * 60, + _ => false, + }; + } + + private List GetRulesetsForTopic(string topic) + { + return _rulesetsByTopic.TryGetValue(topic, out var rulesets) ? rulesets : []; + } + + private List GetTopicsForTvdbId(long tvdbId) + { + var topics = new HashSet(); + foreach (var rulesetList in _rulesetsByTopic.Values) + { + foreach (var ruleset in rulesetList) + { + if (ruleset.Media?.TvdbId == tvdbId) + { + foreach (var topic in ruleset.Topics) + { + topics.Add(topic); + } + } + } + } + return [.. topics]; + } + + private async Task<(List matchedEpisodes, List unmatchedFilteredResultItems)> ApplyRulesetFilters(List results, Models.Tvdb.Data? tvdbData = null) + { + var matchedFilteredResults = new List(); + var unmatchedFilteredResults = new List(); + + foreach (var item in results) + { + if (ShouldSkipItem(item)) + { + unmatchedFilteredResults.Remove(item); + continue; + } + + // Get applicable rulesets for the topic or specific TVDB data + var rulesets = tvdbData is null + ? GetRulesetsForTopic(item.Topic) + : [.. GetRulesetsForTopic(item.Topic).Where(r => r.Media?.TvdbId == tvdbData?.Id)]; + + foreach (var ruleset in rulesets) + { + if (!ruleset.Filters.All(filter => FilterMatches(item, filter))) + { + continue; // Skip this ruleset if any filter fails + } + + MatchedEpisodeInfo? matchInfo = null; + + switch (ruleset.MatchingStrategy) + { + case MatchingStrategy.SeasonAndEpisodeNumber: + matchInfo = await MatchesSeasonAndEpisode(item, ruleset); + break; + case MatchingStrategy.ItemTitleIncludes: + matchInfo = await MatchesItemTitleIncludes(item, ruleset); + break; + case MatchingStrategy.ItemTitleExact: + matchInfo = await MatchesItemTitleExact(item, ruleset); + break; + case MatchingStrategy.ItemTitleEqualsAirdate: + matchInfo = await MatchesItemTitleEqualsAirdate(item, ruleset); + break; + case MatchingStrategy.ByAbsoluteEpisodeNumber: + matchInfo = await MatchesAbsoluteEpisodeNumber(item, ruleset); + break; + } + + if (matchInfo != null) + { + matchedFilteredResults.Add(matchInfo); + break; + } + else + { + unmatchedFilteredResults.Add(item); + } + } + } + + return (matchedFilteredResults, unmatchedFilteredResults); + } + + public async Task FetchSearchResultsForRssSync(int limit, int offset) + { + var cacheKey = $"rss_{limit}_{offset}"; + + // Return cached response if it exists + if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) + { + return cachedResponse ?? ""; + } + + var mediathekViewRequestCacheKey = "rss_mediathekview_results"; + List results; + if (_cache.TryGetValue(mediathekViewRequestCacheKey, out List? cachedResults)) + { + results = cachedResults ?? []; + } + else + { + var queries = new List(); + results = await FetchMediathekViewApiResponseAsync(queries, 10000); + + if (results.Count == 0) + { + return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); + } + + _cache.Set(mediathekViewRequestCacheKey, results, TimeSpan.FromMinutes(20)); + } + + // Deserialize the API response and apply ruleset filters + var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results); + + List? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList(); + List? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, null); + + // Combine the results from ruleset matching and fallback handler + var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset); + + // Cache the response and return it + _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); + return newznabRssResponse; + } + + public async Task FetchSearchResultsFromApiByString(string? q, string? season, int limit, int offset) + { + var cacheKey = $"q_{q ?? "null"}_{season ?? "null"}_{limit}_{offset}"; + + // Return cached response if it exists + if (_cache.TryGetValue(cacheKey, out string? cachedResponse)) + { + return cachedResponse ?? ""; + } + + var mediathekViewRequestCacheKey = $"mediathekapi_{q ?? "null"}_{season ?? "null"}"; + List results; + if (_cache.TryGetValue(mediathekViewRequestCacheKey, out List? cachedResults)) + { + results = cachedResults ?? []; + } + else + { + var queries = new List(); + if (q != null) + { + queries.Add(new { fields = _queryFields, query = q }); + } + + if (!string.IsNullOrEmpty(season)) + { + var zeroBasedSeason = season.Length >= 2 ? season : $"0{season}"; + queries.Add(new { fields = new[] { "title" }, query = $"S{zeroBasedSeason}" }); + } + + results = await FetchMediathekViewApiResponseAsync(queries, 2000); + if (results.Count == 0) + { + return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult()); + } + + _cache.Set(mediathekViewRequestCacheKey, results, _cacheTimeSpan); + } + // Apply ruleset filters + var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results); + + List? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList(); + List? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, season); + + // Combine the results from ruleset matching and fallback handler + var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset); + + // Cache the response and return it + _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan); + return newznabRssResponse; + } + + private List GenerateRssItems(MatchedEpisodeInfo matchedEpisodeInfo) + { + var items = new List(); + + string[] categories = ["5000", "2000"]; + + if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoHd)) + { + items.AddRange(CreateRssItems(matchedEpisodeInfo, "1080p", 1.75, "TV > HD", [.. categories, "5040", "2040"], matchedEpisodeInfo.Item.UrlVideoHd)); + } + + if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideo)) + { + items.AddRange(CreateRssItems(matchedEpisodeInfo, "720p", 1.0, "TV > HD", [.. categories, "5040", "2040"], matchedEpisodeInfo.Item.UrlVideo)); + } + + if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoLow)) + { + items.AddRange(CreateRssItems(matchedEpisodeInfo, "480p", 0.4, "TV > SD", [.. categories, "5030", "2030"], matchedEpisodeInfo.Item.UrlVideoLow)); + + } + + return items; + } + + private static List CreateRssItems(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url) + { + var items = new List + { + CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Standard) + }; + + // also create daily type if season is a year + if (matchedEpisodeInfo.Episode.SeasonNumber > 1950) + { + items.Add(CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Daily)); + } + + return items; + } + + private static string FormatTitle(string? title) + { + if (string.IsNullOrEmpty(title)) + { + return string.Empty; + } + // Remove unwanted characters + title = title.Replace("–", "-"); + title = title.RemoveAccentButKeepGermanUmlauts(); + title = TitleRegexUnd().Replace(title, "and"); + title = TitleRegexInvalidChars().Replace(title, ""); // Remove invalid characters + title = TitleRegexWhitespace().Replace(title, ".").Replace("..", "."); + + return title; + } + + + private static Item CreateRssItem(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url, EpisodeType episodeType) + { + var item = matchedEpisodeInfo.Item; + var adjustedSize = (long)(matchedEpisodeInfo.Item.Size * sizeMultiplier); + + // Enforce m3u8 minimum size to keep Sonarr happy (dependent on quality and duration) + if (url.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase)) + { + long bitrate = 600000; + switch (quality) + { + case "1080p": + bitrate = 750000; + break; + case "720p": + bitrate = 450000; + break; + case "480p": + bitrate = 250000; + break; + } + + long estimatedMinSize = (long)item.Duration * bitrate; + if (adjustedSize < estimatedMinSize) + { + adjustedSize = estimatedMinSize; + } + } + + if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlSubtitle)) + { + adjustedSize += 15000000; // Add 15MB to size if subs are available + } + + var parsedTitle = GenerateTitle(matchedEpisodeInfo, quality, episodeType); + var formattedTitle = FormatTitle(parsedTitle); + var translatedTitle = formattedTitle; + var encodedTitle = Convert.ToBase64String(Encoding.UTF8.GetBytes(translatedTitle)); + var encodedVideoUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(url)); + var encodedSubtitleUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(item.UrlSubtitle)); + + // Generate the full URL for the fake_nzb_download endpoint + var fakeDownloadUrl = $"/api/fake_nzb_download?encodedVideoUrl={encodedVideoUrl}&encodedSubtitleUrl={encodedSubtitleUrl}&encodedTitle={encodedTitle}"; + + return new Item + { + Title = translatedTitle, + Guid = new Guid + { + IsPermaLink = true, + Value = $"{item.UrlWebsite}#{quality}{(episodeType == EpisodeType.Daily ? "" : "-d")}-{item.Language}", + }, + Link = url, + Comments = item.UrlWebsite, + PubDate = DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString("R"), + Category = category, + Description = item.Description, + Enclosure = new Enclosure + { + Url = fakeDownloadUrl, + Length = adjustedSize, + Type = "application/x-nzb" + }, + Attributes = NewznabUtils.GenerateAttributes(matchedEpisodeInfo, categoryValues, episodeType) + }; + } + + private static string GenerateTitle(MatchedEpisodeInfo matchedEpisodeInfo, string quality, EpisodeType episodeType) + { + var episode = matchedEpisodeInfo.Episode; + + if (episodeType == EpisodeType.Daily) + { + return $"{matchedEpisodeInfo.ShowName}.{episode.Aired:yyyy-MM-dd}.{episode.Name}.{matchedEpisodeInfo.Item.Language}.{quality}.WEB.h264-MEDiATHEK".Replace(" ", "."); + } + return $"{matchedEpisodeInfo.ShowName}.S{episode.PaddedSeason}E{episode.PaddedEpisode}.{episode.Name}.{matchedEpisodeInfo.Item.Language}.{quality}.WEB.h264-MEDiATHEK".Replace(" ", "."); + } + + public static bool ShouldSkipItem(ApiResultItem item) + { + if (_skipTitleKeywords.Any(item.Title.Contains)) + { + return true; + } + + // TODO determine if we should keep skipUrlKeywords ard base64 or not. Mediathekview very often has this wrong. + return item.Channel switch + { + "ARD" => _skipUrlKeywords.Any(item.UrlWebsite.Contains), + "SWR" => _skipUrlKeywords.Any(item.UrlVideo.Contains), + _ => false + }; + } + + [GeneratedRegex(@"[&]")] + private static partial Regex TitleRegexUnd(); + [GeneratedRegex(@"[/:;,""„""’’‚’@#?$%^*+=!|<>,()|·]")] + private static partial Regex TitleRegexInvalidChars(); + [GeneratedRegex(@"\s+")] + private static partial Regex TitleRegexWhitespace(); + [GeneratedRegex(@"^S\d{1,4}$")] + private static partial Regex StaticSeasonRegex(); + [GeneratedRegex(@"^E\d{1,4}$")] + private static partial Regex StaticEpisodeRegex(); } \ No newline at end of file diff --git a/MediathekArrServer/Services/RulesetBackgroundService.cs b/MediathekArrServer/Services/RulesetBackgroundService.cs index 06a0d9e..01b9bad 100644 --- a/MediathekArrServer/Services/RulesetBackgroundService.cs +++ b/MediathekArrServer/Services/RulesetBackgroundService.cs @@ -1,4 +1,4 @@ -namespace MediathekArrServer.Services; +namespace MediathekArr.Services; using Microsoft.Extensions.Hosting; using System; diff --git a/README.md b/README.md index 96b810e..3e5dc8b 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,52 @@ mediathekarr # MediathekArr - -work in progress, please report bugs and ideas - -Thanks to https://github.com/mediathekview/mediathekviewweb for the Mediathek API - -Thanks to https://github.com/PCJones/UmlautAdaptarr for the German Title API - -Thanks to https://tvdb.com for the metadata API +Integrate ARD&ZDF Mediathek in Prowlarr, Sonarr and Radarr Example screenshot: ![grafik](https://github.com/user-attachments/assets/654c42fa-4eab-4b6e-b1c7-9b23192c7a98) +## Features +| Feature | Status | +|-------------------------------------------------------------------|---------------| +| Prowlarr & NZB Hydra Support |✓ | +| TV Show & Sonarr Support |✓ | +| Movie & Radarr Support |(✓) limited, WIP| +| MKV creation |✓ | +| Subtitle download (if available in Mediathek) |✓ | +| Webinterface |✓ | +| Ideas? |Suggestions? | -## Install using Docker -1. Configure [docker-compose.yml](https://github.com/PCJones/MediathekArr/blob/main/docker-compose.yml) -2. In Sonarr/Radarr go to Settings>Download Clients -3. Enable Advanced Settings at the top -4. Create a new `SABnzbd` download client (example screenshot at bottom) -5. Name: `MediathekArr Downloader` -6. Host: Depending on your docker network setup either `localhost`, `mediathekarr` or `YOUR_HOST_IP` -7. Port: `5007` -8. Use SSL: no -9. URL Base (important): `download` -10. API Key: `x` (or anything else, just can't be empty) -11. Category: `sonarr` or `tv`if Sonarr, `radarr` or `movie` if Radarr -12. Client Priority (important so it won't be used by normal indexers): `50` -13. Remove Completed: yes -14. Remove Failed: yes -15. Test and Save -16. In Prowlarr/Sonarr/Radarr Go to Settings>Indexers -17. Add new NewzNAB(Sonarr/Radarr) / Newznab Generic(Prowlarr) Indexer (examlpe screenshot at bottom) -18. Enable advanced settings at the bottom -19. URL: Depending on your docker network setup either `http://localhost:5007`, `http://mediathekarr:5007` or `http://YOUR_HOST_IP:5007` -20. API Path: `/api` -21. API Key: Leave blank -22. Categories: SD, HD or both -24. Download Client (important): `MediathekArr Downloader` - (if using Prowlarr, switch to Sonarr/Radarr for this) -25. Test and Save - -## Example Download Client -![grafik](https://github.com/user-attachments/assets/7da76b68-f32a-41b2-b1b8-81d0e5ed1683) -![grafik](https://github.com/user-attachments/assets/364e7fae-fc51-4a4b-bc17-ded68bca30c7) +## Installation +- [Docker](https://hub.docker.com/r/pcjones/mediathekarr) +- Unraid: search for `mediathekarr` -## Example Indexer -![grafik](https://github.com/user-attachments/assets/23a4c00f-4b69-4486-8213-a45021c30d16) -![grafik](https://github.com/user-attachments/assets/eddec856-02a5-4206-a1ec-9840586cc0dd) +Please follow the [installation wiki instructions](https://github.com/PCJones/MediathekArr/wiki/Installation) ## Kontakt & Support - Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst. - [Telegram](https://t.me/pc_jones) -- Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM) +- [UsenetDE Discord Server](https://discord.gg/src6zcH4rr) -> #mediathekarr Channel ## Spenden Über eine Spende freue ich mich natürlich immer :D -PayPal: https://paypal.me/pcjones1 + +Buy Me A Coffee +Coindrop.to me Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke! +## Credits +Thanks to https://github.com/mediathekview/mediathekviewweb for the Mediathek API + +Thanks to https://github.com/PCJones/UmlautAdaptarr for the German Title API + +Thanks to https://thetvdb.com for the metadata API + +## Other *arr projects: +- [UmlautAdaptarr](https://github.com/PCJones/UmlautAdaptarr) - A tool to work around Sonarr, Radarr, Lidarr and Readarrs problems with foreign languages. +- [crowdNFO](https://crowdnfo.net) - Crowd sourced NFO and mediainfo collection (WIP) + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=pcjones/mediathekarr&type=Date)](https://star-history.com/#pcjones/mediathekarr&Date) diff --git a/api/v1/db.php b/api/v1/db.php index 879aabc..23151b5 100644 --- a/api/v1/db.php +++ b/api/v1/db.php @@ -4,7 +4,7 @@ function initializeDatabase() { $isFirstRun = !file_exists(DB_FILE); - + $db = new PDO('sqlite:' . DB_FILE); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); @@ -52,11 +52,19 @@ function createTables($db) { absolute_number INTEGER, FOREIGN KEY(series_id) REFERENCES series_cache(series_id) )"; + + $createTvdbIdsTableQuery = "CREATE TABLE IF NOT EXISTS tvdb_id_cache ( + remote_id TEXT PRIMARY KEY, + series_id INTEGER NOT NULL, + cache_expiry TEXT NOT NULL + )"; $db->exec($createApiKeyTableQuery); $db->exec($createTokenTableQuery); $db->exec($createSeriesCacheTableQuery); $db->exec($createEpisodesTableQuery); + $db->exec($createTvdbIdsTableQuery); + $db->exec("CREATE INDEX IF NOT EXISTS idx_episodes_series_id ON episodes(series_id)"); } function displayApiKeyForm($db) { @@ -105,3 +113,4 @@ function getApiKey($db) { } } ?> + diff --git a/api/v1/get_show.php b/api/v1/get_show.php index ea32874..4463b2a 100644 --- a/api/v1/get_show.php +++ b/api/v1/get_show.php @@ -5,6 +5,15 @@ $db = initializeDatabase(); $apiKey = getApiKey($db); +header("Access-Control-Allow-Origin: https://jones-sanity.vercel.app"); +header("Access-Control-Allow-Methods: GET, OPTIONS, PATCH, DELETE, POST, PUT"); +header("Access-Control-Allow-Headers: Content-Type, Authorization"); +header("Access-Control-Allow-Credentials: true"); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(200); + exit(); +} header('Content-Type: application/json'); // Helper function to determine if cache is expired @@ -79,18 +88,14 @@ function getSeriesData($db, $tvdbId, $apiKey, $debug = false) { } } -function fetchEnglishName($tvdbId, $seriesName) { - // API URL +function fetchTranslationTitles($tvdbId, $defaultEnglishName, $defaultGermanName) { $apiUrl = "https://umlautadaptarr.pcjones.de/api/v1/tvshow_german.php?tvdbid=$tvdbId"; - - // Fetch data from the API $apiResponse = file_get_contents($apiUrl); - - // Decode the JSON response $data = json_decode($apiResponse, true); - - // Return originalTitle if not null, otherwise return the provided series name - return $data['originalTitle'] ?? $seriesName; + return [ + 'englishTitle' => $data['originalTitle'] ?? $defaultEnglishName, + 'germanTitle' => $data['germanTitle'] ?? $defaultGermanName, + ]; } // Function to fetch and cache data from TVDB @@ -124,10 +129,11 @@ function fetchAndCacheSeriesData($db, $tvdbId, $apiKey, $debug = false) { try { $series = $data['data']; - $germanName = $series['nameTranslations']['deu'] ?? $series['name']; - $seriesName = $series['name']; - $englishName = fetchEnglishName($tvdbId, $seriesName); - + $seriesName = $series['name']; + $translations = fetchTranslationTitles($tvdbId, $seriesName, $seriesName); + $englishName = $translations['englishTitle']; + $germanName = $translations['germanTitle']; + $rawAliases = $series['aliases'] ?? []; // Normalize aliases into an array $germanAliases = []; diff --git a/docker-compose.yml b/docker-compose.yml index 9ca15f7..0511279 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,9 @@ services: # You can use the web interface at http://localhost:5007 to configure the following settings # Alternatively, you can set them here #- DOWNLOAD_INCOMPLETE_PATH=/data/mediathek/incomplete # Path to incomplete downloads - #- DOWNLOAD_COMPLETE_PATH=/data/mediathek/complete # Path to complete downloads" + #- DOWNLOAD_COMPLETE_PATH=/data/mediathek/complete # Path to complete downloads + #- CATEGORIES=tv,movies,sonarr_blackhole,radarr_blackhole # Comma separated list of categories to expose + #- MAX_PARALLEL_DOWNLOADS=2 # Number of parallel downloads (1-10, default: 2) # Only change this if you are hosting your own API. Not needed for 99% of users - MEDIATHEKARR_API_BASE_URL=https://mediathekarr.pcjones.de/api/v1 @@ -19,4 +21,4 @@ services: - /data/mediathek/complete:/data/mediathek/complete - /data/mediathek/incomplete:/data/mediathek/incomplete ports: - - "127.0.0.1:5007:5007" # Port on the right side can be changed to any value you like + - "127.0.0.1:5007:5007" # Port on the left side can be changed to any value you like diff --git a/docker_start.sh b/docker_start.sh index fe6815e..926c675 100644 --- a/docker_start.sh +++ b/docker_start.sh @@ -17,8 +17,8 @@ adjust_mounted_volumes() { echo "Checking mounted directory: $mountpoint" if [ -d "$mountpoint" ]; then - echo "Setting ownership for $mountpoint" - chown -R appuser:appgroup "$mountpoint" + echo "Setting ownership for $mountpoint to $user_name:$group_name" + chown -R "$user_name":"$group_name" "$mountpoint" else echo "Skipped non-directory mountpoint: $mountpoint" fi @@ -61,22 +61,44 @@ if [ -z "$PUID" ] || [ -z "$PGID" ]; then else echo "Mediathekarr: Starting with UID: $PUID, GID: $PGID" - # Create group if it doesn't exist - if ! getent group appgroup > /dev/null 2>&1; then - groupadd -g "$PGID" appgroup + # Determine group name based on PGID + existing_group=$(getent group "$PGID" | cut -d: -f1) + if [ -z "$existing_group" ]; then + # Create the group if it doesn't exist + group_name="appgroup" + if ! groupadd -g "$PGID" "$group_name"; then + echo "Failed to create group $group_name with GID $PGID" + exit 1 + fi + echo "Created group $group_name with GID $PGID" + else + # Use existing group name + group_name="$existing_group" + echo "Using existing group $group_name with GID $PGID" fi - # Create user if it doesn't exist - if ! id -u appuser > /dev/null 2>&1; then - useradd -u "$PUID" -g appgroup -m -s /bin/bash appuser + # Determine user name based on PUID + existing_user=$(getent passwd "$PUID" | cut -d: -f1) + if [ -z "$existing_user" ]; then + # Create the user if it doesn't exist + user_name="appuser" + if ! useradd -u "$PUID" -g "$group_name" -m -s /bin/bash "$user_name"; then + echo "Failed to create user $user_name with UID $PUID" + exit 1 + fi + echo "Created user $user_name with UID $PUID" + else + # Use existing user name + user_name="$existing_user" + echo "Using existing user $user_name with UID $PUID" fi # Adjust ownership of relevant directories - chown -R appuser:appgroup /app /app/config /data/mediathek + chown -R "$user_name":"$group_name" /app /app/config /data/mediathek # Adjust ownership dynamically for all mounted volumes adjust_mounted_volumes - # Switch to the created user and re-execute the script - exec gosu appuser /bin/bash "$0" --user-mode -fi + # Switch to the determined user and re-execute the script + exec gosu "$user_name" /bin/bash "$0" --user-mode +fi \ No newline at end of file