[
  {
    "path": ".gitattributes",
    "content": "###############################################################################\n# Set default behavior to automatically normalize line endings.\n###############################################################################\n* text=auto\n\n###############################################################################\n# Set default behavior for command prompt diff.\n#\n# This is need for earlier builds of msysgit that does not have it on by\n# default for csharp files.\n# Note: This is only used by command line\n###############################################################################\n#*.cs     diff=csharp\n\n###############################################################################\n# Set the merge driver for project and solution files\n#\n# Merging from the command prompt will add diff markers to the files if there\n# are conflicts (Merging from VS is not affected by the settings below, in VS\n# the diff markers are never inserted). Diff markers may cause the following \n# file extensions to fail to load in VS. An alternative would be to treat\n# these files as binary and thus will always conflict and require user\n# intervention with every merge. To do so, just uncomment the entries below\n###############################################################################\n#*.sln       merge=binary\n#*.csproj    merge=binary\n#*.vbproj    merge=binary\n#*.vcxproj   merge=binary\n#*.vcproj    merge=binary\n#*.dbproj    merge=binary\n#*.fsproj    merge=binary\n#*.lsproj    merge=binary\n#*.wixproj   merge=binary\n#*.modelproj merge=binary\n#*.sqlproj   merge=binary\n#*.wwaproj   merge=binary\n\n###############################################################################\n# behavior for image files\n#\n# image files are treated as binary by default.\n###############################################################################\n#*.jpg   binary\n#*.png   binary\n#*.gif   binary\n\n###############################################################################\n# diff behavior for common document formats\n# \n# Convert binary document formats to text before diffing them. This feature\n# is only available from the command line. Turn it on by uncommenting the \n# entries below.\n###############################################################################\n#*.doc   diff=astextplain\n#*.DOC   diff=astextplain\n#*.docx  diff=astextplain\n#*.DOCX  diff=astextplain\n#*.dot   diff=astextplain\n#*.DOT   diff=astextplain\n#*.pdf   diff=astextplain\n#*.PDF   diff=astextplain\n#*.rtf   diff=astextplain\n#*.RTF   diff=astextplain\n"
  },
  {
    "path": ".gitignore",
    "content": "## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Mono auto generated files\nmono_crash.*\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Ww][Ii][Nn]32/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Oo]ut/\n[Ll]og/\n[Ll]ogs/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUnit\n*.VisualState.xml\nTestResult.xml\nnunit-*.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n\n# ASP.NET Scaffolding\nScaffoldingReadMe.txt\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_h.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*_wpftmp.csproj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Coverlet is a free, cross platform Code Coverage Tool\ncoverage*.json\ncoverage*.xml\ncoverage*.info\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# NuGet Symbol Packages\n*.snupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n*.appxbundle\n*.appxupload\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!?*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk\n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n*- [Bb]ackup.rdl\n*- [Bb]ackup ([0-9]).rdl\n*- [Bb]ackup ([0-9][0-9]).rdl\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# CodeRush personal settings\n.cr/personal\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output\nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder\n.mfractor/\n\n# Local History for Visual Studio\n.localhistory/\n\n# BeatPulse healthcheck temp database\nhealthchecksdb\n\n# Backup folder for Package Reference Convert tool in Visual Studio 2017\nMigrationBackup/\n\n# Ionide (cross platform F# VS Code tools) working folder\n.ionide/\n\n# Fody - auto-generated XML schema\nFodyWeavers.xsd\n\n# MediathekArr specifics\ntvdb_cache.sqlite"
  },
  {
    "path": "Dockerfile",
    "content": "FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build-env\nWORKDIR /app\n\n# Copy and restore dependencies\nCOPY . ./\nRUN dotnet restore\nRUN dotnet publish -c Release -o out\n\nFROM mcr.microsoft.com/dotnet/aspnet:9.0\n\nRUN apt-get update && apt-get install -y tar xz-utils && rm -rf /var/lib/apt/lists/*\n\n# Set working directory\nWORKDIR /app\n\n# Set up environment variables for user IDs\n#ARG PUID=1000\n#ARG PGID=1000\n#ENV PUID=${PUID} \\\n    #PGID=${PGID}\n\n# Create a user and group with specified IDs\n#RUN addgroup --gid ${PGID} appgroup && \\\n#    adduser --disabled-password --gecos \"\" --uid ${PUID} --gid ${PGID} appuser\n\n# Copy the built app from the build environment\nCOPY --from=build-env /app/out .\n\n# Change ownership to non-root user\n#RUN chown -R appuser:appgroup /app\n#USER appuser\nENTRYPOINT [\"dotnet\", \"MediathekArrServer.dll\"]"
  },
  {
    "path": "Dockerfile.arm64",
    "content": "FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/sdk:9.0 AS build-env\nWORKDIR /app\n\n# Copy and restore dependencies\nCOPY . ./\nRUN dotnet restore\nRUN dotnet publish -c Release -o out\n\nFROM --platform=linux/arm64 mcr.microsoft.com/dotnet/aspnet:9.0\n\nRUN apt-get update && apt-get install -y tar xz-utils && rm -rf /var/lib/apt/lists/*\n\n# Set working directory\nWORKDIR /app\n\n# Set up environment variables for user IDs\n#ARG PUID=1000\n#ARG PGID=1000\n#ENV PUID=${PUID} \\\n    #PGID=${PGID}\n\n# Create a user and group with specified IDs\n#RUN addgroup --gid ${PGID} appgroup && \\\n#    adduser --disabled-password --gecos \"\" --uid ${PUID} --gid ${PGID} appuser\n\n# Copy the built app from the build environment\nCOPY --from=build-env /app/out .\n\n# Change ownership to non-root user\n#RUN chown -R appuser:appgroup /app\n#USER appuser\nENTRYPOINT [\"dotnet\", \"MediathekArrServer.dll\"]\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# MediathekArr License\n\n## Source Code License\n\nMediathekArr source code is licensed under the **MIT License**.\n\nSee the [MIT License text below](#mit-license-text) for full details.\n\n---\n\n## Third-Party Dependencies\n\nThis project includes and/or uses the following third-party software with their respective licenses:\n\n### Open Source Components\n\n| Component | License | Source |\n|-----------|---------|--------|\n| **FFmpeg** | LGPL v2.1+ | https://ffmpeg.org |\n| **MKVToolNix** (mkvmerge) | GPL v2+ | https://mkvtoolnix.download |\n| **gosu** | Apache 2.0 | https://github.com/tianon/gosu |\n| **Debian/Linux Packages** | Various (GPL, LGPL, others) | https://packages.debian.org |\n\n### Important: Docker Image Distribution\n\nWhen MediathekArr is distributed as a **Docker image** (via Docker Hub or similar), the image layers include GPL-licensed components (primarily MKVToolNix/mkvmerge and FFmpeg). \n\n**This means:**\n\n1. **Source Code Availability**: The source code for GPL-licensed components must remain publicly available. These are available from:\n   - FFmpeg: https://github.com/FFmpeg/FFmpeg\n   - MKVToolNix: https://github.com/mkvtoolnix/mkvtoolnix\n   - Debian packages: Available via `deb-src` repositories at https://deb.debian.org\n\n2. **Attribution**: The GPL and LGPL licenses require that copyright notices and license attributions be preserved. This file serves as that attribution for the Docker image distribution.\n\n3. **User Rights**: Users of the Docker image have the right to:\n   - Access the source code of GPL/LGPL components\n   - Modify and rebuild the image with modified components\n   - Rebuild the Dockerfile from this repository\n\n### How to Comply\n\nIf you redistribute this Docker image or derivative works:\n\n1. **Include this LICENSE.md file** or equivalent attribution\n2. **Preserve the Dockerfile** (which documents the build process)\n3. **Link to source repositories**: Users should be able to obtain GPL source code via:\n   - The Dockerfile recipe (which references Debian packages)\n   - Direct links to FFmpeg and MKVToolNix repositories\n   - Debian's source package repositories\n\n### Relicensing Note\n\nThe **MediathekArr source code** remains MIT-licensed. However, when you **distribute the compiled Docker image**, it becomes a derivative work that includes GPL-licensed software. This doesn't change the MIT license of the source code, but it means the distributed artifact must comply with GPL requirements for GPL-licensed components it contains.\n\nFor details on license compatibility, see: https://www.gnu.org/licenses/license-list.html\n\n---\n\n## MIT License Text\n\nMIT License\n\nCopyright (c) 2026 PCJones\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MediathekArr/Controllers/DownloadController.cs",
    "content": "﻿using MediathekArr.Models;\nusing MediathekArr.Services;\nusing Microsoft.AspNetCore.Mvc;\nusing System.Reflection;\nusing System.Text.RegularExpressions;\n\nnamespace MediathekArr.Controllers;\n\n[ApiController]\n[Route(\"[controller]\")]\npublic partial class DownloadController(DownloadService downloadService) : ControllerBase\n{\n    private readonly DownloadService _downloadService = downloadService;\n\n    [HttpGet(\"api\")]\n    public IActionResult GetVersion([FromQuery] string mode, [FromQuery] string? name = null, [FromQuery] string? value = null, [FromQuery] int? del_files = 0)\n    {\n        return mode switch\n        {\n            \"version\" => Ok(new { version = \"4.3.3\" }),\n            \"get_config\" => Content(GetConfigResponse(), \"application/json\"),\n            \"queue\" => Ok(GetQueue()),\n            \"history\" => (name == \"delete\" && !string.IsNullOrEmpty(value))\n                ? DeleteHistoryItem(value, del_files.GetValueOrDefault() == 1)\n                : Ok(GetHistory()),\n            _ => BadRequest(new { error = \"Invalid mode\" }),\n        };\n    }\n\n    private IActionResult DeleteHistoryItem(string nzoId, bool delFiles)\n    {\n        // Call the DeleteHistoryItem method in the service\n        bool isDeleted = _downloadService.DeleteHistoryItem(nzoId, delFiles);\n\n        // Return success or failure response based on deletion result\n        return isDeleted\n            ? Ok(new { status = true })\n            : NotFound(new { status = false, error = \"Item not found\" });\n    }\n\n    [HttpPost(\"api\")]\n    public async Task<IActionResult> AddFile([FromQuery] string mode, [FromQuery] string cat)\n    {\n        if (mode != \"addfile\")\n        {\n            return BadRequest(new { error = \"Invalid mode\" });\n        }\n\n        // Read the fake NZB file from the request body\n        using var reader = new StreamReader(Request.Body);\n        var requestBody = await reader.ReadToEndAsync();\n\n        var filenameMatch = FileNameRegex().Match(requestBody);\n        var urlMatch = UrlRegex().Match(requestBody);\n\n        if (!filenameMatch.Success || !urlMatch.Success)\n        {\n            return BadRequest(new { error = \"Invalid NZB format\" });\n        }\n\n        var fileName = filenameMatch.Groups[1].Value;\n        var downloadUrl = urlMatch.Groups[1].Value;\n\n        // Add to the download queue using DownloadService and capture the created queue item\n        var queueItem = _downloadService.AddToQueue(downloadUrl, fileName, cat);\n\n        // Return response in the specified format\n        return Ok(new\n        {\n            status = true,\n            nzo_ids = new[] { queueItem.Id}\n        });\n    }\n\n    private QueueWrapper GetQueue()\n    {\n        var queueItems = _downloadService.GetQueue();\n\n        var queue = new SabnzbdQueue\n        {\n            Items = queueItems.ToList()\n        };\n\n        return new QueueWrapper\n        {   \n            Queue = queue\n        };\n    }\n\n    private HistoryWrapper GetHistory()\n    {\n        var historytems = _downloadService.GetHistory();\n\n        var history = new SabnzbdHistory\n        {\n            Items = historytems.ToList()\n        };\n\n        return new HistoryWrapper\n        {\n            History = history\n        };\n    }\n\n    private static string GetConfigResponse()\n    {\n        var startupPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;\n        var downloadFolderPathMapping = Environment.GetEnvironmentVariable(\"DOWNLOAD_FOLDER_PATH_MAPPING\");\n\n        var completeDir = !string.IsNullOrEmpty(downloadFolderPathMapping)\n            ? Path.Combine(downloadFolderPathMapping)\n            : Path.Combine(startupPath, \"downloads\"); ;\n\n        return @$\"{{\n        \"\"config\"\": {{\n            \"\"misc\"\": {{\n                \"\"complete_dir\"\": \"\"{completeDir.Replace(\"\\\\\", \"/\")}\"\",\n                \"\"enable_tv_sorting\"\": false,\n                \"\"enable_movie_sorting\"\": false,\n                \"\"pre_check\"\": false,\n                \"\"history_retention\"\": \"\"all\"\"\n            }},\n            \"\"categories\"\": [\n                {{\n                    \"\"name\"\": \"\"sonarr\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n                {{\n                    \"\"name\"\": \"\"tv\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n                {{\n                    \"\"name\"\": \"\"radarr\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n                {{\n                    \"\"name\"\": \"\"movies\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n                {{\n                    \"\"name\"\": \"\"sonarr_blackhole\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n                {{\n                    \"\"name\"\": \"\"radarr_blackhole\"\",\n                    \"\"pp\"\": \"\"\"\",\n                    \"\"script\"\": \"\"Default\"\",\n                    \"\"dir\"\": \"\"\"\",\n                    \"\"priority\"\": -100\n                }},\n            ],\n            \"\"sorters\"\": []\n        }}\n    }}\";\n    }\n\n    [GeneratedRegex(@\"filename=\"\"([^\"\"]+)\\.nzb\"\"\")]\n    private static partial Regex FileNameRegex();\n    [GeneratedRegex(@\"<!--\\s*(https?://[^\\s]+)\\s*-->\")]\n    private static partial Regex UrlRegex();\n}\n"
  },
  {
    "path": "MediathekArr/Controllers/TController.cs",
    "content": "using MediathekArr.Services;\nusing Microsoft.AspNetCore.Mvc;\nusing System.Text;\n\nnamespace MediathekArr.Controllers;\n\n[ApiController]\n[Route(\"api\")]\npublic class TController(MediathekSearchService mediathekSearchService, ItemLookupService itemLookupService) : ControllerBase\n{\n    private readonly MediathekSearchService _mediathekSearchService = mediathekSearchService;\n    private readonly ItemLookupService _itemLookupService = itemLookupService;\n\n    [HttpGet]\n    public async Task<IActionResult> GetCapsXml([FromQuery] string t)\n    {\n        string q = HttpContext.Request.Query[\"q\"];\n        string imdbid = HttpContext.Request.Query[\"imdbid\"];\n        string tvdbid = HttpContext.Request.Query[\"tvdbid\"];\n        string season = HttpContext.Request.Query[\"season\"];\n        string episode = HttpContext.Request.Query[\"ep\"];\n        string cat = HttpContext.Request.Query[\"cat\"];\n\n        if (t == \"caps\")\n        {\n            string xmlContent = @\"<?xml version=\"\"1.0\"\" encoding=\"\"UTF-8\"\"?>\n<caps>\n    <limits max=\"\"5000\"\" default=\"\"5000\"\"/>\n    <registration available=\"\"no\"\" open=\"\"no\"\"/>\n    <searching>\n        <search available=\"\"yes\"\" supportedParams=\"\"q\"\"/>\n        <tv-search available=\"\"yes\"\" supportedParams=\"\"q,season,ep,tvdbid\"\"/>\n        <movie-search available=\"\"yes\"\" supportedParams=\"\"q,imdbid\"\"/>\n        <audio-search available=\"\"no\"\" supportedParams=\"\"\"\" />\n    </searching>\n    <categories>\n        <category id=\"\"2000\"\" name=\"\"Movies\"\">\n            <subcat id=\"\"2040\"\" name=\"\"HD\"\"/>\n            <subcat id=\"\"2030\"\" name=\"\"SD\"\"/>\n        </category>\n        <category id=\"\"5000\"\" name=\"\"TV\"\">\n            <subcat id=\"\"5040\"\" name=\"\"HD\"\"/>\n            <subcat id=\"\"5030\"\" name=\"\"SD\"\"/>\n        </category>\n    </categories>\n</caps>\";\n\n            return Content(xmlContent, \"application/xml\", Encoding.UTF8);\n        }\n        else if (t == \"tvsearch\" || t == \"search\" || t == \"movie\")\n        {\n            try\n            {\n                if (!string.IsNullOrEmpty(tvdbid) && int.TryParse(tvdbid, out var parsedTvdbid))\n                {\n                    var tvdbData = (await _itemLookupService.GetShowInfoByTvdbId(parsedTvdbid)).Data;\n\n                    string searchResults = await _mediathekSearchService.FetchSearchResultsFromApiById(tvdbData, season, episode);\n\n                    return Content(searchResults, \"application/xml\", Encoding.UTF8);\n                }\n                else\n                {\n                    string searchResults = await _mediathekSearchService.FetchSearchResultsFromApiByString(q, season);\n\n                    return Content(searchResults, \"application/xml\", Encoding.UTF8);\n                }\n            }\n            catch (HttpRequestException ex)\n            {\n                return BadRequest(new { error = ex.Message });\n            }\n        }\n\n        return NotFound();\n    }\n\n\n    [HttpGet(\"fake_nzb_download\")]\n    public IActionResult FakeNzbDownload([FromQuery] string encodedUrl, [FromQuery] string encodedTitle)\n    {\n        string decodedUrl;\n        string decodedTitle;\n        try\n            {\n            var base64EncodedBytesUrl = Convert.FromBase64String(encodedUrl);\n            decodedUrl = Encoding.UTF8.GetString(base64EncodedBytesUrl);\n            var base64EncodedBytesTitle = Convert.FromBase64String(encodedTitle);\n            decodedTitle = Encoding.UTF8.GetString(base64EncodedBytesTitle);\n        }\n        catch (FormatException)\n        {\n            return BadRequest(\"Invalid base64 string.\");\n        }\n\n        // Define a basic NZB XML structure with the comment and encoded URL.\n        var nzbContent = $@\"<?xml version=\"\"1.0\"\" encoding=\"\"UTF-8\"\" ?>\n<!DOCTYPE nzb PUBLIC \"\"-//newzBin//DTD NZB 1.0//EN\"\" \"\"http://www.newzbin.com/DTD/nzb/nzb-1.0.dtd\"\">\n<!-- {decodedTitle} -->\n<!-- {decodedUrl} -->\n<nzb>\n    <file post_id=\"\"1\"\">\n        <groups>\n            <group>a.b.zdf</group>\n        </groups>\n        <segments>\n            <segment number=\"\"1\"\">ExampleSegmentID@news.example.com</segment>\n        </segments>\n    </file>\n</nzb>\";\n\n        // Convert the NZB XML content to byte array\n        var fileContent = Encoding.UTF8.GetBytes(nzbContent);\n\n        // Set the .nzb file name\n        var nzbFileName = $\"mediathek-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.nzb\";\n\n        return File(fileContent, \"application/x-nzb\", nzbFileName);\n    }\n}\n"
  },
  {
    "path": "MediathekArr/MediathekArrDownloader.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n\n\t<PropertyGroup>\n\t\t<TargetFramework>net9.0</TargetFramework>\n\t\t<Nullable>enable</Nullable>\n\t\t<ImplicitUsings>enable</ImplicitUsings>\n\t\t<RuntimeIdentifiers>linux-x64</RuntimeIdentifiers>\n\t\t<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>\n\t\t<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:9.0</ContainerBaseImage>\n\t\t<UserSecretsId>c655a1a3-0f6d-45f1-9615-dc576c4c0b84</UserSecretsId>\n\t</PropertyGroup>\n\n\t<ItemGroup>\n\t\t<PackageReference Include=\"Microsoft.AspNetCore.OpenApi\" Version=\"9.0.0\" />\n\t\t<PackageReference Include=\"Scalar.AspNetCore\" Version=\"1.2.44\" />\n\t</ItemGroup>\n\n\t<ItemGroup>\n\t\t<ProjectReference Include=\"..\\MediathekArrLib\\MediathekArrLib.csproj\" />\n\t</ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "MediathekArr/Models/HistoryWrapper.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class HistoryWrapper\n{\n    [JsonPropertyName(\"history\")]\n    public SabnzbdHistory History { get; set; }\n}\n"
  },
  {
    "path": "MediathekArr/Models/QueueWrapper.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class QueueWrapper\n{\n    [JsonPropertyName(\"queue\")]\n    public SabnzbdQueue Queue { get; set; }\n}\n"
  },
  {
    "path": "MediathekArr/Models/SabnzbdDownloadStatus.cs",
    "content": "﻿namespace MediathekArr.Models;\n\npublic enum SabnzbdDownloadStatus\n{\n    Completed,\n    Failed,\n    Downloading,\n    Queued,\n    Extracting\n}\n"
  },
  {
    "path": "MediathekArr/Models/SabnzbdHistory.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class SabnzbdHistory\n{\n    [JsonPropertyName(\"slots\")]\n    public List<SabnzbdHistoryItem> Items { get; set; }\n}\n"
  },
  {
    "path": "MediathekArr/Models/SabnzbdHistoryItem.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class SabnzbdHistoryItem\n{\n    [JsonPropertyName(\"fail_message\")]\n    public string FailMessage { get; set; }\n\n    [JsonPropertyName(\"bytes\")]\n    public long Size { get; set; }\n\n    [JsonPropertyName(\"category\")]\n    public string Category { get; set; }\n\n    [JsonPropertyName(\"nzb_name\")]\n    public string NzbName { get; set; }\n\n    [JsonPropertyName(\"download_time\")]\n    public int DownloadTime { get; set; }\n\n    [JsonPropertyName(\"storage\")]\n    public string Storage { get; set; }\n\n    [JsonPropertyName(\"status\")]\n    [JsonConverter(typeof(JsonStringEnumConverter))] \n    public SabnzbdDownloadStatus Status { get; set; }\n\n    [JsonPropertyName(\"nzo_id\")]\n    public string Id { get; set; }\n\n    [JsonPropertyName(\"name\")]\n    public string Title { get; set; }\n}\n"
  },
  {
    "path": "MediathekArr/Models/SabnzbdQueue.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class SabnzbdQueue\n{\n    [JsonPropertyName(\"paused\")]\n    public bool Paused => false;\n\n    [JsonPropertyName(\"slots\")]\n    public List<SabnzbdQueueItem> Items { get; set; }\n}"
  },
  {
    "path": "MediathekArr/Models/SabnzbdQueueItem.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArr.Models;\n\npublic class SabnzbdQueueItem\n{\n    [JsonPropertyName(\"status\")]\n    [JsonConverter(typeof(JsonStringEnumConverter))]\n    public SabnzbdDownloadStatus Status { get; set; }\n\n    [JsonPropertyName(\"index\")]\n    public int Index { get; set; }\n\n    [JsonPropertyName(\"timeleft\")]\n    public string Timeleft { get; set; } // \"0:00:00\"\n\n    [JsonPropertyName(\"mb\")]\n    public string Size { get; set; } // \"1163.54\"\n\n    [JsonPropertyName(\"filename\")]\n    public string Title { get; set; }\n\n    [JsonPropertyName(\"priority\")]\n    public string Priority => \"Normal\";\n\n    [JsonPropertyName(\"cat\")]\n    public string Category { get; set; }\n\n    [JsonPropertyName(\"mbleft\")]\n    public string Sizeleft { get; set; } // \"756.4 MB\"\n\n    [JsonPropertyName(\"percentage\")]\n    public string Percentage { get; set; } // \"34\"\n\n    [JsonPropertyName(\"nzo_id\")]\n    public string Id { get; set; } = System.Guid.NewGuid().ToString();\n}\n"
  },
  {
    "path": "MediathekArr/Program.cs",
    "content": "using MediathekArr.Services;\nusing Scalar.AspNetCore;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\nbuilder.Services.AddOpenApi();\n\nbuilder.Services.AddMemoryCache();\nbuilder.Services.AddHttpClient(\"MediathekClient\", client =>\n{\n    client.DefaultRequestHeaders.UserAgent.ParseAdd(\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0\");\n    client.DefaultRequestHeaders.AcceptEncoding.ParseAdd(\"gzip\");\n    client.DefaultRequestHeaders.Accept.ParseAdd(\"application/json\");\n})\n.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler\n{\n    AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate\n});\nbuilder.Services.AddSingleton<MediathekSearchService>();\nbuilder.Services.AddSingleton<ItemLookupService>();\nbuilder.Services.AddSingleton<DownloadService>();\n\n\nvar app = builder.Build();\n\n// Middleware to log all incoming requests\napp.Use(async (context, next) =>\n{\n    // Log the incoming request details\n    var logger = app.Services.GetRequiredService<ILogger<Program>>();\n    var request = context.Request;\n    logger.LogInformation(\"Incoming Request: {method} {url}\", request.Method, request.Path + request.QueryString);\n\n    // Check if the request is a POST and has a body\n    if (request.Method == HttpMethods.Post && request.ContentLength > 0)\n    {\n        // Enable buffering so the request can be read multiple times\n        request.EnableBuffering();\n    }\n\n    // Call the next middleware in the pipeline\n    await next.Invoke();\n});\n\n\n// Configure the HTTP request pipeline.\nif (app.Environment.IsDevelopment())\n{\n    app.MapOpenApi();\n    app.MapScalarApiReference();\n}\n\napp.UseHttpsRedirection();\n\napp.UseAuthorization();\n\napp.MapControllers();\n\napp.Run();\n"
  },
  {
    "path": "MediathekArr/Properties/launchSettings.json",
    "content": "{\n  \"profiles\": {\n    \"http\": {\n      \"commandName\": \"Project\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\",\n        \"ASPNETCORE_URLS\": \"http://localhost:5007\"\n      },\n      \"dotnetRunMessages\": true,\n      \"applicationUrl\": \"http://localhost:5007\"\n    },\n    \"https\": {\n      \"commandName\": \"Project\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\",\n        \"ASPNETCORE_URLS\": \"https://localhost:5007\"\n      },\n      \"dotnetRunMessages\": true,\n      \"applicationUrl\": \"https://localhost:5007\"\n    },\n    \"Container (.NET SDK)\": {\n      \"commandName\": \"SdkContainer\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"{Scheme}://{ServiceHost}:{ServicePort}/scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_HTTPS_PORTS\": \"8081\",\n        \"ASPNETCORE_HTTP_PORTS\": \"8080\"\n      },\n      \"publishAllPorts\": true,\n      \"useSSL\": true\n    }\n  },\n  \"$schema\": \"http://json.schemastore.org/launchsettings.json\"\n}"
  },
  {
    "path": "MediathekArr/Services/DownloadService.cs",
    "content": "﻿using MediathekArr.Models;\nusing Microsoft.Extensions.Logging;\nusing System.Collections.Concurrent;\nusing System.Diagnostics;\nusing System.IO.Compression;\nusing System.Reflection;\nusing System.Runtime.InteropServices;\n\nnamespace MediathekArr.Services;\n\npublic partial class DownloadService\n{\n    private readonly ILogger<DownloadService> _logger;\n    private readonly ConcurrentQueue<SabnzbdQueueItem> _downloadQueue = new();\n    private readonly List<SabnzbdHistoryItem> _downloadHistory = new();\n    private static readonly HttpClient _httpClient = new();\n    private static readonly SemaphoreSlim _semaphore = new(2); // Limit concurrent downloads to 2\n    private readonly string _completeDir;\n    private readonly string _ffmpegPath;\n    private readonly bool _isWindows;\n\n    public DownloadService(ILogger<DownloadService> logger)\n    {\n        _logger = logger;\n        _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);\n\n        // Set complete_dir based on the application's startup path\n        var startupPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;\n        _completeDir = Path.Combine(startupPath, \"downloads\");\n        _ffmpegPath = Path.Combine(startupPath, \"ffmpeg\", _isWindows ? \"ffmpeg.exe\" : \"ffmpeg\");\n\n        // Ensure FFmpeg is available\n        Task.Run(EnsureFfmpegExistsAsync).Wait();\n    }\n\n\n    public IEnumerable<SabnzbdQueueItem> GetQueue() => [.. _downloadQueue];\n    public IEnumerable<SabnzbdHistoryItem> GetHistory() => _downloadHistory;\n\n    public SabnzbdQueueItem AddToQueue(string url, string fileName, string category)\n    {\n        var queueItem = new SabnzbdQueueItem\n        {\n            Status = SabnzbdDownloadStatus.Queued,\n            Index = _downloadQueue.Count,\n            Timeleft = \"10:00:00\",\n            Size = \"Unknown\",\n            Title = fileName,\n            Category = category,\n            Sizeleft = \"Unknown\",\n            Percentage = \"0\"\n        };\n\n        _downloadQueue.Enqueue(queueItem);\n\n        Task.Run(() => StartDownloadAsync(url, queueItem));\n\n        return queueItem;\n    }\n\n    private async Task StartDownloadAsync(string url, SabnzbdQueueItem queueItem)\n    {\n        await _semaphore.WaitAsync();\n\n        var stopwatch = Stopwatch.StartNew();\n\n        try\n        {\n            _logger.LogInformation(\"Starting download for {Title} from URL: {URL}\", queueItem.Title, url);\n            await DownloadFileAsync(url, queueItem);\n\n            if (queueItem.Status != SabnzbdDownloadStatus.Failed)\n            {\n                _logger.LogInformation(\"Download complete for {Title}. Starting conversion to MKV.\", queueItem.Title);\n                await ConvertMp4ToMkvAsync(queueItem, stopwatch);\n            }\n            else\n            {\n                _logger.LogWarning(\"Download failed for {Title}, skipping conversion.\", queueItem.Title);\n            }\n        }\n        catch (Exception ex)\n        {\n            _logger.LogError(ex, \"Error occurred during the download or conversion of {Title}.\", queueItem.Title);\n        }\n        finally\n        {\n            _semaphore.Release();\n            _downloadQueue.TryDequeue(out _);\n            stopwatch.Stop();\n        }\n    }\n\n    private async Task DownloadFileAsync(string url, SabnzbdQueueItem queueItem)\n    {\n        try\n        {\n            var categoryDir = Path.Combine(_completeDir, queueItem.Category);\n            _logger.LogInformation(\"Ensuring directory exists for category {Category} at path: {Path}\", queueItem.Category, categoryDir);\n            Directory.CreateDirectory(categoryDir);\n\n            var fileExtension = Path.GetExtension(url) ?? \".mp4\";\n            var filePath = Path.Combine(categoryDir, queueItem.Title + fileExtension);\n\n            _logger.LogInformation(\"Starting download of file to path: {Path} with extension {Extension}\", filePath, fileExtension);\n            queueItem.Status = SabnzbdDownloadStatus.Downloading;\n\n            var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);\n            var totalSize = response.Content.Headers.ContentLength ?? 0;\n\n            queueItem.Size = (totalSize / (1024.0 * 1024.0)).ToString(\"F2\");\n            _logger.LogInformation(\"Total file size for {Title}: {Size} MB\", queueItem.Title, queueItem.Size);\n\n            using (var contentStream = await response.Content.ReadAsStreamAsync())\n            using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None))\n            {\n                var buffer = new byte[8192];\n                var totalRead = 0L;\n                int bytesRead;\n\n                while ((bytesRead = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)\n                {\n                    await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead));\n                    totalRead += bytesRead;\n\n                    // Update queue item progress\n                    queueItem.Sizeleft = ((totalSize - totalRead) / (1024.0 * 1024.0)).ToString(\"F2\");\n                    queueItem.Percentage = (totalRead / (double)totalSize * 100).ToString(\"F0\");\n\n                    _logger.LogDebug(\"Download progress for {Title}: {Percentage}% - {SizeLeft} MB remaining\", queueItem.Title, queueItem.Percentage, queueItem.Sizeleft);\n                }\n            }\n\n            queueItem.Timeleft = \"00:00:00\";\n            _logger.LogInformation(\"Download completed for {Title}. File saved to {Path}\", queueItem.Title, filePath);\n        }\n        catch (Exception ex)\n        {\n            queueItem.Status = SabnzbdDownloadStatus.Failed;\n            _logger.LogError(ex, \"Download failed for {Title}. Adding to download history as failed.\", queueItem.Title);\n\n            _downloadHistory.Add(new SabnzbdHistoryItem\n            {\n                Title = queueItem.Title,\n                NzbName = queueItem.Title,\n                Category = queueItem.Category,\n                Size = 0,\n                DownloadTime = 0,\n                Storage = null,\n                Status = SabnzbdDownloadStatus.Failed,\n                Id = queueItem.Id\n            });\n        }\n    }\n    public bool DeleteHistoryItem(string nzoId, bool delFiles)\n    {\n        var item = _downloadHistory.FirstOrDefault(h => h.Id == nzoId);\n\n        if (item == null)\n        {\n            return false;\n        }\n\n        // Optionally delete the associated file\n        if (delFiles && !string.IsNullOrEmpty(item.Storage) && File.Exists(item.Storage))\n        {\n            try\n            {\n                File.Delete(item.Storage);\n            }\n            catch (Exception ex)\n            {\n                Console.WriteLine($\"Error deleting file: {ex.Message}\");\n            }\n        }\n\n        // Remove the item from the history list\n        _downloadHistory.Remove(item);\n        return true;\n    }\n\n    private async Task ConvertMp4ToMkvAsync(SabnzbdQueueItem queueItem, Stopwatch stopwatch)\n    {\n        var categoryDir = Path.Combine(_completeDir, queueItem.Category);\n        var mp4Path = Path.Combine(categoryDir, queueItem.Title + \".mp4\");\n        var mkvPath = Path.Combine(categoryDir, queueItem.Title + \".mkv\");\n\n        if (!File.Exists(mp4Path))\n        {\n            queueItem.Status = SabnzbdDownloadStatus.Failed;\n            _logger.LogWarning(\"MP4 file not found for conversion. Path: {Mp4Path}. Marking as failed.\", mp4Path);\n            return;\n        }\n\n        queueItem.Status = SabnzbdDownloadStatus.Extracting;\n        _logger.LogInformation(\"Starting conversion of {Title} from MP4 to MKV. MP4 Path: {Mp4Path}, MKV Path: {MkvPath}\", queueItem.Title, mp4Path, mkvPath);\n\n        var ffmpegArgs = $\"-i \\\"{mp4Path}\\\" -map 0:v -map 0:a -c copy -metadata:s:v:0 language=ger -metadata:s:a:0 language=ger \\\"{mkvPath}\\\"\";\n\n        var process = new Process\n        {\n            StartInfo = new ProcessStartInfo\n            {\n                FileName = _ffmpegPath,\n                Arguments = ffmpegArgs,\n                RedirectStandardOutput = true,\n                RedirectStandardError = true,\n                UseShellExecute = false,\n                CreateNoWindow = true\n            }\n        };\n\n        try\n        {\n            process.Start();\n            _logger.LogInformation(\"FFmpeg process started for {Title} with arguments: {Arguments}\", queueItem.Title, ffmpegArgs);\n\n            var standardErrorTask = process.StandardError.ReadToEndAsync();\n\n            await process.WaitForExitAsync();\n            string ffmpegOutput = await standardErrorTask;\n\n            if (process.ExitCode == 0)\n            {\n                queueItem.Status = SabnzbdDownloadStatus.Completed;\n                _logger.LogInformation(\"Conversion completed successfully for {Title}. Output path: {MkvPath}\", queueItem.Title, mkvPath);\n            }\n            else\n            {\n                queueItem.Status = SabnzbdDownloadStatus.Failed;\n                _logger.LogError(\"FFmpeg conversion failed for {Title}. Exit code: {ExitCode}. Error output: {ErrorOutput}\", queueItem.Title, process.ExitCode, ffmpegOutput);\n            }\n\n            File.Delete(mp4Path);\n\n            double sizeInMB = 0;\n            if (double.TryParse(queueItem.Size.Replace(\"GB\", \"\").Replace(\"MB\", \"\").Trim(), out double size))\n            {\n                sizeInMB = queueItem.Size.Contains(\"GB\") ? size * 1024 : size;\n            }\n\n            var downloadFolderPathMapping = Environment.GetEnvironmentVariable(\"DOWNLOAD_FOLDER_PATH_MAPPING\");\n\n            var storagePath = !string.IsNullOrEmpty(downloadFolderPathMapping)\n                ? Path.Combine(downloadFolderPathMapping, queueItem.Category, queueItem.Title + \".mkv\")\n                : mkvPath;\n\n            // Move completed download to history\n            var historyItem = new SabnzbdHistoryItem\n            {\n                Title = queueItem.Title,\n                NzbName = queueItem.Title,\n                Category = queueItem.Category,\n                Size = (long)(sizeInMB * 1024 * 1024), // Convert MB to bytes\n                DownloadTime = (int)stopwatch.Elapsed.TotalSeconds,\n                Storage = storagePath,\n                Status = queueItem.Status,\n                Id = queueItem.Id\n            };\n            _downloadHistory.Add(historyItem);\n\n            _logger.LogInformation(\"Download history updated for {Title}. Status: {Status}, Download Time: {DownloadTime}s, Size: {Size} bytes\",\n                queueItem.Title, queueItem.Status, historyItem.DownloadTime, historyItem.Size);\n        }\n        catch (Exception ex)\n        {\n            queueItem.Status = SabnzbdDownloadStatus.Failed;\n            _logger.LogError(ex, \"An error occurred during the conversion of {Title} from MP4 to MKV.\", queueItem.Title);\n        }\n    }\n\n    private async Task EnsureFfmpegExistsAsync()\n    {\n        if (!File.Exists(_ffmpegPath))\n        {\n            _logger.LogInformation(\"FFmpeg not found at path {FfmpegPath}. Starting download...\", _ffmpegPath);\n\n            // URLs for downloading FFmpeg based on OS\n            string ffmpegDownloadUrl = _isWindows\n                ? \"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip\"\n                : \"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz\";\n\n            var tempFilePath = Path.Combine(Path.GetTempPath(), _isWindows ? \"ffmpeg.zip\" : \"ffmpeg.tar.xz\");\n            var ffmpegDir = Path.Combine(Path.GetDirectoryName(_ffmpegPath) ?? string.Empty);\n\n            try\n            {\n                // Download FFmpeg file\n                using (var response = await _httpClient.GetAsync(ffmpegDownloadUrl, HttpCompletionOption.ResponseHeadersRead))\n                using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None))\n                {\n                    await response.Content.CopyToAsync(fileStream);\n                    _logger.LogInformation(\"FFmpeg downloaded to temporary path {TempFilePath}\", tempFilePath);\n                }\n\n                Directory.CreateDirectory(ffmpegDir);\n                _logger.LogInformation(\"FFmpeg directory ensured at {FfmpegDir}\", ffmpegDir);\n\n                // Extract FFmpeg based on the OS\n                if (_isWindows)\n                {\n                    ZipFile.ExtractToDirectory(tempFilePath, ffmpegDir);\n                    _logger.LogInformation(\"FFmpeg extracted in Windows environment.\");\n\n                    // Move extracted ffmpeg.exe to the expected path\n                    var extractedPath = Directory.GetFiles(ffmpegDir, \"ffmpeg.exe\", SearchOption.AllDirectories).FirstOrDefault();\n                    if (extractedPath != null)\n                    {\n                        File.Move(extractedPath, _ffmpegPath, true);\n                        _logger.LogInformation(\"FFmpeg moved to final path {FfmpegPath}\", _ffmpegPath);\n                    }\n                }\n                else\n                {\n                    // Linux/macOS extraction\n                    var extractionDir = Path.Combine(ffmpegDir, \"extracted\");\n                    Directory.CreateDirectory(extractionDir);\n\n                    _logger.LogInformation(\"Starting extraction of FFmpeg in Linux environment.\");\n\n                    var tarProcess = new Process\n                    {\n                        StartInfo = new ProcessStartInfo\n                        {\n                            FileName = \"tar\",\n                            Arguments = $\"-xf \\\"{tempFilePath}\\\" -C \\\"{extractionDir}\\\"\",\n                            RedirectStandardOutput = true,\n                            RedirectStandardError = true,\n                            UseShellExecute = false,\n                            CreateNoWindow = true\n                        }\n                    };\n\n                    tarProcess.Start();\n                    await tarProcess.WaitForExitAsync();\n\n                    if (tarProcess.ExitCode != 0)\n                    {\n                        string error = await tarProcess.StandardError.ReadToEndAsync();\n                        _logger.LogError(\"Error extracting FFmpeg: {Error}\", error);\n                        return;\n                    }\n\n                    _logger.LogInformation(\"FFmpeg extraction completed.\");\n\n                    // Locate the extracted FFmpeg binary\n                    var extractedPath = Directory.GetFiles(extractionDir, \"ffmpeg\", SearchOption.AllDirectories).FirstOrDefault();\n                    if (extractedPath != null)\n                    {\n                        File.Move(extractedPath, _ffmpegPath, true);\n                        _logger.LogInformation(\"FFmpeg moved to final path {FfmpegPath}\", _ffmpegPath);\n\n                        // Ensure the binary is executable\n                        var chmodProcess = new Process\n                        {\n                            StartInfo = new ProcessStartInfo\n                            {\n                                FileName = \"chmod\",\n                                Arguments = $\"+x \\\"{_ffmpegPath}\\\"\",\n                                RedirectStandardOutput = true,\n                                RedirectStandardError = true,\n                                UseShellExecute = false,\n                                CreateNoWindow = true\n                            }\n                        };\n\n                        chmodProcess.Start();\n                        await chmodProcess.WaitForExitAsync();\n                        _logger.LogInformation(\"Executable permissions set for FFmpeg at {FfmpegPath}\", _ffmpegPath);\n                    }\n                    else\n                    {\n                        _logger.LogError(\"FFmpeg binary not found after extraction.\");\n                    }\n                }\n            }\n            catch (Exception ex)\n            {\n                _logger.LogError(ex, \"An error occurred during FFmpeg download or extraction.\");\n            }\n            finally\n            {\n                if (File.Exists(tempFilePath))\n                {\n                    File.Delete(tempFilePath);\n                    _logger.LogInformation(\"Temporary download file deleted at {TempFilePath}\", tempFilePath);\n                }\n\n                var extractionDir = Path.Combine(ffmpegDir, \"extracted\");\n                if (Directory.Exists(extractionDir))\n                {\n                    Directory.Delete(extractionDir, true);\n                    _logger.LogInformation(\"Temporary extraction directory deleted at {ExtractionDir}\", extractionDir);\n                }\n            }\n\n            _logger.LogInformation(\"FFmpeg download and setup complete.\");\n        }\n        else\n        {\n            _logger.LogInformation(\"FFmpeg already exists at path {FfmpegPath}. Skipping download.\", _ffmpegPath);\n        }\n    }\n}\n"
  },
  {
    "path": "MediathekArr/Services/ItemLookupService.cs",
    "content": "﻿using MediathekArrLib.Models;\nusing Microsoft.Extensions.Caching.Memory;\nusing System.Text.Json;\n\nnamespace MediathekArr.Services;\n\npublic class ItemLookupService(IHttpClientFactory httpClientFactory, IConfiguration configuration, IMemoryCache memoryCache)\n{\n    private readonly HttpClient _httpClient = httpClientFactory.CreateClient();\n    private readonly string _apiBaseUrl = configuration[\"MEDIATHEKARR_API_BASE_URL\"] ?? \"https://mediathekarr.pcjones.de/api/v1\";\n    private readonly IMemoryCache _memoryCache = memoryCache;\n\n    private static JsonSerializerOptions GetJsonSerializerOptions()\n    {\n        return new JsonSerializerOptions\n        {\n            PropertyNameCaseInsensitive = true\n        };\n    }\n\n    public async Task<TvdbInfoResponse> GetShowInfoByTvdbId(int tvdbid)\n    {\n        var cacheKey = $\"TvdbInfo_{tvdbid}\";\n        if (_memoryCache.TryGetValue(cacheKey, out TvdbInfoResponse? cachedTvdbInfo))\n        {\n            if (cachedTvdbInfo != null)\n            {\n                return cachedTvdbInfo;\n            }\n        }\n\n        var requestUrl = $\"{_apiBaseUrl}/get_show.php?tvdbid={tvdbid}\";\n\n        var response = await _httpClient.GetAsync(requestUrl);\n\n        if (!response.IsSuccessStatusCode)\n        {\n            var errorContent = await response.Content.ReadAsStringAsync();\n            throw new HttpRequestException($\"Error fetching data: {errorContent}\");\n        }\n\n        var jsonResponse = await response.Content.ReadAsStringAsync();\n        var tvdbInfo = JsonSerializer.Deserialize<TvdbInfoResponse>(jsonResponse, GetJsonSerializerOptions());\n\n        if (tvdbInfo == null || tvdbInfo.Status != \"success\" || tvdbInfo.Data == null)\n        {\n            throw new HttpRequestException($\"Failed to fetch TVDB data. Response: {jsonResponse}\");\n        }\n\n        _memoryCache.Set(cacheKey, tvdbInfo, TimeSpan.FromHours(12));\n\n        return tvdbInfo;\n    }\n}\n"
  },
  {
    "path": "MediathekArr/Services/MediathekSearchService.cs",
    "content": "﻿using System.Globalization;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing System.Xml.Serialization;\nusing MediathekArrLib.Models;\nusing MediathekArrLib.Models.Newznab;\nusing Microsoft.Extensions.Caching.Memory;\nusing Guid = MediathekArrLib.Models.Newznab.Guid;\n\nnamespace MediathekArr.Services\n{\n    public partial class MediathekSearchService(IHttpClientFactory httpClientFactory, IMemoryCache cache)\n    {\n        private readonly IMemoryCache _cache = cache;\n        private readonly HttpClient _httpClient = httpClientFactory.CreateClient(\"MediathekClient\");\n        private readonly TimeSpan _cacheTimeSpan = TimeSpan.FromMinutes(55);\n        private static readonly string[] SkipKeywords = [\"Audiodeskription\", \"(klare Sprache)\", \"(Gebärdensprache)\", \"Trailer\", \"Outtakes:\"];\n        private static readonly string[] queryField = [\"topic\"];\n        public async Task<string> FetchSearchResultsFromApiById(TvdbData tvdbData, string? season, string? episodeNumber)\n        {\n            var cacheKey = $\"tvdb_{tvdbData.Id}_{season ?? \"null\"}_{episodeNumber ?? \"null\"}\";\n\n        if (_cache.TryGetValue(cacheKey, out string? cachedResponse))\n        {\n            return cachedResponse ?? \"\";\n        }\n\n        // Find correct episode in tvdbData\n        TvdbEpisode? episode;\n        if (season?.Length == 4 && (episodeNumber?.Contains('/') ?? false))\n        {\n            var episodeNumberSplitted = episodeNumber?.Split('/');\n            if (episodeNumberSplitted?.Length == 2 && DateTime.TryParse($\"{season}-{episodeNumberSplitted[0]}-{episodeNumberSplitted[1]}\", out DateTime searchAirDate))\n            {\n                episode = tvdbData.FindEpisodeByAirDate(searchAirDate);\n            }\n            else\n            {\n                episode = null;\n            }\n        }\n        else\n        {\n            episode = tvdbData.FindEpisodeBySeasonAndNumber(season, episodeNumber);\n        }\n\n        if (episode is null || episode.Aired is null || episode.Aired.Value.Year <= 1970)\n        {\n            _cache.Set(cacheKey, string.Empty, _cacheTimeSpan);\n            return ConvertIdSearchApiResponseToRss(null, string.Empty, string.Empty, tvdbData);\n        }\n\n        var queries = new List<object>();\n        var searchName = string.IsNullOrEmpty(tvdbData.GermanName) ? tvdbData.Name : tvdbData.GermanName;\n        queries.Add(new { fields = queryField, query = searchName });\n\n        var requestBody = new\n        {\n            queries,\n            sortBy = \"timestamp\",\n            sortOrder = \"desc\",\n            future = true,\n            offset = 0,\n            size = 5000 // 5000 for id search\n        };\n\n        var requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8);\n\n        var response = await _httpClient.PostAsync(\"https://mediathekviewweb.de/api/query\", requestContent);\n\n        if (response.IsSuccessStatusCode)\n        {\n            var apiResponse = await response.Content.ReadAsStringAsync();\n            var filteredResponse = ApplyFilters(apiResponse, episode);\n\n            var newznabRssResponse = ConvertIdSearchApiResponseToRss(filteredResponse, episode.SeasonNumber.ToString(), episode.EpisodeNumber.ToString(), tvdbData);\n            _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan);\n\n            return newznabRssResponse;\n        }\n\n        return null;\n    }\n\n    private static MediathekApiResponse? ApplyFilters(string apiResponse, TvdbEpisode episode)\n    {\n        var responseObject = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse);\n\n        if (responseObject?.Result?.Results == null)\n        {\n            return null;\n        }\n\n        var initialResults = responseObject.Result.Results;\n        var resultsFilteredByRuntime = FilterByRuntime(initialResults, episode.Runtime);\n        var resultsByAiredDate = FilterByAiredDate(resultsFilteredByRuntime, episode.Aired!.Value).Where(item => !ShouldSkipItem(item)).ToList();\n        var resultsByTitleDate = FilterByTitleDate(resultsFilteredByRuntime, episode.Aired.Value).Where(item => !ShouldSkipItem(item)).ToList();\n        var resultsByDescriptionDate = FilterByDescriptionDate(resultsFilteredByRuntime, episode.Aired.Value).Where(item => !ShouldSkipItem(item)).ToList();\n        var resultsByEpisodeTitleMatch = FilterByEpisodeTitleMatch(resultsFilteredByRuntime, episode.Name).Where(item => !ShouldSkipItem(item)).ToList();\n        List<ApiResultItem> resultsBySeasonEpisodeMatch = [];\n        // if more than 3 results we assume episode title match wasn't correct\n        if (resultsByEpisodeTitleMatch.Count > 3)\n        {\n            resultsByEpisodeTitleMatch.Clear();\n        }\n\n        // if we have episode title match that is the best we got\n        if (resultsByEpisodeTitleMatch.Count > 0)\n        {\n            // we ignore air date in this case as it is not as reliable\n            resultsByAiredDate.Clear();\n        }\n\n            if (resultsByAiredDate.Count == 0 && resultsByTitleDate.Count == 0 && resultsByDescriptionDate.Count == 0 && resultsByEpisodeTitleMatch.Count == 0)\n            {\n                // Only trust Mediathek season/episode if no other match:\n                resultsBySeasonEpisodeMatch =\n                    FilterBySeasonEpisodeMatch(resultsFilteredByRuntime, episode.SeasonNumber.ToString(), episode.EpisodeNumber.ToString())\n                    .Where(item => !ShouldSkipItem(item)).ToList(); ;\n            }\n\n        // HashSet to remove duplicates\n        HashSet<ApiResultItem> filteredResults = [.. resultsByAiredDate, .. resultsByTitleDate, .. resultsByDescriptionDate, .. resultsByEpisodeTitleMatch, .. resultsBySeasonEpisodeMatch];\n\n        // Create a filtered API response\n        var filteredApiResponse = new MediathekApiResponse\n        {\n            Result = new MediathekApiResult\n            {\n                Results = [.. filteredResults],\n                QueryInfo = responseObject.Result.QueryInfo\n            },\n            Err = responseObject.Err\n        };\n\n        return filteredApiResponse;\n    }\n\n\n    private static List<ApiResultItem> FilterByRuntime(List<ApiResultItem> results, int? runtime)\n    {\n        if (runtime is null || runtime is 0)\n        {\n            return results;\n        }\n        var minRuntime = Math.Max(5, (int)(runtime * 0.65)) * 60;\n        var maxRuntime = (int)(runtime * 1.35) * 60;\n        return results.Where(item =>\n            item.Duration >= minRuntime && item.Duration <= maxRuntime)\n            .ToList();\n    }\n\n    private static List<ApiResultItem> FilterByAiredDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        return results.Where(item =>\n            ConvertToBerlinTimezone(UnixTimeStampToDateTime(item.Timestamp)).Date == airedDate)\n            .ToList();\n    }\n\n    private static List<ApiResultItem> FilterByTitleDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        var formattedAiredDate = airedDate.ToString(\"yyyy-MM-dd\");\n\n        return results.Where(item =>\n        {\n            var extractedDate = ExtractDate(item.Title);\n            return !string.IsNullOrEmpty(extractedDate) && extractedDate == formattedAiredDate;\n        }).ToList();\n    }\n\n    private static List<ApiResultItem> FilterByDescriptionDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        var formattedAiredDate = airedDate.ToString(\"yyyy-MM-dd\");\n\n        return results.Where(item =>\n        {\n            var extractedDate = ExtractDate(item.Description);\n            return !string.IsNullOrEmpty(extractedDate) && extractedDate == formattedAiredDate;\n        }).ToList();\n    }\n\n\n    private static List<ApiResultItem> FilterByEpisodeTitleMatch(List<ApiResultItem> results, string episodeName)\n    {\n        var normalizedEpisodeName = NormalizeString(episodeName);\n\n            return results.Where(item =>\n            {\n                var normalizedTitle = NormalizeString(item.Title);\n                if (normalizedTitle.Contains(normalizedEpisodeName, StringComparison.OrdinalIgnoreCase)) {\n                    return true;\n                }\n                else if (normalizedEpisodeName.Length >= 13 && normalizedTitle.Length >= 10)\n                {\n                    return normalizedEpisodeName.Contains(normalizedTitle, StringComparison.OrdinalIgnoreCase);\n\t\t\t\t}\n                else\n                {\n                    return false;\n                }\n            }).ToList();\n    }\n\n    private static List<ApiResultItem> FilterBySeasonEpisodeMatch(List<ApiResultItem> results, string season, string episode)\n    {\n        var zeroBasedSeason = season.Length >= 2 ? season : $\"0{season}\";\n        var zeroBasedEpisode = episode.Length >= 2 ? episode : $\"0{episode}\";\n\n        return results.Where(item =>\n        {\n            return item.Title.Contains($\"S{zeroBasedSeason}\") && item.Title.Contains($\"E{zeroBasedEpisode}\");\n        }).ToList();\n    }\n\n    // Normalize a string to remove special characters and retain only A-Z, äöüÄÖÜß\n    private static string NormalizeString(string input)\n    {\n        var regex = NormalizeRegex();\n        return regex.Replace(input, \"\").ToLowerInvariant();\n    }\n\n    public async Task<string> FetchSearchResultsFromApiByString(string? q, string? season)\n    {\n        var cacheKey = $\"q_{q ?? \"null\"}_{season ?? \"null\"}\";\n\n        if (_cache.TryGetValue(cacheKey, out string? cachedResponse))\n        {\n            return cachedResponse ?? \"\";\n        }\n\n        var zeroBasedSeason = season == null || season.Length >= 2 ? season : $\"0{season}\";\n        \n        var queries = new List<object>();\n        if (q != null)\n        {\n            queries.Add(new { fields = queryField, query = q });\n        }\n\n        if (!string.IsNullOrEmpty(season))\n        {\n            if (season.Length == 4 && season.StartsWith(\"20\") || season.StartsWith(\"19\"))\n            {\n                queries.Add(new { fields = new[] { \"title\" }, query = $\"{season}\" });\n            }\n            else\n            {\n                queries.Add(new { fields = new[] { \"title\" }, query = $\"S{zeroBasedSeason}\" });\n            }\n        }\n\n        var requestBody = new\n        {\n            queries,\n            sortBy = \"timestamp\",\n            sortOrder = \"desc\",\n            future = true,\n            offset = 0,\n            size = 300 // 300 for RSS sync and string search\n        };\n\n        var requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8);\n\n        var response = await _httpClient.PostAsync(\"https://mediathekviewweb.de/api/query\", requestContent);\n\n        if (response.IsSuccessStatusCode)\n        {\n            var apiResponse = await response.Content.ReadAsStringAsync();\n            var newznabRssResponse = ConvertStringSearchApiResponseToRss(apiResponse, season);\n            _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan);\n\n            return newznabRssResponse;\n        }\n\n        return null;\n    }\n\n    private string ConvertIdSearchApiResponseToRss(MediathekApiResponse? filteredResponse, string season, string episode, TvdbData tvdbData)\n    {\n        if (filteredResponse is null || filteredResponse.Result.Results == null)\n        {\n            return SerializeRss(GetEmptyRssResult());\n        }\n\n        var rss = new Rss\n        {\n            Channel = new Channel\n            {\n                Title = \"MediathekArr\",\n                Description = \"MediathekArr API results\",\n                Response = new Response\n                {\n                    Offset = 0,\n                    Total = filteredResponse.Result.QueryInfo.ResultCount\n                },\n                Items = filteredResponse.Result.Results\n                    // .Where(item => !ShouldSkipItem(item)) we already do this earlier for id searches in ApplyFilters\n                    .SelectMany(item => GenerateRssItems(item, season, episode, tvdbData)) // Generate RSS items for each link\n                    .ToList()\n            }\n        };\n\n        return SerializeRss(rss);\n    }\n\n    private string ConvertStringSearchApiResponseToRss(string apiResponse, string? season = null, bool sonarr = true)\n    {\n        if (string.IsNullOrWhiteSpace(apiResponse))\n        {\n            return SerializeRss(GetEmptyRssResult());\n        }\n\n        var responseObject = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse);\n\n        if (responseObject?.Result?.Results == null)\n        {\n            return SerializeRss(GetEmptyRssResult());\n        }\n\n        var rss = new Rss\n        {\n            Channel = new Channel\n            {\n                Title = \"MediathekArr\",\n                Description = \"MediathekArr API results\",\n                Response = new Response\n                {\n                    Offset = 0,\n                    Total = responseObject.Result.QueryInfo.ResultCount\n                },\n                Items = responseObject.Result.Results\n                    .Where(item => !ShouldSkipItem(item))\n                    .SelectMany(item => GenerateRssItems(item, season, null)) // Generate RSS items for each link\n                    .ToList()\n            }\n        };\n\n        return SerializeRss(rss);\n    }\n\n    private Rss GetEmptyRssResult()\n    {\n        return new Rss\n        {\n            Channel = new Channel\n            {\n                Title = \"MediathekArr\",\n                Description = \"MediathekArr API results\",\n                Response = new Response\n                {\n                    Offset = 0,\n                    Total = 0\n                },\n                Items = []\n            }\n        };\n    }\n\n    private List<Item> GenerateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData = null)\n    {\n        var items = new List<Item>();\n\n        string[] categories = [\"5000\", \"2000\"];\n\n        if (!string.IsNullOrEmpty(item.UrlVideoHd))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"1080p\", 1.6, \"TV > HD\", [..categories, \"5040\", \"2040\"], item.UrlVideoHd));\n        }\n\n        if (!string.IsNullOrEmpty(item.UrlVideo))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"720p\", 1.0, \"TV > HD\", [.. categories, \"5040\", \"2040\"], item.UrlVideo));\n        }\n\n        if (!string.IsNullOrEmpty(item.UrlVideoLow))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"480p\", 0.4, \"TV > SD\", [.. categories, \"5030\", \"2030\"], item.UrlVideoLow));\n\n        }\n\n        return items;\n    }\n\n    private List<Item> CreateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url)\n    {\n        var items = new List<Item>();\n\n        // Generate title with season and formatted date\n        var formattedDate = ExtractDate(item.Title);\n\n        // Create two items if both season and formatted date are present\n        if (!string.IsNullOrEmpty(formattedDate))\n        {\n            // Title with formattedDate in it\n            if (!string.IsNullOrEmpty(formattedDate))\n            {\n                items.Add(CreateRssItem(item, formattedDate.Split('-')[0], null, episode, tvdbData, quality, sizeMultiplier, category, categoryValues, url, formattedDate));\n            }\n        }\n\n        items.Add(CreateRssItem(item, null, season, episode, tvdbData, quality, sizeMultiplier, category, categoryValues, url));\n\n        return items;\n    }\n\n    private static string FormatTitle(string title)\n    {\n        // Replace German Umlaute and special characters\n        title = title.Replace(\"ä\", \"ae\")\n                     .Replace(\"ö\", \"oe\")\n                     .Replace(\"ü\", \"ue\")\n                     .Replace(\"ß\", \"ss\")\n                     .Replace(\"Ä\", \"Ae\")\n                     .Replace(\"Ö\", \"Oe\")\n                     .Replace(\"Ü\", \"Ue\");\n\n        // Remove unwanted characters\n        title = TitleRegexUnd().Replace(title, \"und\");\n        title = TitleRegexSymbols().Replace(title, \"\"); // Remove various symbols\n        title = TitleRegexWhitespace().Replace(title, \".\").Replace(\"..\", \".\");\n\n        return title;\n    }\n\n\n    private 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)\n    {\n        var adjustedSize = (long)(item.Size * sizeMultiplier);\n        var parsedTitle = GenerateTitle(item.Topic, item.Title, quality, formattedDate, season, episode);\n        var formattedTitle = FormatTitle(parsedTitle);\n        //var translatedTitle = TranslateTitle(formattedTitle, tvdbData);\n        var translatedTitle = formattedTitle; // TODO see if translation is needed\n        var encodedTitle = Convert.ToBase64String(Encoding.UTF8.GetBytes(translatedTitle));\n        var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(url));\n\n        // Generate the full URL for the fake_nzb_download endpoint\n        var fakeDownloadUrl = $\"/api/fake_nzb_download?encodedUrl={encodedUrl}&encodedTitle={encodedTitle}\";\n\n        return new Item\n        {\n            Title = translatedTitle,\n            Guid = new Guid\n            {\n                IsPermaLink = true,\n                Value = $\"{item.UrlWebsite}#{quality}{(string.IsNullOrEmpty(formattedDate) ? \"\" : \"-a\")}\",\n            },\n            Link = url,\n            Comments = item.UrlWebsite,\n            PubDate = DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString(\"R\"),\n            Category = category,\n            Description = item.Description,\n            Enclosure = new Enclosure\n            {\n                Url = fakeDownloadUrl,\n                Length = adjustedSize,\n                Type = \"application/x-nzb\"\n            },\n            Attributes = GenerateAttributes(yearSeason ?? season, categoryValues)\n        };\n    }\n\n    private static string TranslateTitle(string title, TvdbData? tvdbData)\n    {\n        if (tvdbData is null)\n        {\n            return title;\n        }\n\n        return title.Replace(tvdbData.GermanName, tvdbData.Name, StringComparison.OrdinalIgnoreCase);\n    }\n\n    // TODO refactor and make this look good, It's too late right now:D\n    // TODO now it's even worse :D oh god\n    private string GenerateTitle(string topic, string title, string quality, string? formattedDate, string? seasonOverride, string? episodeOverride)\n    {\n        if (!string.IsNullOrEmpty(formattedDate))\n        {\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            return $\"{topic}.{formattedDate}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n        }\n        var episodePattern = @\"S\\d{1,4}/E\\d{1,4}\";\n        var match = Regex.Match(title, episodePattern);\n\n        if (match.Success)\n        {\n            var seasonAndEpisode = match.Value.Replace(\"/\", \"\");\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Replace($\"({match.Value})\", \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            if (seasonOverride is null || episodeOverride is null)\n            {\n                // use data from mediathek\n                return $\"{topic}.{seasonAndEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n            }\n\n            // use overwrite data\n            var zeroBasedSeason = seasonOverride.Length >= 2 ? seasonOverride : $\"0{seasonOverride}\";\n            var zeroBasedEpisode = episodeOverride.Length >= 2 ? episodeOverride : $\"0{episodeOverride}\";\n            return $\"{topic}.S{zeroBasedSeason}E{zeroBasedEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n        }\n\n        if (seasonOverride is null || episodeOverride is null)\n        {\n            return title;\n        }\n        else\n        {\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            var zeroBasedSeason = seasonOverride.Length >= 2 ? seasonOverride : $\"0{seasonOverride}\";\n            var zeroBasedEpisode = episodeOverride.Length >= 2 ? episodeOverride : $\"0{episodeOverride}\";\n\n            return $\"{topic}.S{zeroBasedSeason}E{zeroBasedEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : title)}GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n        }\n    }\n\n    private static string ExtractDate(string title)\n    {\n        // Numeric format pattern (e.g., \"24.10.2024\" or \"24.10.24\")\n        var numericDatePattern = @\"(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2}|\\d{4})\";\n        // Nonth name format pattern (e.g., \"16. Juli 2024\")\n        var germanMonthPattern = @\"(\\d{1,2})\\.\\s*(\\w+)\\s+(\\d{4})\";\n\n        var numericDateMatch = Regex.Match(title, numericDatePattern);\n        if (numericDateMatch.Success)\n        {\n            int day = int.Parse(numericDateMatch.Groups[1].Value);\n            int month = int.Parse(numericDateMatch.Groups[2].Value);\n            int year = int.Parse(numericDateMatch.Groups[3].Value);\n\n            if (year < 100)\n            {\n                year += 2000;\n            }\n\n            DateTime date = new(year, month, day);\n            return date.ToString(\"yyyy-MM-dd\");\n        }\n\n        var longMonthMatch = Regex.Match(title, germanMonthPattern);\n        if (longMonthMatch.Success)\n        {\n            int day = int.Parse(longMonthMatch.Groups[1].Value);\n            string monthName = longMonthMatch.Groups[2].Value;\n            int year = int.Parse(longMonthMatch.Groups[3].Value);\n\n            var germanCulture = new CultureInfo(\"de-DE\");\n            if (DateTime.TryParseExact($\"{day} {monthName} {year}\",\n                                       \"d MMMM yyyy\",\n                                       germanCulture,\n                                       DateTimeStyles.None,\n                                       out DateTime date))\n            {\n                return date.ToString(\"yyyy-MM-dd\");\n            }\n        }\n\n        return string.Empty;\n    }\n\n    private List<MediathekArrLib.Models.Newznab.Attribute> GenerateAttributes(string? season, string[] categoryValues)\n    {\n        var attributes = new List<MediathekArrLib.Models.Newznab.Attribute>();\n\n        foreach (var categoryValue in categoryValues)\n        {\n            attributes.Add(new MediathekArrLib.Models.Newznab.Attribute { Name = \"category\", Value = categoryValue });\n        }\n\n        if (season != null)\n        {\n            attributes.Add(new MediathekArrLib.Models.Newznab.Attribute { Name = \"season\", Value = season });\n        }\n\n        return attributes;\n    }\n\n    private static bool ShouldSkipItem(ApiResultItem item)\n    {\n        return item.UrlVideo.EndsWith(\".m3u8\") || SkipKeywords.Any(item.Title.Contains);\n    }\n\n    private string SerializeRss(Rss rss)\n    {\n        var serializer = new XmlSerializer(typeof(Rss));\n\n        // Define the namespaces and add the newznab namespace\n        var namespaces = new XmlSerializerNamespaces();\n        namespaces.Add(\"newznab\", \"http://www.newznab.com/DTD/2010/feeds/attributes/\");\n\n        using var stringWriter = new StringWriter();\n        serializer.Serialize(stringWriter, rss, namespaces);\n\n        // TODO quick fix\n        string result = stringWriter.ToString();\n        result = result.Replace(\":newznab_x003A_\", \":\");\n\n        return result;\n    }\n\n    private static DateTime UnixTimeStampToDateTime(long unixTimeStamp)\n    {\n        return DateTimeOffset.FromUnixTimeSeconds(unixTimeStamp).UtcDateTime;\n    }\n\n    private static DateTime ConvertToBerlinTimezone(DateTime utcDateTime)\n    {\n        var berlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById(\"Europe/Berlin\");\n        return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, berlinTimeZone);\n    }\n\n\n\n        [GeneratedRegex(@\"[&]\")]\n        private static partial Regex TitleRegexUnd();\n        [GeneratedRegex(@\"[/:;\"\"'@#?$%^*+=!<>,()]\")]\n        private static partial Regex TitleRegexSymbols();\n        [GeneratedRegex(@\"\\s+\")]\n        private static partial Regex TitleRegexWhitespace();\n        [GeneratedRegex(@\"Folge\\s*\\d+:\\s*\")]\n        private static partial Regex EpisodeRegex();\n        [GeneratedRegex(\"[^a-zA-ZäöüÄÖÜß]\")]\n        private static partial Regex NormalizeRegex();\n    }\n}"
  },
  {
    "path": "MediathekArr/appsettings.Development.json",
    "content": "{\n  \"Logging\": {\n    \"LogLevel\": {\n      \"Default\": \"Information\",\n      \"Microsoft.AspNetCore\": \"Warning\"\n    }\n  }\n}\n"
  },
  {
    "path": "MediathekArr/appsettings.Production.json",
    "content": "﻿{\n  \"Kestrel\": {\n    \"Endpoints\": {\n      \"Http\": {\n        \"Url\": \"http://[::]:5007\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "MediathekArr/appsettings.json",
    "content": "{\n  \"Logging\": {\n    \"LogLevel\": {\n      \"Default\": \"Information\",\n      \"Microsoft.AspNetCore\": \"Warning\"\n    }\n  },\n  \"AllowedHosts\": \"*\"\n}\n"
  },
  {
    "path": "MediathekArr.sln",
    "content": "﻿\nMicrosoft Visual Studio Solution File, Format Version 12.00\n# Visual Studio Version 17\nVisualStudioVersion = 17.9.34728.123\nMinimumVisualStudioVersion = 10.0.40219.1\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"MediathekArrDownloader\", \"MediathekArr\\MediathekArrDownloader.csproj\", \"{325043A5-5585-4C48-B947-A1E69EAE8343}\"\nEndProject\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"MediathekArrServer\", \"MediathekArrServer\\MediathekArrServer.csproj\", \"{F6A03A18-04C6-4BC1-8969-A8BADDD64718}\"\nEndProject\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"MediathekArrLib\", \"MediathekArrLib\\MediathekArrLib.csproj\", \"{E6785AB3-92DA-4DF8-8EAC-362BD3DE8AE2}\"\nEndProject\nProject(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"Solution Items\", \"Solution Items\", \"{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}\"\n\tProjectSection(SolutionItems) = preProject\n\t\t.env.example = .env.example\n\t\t.gitattributes = .gitattributes\n\t\t.gitignore = .gitignore\n\t\tbuild_and_push_docker_image.bat = build_and_push_docker_image.bat\n\t\tdocker-compose.yml = docker-compose.yml\n\t\tDockerfile = Dockerfile\n\t\tLICENSE = LICENSE\n\t\tREADME.md = README.md\n\tEndProjectSection\nEndProject\nGlobal\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n\t\tDebug|Any CPU = Debug|Any CPU\n\t\tRelease|Any CPU = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n\t\t{325043A5-5585-4C48-B947-A1E69EAE8343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{325043A5-5585-4C48-B947-A1E69EAE8343}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{325043A5-5585-4C48-B947-A1E69EAE8343}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{325043A5-5585-4C48-B947-A1E69EAE8343}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{F6A03A18-04C6-4BC1-8969-A8BADDD64718}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{F6A03A18-04C6-4BC1-8969-A8BADDD64718}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{F6A03A18-04C6-4BC1-8969-A8BADDD64718}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{F6A03A18-04C6-4BC1-8969-A8BADDD64718}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{E6785AB3-92DA-4DF8-8EAC-362BD3DE8AE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{E6785AB3-92DA-4DF8-8EAC-362BD3DE8AE2}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{E6785AB3-92DA-4DF8-8EAC-362BD3DE8AE2}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{E6785AB3-92DA-4DF8-8EAC-362BD3DE8AE2}.Release|Any CPU.Build.0 = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(SolutionProperties) = preSolution\n\t\tHideSolutionNode = FALSE\n\tEndGlobalSection\n\tGlobalSection(ExtensibilityGlobals) = postSolution\n\t\tSolutionGuid = {F9EB85C0-479B-49B9-92A6-79FF03690BCA}\n\tEndGlobalSection\nEndGlobal\n"
  },
  {
    "path": "MediathekArr.slnLaunch",
    "content": "[\n  {\n    \"Name\": \"Downloader+Server\",\n    \"Projects\": [\n      {\n        \"Path\": \"MediathekArr\\\\MediathekArrDownloader.csproj\",\n        \"Action\": \"Start\",\n        \"DebugTarget\": \"http\"\n      },\n      {\n        \"Path\": \"MediathekArrServer\\\\MediathekArrServer.csproj\",\n        \"Action\": \"Start\",\n        \"DebugTarget\": \"http\"\n      }\n    ]\n  }\n]"
  },
  {
    "path": "MediathekArrLib/MediathekArrLib.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk\">\n\n\t<PropertyGroup>\n\t\t<TargetFramework>net9.0</TargetFramework>\n\t\t<ImplicitUsings>enable</ImplicitUsings>\n\t\t<Nullable>enable</Nullable>\n\t</PropertyGroup>\n\n</Project>\n"
  },
  {
    "path": "MediathekArrLib/Models/ApiResultItem.cs",
    "content": "﻿using MediathekArrLib.Utilities;\nusing System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models;\n\npublic class ApiResultItem\n{\n    [JsonPropertyName(\"channel\")]\n    public string Channel { get; set; }\n\n    [JsonPropertyName(\"topic\")]\n    public string Topic { get; set; }\n\n    [JsonPropertyName(\"title\")]\n    public string Title { get; set; }\n\n    [JsonPropertyName(\"description\")]\n    public string Description { get; set; }\n\n    [JsonPropertyName(\"filmlisteTimestamp\")]\n    [JsonConverter(typeof(NumberOrEmptyConverter<long>))]\n    public long Timestamp { get; set; }\n\n    [JsonPropertyName(\"duration\")]\n    [JsonConverter(typeof(NumberOrEmptyConverter<int>))]\n    public int Duration { get; set; }\n\n    [JsonPropertyName(\"size\")]\n    [JsonConverter(typeof(NumberOrEmptyConverter<long>))]\n    public long Size { get; set; }\n\n    [JsonPropertyName(\"url_website\")]\n    public string UrlWebsite { get; set; }\n\n    [JsonPropertyName(\"url_video\")]\n    public string UrlVideo { get; set; }\n\n    [JsonPropertyName(\"url_video_low\")]\n    public string UrlVideoLow { get; set; }\n\n    [JsonPropertyName(\"url_video_hd\")]\n    public string UrlVideoHd { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/MediathekApiResponse.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models;\n\npublic class MediathekApiResponse\n{\n    [JsonPropertyName(\"result\")]\n    public MediathekApiResult Result { get; set; }\n\n    [JsonPropertyName(\"err\")]\n    public object? Err { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/MediathekApiResult.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models;\n\npublic class MediathekApiResult\n{\n    [JsonPropertyName(\"results\")]\n    public List<ApiResultItem> Results { get; set; }\n\n    [JsonPropertyName(\"queryInfo\")]\n    public QueryInfo QueryInfo { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Attribute.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Attribute\n{\n    [XmlAttribute(\"name\")]\n    public string Name { get; set; }\n\n    [XmlAttribute(\"value\")]\n    public string Value { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Channel.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Channel\n{\n    [XmlElement(\"title\")]\n    public string Title { get; set; }\n\n    [XmlElement(\"description\")]\n    public string Description { get; set; }\n\n    [XmlElement(\"newznab:response\", Namespace = \"http://www.newznab.com/DTD/2010/feeds/attributes/\")]\n    public Response Response { get; set; }\n\n    [XmlElement(\"item\")]\n    public List<Item> Items { get; set; } = [];\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Enclosure.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Enclosure\n{\n    [XmlAttribute(\"url\")]\n    public string Url { get; set; }\n\n    [XmlAttribute(\"length\")]\n    public long Length { get; set; }\n\n    [XmlAttribute(\"type\")]\n    public string Type { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Guid.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Guid\n{\n    [XmlAttribute(\"isPermaLink\")]\n    public bool IsPermaLink { get; set; }\n\n    [XmlText]\n    public string Value { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Item.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Item\n{\n    [XmlElement(\"title\")]\n    public string Title { get; set; }\n\n    [XmlElement(\"guid\")]\n    public Guid Guid { get; set; }\n\n    [XmlElement(\"link\")]\n    public string Link { get; set; }\n\n    [XmlElement(\"comments\")]\n    public string Comments { get; set; }\n\n    [XmlElement(\"pubDate\")]\n    public string PubDate { get; set; }\n\n    [XmlElement(\"category\")]\n    public string Category { get; set; }\n\n    [XmlElement(\"description\")]\n    public string Description { get; set; }\n\n    [XmlElement(\"enclosure\")]\n    public Enclosure Enclosure { get; set; }\n\n    [XmlElement(\"newznab:attr\", Namespace = \"http://www.newznab.com/DTD/2010/feeds/attributes/\")]\n    public List<Attribute> Attributes { get; set; } = [];\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Response.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\npublic class Response\n{\n    [XmlAttribute(\"offset\")]\n    public int Offset { get; set; }\n\n    [XmlAttribute(\"total\")]\n    public int Total { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Newznab/Rss.cs",
    "content": "﻿using System.Xml;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Models.Newznab;\n\n[XmlRoot(\"rss\")]\npublic class Rss\n{\n    [XmlAttribute(\"version\")]\n    public string Version { get; set; } = \"2.0\";\n\n    [XmlElement(\"channel\")]\n    public Channel Channel { get; set; }\n\n    [XmlNamespaceDeclarations]\n    public XmlSerializerNamespaces Xmlns { get; } = new XmlSerializerNamespaces(\n    [\n        new XmlQualifiedName(\"newznab\", \"http://www.newznab.com/DTD/2010/feeds/attributes/\")\n    ]);\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/QueryInfo.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models;\n\npublic class QueryInfo\n{\n    [JsonPropertyName(\"filmlisteTimestamp\")]\n    public long FilmlisteTimestamp { get; set; }\n\n    [JsonPropertyName(\"searchEngineTime\")]\n    public string SearchEngineTime { get; set; }\n\n    [JsonPropertyName(\"resultCount\")]\n    public int ResultCount { get; set; }\n\n    [JsonPropertyName(\"totalResults\")]\n    public int TotalResults { get; set; }\n}"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/EpisodeType.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic enum EpisodeType\n{\n    Standard,\n    Daily,\n    Anime\n}"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/Filter.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class Filter\n{\n    [JsonPropertyName(\"attribute\")]\n    public string Attribute { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"type\")]\n    [JsonConverter(typeof(JsonStringEnumConverter))]\n    public MatchType Type { get; set; }\n\n    [JsonPropertyName(\"value\")]\n    public object Value { get; set; } = string.Empty;\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/IdentificationResult.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic record IdentificationResult(string UsedRuleset, string Name, string GermanName, int? SeasonNumber, int? EpisodeNumber, string ItemTitle, TvdbEpisode MatchedEpisode);"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/MatchType.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic enum MatchType\n{\n    ExactMatch,\n    Contains,\n    Regex,\n    GreaterThan,\n    LessThan\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/MatchedEpisodeInfo.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic record MatchedEpisodeInfo(TvdbEpisode Episode, ApiResultItem Item, string ShowName, string MatchedTitle);\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/MatchingStrategy.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic enum MatchingStrategy\n{\n    SeasonAndEpisodeNumber, // Use season + episode number for matching\n    ItemTitleIncludes,      // Match episodes where the tvdb episode name contains this title\n    ItemTitleExact,          // Match episodes with an exact itemTitle\n    ItemTitleEqualsAirdate\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/Media.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class Media\n{\n    [JsonPropertyName(\"media_id\")]\n    public int Id { get; set; }\n\n    [JsonPropertyName(\"media_name\")]\n    public string Name { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"media_type\")]\n    public string Type { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"media_tmdbId\")]\n    public int? TmdbId { get; set; }\n\n    [JsonPropertyName(\"media_imdbId\")]\n    public string? ImdbId { get; set; }\n\n    [JsonPropertyName(\"media_tvdbId\")]\n    public int? TvdbId { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/Pagination.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class Pagination\n{\n    [JsonPropertyName(\"currentPage\")]\n    public int CurrentPage { get; set; }\n\n    [JsonPropertyName(\"totalPages\")]\n    public int TotalPages { get; set; }\n\n    [JsonPropertyName(\"totalItems\")]\n    public int TotalItems { get; set; }\n\n    [JsonPropertyName(\"itemsPerPage\")]\n    public int ItemsPerPage { get; set; }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/RegexRule.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class RegexRule\n{\n    [JsonPropertyName(\"field\")]\n    public string Field { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"pattern\")]\n    public string Pattern { get; set; } = string.Empty;\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/Ruleset.cs",
    "content": "﻿using System.Text.Json;\nusing System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class Ruleset\n{\n    [JsonPropertyName(\"id\")]\n    public int Id { get; set; }\n\n    [JsonPropertyName(\"mediaId\")]\n    public int MediaId { get; set; }\n\n    [JsonPropertyName(\"topic\")]\n    public string Topic { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"priority\")]\n    public int Priority { get; set; }\n    [JsonPropertyName(\"filters\")]\n    public string FiltersJson { get; set; } = string.Empty;\n\n    [JsonIgnore]\n    public List<Filter> Filters\n    {\n        get\n        {\n            return JsonSerializer.Deserialize<List<Filter>>(FiltersJson) ?? [];\n        }\n    }\n\n    [JsonPropertyName(\"titleRegexRules\")]\n    public string TitleRegexRulesJson { get; set; } = string.Empty;\n\n    [JsonIgnore]\n    public List<TitleRegexRule> TitleRegexRules\n    {\n        get\n        {\n            var options = new JsonSerializerOptions\n            {\n                PropertyNameCaseInsensitive = true,\n                Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }\n            };\n            return JsonSerializer.Deserialize<List<TitleRegexRule>>(TitleRegexRulesJson, options) ?? [];\n        }\n    }\n\n    [JsonPropertyName(\"episodeRegex\")]\n    public string? EpisodeRegex { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"seasonRegex\")]\n    public string? SeasonRegex { get; set; } = string.Empty;\n\n    [JsonPropertyName(\"matchingStrategy\")]\n    [JsonConverter(typeof(JsonStringEnumConverter))]\n    public MatchingStrategy MatchingStrategy { get; set; }\n\n    [JsonPropertyName(\"media\")]\n    public Media Media { get; set; } = new Media();\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/RulesetApiResponse.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class RulesetApiResponse\n{\n    [JsonPropertyName(\"rulesets\")]\n    public List<Ruleset> Rulesets { get; set; } = [];\n\n    [JsonPropertyName(\"pagination\")]\n    public Pagination Pagination { get; set; } = new();\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/TitleRegexRule.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Text.Json.Serialization;\nusing System.Threading.Tasks;\n\nnamespace MediathekArrLib.Models.Rulesets;\n\npublic class TitleRegexRule\n{\n    [JsonPropertyName(\"type\")]\n    public TitleRegexRuleType Type { get; set; }\n\n    [JsonPropertyName(\"value\")]\n    public string? Value { get; set; } // For static text\n\n    [JsonPropertyName(\"field\")]\n    public string? Field { get; set; } // API field to extract from\n\n    [JsonPropertyName(\"pattern\")]\n    public string? Pattern { get; set; } // Regex pattern\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/Rulesets/TitleRegexRuleType.cs",
    "content": "﻿namespace MediathekArrLib.Models.Rulesets;\n\npublic enum TitleRegexRuleType\n{\n    Static, // Static text to include in the title\n    Regex   // Regex to extract text from an API field\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/TvdbAlias.cs",
    "content": "﻿namespace MediathekArrLib.Models;\n\npublic record TvdbAlias(string Language, string Name);\n"
  },
  {
    "path": "MediathekArrLib/Models/TvdbData.cs",
    "content": "﻿using System.Text.Json.Serialization;\n\nnamespace MediathekArrLib.Models;\n\npublic record TvdbData(int Id, string Name, [property: JsonPropertyName(\"german_name\")] string GermanName, List<TvdbAlias> Aliases, List<TvdbEpisode> Episodes)\n{\n    /// <summary>\n    /// Finds an episode by its air date.\n    /// </summary>\n    /// <param name=\"airDate\">The air date to search for.</param>\n    /// <returns>The TvdbEpisode if found, or null if not found.</returns>\n    public TvdbEpisode? FindEpisodeByAirDate(DateTime airDate)\n    {\n        return Episodes?.FirstOrDefault(episode => episode.Aired?.Date == airDate.Date);\n    }\n\n    /// <summary>\n    /// Finds episodes by their air month.\n    /// </summary>\n    /// <param name=\"year\">The year of the episodes to search for.</param>\n    /// <param name=\"month\">The month of the episodes to search for.</param>\n    /// <returns>A list of TvdbEpisode objects that aired in the specified year and month.</returns>\n    public List<TvdbEpisode>? FindEpisodeByAirMonth(int year, int month)\n    {\n        return Episodes?\n            .Where(episode => episode.Aired.HasValue &&\n                              episode.Aired.Value.Year == year &&\n                              episode.Aired.Value.Month == month)\n            .ToList();\n    }\n\n    /// <summary>\n    /// Finds all episodes aired in a specified year.\n    /// </summary>\n    /// <param name=\"year\">The year to search for.</param>\n    /// <returns>A list of TvdbEpisode objects aired in the specified year, or an empty list if none are found.</returns>\n    public List<TvdbEpisode> FindEpisodesByAirYear(int year)\n    {\n        return Episodes?\n            .Where(episode => episode.Aired?.Year == year)\n            .ToList() ?? [];\n    }\n\n    /// <summary>\n    /// Finds all episodes from a given season.\n    /// </summary>\n    /// <param name=\"seasonNumber\">The season number to search for.</param>\n    /// <returns>A list of TvdbEpisode objects in the specified season, or an empty list if none are found.</returns>\n    public List<TvdbEpisode> FindEpisodesBySeason(int seasonNumber)\n    {\n        return Episodes?.Where(episode => episode.SeasonNumber == seasonNumber).ToList() ?? [];\n    }\n\n    /// <summary>\n    /// Finds all episodes from a given season.\n    /// </summary>\n    /// <param name=\"seasonNumber\">The season number to search for.</param>\n    /// <returns>A list of TvdbEpisode objects in the specified season, or an empty list if none are found.</returns>\n    public List<TvdbEpisode> FindEpisodesBySeason(string? seasonNumber)\n    {\n        if (int.TryParse(seasonNumber, out int parsedSeason))\n        {\n            return FindEpisodesBySeason(parsedSeason);\n        }\n\n        return [];\n    }\n\n    /// <summary>\n    /// Finds a specific episode by season and episode number.\n    /// </summary>\n    /// <param name=\"seasonNumber\">The season number of the episode.</param>\n    /// <param name=\"episodeNumber\">The episode number within the season.</param>\n    /// <returns>The TvdbEpisode if found, or null if not found.</returns>\n    public TvdbEpisode? FindEpisodeBySeasonAndNumber(int seasonNumber, int episodeNumber)\n    {\n        return Episodes?.FirstOrDefault(episode =>\n            episode.SeasonNumber == seasonNumber && episode.EpisodeNumber == episodeNumber);\n    }\n\n    /// <summary>\n    /// Finds a specific episode by season and episode number.\n    /// </summary>\n    /// <param name=\"seasonNumber\">The season number of the episode.</param>\n    /// <param name=\"episodeNumber\">The episode number within the season.</param>\n    /// <returns>The TvdbEpisode if found, or null if not found.</returns>\n    public TvdbEpisode? FindEpisodeBySeasonAndNumber(string? seasonNumber, string? episodeNumber)\n    {\n        if (int.TryParse(seasonNumber, out int parsedSeason) &&\n            int.TryParse(episodeNumber, out int parsedEpisode))\n        {\n            return FindEpisodeBySeasonAndNumber(parsedSeason, parsedEpisode);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "MediathekArrLib/Models/TvdbEpisode.cs",
    "content": "﻿namespace MediathekArrLib.Models;\n\npublic record TvdbEpisode(string Name, DateTime? Aired, int? Runtime, int SeasonNumber, int EpisodeNumber)\n{\n    public string PaddedSeason => SeasonNumber.ToString(\"D2\");\n    public string PaddedEpisode => EpisodeNumber.ToString(\"D2\");\n};\n"
  },
  {
    "path": "MediathekArrLib/Models/TvdbInfoResponse.cs",
    "content": "﻿namespace MediathekArrLib.Models;\n\npublic record TvdbInfoResponse(string Status, TvdbData Data);\n"
  },
  {
    "path": "MediathekArrLib/Utilities/JsonConverter.cs",
    "content": "﻿using System.Text.Json.Serialization;\nusing System.Text.Json;\n\nnamespace MediathekArrLib.Utilities;\n\npublic class NumberOrEmptyConverter<T> : JsonConverter<T>\n    where T : struct, IConvertible\n{\n    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n    {\n        if (reader.TokenType == JsonTokenType.Null || (reader.TokenType == JsonTokenType.String && reader.GetString() == \"\"))\n        {\n            return default; // Return default value, which will be 0 for int, long, etc.\n        }\n\n        // Convert to the target numeric type (int, long, etc.)\n        try\n        {\n            if (reader.TokenType == JsonTokenType.Number)\n            {\n                // Handle numeric values directly\n                if (typeof(T) == typeof(int))\n                {\n                    return (T)(object)reader.GetInt32();\n                }\n                else if (typeof(T) == typeof(long))\n                {\n                    return (T)(object)reader.GetInt64();\n                }\n            }\n            else if (reader.TokenType == JsonTokenType.String)\n            {\n                // Try parsing string as a number\n                string? stringValue = reader.GetString();\n                if (!string.IsNullOrEmpty(stringValue))\n                {\n                    if (typeof(T) == typeof(int) && int.TryParse(stringValue, out int intValue))\n                    {\n                        return (T)(object)intValue;\n                    }\n                    else if (typeof(T) == typeof(long) && long.TryParse(stringValue, out long longValue))\n                    {\n                        return (T)(object)longValue;\n                    }\n                }\n            }\n        }\n        catch (Exception ex)\n        {\n            throw new JsonException($\"Error converting value to type {typeof(T)}: {ex.Message}\", ex);\n        }\n\n        throw new NotSupportedException($\"The converter does not support type {typeof(T)}.\");\n    }\n\n    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)\n    {\n        writer.WriteNumberValue(Convert.ToDouble(value));\n    }\n}"
  },
  {
    "path": "MediathekArrLib/Utilities/NewznabUtils.cs",
    "content": "﻿using MediathekArrLib.Models;\nusing MediathekArrLib.Models.Newznab;\nusing System.Xml.Serialization;\n\nnamespace MediathekArrLib.Utilities;\npublic static class NewznabUtils\n{\n    public static List<Models.Newznab.Attribute> GenerateAttributes(string? season, string[] categoryValues)\n    {\n        var attributes = new List<Models.Newznab.Attribute>();\n\n        foreach (var categoryValue in categoryValues)\n        {\n            attributes.Add(new Models.Newznab.Attribute { Name = \"category\", Value = categoryValue });\n        }\n\n        if (season != null)\n        {\n            attributes.Add(new Models.Newznab.Attribute { Name = \"season\", Value = season });\n        }\n\n        return attributes;\n    }\n    public static string SerializeRss(Rss rss)\n    {\n        var serializer = new XmlSerializer(typeof(Rss));\n\n        // Define the namespaces and add the newznab namespace\n        var namespaces = new XmlSerializerNamespaces();\n        namespaces.Add(\"newznab\", \"http://www.newznab.com/DTD/2010/feeds/attributes/\");\n\n        using var stringWriter = new StringWriter();\n        serializer.Serialize(stringWriter, rss, namespaces);\n\n        // TODO quick fix\n        string result = stringWriter.ToString();\n        result = result.Replace(\":newznab_x003A_\", \":\");\n\n        return result;\n    }\n\n    public static Rss GetEmptyRssResult()\n    {\n        return new Rss\n        {\n            Channel = new Channel\n            {\n                Title = \"MediathekArr\",\n                Description = \"MediathekArr API results\",\n                Response = new Response\n                {\n                    Offset = 0,\n                    Total = 0\n                },\n                Items = []\n            }\n        };\n    }\n}"
  },
  {
    "path": "MediathekArrServer/Controllers/TController.cs",
    "content": "using MediathekArrServer.Services;\nusing Microsoft.AspNetCore.Mvc;\nusing System.Text;\n\nnamespace MediathekArrServer.Controllers;\n\n[ApiController]\n[Route(\"api\")]\npublic class TController(MediathekSearchService mediathekSearchService, ItemLookupService itemLookupService) : ControllerBase\n{\n    private readonly MediathekSearchService _mediathekSearchService = mediathekSearchService;\n    private readonly ItemLookupService _itemLookupService = itemLookupService;\n\n    [HttpGet]\n    public async Task<IActionResult> GetCapsXml([FromQuery] string t)\n    {\n        var limit = int.TryParse(HttpContext.Request.Query[\"limit\"], out var parsedLimit) ? parsedLimit : 100;\n        var offset = int.TryParse(HttpContext.Request.Query[\"offset\"], out var parsedOffset) ? parsedOffset: 0;\n        string q = HttpContext.Request.Query[\"q\"];\n        string imdbid = HttpContext.Request.Query[\"imdbid\"];\n        string tvdbid = HttpContext.Request.Query[\"tvdbid\"];\n        string tmdbid = HttpContext.Request.Query[\"tmdbid\"];\n        string season = HttpContext.Request.Query[\"season\"];\n        string episode = HttpContext.Request.Query[\"ep\"];\n        string cat = HttpContext.Request.Query[\"cat\"];\n\n        if (t == \"caps\")\n        {\n            string xmlContent = @\"<?xml version=\"\"1.0\"\" encoding=\"\"UTF-8\"\"?>\n<caps>\n    <limits max=\"\"5000\"\" default=\"\"5000\"\"/>\n    <registration available=\"\"no\"\" open=\"\"no\"\"/>\n    <searching>\n        <search available=\"\"yes\"\" supportedParams=\"\"q\"\"/>\n        <tv-search available=\"\"yes\"\" supportedParams=\"\"q,season,ep,tvdbid\"\"/>\n        <movie-search available=\"\"yes\"\" supportedParams=\"\"q,imdbid\"\"/>\n        <audio-search available=\"\"no\"\" supportedParams=\"\"\"\" />\n    </searching>\n    <categories>\n        <category id=\"\"2000\"\" name=\"\"Movies\"\">\n            <subcat id=\"\"2040\"\" name=\"\"HD\"\"/>\n            <subcat id=\"\"2030\"\" name=\"\"SD\"\"/>\n        </category>\n        <category id=\"\"5000\"\" name=\"\"TV\"\">\n            <subcat id=\"\"5040\"\" name=\"\"HD\"\"/>\n            <subcat id=\"\"5030\"\" name=\"\"SD\"\"/>\n        </category>\n    </categories>\n</caps>\";\n\n            return Content(xmlContent, \"application/xml\", Encoding.UTF8);\n        }\n        else if (t == \"tvsearch\" || t == \"search\" || t == \"movie\")\n        {   \n            try\n            {\n                if (!string.IsNullOrEmpty(tvdbid) && int.TryParse(tvdbid, out var parsedTvdbid))\n                {\n                    var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(parsedTvdbid);\n\n                    string searchResults = await _mediathekSearchService.FetchSearchResultsFromApiById(tvdbData, season, episode, limit, offset);\n\n                    return Content(searchResults, \"application/xml\", Encoding.UTF8);\n                }\n                else if (q is null && season is null && imdbid is null && tvdbid is null && tmdbid is null)\n                {\n                    string searchResults = await _mediathekSearchService.FetchSearchResultsForRssSync(limit, offset);\n                    return Content(searchResults, \"application/xml\", Encoding.UTF8);\n                }\n                else\n                {\n                    string searchResults = await _mediathekSearchService.FetchSearchResultsFromApiByString(q, season, limit, offset);\n                    return Content(searchResults, \"application/xml\", Encoding.UTF8);\n                }\n            }\n            catch (HttpRequestException ex)\n            {\n                return BadRequest(new { error = ex.Message });\n            }\n        }\n\n        return NotFound();\n    }\n\n\n    [HttpGet(\"fake_nzb_download\")]\n    public IActionResult FakeNzbDownload([FromQuery] string encodedUrl, [FromQuery] string encodedTitle)\n    {\n        string decodedUrl;\n        string decodedTitle;\n        try\n            {\n            var base64EncodedBytesUrl = Convert.FromBase64String(encodedUrl);\n            decodedUrl = Encoding.UTF8.GetString(base64EncodedBytesUrl);\n            var base64EncodedBytesTitle = Convert.FromBase64String(encodedTitle);\n            decodedTitle = Encoding.UTF8.GetString(base64EncodedBytesTitle);\n        }\n        catch (FormatException)\n        {\n            return BadRequest(\"Invalid base64 string.\");\n        }\n\n        // Define a basic NZB XML structure with the comment and encoded URL.\n        var nzbContent = $@\"<?xml version=\"\"1.0\"\" encoding=\"\"UTF-8\"\" ?>\n<!DOCTYPE nzb PUBLIC \"\"-//newzBin//DTD NZB 1.0//EN\"\" \"\"http://www.newzbin.com/DTD/nzb/nzb-1.0.dtd\"\">\n<!-- {decodedTitle} -->\n<!-- {decodedUrl} -->\n<nzb>\n    <file post_id=\"\"1\"\">\n        <groups>\n            <group>a.b.zdf</group>\n        </groups>\n        <segments>\n            <segment number=\"\"1\"\">ExampleSegmentID@news.example.com</segment>\n        </segments>\n    </file>\n</nzb>\";\n\n        // Convert the NZB XML content to byte array\n        var fileContent = Encoding.UTF8.GetBytes(nzbContent);\n\n        // Set the .nzb file name\n        var nzbFileName = $\"mediathek-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.nzb\";\n\n        return File(fileContent, \"application/x-nzb\", nzbFileName);\n    }\n}\n"
  },
  {
    "path": "MediathekArrServer/MediathekArrServer.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n\n\t<PropertyGroup>\n\t\t<TargetFramework>net9.0</TargetFramework>\n\t\t<Nullable>enable</Nullable>\n\t\t<ImplicitUsings>enable</ImplicitUsings>\n\t\t<RuntimeIdentifiers>linux-x64</RuntimeIdentifiers>\n\t\t<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>\n\t\t<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:9.0</ContainerBaseImage>\n\t\t<UserSecretsId>6f9f5643-8dc2-4efe-8c30-608b5c2bb8c5</UserSecretsId>\n\t</PropertyGroup>\n\n\t<ItemGroup>\n\t\t<PackageReference Include=\"Microsoft.AspNetCore.OpenApi\" Version=\"9.0.0\" />\n\t\t<PackageReference Include=\"Scalar.AspNetCore\" Version=\"1.2.44\" />\n\t</ItemGroup>\n\n\t<ItemGroup>\n\t\t<ProjectReference Include=\"..\\MediathekArrLib\\MediathekArrLib.csproj\" />\n\t</ItemGroup>\n\n\t<ItemGroup>\n\t  <ContainerPort Include=\"8081\" />\n\t</ItemGroup>\n\n\t<ItemGroup>\n\t  <Content Update=\"appsettings.Development.json\">\n\t    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n\t    <ExcludeFromSingleFile>true</ExcludeFromSingleFile>\n\t    <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>\n\t  </Content>\n\t  <Content Update=\"appsettings.json\">\n\t    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n\t    <ExcludeFromSingleFile>true</ExcludeFromSingleFile>\n\t    <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>\n\t  </Content>\n\t  <Content Update=\"appsettings.Production.json\">\n\t    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n\t    <ExcludeFromSingleFile>true</ExcludeFromSingleFile>\n\t    <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>\n\t  </Content>\n\t</ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "MediathekArrServer/Program.cs",
    "content": "using MediathekArrServer.Services;\nusing Scalar.AspNetCore;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\nbuilder.Services.AddOpenApi();\nbuilder.Services.AddMemoryCache();\nbuilder.Services.AddHttpClient(\"MediathekClient\", client =>\n{\n    client.DefaultRequestHeaders.UserAgent.ParseAdd(\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0\");\n    client.DefaultRequestHeaders.AcceptEncoding.ParseAdd(\"gzip\");\n    client.DefaultRequestHeaders.Accept.ParseAdd(\"application/json\");\n})\n.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler\n{\n    AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate\n});\n\nbuilder.Services.AddHostedService<RulesetBackgroundService>();\nbuilder.Services.AddSingleton<MediathekSearchService>();\nbuilder.Services.AddSingleton<ItemLookupService>();\n\nvar app = builder.Build();\n\n// Middleware to log all incoming requests\napp.Use(async (context, next) =>\n{\n    // Log the incoming request details\n    var logger = app.Services.GetRequiredService<ILogger<Program>>();\n    var request = context.Request;\n    logger.LogInformation(\"Incoming Request: {method} {url}\", request.Method, request.Path + request.QueryString);\n\n    // Check if the request is a POST and has a body\n    if (request.Method == HttpMethods.Post && request.ContentLength > 0)\n    {\n        // Enable buffering so the request can be read multiple times\n        request.EnableBuffering();\n    }\n\n    // Call the next middleware in the pipeline\n    await next.Invoke();\n});\n\n\n// Configure the HTTP request pipeline.\nif (app.Environment.IsDevelopment())\n{\n    app.MapOpenApi();\n    app.MapScalarApiReference();\n}\n\napp.UseHttpsRedirection();\n\napp.UseAuthorization();\n\napp.MapControllers();\n\napp.Run();"
  },
  {
    "path": "MediathekArrServer/Properties/launchSettings.json",
    "content": "{\n  \"profiles\": {\n    \"http\": {\n      \"commandName\": \"Project\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\",\n        \"ASPNETCORE_URLS\": \"http://localhost:5008\"\n      },\n      \"dotnetRunMessages\": true,\n      \"applicationUrl\": \"http://localhost:5008\"\n    },\n    \"https\": {\n      \"commandName\": \"Project\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\",\n        \"ASPNETCORE_URLS\": \"https://localhost:5008\"\n      },\n      \"dotnetRunMessages\": true,\n      \"applicationUrl\": \"https://localhost:5008\"\n    },\n    \"Container (.NET SDK)\": {\n      \"commandName\": \"SdkContainer\",\n      \"launchBrowser\": true,\n      \"launchUrl\": \"{Scheme}://{ServiceHost}:{ServicePort}/scalar/v1\",\n      \"environmentVariables\": {\n        \"ASPNETCORE_HTTPS_PORTS\": \"8081\",\n        \"ASPNETCORE_HTTP_PORTS\": \"8080\"\n      },\n      \"publishAllPorts\": true,\n      \"useSSL\": true\n    }\n  },\n  \"$schema\": \"http://json.schemastore.org/launchsettings.json\"\n}"
  },
  {
    "path": "MediathekArrServer/Services/ItemLookupService.cs",
    "content": "﻿using MediathekArrLib.Models;\nusing Microsoft.Extensions.Caching.Memory;\nusing System.Text.Json;\n\nnamespace MediathekArrServer.Services;\n\npublic class ItemLookupService(IHttpClientFactory httpClientFactory, IConfiguration configuration, IMemoryCache memoryCache)\n{\n    private readonly HttpClient _httpClient = httpClientFactory.CreateClient();\n    private readonly string _apiBaseUrl = configuration[\"MEDIATHEKARR_API_BASE_URL\"] ?? \"https://mediathekarr.pcjones.de/api/v1\";\n    private readonly IMemoryCache _memoryCache = memoryCache;\n\n    private static JsonSerializerOptions GetJsonSerializerOptions()\n    {\n        return new JsonSerializerOptions\n        {\n            PropertyNameCaseInsensitive = true\n        };\n    }\n\n    public async Task<TvdbData?> GetShowInfoByTvdbId(int? tvdbid)\n    {\n        if (tvdbid == null)\n        {\n            return null;\n        }\n\n        var cacheKey = $\"TvdbInfo_{tvdbid}\";\n        if (_memoryCache.TryGetValue(cacheKey, out TvdbData? cachedTvdbInfo))\n        {\n            if (cachedTvdbInfo != null)\n            {\n                return cachedTvdbInfo;\n            }\n        }\n\n        var requestUrl = $\"{_apiBaseUrl}/get_show.php?tvdbid={tvdbid}\";\n\n        var response = await _httpClient.GetAsync(requestUrl);\n\n        if (!response.IsSuccessStatusCode)\n        {\n            var errorContent = await response.Content.ReadAsStringAsync();\n            throw new HttpRequestException($\"Error fetching data: {errorContent}\");\n        }\n\n        var jsonResponse = await response.Content.ReadAsStringAsync();\n        var tvdbInfo = JsonSerializer.Deserialize<TvdbInfoResponse>(jsonResponse, GetJsonSerializerOptions());\n\n        if (tvdbInfo == null || tvdbInfo.Status != \"success\" || tvdbInfo.Data == null)\n        {\n            throw new HttpRequestException($\"Failed to fetch TVDB data. Response: {jsonResponse}\");\n            // TODO log and return null\n        }\n\n        _memoryCache.Set(cacheKey, tvdbInfo.Data, TimeSpan.FromHours(12));\n\n        return tvdbInfo.Data;\n    }\n}\n"
  },
  {
    "path": "MediathekArrServer/Services/MediathekSearchFallbackHandler.cs",
    "content": "﻿using MediathekArrLib.Models;\nusing MediathekArrLib.Models.Newznab;\nusing MediathekArrLib.Utilities;\nusing System.Globalization;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing Guid = MediathekArrLib.Models.Newznab.Guid;\n\nnamespace MediathekArrServer.Services;\n\npublic partial class MediathekSearchFallbackHandler\n{\n    public static List<Item> GetFallbackSearchResultItemsById(string? apiResponse, TvdbEpisode episode, TvdbData tvdbData)\n    {\n        if (string.IsNullOrWhiteSpace(apiResponse))\n        {\n            return [];\n        }\n\n        var filteredResponse = ApplyFilters(apiResponse, episode);\n        var seasonNumber = episode.SeasonNumber.ToString();\n        var episodeNumber = episode.EpisodeNumber.ToString();\n        return filteredResponse?.Result.Results.SelectMany<ApiResultItem, Item>(item => GenerateRssItems(item, seasonNumber, episodeNumber, tvdbData)).ToList() ?? [];\n    }\n\n    public static List<Item> GetFallbackSearchResultItemsByString(List<ApiResultItem>? unmatchedFilteredResultItems, string? season)\n    {\n        if (unmatchedFilteredResultItems is null || unmatchedFilteredResultItems.Count == 0)\n        {\n            return [];\n        }\n\n        return unmatchedFilteredResultItems.SelectMany(item => GenerateRssItems(item, season, null)).ToList() ?? [];\n    }\n\n\n    private static List<Item> GenerateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData = null)\n    {\n        var items = new List<Item>();\n\n        string[] categories = [\"5000\", \"2000\"];\n\n        if (!string.IsNullOrEmpty(item.UrlVideoHd))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"1080p\", 1.6, \"TV > HD\", [.. categories, \"5040\", \"2040\"], item.UrlVideoHd));\n        }\n\n        if (!string.IsNullOrEmpty(item.UrlVideo))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"720p\", 1.0, \"TV > HD\", [.. categories, \"5040\", \"2040\"], item.UrlVideo));\n        }\n\n        if (!string.IsNullOrEmpty(item.UrlVideoLow))\n        {\n            items.AddRange(CreateRssItems(item, season, episode, tvdbData, \"480p\", 0.4, \"TV > SD\", [.. categories, \"5030\", \"2030\"], item.UrlVideoLow));\n\n        }\n\n        return items;\n    }\n\n    private static List<Item> CreateRssItems(ApiResultItem item, string? season, string? episode, TvdbData? tvdbData, string quality, double sizeMultiplier, string category, string[] categoryValues, string url)\n    {\n        var items = new List<Item>();\n\n        // Generate title with season and formatted date\n        var formattedDate = ExtractDate(item.Title);\n\n        // Create two items if both season and formatted date are present\n        if (!string.IsNullOrEmpty(formattedDate))\n        {\n            // Title with formattedDate in it\n            if (!string.IsNullOrEmpty(formattedDate))\n            {\n                items.Add(CreateRssItem(item, formattedDate.Split('-')[0], null, episode, tvdbData, quality, sizeMultiplier, category, categoryValues, url, formattedDate));\n            }\n        }\n\n        items.Add(CreateRssItem(item, null, season, episode, tvdbData, quality, sizeMultiplier, category, categoryValues, url));\n\n        return items;\n    }\n\n    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)\n    {\n        var adjustedSize = (long)(item.Size * sizeMultiplier);\n        var parsedTitle = GenerateTitle(item.Topic, item.Title, quality, formattedDate, season, episode);\n        var formattedTitle = FormatTitle(parsedTitle);\n        //var translatedTitle = TranslateTitle(formattedTitle, tvdbData);\n        var translatedTitle = formattedTitle; // TODO see if translation is needed\n        var encodedTitle = Convert.ToBase64String(Encoding.UTF8.GetBytes(translatedTitle));\n        var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(url));\n\n        // Generate the full URL for the fake_nzb_download endpoint\n        var fakeDownloadUrl = $\"/api/fake_nzb_download?encodedUrl={encodedUrl}&encodedTitle={encodedTitle}\";\n\n        return new Item\n        {\n            Title = translatedTitle,\n            Guid = new Guid\n            {\n                IsPermaLink = true,\n                Value = $\"{item.UrlWebsite}#{quality}{(string.IsNullOrEmpty(formattedDate) ? \"\" : \"-a\")}\",\n            },\n            Link = url,\n            Comments = item.UrlWebsite,\n            PubDate = DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString(\"R\"),\n            Category = category,\n            Description = item.Description,\n            Enclosure = new Enclosure\n            {\n                Url = fakeDownloadUrl,\n                Length = adjustedSize,\n                Type = \"application/x-nzb\"\n            },\n            Attributes = NewznabUtils.GenerateAttributes(yearSeason ?? season, categoryValues)\n        };\n    }\n\n    // TODO refactor and make this look good, It's too late right now:D\n    // TODO now it's even worse :D oh god\n    private static string GenerateTitle(string topic, string title, string quality, string? formattedDate, string? seasonOverride, string? episodeOverride)\n    {\n        if (!string.IsNullOrEmpty(formattedDate))\n        {\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            return $\"{topic}.{formattedDate}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264.MATCH.UNCERTAIN-MEDiATHEK\".Replace(\" \", \".\");\n        }\n        var episodePattern = @\"S\\d{1,4}/E\\d{1,4}\";\n        var match = Regex.Match(title, episodePattern);\n\n        if (match.Success)\n        {\n            var seasonAndEpisode = match.Value.Replace(\"/\", \"\");\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Replace($\"({match.Value})\", \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            if (seasonOverride is null || episodeOverride is null)\n            {\n                // use data from mediathek\n                return $\"{topic}.{seasonAndEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264.MATCH.UNCERTAIN-MEDiATHEK\".Replace(\" \", \".\");\n            }\n\n            // use overwrite data\n            var zeroBasedSeason = seasonOverride.Length >= 2 ? seasonOverride : $\"0{seasonOverride}\";\n            var zeroBasedEpisode = episodeOverride.Length >= 2 ? episodeOverride : $\"0{episodeOverride}\";\n            return $\"{topic}.S{zeroBasedSeason}E{zeroBasedEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : \"\")}GERMAN.{quality}.WEB.h264.MATCH.UNCERTAIN-MEDiATHEK\".Replace(\" \", \".\");\n        }\n\n        if (seasonOverride is null || episodeOverride is null)\n        {\n            return $\"{topic} - {title}.GERMAN.{quality}.WEB.h264.NO.MATCH-MEDiATHEK\";\n        }\n        else\n        {\n            var cleanedTitle = EpisodeRegex().Replace(title, \"\").Trim();\n\n            if (cleanedTitle == topic)\n            {\n                cleanedTitle = null;\n            }\n\n            var zeroBasedSeason = seasonOverride.Length >= 2 ? seasonOverride : $\"0{seasonOverride}\";\n            var zeroBasedEpisode = episodeOverride.Length >= 2 ? episodeOverride : $\"0{episodeOverride}\";\n\n            return $\"{topic}.S{zeroBasedSeason}E{zeroBasedEpisode}.{(cleanedTitle != null ? $\"{cleanedTitle}.\" : title)}GERMAN.{quality}.WEB.h264.MATCH.UNCERTAIN-MEDiATHEK\".Replace(\" \", \".\");\n        }\n    }\n    private static string FormatTitle(string title)\n    {\n        // Replace German Umlaute and special characters\n        title = title.Replace(\"ä\", \"ae\")\n                     .Replace(\"ö\", \"oe\")\n                     .Replace(\"ü\", \"ue\")\n                     .Replace(\"ß\", \"ss\")\n                     .Replace(\"Ä\", \"Ae\")\n                     .Replace(\"Ö\", \"Oe\")\n                     .Replace(\"Ü\", \"Ue\");\n\n        // Remove unwanted characters\n        title = TitleRegexUnd().Replace(title, \"und\");\n        title = TitleRegexSymbols().Replace(title, \"\"); // Remove various symbols\n        title = TitleRegexWhitespace().Replace(title, \".\").Replace(\"..\", \".\");\n\n        return title;\n    }\n\n    private static MediathekApiResponse? ApplyFilters(string apiResponse, TvdbEpisode episode)\n    {\n        var responseObject = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse);\n\n        if (responseObject?.Result?.Results == null)\n        {\n            return null;\n        }\n\n        var initialResults = responseObject.Result.Results;\n        var resultsFilteredByRuntime = FilterByRuntime(initialResults, episode.Runtime);\n        var resultsByAiredDate = FilterByAiredDate(resultsFilteredByRuntime, episode.Aired!.Value).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList();\n        var resultsByTitleDate = FilterByTitleDate(resultsFilteredByRuntime, episode.Aired.Value).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList();\n        var resultsByDescriptionDate = FilterByDescriptionDate(resultsFilteredByRuntime, episode.Aired.Value).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList();\n        var resultsByEpisodeTitleMatch = FilterByEpisodeTitleMatch(resultsFilteredByRuntime, episode.Name).Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList();\n        List<ApiResultItem> resultsBySeasonEpisodeMatch = [];\n        // if more than 3 results we assume episode title match wasn't correct\n        if (resultsByEpisodeTitleMatch.Count > 3)\n        {\n            resultsByEpisodeTitleMatch.Clear();\n        }\n\n        // if we have episode title match that is the best we got\n        if (resultsByEpisodeTitleMatch.Count > 0)\n        {\n            // we ignore air date in this case as it is not as reliable\n            resultsByAiredDate.Clear();\n        }\n\n        if (resultsByAiredDate.Count == 0 && resultsByTitleDate.Count == 0 && resultsByDescriptionDate.Count == 0 && resultsByEpisodeTitleMatch.Count == 0)\n        {\n            // Only trust Mediathek season/episode if no other match:\n            resultsBySeasonEpisodeMatch =\n                FilterBySeasonEpisodeMatch(resultsFilteredByRuntime, episode.SeasonNumber.ToString(), episode.EpisodeNumber.ToString())\n                .Where(item => !MediathekSearchService.ShouldSkipItem(item)).ToList(); ;\n        }\n\n        // HashSet to remove duplicates\n        HashSet<ApiResultItem> filteredResults = [.. resultsByAiredDate, .. resultsByTitleDate, .. resultsByDescriptionDate, .. resultsByEpisodeTitleMatch, .. resultsBySeasonEpisodeMatch];\n\n        // Create a filtered API response\n        var filteredApiResponse = new MediathekApiResponse\n        {\n            Result = new MediathekApiResult\n            {\n                Results = [.. filteredResults],\n                QueryInfo = responseObject.Result.QueryInfo\n            },\n            Err = responseObject.Err\n        };\n\n        return filteredApiResponse;\n    }\n\n    private static List<ApiResultItem> FilterByRuntime(List<ApiResultItem> results, int? runtime)\n    {\n        if (runtime is null || runtime is 0)\n        {\n            return results;\n        }\n        var minRuntime = Math.Max(5, (int)(runtime * 0.65)) * 60;\n        var maxRuntime = (int)(runtime * 1.35) * 60;\n        return results.Where(item =>\n            item.Duration >= minRuntime && item.Duration <= maxRuntime)\n            .ToList();\n    }\n\n    private static List<ApiResultItem> FilterByAiredDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        return results.Where(item =>\n            ConvertToBerlinTimezone(UnixTimeStampToDateTime(item.Timestamp)).Date == airedDate)\n            .ToList();\n    }\n\n    private static List<ApiResultItem> FilterByTitleDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        var formattedAiredDate = airedDate.ToString(\"yyyy-MM-dd\");\n\n        return results.Where(item =>\n        {\n            var extractedDate = ExtractDate(item.Title);\n            return !string.IsNullOrEmpty(extractedDate) && extractedDate == formattedAiredDate;\n        }).ToList();\n    }\n\n    private static List<ApiResultItem> FilterByDescriptionDate(List<ApiResultItem> results, DateTime airedDate)\n    {\n        var formattedAiredDate = airedDate.ToString(\"yyyy-MM-dd\");\n\n        return results.Where(item =>\n        {\n            var extractedDate = ExtractDate(item.Description);\n            return !string.IsNullOrEmpty(extractedDate) && extractedDate == formattedAiredDate;\n        }).ToList();\n    }\n\n\n    private static List<ApiResultItem> FilterByEpisodeTitleMatch(List<ApiResultItem> results, string episodeName)\n    {\n        var normalizedEpisodeName = NormalizeString(episodeName);\n\n        return results.Where(item =>\n        {\n            var normalizedTitle = NormalizeString(item.Title);\n            return normalizedTitle.Contains(normalizedEpisodeName, StringComparison.OrdinalIgnoreCase);\n        }).ToList();\n    }\n\n    private static List<ApiResultItem> FilterBySeasonEpisodeMatch(List<ApiResultItem> results, string season, string episode)\n    {\n        var zeroBasedSeason = season.Length >= 2 ? season : $\"0{season}\";\n        var zeroBasedEpisode = episode.Length >= 2 ? episode : $\"0{episode}\";\n\n        return results.Where(item =>\n        {\n            return item.Title.Contains($\"S{zeroBasedSeason}\") && item.Title.Contains($\"E{zeroBasedEpisode}\");\n        }).ToList();\n    }\n\n    // Normalize a string to remove special characters and retain only A-Z, äöüÄÖÜß\n    private static string NormalizeString(string input)\n    {\n        var regex = NormalizeRegex();\n        return regex.Replace(input, \"\").ToLowerInvariant();\n    }\n\n    private static string ExtractDate(string title)\n    {\n        // Numeric format pattern (e.g., \"24.10.2024\" or \"24.10.24\")\n        var numericDatePattern = @\"(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2}|\\d{4})\";\n        // Nonth name format pattern (e.g., \"16. Juli 2024\")\n        var germanMonthPattern = @\"(\\d{1,2})\\.\\s*(\\w+)\\s+(\\d{4})\";\n\n        var numericDateMatch = Regex.Match(title, numericDatePattern);\n        if (numericDateMatch.Success)\n        {\n            int day = int.Parse(numericDateMatch.Groups[1].Value);\n            int month = int.Parse(numericDateMatch.Groups[2].Value);\n            int year = int.Parse(numericDateMatch.Groups[3].Value);\n\n            if (year < 100)\n            {\n                year += 2000;\n            }\n\n            DateTime date = new(year, month, day);\n            return date.ToString(\"yyyy-MM-dd\");\n        }\n\n        var longMonthMatch = Regex.Match(title, germanMonthPattern);\n        if (longMonthMatch.Success)\n        {\n            int day = int.Parse(longMonthMatch.Groups[1].Value);\n            string monthName = longMonthMatch.Groups[2].Value;\n            int year = int.Parse(longMonthMatch.Groups[3].Value);\n\n            var germanCulture = new CultureInfo(\"de-DE\");\n            if (DateTime.TryParseExact($\"{day} {monthName} {year}\",\n                                       \"d MMMM yyyy\",\n                                       germanCulture,\n                                       DateTimeStyles.None,\n                                       out DateTime date))\n            {\n                return date.ToString(\"yyyy-MM-dd\");\n            }\n        }\n\n        return string.Empty;\n    }\n    private static DateTime UnixTimeStampToDateTime(long unixTimeStamp)\n    {\n        return DateTimeOffset.FromUnixTimeSeconds(unixTimeStamp).UtcDateTime;\n    }\n\n    private static DateTime ConvertToBerlinTimezone(DateTime utcDateTime)\n    {\n        var berlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById(\"Europe/Berlin\");\n        return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, berlinTimeZone);\n    }\n\n    [GeneratedRegex(@\"[&]\")]\n    private static partial Regex TitleRegexUnd();\n    [GeneratedRegex(@\"[/:;\"\"'@#?$%^*+=!<>],()\")]\n    private static partial Regex TitleRegexSymbols();\n    [GeneratedRegex(@\"\\s+\")]\n    private static partial Regex TitleRegexWhitespace();\n    [GeneratedRegex(@\"Folge\\s*\\d+:\\s*\")]\n    private static partial Regex EpisodeRegex();\n    [GeneratedRegex(\"[^a-zA-ZäöüÄÖÜß]\")]\n    private static partial Regex NormalizeRegex();\n}\n"
  },
  {
    "path": "MediathekArrServer/Services/MediathekSearchService.cs",
    "content": "﻿using System.Collections.Concurrent;\nusing System.Globalization;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing MediathekArrLib.Models;\nusing MediathekArrLib.Models.Newznab;\nusing MediathekArrLib.Models.Rulesets;\nusing MediathekArrLib.Utilities;\nusing Microsoft.Extensions.Caching.Memory;\nusing Guid = MediathekArrLib.Models.Newznab.Guid;\nusing MatchType = MediathekArrLib.Models.Rulesets.MatchType;\n\nnamespace MediathekArrServer.Services;\n\npublic partial class MediathekSearchService(IHttpClientFactory httpClientFactory, IMemoryCache cache, ItemLookupService itemLookupService)\n{\n    private readonly IMemoryCache _cache = cache;\n    private readonly ItemLookupService _itemLookupService = itemLookupService;\n    private readonly HttpClient _httpClient = httpClientFactory.CreateClient(\"MediathekClient\");\n    private readonly TimeSpan _cacheTimeSpan = TimeSpan.FromMinutes(55);\n    private static readonly string[] _skipKeywords = [\"Audiodeskription\", \"Hörfassung\", \"(klare Sprache)\", \"(Gebärdensprache)\", \"Trailer\", \"Outtakes:\"];\n    private static readonly string[] _queryFields = [\"topic\", \"title\"];\n    private readonly ConcurrentDictionary<string, List<Ruleset>> _rulesetsByTopic = new();\n\n    public async Task UpdateRulesetsAsync()\n    {\n        var allRulesets = new List<Ruleset>();\n        int currentPage = 1;\n\n        while (true && currentPage < 100)\n        {\n            var response = await _httpClient.GetAsync($\"https://mediathekarr.pcjones.de/metadata/api/rulesets.php?page={currentPage++}\");\n            if (response.IsSuccessStatusCode)\n            {\n                var responseContent = await response.Content.ReadAsStringAsync();\n                var rulesetResponse = JsonSerializer.Deserialize<RulesetApiResponse>(responseContent);\n\n                if (rulesetResponse?.Rulesets != null)\n                {\n                    allRulesets.AddRange(rulesetResponse.Rulesets);\n                }\n\n                if (rulesetResponse?.Pagination?.CurrentPage >= rulesetResponse?.Pagination.TotalPages)\n                {\n                    break;\n                }\n            }\n            else\n            {\n                // Exit if the request fails\n                Console.WriteLine(\"Failed to fetch rulesets from the API.\");\n                break;\n            }\n        }\n\n        _rulesetsByTopic.Clear();\n        foreach (var group in allRulesets.GroupBy(r => r.Topic))\n        {\n            // Sort each group by priority before adding it\n            _rulesetsByTopic[group.Key] = [.. group.OrderBy(ruleset => ruleset.Priority)];\n        }\n    }\n\n    private async Task<string> FetchMediathekViewApiResponseAsync(List<object> queries, int size)\n    {\n        var requestBody = new\n        {\n            queries,\n            sortBy = \"filmlisteTimestamp\",\n            sortOrder = \"desc\",\n            future = true,\n            offset = 0,\n            size\n        };\n\n        var requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8);\n        var response = await _httpClient.PostAsync(\"https://mediathekviewweb.de/api/query\", requestContent);\n\n        if (response.IsSuccessStatusCode)\n        {\n            return await response.Content.ReadAsStringAsync();\n        }\n\n        return string.Empty;\n    }\n\n    public async Task<string> FetchSearchResultsFromApiById(TvdbData tvdbData, string? season, string? episodeNumber, int limit, int offset)\n    {\n        var cacheKey = $\"tvdb_{tvdbData.Id}_{season ?? \"null\"}_{episodeNumber ?? \"null\"}_{limit}_{offset}\";\n\n        if (_cache.TryGetValue(cacheKey, out string? cachedResponse))\n        {\n            return cachedResponse ?? \"\";\n        }\n\n        List<TvdbEpisode>? desiredEpisodes = GetDesiredEpisodes(tvdbData, season, episodeNumber);\n        if (season != null && desiredEpisodes?.Count == 0)\n        {\n            var response = NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult());\n            _cache.Set(cacheKey, response, _cacheTimeSpan);\n            return response;\n        }\n\n        var mediathekViewRequestCacheKey = $\"mediathekapi_{tvdbData.Id}\";\n        string apiResponse;\n        if (_cache.TryGetValue(mediathekViewRequestCacheKey, out string? cachedApiResponse))\n        {\n            apiResponse = cachedApiResponse ?? string.Empty;\n        }\n        else\n        {\n            var queries = new List<object>\n            {\n                new { fields = _queryFields, query = tvdbData.GermanName ?? tvdbData.Name }\n            };\n\n            apiResponse = await FetchMediathekViewApiResponseAsync(queries, 10000);\n            if (string.IsNullOrEmpty(apiResponse))\n            {\n                return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult());\n            }\n\n            _cache.Set(mediathekViewRequestCacheKey, apiResponse, _cacheTimeSpan);\n        }\n\n        var results = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse)?.Result.Results ?? [];\n        var (matchedEpisodes, _) = await ApplyRulesetFilters(results, tvdbData);\n        var matchedDesiredEpisodes = ApplyDesiredEpisodeFilter(matchedEpisodes, desiredEpisodes);\n\n        List<Item>? newznabItems;\n        if (matchedDesiredEpisodes.Count == 0 && desiredEpisodes?.Count > 0)\n        {\n            // Fallback to best effort matching \n            newznabItems = desiredEpisodes\n                .SelectMany(episode => MediathekSearchFallbackHandler.GetFallbackSearchResultItemsById(apiResponse, episode, tvdbData))\n                .ToList();\n        }\n        else\n        {\n            newznabItems = matchedDesiredEpisodes.SelectMany(GenerateRssItems).ToList();\n        }\n\n        var newznabRssResponse = ConvertNewznabItemsToRss(newznabItems, limit, offset);\n\n        _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan);\n\n        return newznabRssResponse;\n    }\n\n    private static List<TvdbEpisode>? GetDesiredEpisodes(TvdbData tvdbData, string? season, string? episodeNumber)\n    {\n        List<TvdbEpisode>? desiredEpisodes;\n        if (season != null)\n        {\n            desiredEpisodes = [];\n            if (episodeNumber is null)\n            {\n                desiredEpisodes.AddRange(tvdbData.FindEpisodesBySeason(season));\n                if (season.Length == 4 && int.TryParse(season, out var year))\n                {\n                    if (year >= 1900 && year <= 2100)\n                    {\n                        desiredEpisodes.AddRange(tvdbData.FindEpisodesByAirYear(year));\n                        desiredEpisodes = desiredEpisodes.Distinct().ToList();\n                    }\n                }\n            }\n            else\n            {\n                TvdbEpisode? desiredEpisode;\n                if (season?.Length == 4 && episodeNumber.Contains('/'))\n                {\n                    var episodeNumberSplitted = episodeNumber?.Split('/');\n                    if (episodeNumberSplitted?.Length == 2 && DateTime.TryParse($\"{season}-{episodeNumberSplitted[0]}-{episodeNumberSplitted[1]}\", out DateTime searchAirDate))\n                    {\n                        desiredEpisode = tvdbData.FindEpisodeByAirDate(searchAirDate);\n                    }\n                    else\n                    {\n                        desiredEpisode = null;\n                    }\n                }\n                else\n                {\n                    desiredEpisode = tvdbData.FindEpisodeBySeasonAndNumber(season, episodeNumber);\n                }\n\n                if (desiredEpisode != null)\n                {\n                    desiredEpisodes.Add(desiredEpisode);\n                }\n            }\n        }\n        else\n        {\n            desiredEpisodes = null;\n        }\n\n        return desiredEpisodes;\n    }\n\n    private static string ConvertNewznabItemsToRss(List<Item> items, int limit, int offset)\n    {\n        if (items == null || items.Count == 0)\n        {\n            return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult());\n        }\n\n        var paginatedItems = items.Skip(offset).Take(limit).ToList();\n\n        var rss = new Rss\n        {\n            Channel = new Channel\n            {\n                Title = \"MediathekArr\",\n                Description = \"MediathekArr API results\",\n                Response = new Response\n                {\n                    Offset = offset,\n                    Total = items.Count\n                },\n                Items = paginatedItems,\n            }\n        };\n\n        return NewznabUtils.SerializeRss(rss);\n    }\n\n    private static List<MatchedEpisodeInfo> ApplyDesiredEpisodeFilter(List<MatchedEpisodeInfo> matchedEpisodes, List<TvdbEpisode>? desiredEpisodes)\n    {\n        if (desiredEpisodes is null)\n        {\n            return matchedEpisodes;\n        }\n\n        return matchedEpisodes.Where(matched =>\n            desiredEpisodes.Any(desiredEpisode =>\n                desiredEpisode.SeasonNumber == matched.Episode.SeasonNumber &&\n                desiredEpisode.EpisodeNumber == matched.Episode.EpisodeNumber\n            )\n        ).ToList();\n    }\n\n    private async Task<MatchedEpisodeInfo?> MatchesSeasonAndEpisode(ApiResultItem item, Ruleset ruleset)\n    {\n        // Fetch TVDB episode information\n        var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId);\n\n        if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0)\n        {\n            return null;\n        }\n\n        // Extract season and episode from the item using the ruleset\n        string? season = ExtractValueUsingRegex(item, ruleset.SeasonRegex);\n        string? episode = ExtractValueUsingRegex(item, ruleset.EpisodeRegex);\n\n        if (string.IsNullOrEmpty(season) || string.IsNullOrEmpty(episode))\n        {\n            return null;\n        }\n\n        if (!int.TryParse(season, out var seasonNumber) || !int.TryParse(episode, out var episodeNumber))\n        {\n            return null; // Invalid season or episode format\n        }\n\n        // Find the matching episode in the TVDB data\n        var matchedEpisode = tvdbData.FindEpisodeBySeasonAndNumber(seasonNumber, episodeNumber);\n\n        if (matchedEpisode == null)\n        {\n            return null; // No matching episode found\n        }\n\n        return new MatchedEpisodeInfo(\n            Episode: matchedEpisode,\n            Item: item,\n            ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name,\n            MatchedTitle: $\"S{season}E{episode}\"\n        );\n    }\n\n    /// <summary>\n    /// Extracts a value from the item using the specified regex rule.\n    /// </summary>\n    /// <param name=\"item\">The API result item.</param>\n    /// <param name=\"regexRule\">The regex rule.</param>\n    /// <returns>The extracted value, or null if not found.</returns>\n    private static string? ExtractValueUsingRegex(ApiResultItem item, string? pattern)\n    {\n        if (string.IsNullOrEmpty(pattern))\n        {\n            return null;\n        }\n\n        string fieldValue = GetFieldValue(item, \"title\");\n\n        if (string.IsNullOrEmpty(fieldValue))\n        {\n            return null;\n        }\n\n        var match = Regex.Match(fieldValue, pattern);\n\n        return match.Success && match.Groups.Count > 1 ? match.Groups[1].Value : null;\n    }\n\n    private async Task<MatchedEpisodeInfo?> MatchesItemTitleIncludes(ApiResultItem item, Ruleset ruleset)\n    {\n        // Fetch TVDB episode information\n        var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId);\n\n        if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0)\n        {\n            return null;\n        }\n\n        // Construct the title based on ruleset\n        var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules);\n\n        if (constructedTitle is null)\n        {\n            return null;\n        }\n\n        // Check if the constructed title is included in any episode title\n        var matchedEpisode = \n            tvdbData.Episodes\n            .FirstOrDefault(episode => FormatTitle(episode.Name)\n            .Contains(FormatTitle(constructedTitle), StringComparison.OrdinalIgnoreCase));\n\n        if (matchedEpisode is null)\n        {\n            return null;\n        }\n\n        return new MatchedEpisodeInfo(\n            Episode: matchedEpisode,\n            Item: item,\n            ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name,\n            MatchedTitle: constructedTitle\n\t\t\t);\n    }\n\n    private async Task<MatchedEpisodeInfo?> MatchesItemTitleExact(ApiResultItem item, Ruleset ruleset)\n\t\t{\n\t\t\t// Fetch TVDB episode information\n\t\t\tvar tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId);\n\n\t\t\tif (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0)\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Construct the title based on ruleset\n\t\t\tvar constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules);\n\n\t\t\tif (constructedTitle is null)\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\n        var formattedConstructedTitle = FormatTitle(constructedTitle);\n\n\t\t\t// Check if the constructed title matches any episode title exactly\n\t\t\tvar matchedEpisodes =\n\t\t\t\ttvdbData.Episodes\n\t\t\t\t.Where(episode => FormatTitle(episode.Name)\n\t\t\t\t.Equals(formattedConstructedTitle, StringComparison.OrdinalIgnoreCase))\n\t\t\t\t.ToArray();\n\n        TvdbEpisode? matchedEpisode = GuessCorrectMatch(item, matchedEpisodes);\n\n\t\t\tif (matchedEpisode != null)\n\t\t\t{\n\t\t\t\treturn new MatchedEpisodeInfo(\n\t\t\t\t\tEpisode: matchedEpisode,\n\t\t\t\t\tItem: item,\n\t\t\t\t\tShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name,\n\t\t\t\t\tMatchedTitle: constructedTitle\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn null;\n\t\t}\n\n\t\tprivate static TvdbEpisode? GuessCorrectMatch(ApiResultItem item, TvdbEpisode[] matchedEpisodes)\n\t\t{\n\t\t\tif (matchedEpisodes.Length == 1)\n\t\t\t{\n\t\t\t\treturn matchedEpisodes[0];\n\t\t\t}\n\t\t\telse // multiple matched episodes found, we try to guess which one is the best\n\t\t\t{\n\t\t\t\t// Try to match by aired date\n\t\t\t\tvar matchedEpisodeByAirDate = matchedEpisodes.FirstOrDefault(episode => episode.Aired == DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).UtcDateTime.Date);\n\t\t\t\tif (matchedEpisodeByAirDate != null)\n\t\t\t\t{\n\t\t\t\t\treturn matchedEpisodeByAirDate;\n\t\t\t\t}\n            // chose the newest one\n            return matchedEpisodes.OrderByDescending(episode => episode.Aired).FirstOrDefault();\n\t\t\t}\n\t\t}\n\n\t\tprivate async Task<MatchedEpisodeInfo?> MatchesItemTitleEqualsAirdate(ApiResultItem item, Ruleset ruleset)\n    {\n        // Fetch TVDB episode information\n        var tvdbData = await _itemLookupService.GetShowInfoByTvdbId(ruleset.Media.TvdbId);\n\n        if (tvdbData?.Episodes == null || tvdbData.Episodes.Count == 0)\n        {\n            return null;\n        }\n\n        // Construct the title based on ruleset\n        var constructedTitle = BuildTitleFromRegexRules(item, ruleset.TitleRegexRules);\n\n        if (constructedTitle is null)\n        {\n            return null;\n        }\n\n        if (TryParseDate(constructedTitle, out var parsedDate))\n        {\n            // Find the episode by airdate\n            var matchedEpisode = tvdbData.FindEpisodeByAirDate(parsedDate);\n\n            if (matchedEpisode != null)\n            {\n                return new MatchedEpisodeInfo(\n                    Episode: matchedEpisode,\n                    Item: item,\n                    ShowName: string.IsNullOrEmpty(tvdbData.Name) ? tvdbData.GermanName : tvdbData.Name,\n                    MatchedTitle: constructedTitle\n\t\t\t\t\t);\n            }\n        }\n\n        return null;\n    }\n\n    private static bool TryParseDate(string dateString, out DateTime date)\n    {\n        // Attempt parsing with various formats\n        var formats = new[]\n        {\n            \"d. MMMM yyyy\", // e.g., \"7. Juni 2024\"\n            \"dd.MM.yyyy\",    // e.g., \"31.12.2017\"\n            \"yyyy-MM-dd\",    // e.g., \"2017-12-01\"\n            \"yyyyMMdd\",       // e.g., \"20171201\"\n            \"dd. MMMM yyyy\", // e.g., \"07. Juni 2024\"\n        };\n\n        return DateTime.TryParseExact(\n            dateString,\n            formats,\n            CultureInfo.GetCultureInfo(\"de-DE\"),\n            DateTimeStyles.None,\n            out date\n        );\n    }\n\n    private static string? BuildTitleFromRegexRules(ApiResultItem item, List<TitleRegexRule> titleRegexRules)\n    {\n        var stringBuilder = new StringBuilder();\n\n        foreach (var rule in titleRegexRules)\n        {\n            switch (rule.Type)\n            {\n                case TitleRegexRuleType.Static:\n                    // Append the static value directly\n                    if (!string.IsNullOrEmpty(rule.Value))\n                    {\n                        stringBuilder.Append(rule.Value);\n                    }\n                    break;\n\n                case TitleRegexRuleType.Regex:\n                    // Extract substring using the regex pattern from the specified field\n                    if (!string.IsNullOrEmpty(rule.Pattern) && !string.IsNullOrEmpty(rule.Field))\n                    {\n                        var fieldValue = GetFieldValue(item, rule.Field);\n                        if (!string.IsNullOrEmpty(fieldValue))\n                        {\n                            var match = Regex.Match(fieldValue, rule.Pattern);\n                            if (match.Success && match.Groups[^1].Length > 0)\n                            {\n                                // Use the last group\n                                stringBuilder.Append(match.Groups[^1].Value);\n                            }\n                            else\n                            {\n                                // abort if regex match failed\n                                return null;\n                            }\n                        }\n                    }\n                    break;\n            }\n        }\n\n        return stringBuilder.ToString();\n    }\n\n    private static string GetFieldValue(ApiResultItem item, string fieldName)\n    {\n        return fieldName switch\n        {\n            \"channel\" => item.Channel,\n            \"topic\" => item.Topic,\n            \"title\" => item.Title,\n            \"description\" => item.Description,\n            \"timestamp\" => item.Timestamp.ToString(),\n            \"duration\" => item.Duration.ToString(),\n            \"size\" => item.Size.ToString(),\n            \"url_website\" => item.UrlWebsite,\n            \"url_video\" => item.UrlVideo,\n            \"url_video_low\" => item.UrlVideoLow,\n            \"url_video_hd\" => item.UrlVideoHd,\n            _ => string.Empty\n        };\n    }\n\n\n    private static bool FilterMatches(ApiResultItem item, Filter filter)\n    {\n        string? attributeValue = GetFieldValue(item, filter.Attribute);\n\n        return filter.Type switch\n        {\n            MatchType.ExactMatch => attributeValue.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase),\n            MatchType.Contains => attributeValue.Contains(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase),\n            MatchType.Regex => Regex.IsMatch(attributeValue, filter.Value.ToString()),\n            MatchType.GreaterThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue > filterValue * 60,\n            MatchType.LessThan => double.TryParse(attributeValue, out var attrValue) && double.TryParse(filter.Value.ToString(), out var filterValue) && attrValue < filterValue * 60,\n            _ => false,\n        };\n    }\n\n    private List<Ruleset> GetRulesetsForTopic(string topic)\n    {\n\t\t\treturn _rulesetsByTopic.TryGetValue(topic, out var rulesets) ? rulesets : [];\n    }\n\n    private async Task<(List<MatchedEpisodeInfo> matchedEpisodes, List<ApiResultItem> unmatchedFilteredResultItems)> ApplyRulesetFilters(List<ApiResultItem> results, TvdbData? tvdbData = null)\n    {\n        var matchedFilteredResults = new List<MatchedEpisodeInfo>();\n        var unmatchedFilteredResults = new List<ApiResultItem>(results);\n\n        foreach (var item in results)\n        {\n            if(ShouldSkipItem(item))\n            {\n                unmatchedFilteredResults.Remove(item);\n                continue;\n            }\n\n            // Get applicable rulesets for the topic or specific TVDB data\n            var rulesets = tvdbData is null\n                ? GetRulesetsForTopic(item.Topic)\n                : GetRulesetsForTopic(item.Topic).Where(r => r.Media?.TvdbId == tvdbData.Id).ToList();\n\n            foreach (var ruleset in rulesets)\n            {\n                if (!ruleset.Filters.All(filter => FilterMatches(item, filter)))\n                {\n                    unmatchedFilteredResults.Remove(item);\n                    continue; // Skip this ruleset if any filter fails\n                }\n\n                MatchedEpisodeInfo? matchInfo = null;\n\n                switch (ruleset.MatchingStrategy)\n                {\n                    case MatchingStrategy.SeasonAndEpisodeNumber:\n                        matchInfo = await MatchesSeasonAndEpisode(item, ruleset);\n                        break;\n                    case MatchingStrategy.ItemTitleIncludes:\n                        matchInfo = await MatchesItemTitleIncludes(item, ruleset);\n                        break;\n                    case MatchingStrategy.ItemTitleExact:\n                        matchInfo = await MatchesItemTitleExact(item, ruleset);\n                        break;\n                    case MatchingStrategy.ItemTitleEqualsAirdate:\n                        matchInfo = await MatchesItemTitleEqualsAirdate(item, ruleset);\n                        break;\n                }\n\n                if (matchInfo != null)\n                {\n                    matchedFilteredResults.Add(matchInfo);\n                    break;\n                }\n                else\n                {\n                    unmatchedFilteredResults.Remove(item);\n                }\n            }\n        }\n\n        return (matchedFilteredResults, unmatchedFilteredResults);\n    }\n\n    public async Task<string> FetchSearchResultsForRssSync(int limit, int offset)\n    {\n        var cacheKey = $\"rss_{limit}_{offset}\";\n\n        // Return cached response if it exists\n        if (_cache.TryGetValue(cacheKey, out string? cachedResponse))\n        {\n            return cachedResponse ?? \"\";\n        }\n\n        var mediathekViewRequestCacheKey = \"rss_mediathekview_results\";\n        List<ApiResultItem> results;\n        if (_cache.TryGetValue(mediathekViewRequestCacheKey, out List<ApiResultItem>? cachedResults))\n        {\n            results = cachedResults ?? [];\n        }\n        else\n        {\n            var queries = new List<object>();\n            var apiResponse = await FetchMediathekViewApiResponseAsync(queries, 6000);\n\n            if (string.IsNullOrEmpty(apiResponse))\n            {\n                return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult());\n            }\n\n            results = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse)?.Result.Results ?? [];\n            _cache.Set(mediathekViewRequestCacheKey, results, TimeSpan.FromMinutes(20));\n        }\n\n        // Deserialize the API response and apply ruleset filters\n        var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results);\n\n        List<Item>? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList();\n        List<Item>? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, null);\n\n        // Combine the results from ruleset matching and fallback handler\n        var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset);\n\n        // Cache the response and return it\n        _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan);\n        return newznabRssResponse;\n    }\n\n    public async Task<string> FetchSearchResultsFromApiByString(string? q, string? season, int limit, int offset)\n    {\n        var cacheKey = $\"q_{q ?? \"null\"}_{season ?? \"null\"}_{limit}_{offset}\";\n\n        // Return cached response if it exists\n        if (_cache.TryGetValue(cacheKey, out string? cachedResponse))\n        {\n            return cachedResponse ?? \"\";\n        }\n\n        var mediathekViewRequestCacheKey = $\"mediathekapi_{q ?? \"null\"}_{season ?? \"null\"}\";\n        string apiResponse;\n        if (_cache.TryGetValue(mediathekViewRequestCacheKey, out string? cachedApiResponse))\n        {\n            apiResponse = cachedApiResponse ?? string.Empty;\n        }\n        else\n        {\n            var queries = new List<object>();\n            if (q != null)\n            {\n                queries.Add(new { fields = _queryFields, query = q });\n            }\n\n            if (!string.IsNullOrEmpty(season))\n            {\n                var zeroBasedSeason = season.Length >= 2 ? season : $\"0{season}\";\n                queries.Add(new { fields = new[] { \"title\" }, query = $\"S{zeroBasedSeason}\" });\n            }\n\n            apiResponse = await FetchMediathekViewApiResponseAsync(queries, 1500);\n            if (string.IsNullOrEmpty(apiResponse))\n            {\n                return NewznabUtils.SerializeRss(NewznabUtils.GetEmptyRssResult());\n            }\n\n            _cache.Set(mediathekViewRequestCacheKey, apiResponse, _cacheTimeSpan);\n        }\n        // Deserialize the API response and apply ruleset filters\n        var results = JsonSerializer.Deserialize<MediathekApiResponse>(apiResponse)?.Result.Results ?? [];\n        var (matchedEpisodes, unmatchedFilteredResultItems) = await ApplyRulesetFilters(results);\n\n        List<Item>? newznabItemsByRuleset = matchedEpisodes.SelectMany(GenerateRssItems).ToList();\n        List<Item>? newznabItemsByFallback = MediathekSearchFallbackHandler.GetFallbackSearchResultItemsByString(unmatchedFilteredResultItems, season);\n\n        // Combine the results from ruleset matching and fallback handler\n        var newznabRssResponse = ConvertNewznabItemsToRss([.. newznabItemsByRuleset, .. newznabItemsByFallback], limit, offset);\n\n        // Cache the response and return it\n        _cache.Set(cacheKey, newznabRssResponse, _cacheTimeSpan);\n        return newznabRssResponse;\n    }\n\n    private List<Item> GenerateRssItems(MatchedEpisodeInfo matchedEpisodeInfo)\n    {\n        var items = new List<Item>();\n\n        string[] categories = [\"5000\", \"2000\"];\n\n        if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoHd))\n        {\n            items.AddRange(CreateRssItems(matchedEpisodeInfo, \"1080p\", 1.6, \"TV > HD\", [.. categories, \"5040\", \"2040\"], matchedEpisodeInfo.Item.UrlVideoHd));\n        }\n\n        if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideo))\n        {\n            items.AddRange(CreateRssItems(matchedEpisodeInfo, \"720p\", 1.0, \"TV > HD\", [.. categories, \"5040\", \"2040\"], matchedEpisodeInfo.Item.UrlVideo));\n        }\n\n        if (!string.IsNullOrEmpty(matchedEpisodeInfo.Item.UrlVideoLow))\n        {\n            items.AddRange(CreateRssItems(matchedEpisodeInfo, \"480p\", 0.4, \"TV > SD\", [.. categories, \"5030\", \"2030\"], matchedEpisodeInfo.Item.UrlVideoLow));\n\n        }\n\n        return items;\n    }\n\n    private List<Item> CreateRssItems(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url)\n    {\n        var items = new List<Item>\n        {\n            CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Standard)\n        };\n\n        // also create daily type if season is a year\n        if (matchedEpisodeInfo.Episode.SeasonNumber > 1950)\n        {\n            items.Add(CreateRssItem(matchedEpisodeInfo, quality, sizeMultiplier, category, categoryValues, url, EpisodeType.Daily));\n        }\n\n        return items;\n    }\n\n    private static string FormatTitle(string title)\n    {\n        // Replace German Umlaute and special characters\n        title = title.Replace(\"ä\", \"ae\")\n                     .Replace(\"ö\", \"oe\")\n                     .Replace(\"ü\", \"ue\")\n                     .Replace(\"ß\", \"ss\")\n                     .Replace(\"Ä\", \"Ae\")\n                     .Replace(\"Ö\", \"Oe\")\n                     .Replace(\"Ü\", \"Ue\");\n\n        // Remove unwanted characters\n        title = TitleRegexUnd().Replace(title, \"and\");\n        title = TitleRegexSymbols().Replace(title, \"\"); // Remove various symbols\n        title = TitleRegexWhitespace().Replace(title, \".\").Replace(\"..\", \".\");\n\n        return title;\n    }\n\n\n    private static Item CreateRssItem(MatchedEpisodeInfo matchedEpisodeInfo, string quality, double sizeMultiplier, string category, string[] categoryValues, string url, EpisodeType episodeType)\n    {\n        var adjustedSize = (long)(matchedEpisodeInfo.Item.Size * sizeMultiplier);\n        var parsedTitle = GenerateTitle(matchedEpisodeInfo, quality, episodeType);\n        var formattedTitle = FormatTitle(parsedTitle);\n        var translatedTitle = formattedTitle;\n        var encodedTitle = Convert.ToBase64String(Encoding.UTF8.GetBytes(translatedTitle));\n        var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(url));\n\n        // Generate the full URL for the fake_nzb_download endpoint\n        var fakeDownloadUrl = $\"/api/fake_nzb_download?encodedUrl={encodedUrl}&encodedTitle={encodedTitle}\";\n        var item = matchedEpisodeInfo.Item;\n\n        return new Item\n        {\n            Title = translatedTitle,\n            Guid = new Guid\n            {\n                IsPermaLink = true,\n\t\t\t\t\tValue = $\"{item.UrlWebsite}#{quality}{(episodeType == EpisodeType.Daily ? \"\" : \"-d\")}\",\n\t\t\t\t},\n            Link = url,\n            Comments = item.UrlWebsite,\n            PubDate = DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).ToString(\"R\"),\n            Category = category,\n            Description = item.Description,\n            Enclosure = new Enclosure\n            {\n                Url = fakeDownloadUrl,\n                Length = adjustedSize,\n                Type = \"application/x-nzb\"\n            },\n            Attributes = NewznabUtils.GenerateAttributes(matchedEpisodeInfo.Episode.PaddedSeason, categoryValues)\n        };\n    }\n\n    private static string GenerateTitle(MatchedEpisodeInfo matchedEpisodeInfo, string quality, EpisodeType episodeType)\n    {\n        var episode = matchedEpisodeInfo.Episode;\n\n        if (episodeType == EpisodeType.Daily)\n        {\n            return $\"{matchedEpisodeInfo.ShowName}.{episode.Aired:yyyy-MM-dd}.{episode.Name}.GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n        }\n        return $\"{matchedEpisodeInfo.ShowName}.S{episode.PaddedSeason}E{episode.PaddedEpisode}.{episode.Name}.GERMAN.{quality}.WEB.h264-MEDiATHEK\".Replace(\" \", \".\");\n    }\n\n    public static bool ShouldSkipItem(ApiResultItem item)\n    {\n        return item.UrlVideo.EndsWith(\".m3u8\") || _skipKeywords.Any(item.Title.Contains);\n    }\n\n    [GeneratedRegex(@\"[&]\")]\n    private static partial Regex TitleRegexUnd();\n    [GeneratedRegex(@\"[/:;,\"\"'’@#?$%^*+=!|<>,()]\")]\n    private static partial Regex TitleRegexSymbols();\n    [GeneratedRegex(@\"\\s+\")]\n    private static partial Regex TitleRegexWhitespace();\n}"
  },
  {
    "path": "MediathekArrServer/Services/RulesetBackgroundService.cs",
    "content": "﻿namespace MediathekArrServer.Services;\n\nusing Microsoft.Extensions.Hosting;\nusing System;\nusing System.Threading;\nusing System.Threading.Tasks;\n\npublic class RulesetBackgroundService(IServiceProvider serviceProvider, ILogger<RulesetBackgroundService> logger) : BackgroundService\n{\n    private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(30);\n\n    protected override async Task ExecuteAsync(CancellationToken stoppingToken)\n    {\n        while (!stoppingToken.IsCancellationRequested)\n        {\n            using (var scope = serviceProvider.CreateScope())\n            {\n                var searchService = scope.ServiceProvider.GetRequiredService<MediathekSearchService>();\n\n                try\n                {\n                    logger.LogInformation(\"Starting ruleset update at {Time}\", DateTime.UtcNow);\n                    await searchService.UpdateRulesetsAsync();\n                    logger.LogInformation(\"Ruleset update completed successfully at {Time}\", DateTime.UtcNow);\n                }\n                catch (Exception ex)\n                {\n                    logger.LogError(ex, \"Error updating rulesets at {Time}\", DateTime.UtcNow);\n                }\n            }\n\n            await Task.Delay(_refreshInterval, stoppingToken);\n        }\n    }\n}\n"
  },
  {
    "path": "MediathekArrServer/appsettings.Development.json",
    "content": "{\n  \"Logging\": {\n    \"LogLevel\": {\n      \"Default\": \"Information\",\n      \"Microsoft.AspNetCore\": \"Warning\"\n    }\n  }\n}\n"
  },
  {
    "path": "MediathekArrServer/appsettings.Production.json",
    "content": "﻿{\n  \"Kestrel\": {\n    \"Endpoints\": {\n      \"Http\": {\n        \"Url\": \"http://[::]:5008\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "MediathekArrServer/appsettings.json",
    "content": "{\n  \"Logging\": {\n    \"LogLevel\": {\n      \"Default\": \"Information\",\n      \"Microsoft.AspNetCore\": \"Warning\"\n    }\n  },\n  \"AllowedHosts\": \"*\"\n}\n"
  },
  {
    "path": "README.md",
    "content": "<img width=\"90\" alt=\"mediathekarr\" src=\"https://github.com/user-attachments/assets/0e3b6d3a-214b-4382-9111-4b5c001ffc00\">\n\n# MediathekArr\n\nwork in progress, please report bugs and ideas\n\nThanks to https://github.com/mediathekview/mediathekviewweb for the Mediathek API\n\nThanks to https://github.com/PCJones/UmlautAdaptarr for the German Title API\n\nThanks to https://thetvdb.com for the metadata API\n\nExample screenshot:\n![grafik](https://github.com/user-attachments/assets/654c42fa-4eab-4b6e-b1c7-9b23192c7a98)\n\n## Features\n\n| Feature                                                           | Status        |\n|-------------------------------------------------------------------|---------------|\n| Prowlarr & NZB Hydra Support                                      |✓              |\n| Sonarr (TV Show) Support                                          |✓              |\n| Radarr (Movie) Support*                                           |limited*, WIP  |\n| Subtitle Support                                                  |✓              |\n| MKV Creation                                                      |✓              |\n| Web-Interface with installation wizard                            |✓              |\n| Advanced filter and matching system for TV shows, seasons and episodes...\ndue to the horrendous lack of consistency and metadata in ARD/ZDF Mediatheken|✓     |\n| Ideas?                                                            | Wishes?   |\n\n\\* You can find a few movies via interactive search, but not a lot. You can however find all movies via a text search in prowlarr and send the result to radarr.\n\n## Installation using docker\n\n## Important Note:\n**You should use the beta image until 1.0 is released. Latest/Main is not working.**\n\n\n1. Configure docker-compose.yml - you can find the most recent beta docker compose [here](https://github.com/PCJones/MediathekArr/releases/latest)\n2. Find out your wizard url: Depending on your docker network setup either `http://localhost:5007`, `http://mediathekarr:5007` or `http://YOUR_HOST_IP:5007`\n3. Open the wizard and follow the wizards instructions :-)\n4. You are done! In canse you encounter any problems please don't hesitate to create an issue or to [contact me]([url](https://github.com/PCJones/MediathekArr/tree/main?tab=readme-ov-file#kontakt--support)).\n\n## How does it work\n- Indexer: MediathekArr is pretending to be a usenet indexer, but are actually just fetching and parsing search results from MediathekViewWeb\n- Downloader: MediathekArr is pretending to be a SABnzbd usenet downloader but is actually just downloading the video and subtitles via HTTP directly from the Mediatheken\n\n## Kontakt & Support\n- Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst.\n- [Telegram](https://t.me/pc_jones)\n- [UsenetDE Discord Server](https://discord.gg/src6zcH4rr) -> #mediathekarr Channel\n\n## Spenden\nÜber eine Spende freue ich mich natürlich immer :D\n\n<a href=\"https://www.buymeacoffee.com/pcjones\" target=\"_blank\"><img src=\"https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png\" alt=\"Buy Me A Coffee\" height=\"60px\" width=\"217px\" ></a>\n<a href=\"https://coindrop.to/pcjones\" target=\"_blank\"><img src=\"https://coindrop.to/embed-button.png\" style=\"border-radius: 10px; height: 57px !important;width: 229px !important;\" alt=\"Coindrop.to me\"></img></a>\n\nFür andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=pcjones/mediathekarr&type=Date)](https://star-history.com/#pcjones/mediathekarr&Date)\n"
  },
  {
    "path": "api/v1/db.php",
    "content": "<?php\n\ndefine('DB_FILE', 'tvdb_cache.sqlite');\n\nfunction initializeDatabase() {\n    $isFirstRun = !file_exists(DB_FILE);\n    \n    $db = new PDO('sqlite:' . DB_FILE);\n    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);\n\n    if ($isFirstRun) {\n        createTables($db);\n        displayApiKeyForm($db);\n    }\n\n    return $db;\n}\n\nfunction createTables($db) {\n    // Create table to store the API key\n    $createApiKeyTableQuery = \"CREATE TABLE IF NOT EXISTS api_key (\n        id INTEGER PRIMARY KEY,\n        key TEXT NOT NULL\n    )\";\n\n    // Create table to store the API token and its expiration\n    $createTokenTableQuery = \"CREATE TABLE IF NOT EXISTS api_token (\n        id INTEGER PRIMARY KEY,\n        token TEXT NOT NULL,\n        expiration_date TEXT NOT NULL\n    )\";\n\n    $createSeriesCacheTableQuery = \"CREATE TABLE IF NOT EXISTS series_cache (\n        series_id INTEGER PRIMARY KEY,\n        name TEXT,\n        german_name TEXT,\n        aliases TEXT,\n        last_updated TEXT,\n        next_aired TEXT,\n        last_aired TEXT,\n        cache_expiry TEXT\n    )\";\n\n    $createEpisodesTableQuery = \"CREATE TABLE IF NOT EXISTS episodes (\n        id INTEGER PRIMARY KEY,\n        series_id INTEGER,\n        name TEXT,\n        aired TEXT,\n        runtime INTEGER,\n        season_number INTEGER,\n        episode_number INTEGER,\n        FOREIGN KEY(series_id) REFERENCES series_cache(series_id)\n    )\";\n\n    $db->exec($createApiKeyTableQuery);\n    $db->exec($createTokenTableQuery);\n    $db->exec($createSeriesCacheTableQuery);\n    $db->exec($createEpisodesTableQuery);\n}\n\nfunction displayApiKeyForm($db) {\n    if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['api_key'])) {\n        $apiKey = trim($_POST['api_key']);\n        \n        if ($apiKey) {\n            // Store the API key in the database\n            $stmt = $db->prepare(\"INSERT INTO api_key (id, key) VALUES (1, :key)\");\n            $stmt->execute(['key' => $apiKey]);\n            echo \"API key saved successfully. You can now use the application.\";\n            exit;\n        } else {\n            echo \"Please enter a valid API key.\";\n        }\n    }\n\n    echo '<!DOCTYPE html>\n    <html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <title>Set TVDB API Key</title>\n    </head>\n    <body>\n        <h1>Enter TVDB API Key</h1>\n        <form method=\"post\">\n            <label for=\"api_key\">API Key:</label>\n            <input type=\"text\" id=\"api_key\" name=\"api_key\" required>\n            <button type=\"submit\">Save API Key</button>\n        </form>\n    </body>\n    </html>';\n    exit;\n}\n\nfunction getApiKey($db) {\n    // Retrieve the API key from the database\n    $stmt = $db->query(\"SELECT key FROM api_key WHERE id = 1\");\n    $result = $stmt->fetch(PDO::FETCH_ASSOC);\n\n    if ($result) {\n        return $result['key'];\n    } else {\n        // Show API key form if not set\n        displayApiKeyForm($db);\n    }\n}\n?>\n"
  },
  {
    "path": "api/v1/get_show.php",
    "content": "<?php\nrequire 'db.php';\nrequire 'token_manager.php';\n\n$db = initializeDatabase();\n$apiKey = getApiKey($db);\n\nheader('Content-Type: application/json');\n\n// Helper function to determine if cache is expired\nfunction isCacheExpired($row) {\n    try {\n        $now = new DateTime();\n        $cacheExpiry = new DateTime($row['cache_expiry']);\n        return $now > $cacheExpiry;\n    } catch (Exception $e) {\n        return true; // If date parsing fails, consider cache expired\n    }\n}\n\n// Main function to fetch series information\nfunction getSeriesData($db, $tvdbId, $apiKey, $debug = false) {\n    try {\n        // Fetch from cache\n        $stmt = $db->prepare(\"SELECT * FROM series_cache WHERE series_id = :tvdb_id\");\n        $stmt->bindValue(':tvdb_id', (int)$tvdbId, PDO::PARAM_INT);\n        $stmt->execute();\n        $seriesData = $stmt->fetch(PDO::FETCH_ASSOC);\n\n        $cached = false;\n        $cacheExpiry = null;\n\n        if ($seriesData) {\n            $cached = !isCacheExpired($seriesData);\n            $cacheExpiry = $seriesData['cache_expiry'];\n        }\n\n        // Return cached data if available and not expired\n        if ($cached) {\n            $episodesStmt = $db->prepare(\"SELECT * FROM episodes WHERE series_id = :tvdb_id\");\n            $episodesStmt->bindValue(':tvdb_id', (int)$tvdbId, PDO::PARAM_INT);\n            $episodesStmt->execute();\n            $episodes = $episodesStmt->fetchAll(PDO::FETCH_ASSOC);\n\n            $response = [\n                \"status\" => \"success\",\n                \"data\" => [\n                    \"id\" => $tvdbId,\n                    \"name\" => $seriesData['name'],\n                    \"german_name\" => $seriesData['german_name'],\n                    \"aliases\" => json_decode($seriesData['aliases']),\n                    \"episodes\" => array_map(function ($episode) {\n                        return [\n                            \"name\" => $episode['name'],\n                            \"aired\" => $episode['aired'],\n                            \"runtime\" => $episode['runtime'],\n                            \"seasonNumber\" => $episode['season_number'],\n                            \"episodeNumber\" => $episode['episode_number'],\n                        ];\n                    }, $episodes)\n                ]\n            ];\n\n            if ($debug) {\n                $response['debug'] = [\n                    \"cached\" => true,\n                    \"cache_expiry\" => $cacheExpiry\n                ];\n            }\n\n            return $response;\n        } else {\n            // Fetch new data if cache is expired or unavailable\n            return fetchAndCacheSeriesData($db, $tvdbId, $apiKey, $debug);\n        }\n    } catch (Exception $e) {\n        return [\"status\" => \"error\", \"message\" => \"Error retrieving series data: \" . $e->getMessage()];\n    }\n}\n\n// Function to fetch and cache data from TVDB\nfunction fetchAndCacheSeriesData($db, $tvdbId, $apiKey, $debug = false) {\n    $token = getToken($db, $apiKey);\n    if (!$token) {\n        return [\"status\" => \"error\", \"message\" => \"Failed to retrieve valid token from TVDB\"];\n    }\n\n    $curl = curl_init(\"https://api4.thetvdb.com/v4/series/$tvdbId/extended?meta=episodes&short=true\");\n    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);\n    curl_setopt($curl, CURLOPT_HTTPHEADER, [\n        \"Authorization: Bearer $token\",\n        \"Accept: application/json\"\n    ]);\n    $response = curl_exec($curl);\n\n    // Check for Curl errors\n    if (curl_errno($curl)) {\n        $error_msg = curl_error($curl);\n        curl_close($curl);\n        return [\"status\" => \"error\", \"message\" => \"Curl error: \" . $error_msg];\n    }\n    curl_close($curl);\n\n    // Decode response and check for errors\n    $data = json_decode($response, true);\n    if (!$data || $data['status'] !== 'success') {\n        return [\"status\" => \"error\", \"message\" => \"Failed to fetch data from TVDB\"];\n    }\n\n    try {\n        $series = $data['data'];\n        $germanName = $series['nameTranslations']['deu'] ?? $series['name'];\n\t\t\n        $rawAliases = $series['aliases'] ?? [];\n        // Normalize aliases into an array\n        $germanAliases = [];\n        if (is_array($rawAliases)) {\n            foreach ($rawAliases as $alias) {\n                if (isset($alias['language']) && $alias['language'] === 'deu') {\n                    $germanAliases[] = $alias;\n                }\n            }\n        } elseif (is_object($rawAliases)) {\n            foreach ((array)$rawAliases as $alias) {\n                if (isset($alias['language']) && $alias['language'] === 'deu') {\n                    $germanAliases[] = $alias;\n                }\n            }\n        } // If neither, default to an empty array\n        $germanAliases = $germanAliases ?: [];\n\t\t\n        $nextAired = !empty($series['nextAired']) ? new DateTime($series['nextAired']) : new DateTime('1970-01-01');\n        $lastAired = !empty($series['lastAired']) ? new DateTime($series['lastAired']) : new DateTime('1970-01-01');\n        $lastUpdated = new DateTime($series['lastUpdated']);\n        \n        $cacheExpiry = new DateTime();\n        if ($lastUpdated->diff($cacheExpiry)->days < 7 ||\n            ($nextAired != new DateTime('1970-01-01') && $nextAired->diff($cacheExpiry)->days < 6) ||\n            ($lastAired != new DateTime('1970-01-01') && $lastAired->diff($cacheExpiry)->days < 3)) {\n            $cacheExpiry->modify('+2 days');\n        } else {\n            $cacheExpiry->modify('+6 days');\n        }\n\n        // Cache series data\n        $db->beginTransaction();\n        $db->exec(\"DELETE FROM series_cache WHERE series_id = $tvdbId\");\n        $stmt = $db->prepare(\"INSERT INTO series_cache (series_id, name, german_name, aliases, last_updated, next_aired, last_aired, cache_expiry) VALUES (:tvdb_id, :name, :german_name, :aliases, :last_updated, :next_aired, :last_aired, :cache_expiry)\");\n        $stmt->execute([\n            'tvdb_id' => $tvdbId,\n            'name' => $series['name'],\n            'german_name' => $germanName,\n            'aliases' => json_encode($germanAliases),\n            'last_updated' => $series['lastUpdated'],\n            'next_aired' => $nextAired->format('Y-m-d H:i:s'),\n            'last_aired' => $lastAired->format('Y-m-d H:i:s'),\n            'cache_expiry' => $cacheExpiry->format('Y-m-d H:i:s')\n        ]);\n\n        $db->exec(\"DELETE FROM episodes WHERE series_id = $tvdbId\");\n        $episodesStmt = $db->prepare(\"INSERT INTO episodes (id, series_id, name, aired, runtime, season_number, episode_number) VALUES (:id, :tvdb_id, :name, :aired, :runtime, :season_number, :episode_number)\");\n        foreach ($series['episodes'] as $episode) {\n            $episodesStmt->execute([\n                'id' => $episode['id'],\n                'tvdb_id' => $tvdbId,\n                'name' => $episode['name'],\n                'aired' => $episode['aired'],\n                'runtime' => $episode['runtime'],\n                'season_number' => $episode['seasonNumber'],\n                'episode_number' => $episode['number']\n            ]);\n        }\n        $db->commit();\n\n        $response = [\n            \"status\" => \"success\",\n            \"data\" => [\n                \"id\" => $tvdbId,\n                \"name\" => $series['name'],\n                \"german_name\" => $germanName,\n                \"aliases\" => $germanAliases,\n                \"episodes\" => array_map(function ($episode) {\n                    return [\n                        \"name\" => $episode['name'],\n                        \"aired\" => $episode['aired'],\n                        \"runtime\" => $episode['runtime'],\n                        \"seasonNumber\" => $episode['seasonNumber'],\n                        \"episodeNumber\" => $episode['number'],\n                    ];\n                }, $series['episodes'])\n            ]\n        ];\n\n        if ($debug) {\n            $response['debug'] = [\n                \"cached\" => false,\n                \"cache_expiry\" => $cacheExpiry->format('Y-m-d H:i:s')\n            ];\n        }\n\n        return $response;\n    } catch (Exception $e) {\n        $db->rollBack();\n        return [\"status\" => \"error\", \"message\" => \"Database error: \" . $e->getMessage()];\n    }\n}\n\n// Process request\n$tvdbId = filter_input(INPUT_GET, 'tvdbid', FILTER_VALIDATE_INT);\n$debug = filter_input(INPUT_GET, 'debug', FILTER_VALIDATE_BOOLEAN);\n\nif ($tvdbId) {\n    echo json_encode(getSeriesData($db, $tvdbId, $apiKey, $debug));\n} else {\n    echo json_encode([\"status\" => \"error\", \"message\" => \"TVDB ID is required and must be an integer\"]);\n}\n?>\n"
  },
  {
    "path": "api/v1/token_manager.php",
    "content": "<?php\n\nfunction getToken($db) {\n    // Check if token is stored and still valid\n    $stmt = $db->query(\"SELECT token, expiration_date FROM api_token WHERE id = 1\");\n    $result = $stmt->fetch(PDO::FETCH_ASSOC);\n\n    if ($result && new DateTime() < new DateTime($result['expiration_date'])) {\n        return $result['token'];\n    } else {\n        // If no valid token, refresh the token\n        $apiKey = getApiKey($db);\n        return refreshToken($db, $apiKey);\n    }\n}\n\nfunction refreshToken($db, $apiKey) {\n    $curl = curl_init('https://api4.thetvdb.com/v4/login');\n    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);\n    curl_setopt($curl, CURLOPT_POST, true);\n    curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);\n    curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode(['apikey' => $apiKey]));\n    \n    $response = curl_exec($curl);\n    $data = json_decode($response, true);\n    \n    if ($data && $data['status'] == 'success') {\n        $token = $data['data']['token'];\n        $expirationDate = date('Y-m-d H:i:s', time() + 86400); // Assuming token expires after 24 hours\n\n        // Update or insert the new token and expiration into the api_token table\n        $db->exec(\"DELETE FROM api_token WHERE id = 1\"); // Clear existing token\n        $stmt = $db->prepare(\"INSERT INTO api_token (id, token, expiration_date) VALUES (1, :token, :expiration_date)\");\n        $stmt->execute(['token' => $token, 'expiration_date' => $expirationDate]);\n\n        return $token;\n    } else {\n        // Handle error or retry logic\n        return null;\n    }\n}\n?>\n"
  },
  {
    "path": "build_and_push_docker_image.bat",
    "content": "@echo off\nSET IMAGE_NAME=pcjones/mediathekarr\n\necho Enter the version number for the Docker image:\nset /p VERSION=\"Version: \"\n\necho Building Docker image with version %VERSION%...\ndocker build -t %IMAGE_NAME%:%VERSION% .\ndocker tag %IMAGE_NAME%:%VERSION% %IMAGE_NAME%:latest\n\necho Pushing Docker image with version %VERSION%...\ndocker push %IMAGE_NAME%:%VERSION%\n\necho Pushing Docker image with tag latest...\ndocker push %IMAGE_NAME%:latest\n\necho Done.\npause"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  mediathekarr:\n    image: pcjones/mediathekarr:latest\n    container_name: mediathekarr\n    environment:\n      - TZ=Europe/Berlin\n      - DOWNLOAD_FOLDER_PATH_MAPPING=/downloads/completed # Change right side for correct path mapping\n      # - MEDIATHEKARR_API_BASE_URL=https://mediathekarr.pcjones.de/api/v1 # Only change this if you are hosting your own API. Not needed for 99% of users\n    volumes:\n      - ./your_temp_downloads_folder/:/app/downloads      # Change left side to your temp download folder location\n    ports:\n      - \"127.0.0.1:5007:5007\"                             # Port on the right side can be changed to any value you like\n    restart: unless-stopped\n"
  }
]