refactor(parser): restructure service with proper logging and sepofcon

This commit is contained in:
Sam Chau
2026-01-20 18:40:28 +10:30
parent facf391f16
commit ff6a9be8c1
23 changed files with 958 additions and 258 deletions

View File

@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<!-- Output to root dist/parser/ -->
<BaseOutputPath>$(MSBuildThisFileDirectory)..\..\..\dist\parser\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)..\..\..\dist\parser\obj\</BaseIntermediateOutputPath>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using Parser.Logging;
namespace Parser.Endpoints;
public static class HealthEndpoints
{
public static void Map(WebApplication app, string version)
{
app.MapGet("/health", () =>
{
Log.Debug("Health check requested", "Health");
return Results.Ok(new { status = "healthy", version });
});
}
}

View File

@@ -0,0 +1,123 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Parser.Logging;
using Parser.Models;
namespace Parser.Endpoints;
public static class MatchEndpoints
{
public static void Map(WebApplication app)
{
app.MapPost("/match", HandleMatch);
app.MapPost("/match/batch", HandleBatchMatch);
}
private static IResult HandleMatch(MatchRequest request)
{
if (string.IsNullOrWhiteSpace(request.Text))
{
Log.Debug("Match request rejected: missing text", "Match");
return Results.BadRequest(new { error = "Text is required" });
}
if (request.Patterns == null || request.Patterns.Count == 0)
{
Log.Debug("Match request rejected: no patterns", "Match");
return Results.BadRequest(new { error = "At least one pattern is required" });
}
Log.Info($"Matching {request.Patterns.Count} patterns against text", "Match");
var results = new Dictionary<string, bool>();
foreach (var pattern in request.Patterns)
{
try
{
var regex = new Regex(
pattern,
RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100) // Timeout to prevent ReDoS
);
results[pattern] = regex.IsMatch(request.Text);
}
catch (RegexMatchTimeoutException)
{
Log.Warn($"Pattern timed out: {pattern}", "Match");
results[pattern] = false;
}
catch (ArgumentException ex)
{
Log.Debug($"Invalid regex pattern: {pattern} - {ex.Message}", "Match");
results[pattern] = false;
}
}
return Results.Ok(new MatchResponse { Results = results });
}
private static IResult HandleBatchMatch(BatchMatchRequest request)
{
if (request.Texts == null || request.Texts.Count == 0)
{
Log.Debug("Batch match request rejected: no texts", "Match");
return Results.BadRequest(new { error = "At least one text is required" });
}
if (request.Patterns == null || request.Patterns.Count == 0)
{
Log.Debug("Batch match request rejected: no patterns", "Match");
return Results.BadRequest(new { error = "At least one pattern is required" });
}
Log.Info($"Batch matching {request.Patterns.Count} patterns against {request.Texts.Count} texts", "Match");
// Pre-compile all regexes once
var compiledPatterns = new Dictionary<string, Regex?>();
foreach (var pattern in request.Patterns)
{
try
{
compiledPatterns[pattern] = new Regex(
pattern,
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100)
);
}
catch (ArgumentException ex)
{
Log.Debug($"Invalid regex pattern: {pattern} - {ex.Message}", "Match");
compiledPatterns[pattern] = null; // Invalid pattern
}
}
// Process texts in parallel for better performance
var results = new ConcurrentDictionary<string, Dictionary<string, bool>>();
Parallel.ForEach(request.Texts, text =>
{
var textResults = new Dictionary<string, bool>();
foreach (var (pattern, regex) in compiledPatterns)
{
if (regex == null)
{
textResults[pattern] = false;
continue;
}
try
{
textResults[pattern] = regex.IsMatch(text);
}
catch (RegexMatchTimeoutException)
{
textResults[pattern] = false;
}
}
results[text] = textResults;
});
return Results.Ok(new BatchMatchResponse { Results = results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) });
}
}

View File

@@ -0,0 +1,136 @@
using Parser.Logging;
using Parser.Models;
using Parser.Parsers;
namespace Parser.Endpoints;
public static class ParseEndpoints
{
public static void Map(WebApplication app)
{
app.MapPost("/parse", Handle);
}
private static IResult Handle(ParseRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
{
Log.Debug("Parse request rejected: missing title", "Parse");
return Results.BadRequest(new { error = "Title is required" });
}
if (string.IsNullOrWhiteSpace(request.Type) ||
(request.Type != "movie" && request.Type != "series"))
{
Log.Debug($"Parse request rejected: invalid type '{request.Type}'", "Parse");
return Results.BadRequest(new { error = "Type is required and must be 'movie' or 'series'" });
}
var qualityResult = QualityParser.ParseQuality(request.Title);
var languages = LanguageParser.ParseLanguages(request.Title);
var releaseGroup = ReleaseGroupParser.ParseReleaseGroup(request.Title);
if (request.Type == "movie")
{
var titleInfo = TitleParser.ParseMovieTitle(request.Title);
var response = new ParseResponse
{
Title = request.Title,
Type = "movie",
Source = qualityResult.Source.ToString(),
Resolution = (int)qualityResult.Resolution,
Modifier = qualityResult.Modifier.ToString(),
Revision = new RevisionResponse
{
Version = qualityResult.Revision.Version,
Real = qualityResult.Revision.Real,
IsRepack = qualityResult.Revision.IsRepack
},
Languages = languages.Select(l => l.ToString()).ToList(),
ReleaseGroup = releaseGroup,
MovieTitles = titleInfo?.MovieTitles ?? new List<string>(),
Year = titleInfo?.Year ?? 0,
Edition = titleInfo?.Edition,
ImdbId = titleInfo?.ImdbId,
TmdbId = titleInfo?.TmdbId ?? 0,
HardcodedSubs = titleInfo?.HardcodedSubs,
ReleaseHash = titleInfo?.ReleaseHash,
Episode = null
};
Log.Info($"Parsed movie: {request.Title}", new LogOptions
{
Source = "Parse",
Meta = new
{
source = response.Source,
resolution = response.Resolution,
languages = response.Languages,
releaseGroup = response.ReleaseGroup,
year = response.Year,
title = titleInfo?.PrimaryMovieTitle
}
});
return Results.Ok(response);
}
else // series
{
var episodeInfo = EpisodeParser.ParseTitle(request.Title);
var response = new ParseResponse
{
Title = request.Title,
Type = "series",
Source = qualityResult.Source.ToString(),
Resolution = (int)qualityResult.Resolution,
Modifier = qualityResult.Modifier.ToString(),
Revision = new RevisionResponse
{
Version = qualityResult.Revision.Version,
Real = qualityResult.Revision.Real,
IsRepack = qualityResult.Revision.IsRepack
},
Languages = languages.Select(l => l.ToString()).ToList(),
ReleaseGroup = releaseGroup,
MovieTitles = new List<string>(),
Year = 0,
Edition = null,
ImdbId = null,
TmdbId = 0,
HardcodedSubs = null,
ReleaseHash = null,
Episode = episodeInfo != null ? new EpisodeResponse
{
SeriesTitle = episodeInfo.SeriesTitle,
SeasonNumber = episodeInfo.SeasonNumber,
EpisodeNumbers = episodeInfo.EpisodeNumbers.ToList(),
AbsoluteEpisodeNumbers = episodeInfo.AbsoluteEpisodeNumbers.ToList(),
AirDate = episodeInfo.AirDate,
FullSeason = episodeInfo.FullSeason,
IsPartialSeason = episodeInfo.IsPartialSeason,
IsMultiSeason = episodeInfo.IsMultiSeason,
IsMiniSeries = episodeInfo.IsMiniSeries,
Special = episodeInfo.Special,
ReleaseType = episodeInfo.ReleaseType.ToString()
} : null
};
Log.Info($"Parsed series: {request.Title}", new LogOptions
{
Source = "Parse",
Meta = new
{
source = response.Source,
resolution = response.Resolution,
languages = response.Languages,
releaseGroup = response.ReleaseGroup,
series = episodeInfo?.SeriesTitle,
season = episodeInfo?.SeasonNumber,
episodes = episodeInfo?.EpisodeNumbers
}
});
return Results.Ok(response);
}
}
}

View File

@@ -0,0 +1,14 @@
namespace Parser.Logging;
/// <summary>
/// ANSI color codes for terminal output
/// </summary>
public static class Colors
{
public const string Reset = "\x1b[0m";
public const string Grey = "\x1b[90m";
public const string Cyan = "\x1b[36m";
public const string Green = "\x1b[32m";
public const string Yellow = "\x1b[33m";
public const string Red = "\x1b[31m";
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Configuration;
namespace Parser.Logging;
/// <summary>
/// Log settings manager
/// Loads configuration from appsettings.json and environment variables
/// </summary>
public class LogSettingsManager
{
private LoggerConfig _config;
private readonly IConfiguration? _configuration;
public LogSettingsManager(IConfiguration? configuration = null)
{
_configuration = configuration;
_config = LoadConfig();
}
/// <summary>
/// Load settings from configuration
/// </summary>
private LoggerConfig LoadConfig()
{
var config = new LoggerConfig();
if (_configuration != null)
{
var section = _configuration.GetSection("ParserLogging");
config.LogsDir = section["LogsDir"] ?? GetEnvOrDefault("PARSER_LOGS_DIR", "/tmp/parser-logs");
config.Enabled = ParseBool(section["Enabled"], GetEnvBool("PARSER_LOG_ENABLED", true));
config.FileLogging = ParseBool(section["FileLogging"], GetEnvBool("PARSER_LOG_FILE", true));
config.ConsoleLogging = ParseBool(section["ConsoleLogging"], GetEnvBool("PARSER_LOG_CONSOLE", true));
config.MinLevel = ParseLogLevel(section["MinLevel"], GetEnvLogLevel("PARSER_LOG_LEVEL", LogLevel.INFO));
}
else
{
// Fallback to environment variables only
config.LogsDir = GetEnvOrDefault("PARSER_LOGS_DIR", "/tmp/parser-logs");
config.Enabled = GetEnvBool("PARSER_LOG_ENABLED", true);
config.FileLogging = GetEnvBool("PARSER_LOG_FILE", true);
config.ConsoleLogging = GetEnvBool("PARSER_LOG_CONSOLE", true);
config.MinLevel = GetEnvLogLevel("PARSER_LOG_LEVEL", LogLevel.INFO);
}
return config;
}
/// <summary>
/// Reload settings from configuration
/// </summary>
public void Reload()
{
_config = LoadConfig();
}
/// <summary>
/// Get current configuration
/// </summary>
public LoggerConfig Get() => _config;
/// <summary>
/// Check if logging is enabled
/// </summary>
public bool IsEnabled() => _config.Enabled;
/// <summary>
/// Check if file logging is enabled
/// </summary>
public bool IsFileLoggingEnabled() => _config.FileLogging;
/// <summary>
/// Check if console logging is enabled
/// </summary>
public bool IsConsoleLoggingEnabled() => _config.ConsoleLogging;
/// <summary>
/// Get minimum log level
/// </summary>
public LogLevel GetMinLevel() => _config.MinLevel;
/// <summary>
/// Check if a log level should be logged based on minimum level
/// </summary>
public bool ShouldLog(LogLevel level)
{
if (!IsEnabled()) return false;
return level >= _config.MinLevel;
}
// Helper methods for parsing config values
private static string GetEnvOrDefault(string key, string defaultValue)
=> Environment.GetEnvironmentVariable(key) ?? defaultValue;
private static bool GetEnvBool(string key, bool defaultValue)
{
var value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrEmpty(value)) return defaultValue;
return value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
value.Equals("1", StringComparison.OrdinalIgnoreCase);
}
private static LogLevel GetEnvLogLevel(string key, LogLevel defaultValue)
{
var value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrEmpty(value)) return defaultValue;
return Enum.TryParse<LogLevel>(value, true, out var level) ? level : defaultValue;
}
private static bool ParseBool(string? value, bool defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
return value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
value.Equals("1", StringComparison.OrdinalIgnoreCase);
}
private static LogLevel ParseLogLevel(string? value, LogLevel defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
return Enum.TryParse<LogLevel>(value, true, out var level) ? level : defaultValue;
}
}
/// <summary>
/// Singleton instance for global access
/// </summary>
public static class LogSettings
{
private static LogSettingsManager? _instance;
public static void Initialize(IConfiguration? configuration = null)
{
_instance = new LogSettingsManager(configuration);
}
public static LogSettingsManager Instance =>
_instance ?? throw new InvalidOperationException("LogSettings not initialized. Call Initialize() first.");
}

View File

@@ -0,0 +1,241 @@
using System.Text.Json;
namespace Parser.Logging;
/// <summary>
/// Logger with console and file output
/// Supports configurable settings and daily rotation
/// </summary>
public class Logger
{
private readonly LoggerConfig _config;
private readonly LogSettingsManager? _settings;
private static readonly object _fileLock = new();
public Logger(LoggerConfig? config = null, LogSettingsManager? settings = null)
{
_settings = settings;
_config = config ?? _settings?.Get() ?? new LoggerConfig();
}
private string FormatTimestamp()
{
var timestamp = DateTime.UtcNow.ToString("o");
return $"{Colors.Grey}{timestamp}{Colors.Reset}";
}
private string FormatLevel(LogLevel level)
{
var color = level switch
{
LogLevel.DEBUG => Colors.Cyan,
LogLevel.INFO => Colors.Green,
LogLevel.WARN => Colors.Yellow,
LogLevel.ERROR => Colors.Red,
_ => Colors.Reset
};
return $"{color}{level.ToString().PadRight(5)}{Colors.Reset}";
}
private string FormatSource(string? source)
{
if (string.IsNullOrEmpty(source)) return "";
return $"{Colors.Grey}[{source}]{Colors.Reset}";
}
private string FormatMeta(object? meta)
{
if (meta == null) return "";
return $"{Colors.Grey}{JsonSerializer.Serialize(meta)}{Colors.Reset}";
}
/// <summary>
/// Get log file path with daily rotation (YYYY-MM-DD.log)
/// </summary>
private string GetLogFilePath()
{
var date = DateTime.UtcNow.ToString("yyyy-MM-dd");
return Path.Combine(_config.LogsDir, $"{date}.log");
}
private bool IsEnabled()
=> _settings?.IsEnabled() ?? _config.Enabled;
private bool IsFileLoggingEnabled()
=> _settings?.IsFileLoggingEnabled() ?? _config.FileLogging;
private bool IsConsoleLoggingEnabled()
=> _settings?.IsConsoleLoggingEnabled() ?? _config.ConsoleLogging;
private bool ShouldLog(LogLevel level)
{
if (!IsEnabled()) return false;
var minLevel = _settings?.GetMinLevel() ?? _config.MinLevel;
return level >= minLevel;
}
private void Log(LogLevel level, string message, LogOptions? options = null)
{
if (!ShouldLog(level)) return;
var timestamp = DateTime.UtcNow.ToString("o");
// Console output (colored)
if (IsConsoleLoggingEnabled())
{
var parts = new List<string>
{
FormatTimestamp(),
FormatLevel(level),
message
};
if (!string.IsNullOrEmpty(options?.Source))
parts.Add(FormatSource(options.Source));
if (options?.Meta != null)
parts.Add(FormatMeta(options.Meta));
Console.WriteLine(string.Join(" | ", parts));
}
// File output (JSON)
if (IsFileLoggingEnabled())
{
var logEntry = new LogEntry
{
Timestamp = timestamp,
Level = level.ToString(),
Message = message,
Source = options?.Source,
Meta = options?.Meta
};
try
{
// Ensure logs directory exists
Directory.CreateDirectory(_config.LogsDir);
var filePath = GetLogFilePath();
var json = JsonSerializer.Serialize(logEntry) + Environment.NewLine;
// Thread-safe file write
lock (_fileLock)
{
File.AppendAllText(filePath, json);
}
}
catch (Exception ex)
{
// If file write fails, at least we have console output
Console.Error.WriteLine($"Failed to write to log file: {ex.Message}");
}
}
}
public void Debug(string message, LogOptions? options = null)
=> Log(LogLevel.DEBUG, message, options);
public void Debug(string message, string source)
=> Log(LogLevel.DEBUG, message, new LogOptions { Source = source });
public void Info(string message, LogOptions? options = null)
=> Log(LogLevel.INFO, message, options);
public void Info(string message, string source)
=> Log(LogLevel.INFO, message, new LogOptions { Source = source });
public void Warn(string message, LogOptions? options = null)
=> Log(LogLevel.WARN, message, options);
public void Warn(string message, string source)
=> Log(LogLevel.WARN, message, new LogOptions { Source = source });
public void Error(string message, LogOptions? options = null)
=> Log(LogLevel.ERROR, message, options);
public void Error(string message, string source)
=> Log(LogLevel.ERROR, message, new LogOptions { Source = source });
public void Error(string message, Exception ex, LogOptions? options = null)
{
Log(LogLevel.ERROR, message, options);
// Print stack trace to console
if (ex.StackTrace != null && IsConsoleLoggingEnabled())
{
Console.WriteLine($"{Colors.Grey}{ex.StackTrace}{Colors.Reset}");
}
// Write stack trace to file
if (ex.StackTrace != null && IsFileLoggingEnabled())
{
var traceEntry = new LogEntry
{
Timestamp = DateTime.UtcNow.ToString("o"),
Level = "ERROR",
Message = "Stack trace",
Meta = new { stack = ex.StackTrace }
};
try
{
var filePath = GetLogFilePath();
var json = JsonSerializer.Serialize(traceEntry) + Environment.NewLine;
lock (_fileLock)
{
File.AppendAllText(filePath, json);
}
}
catch (Exception writeEx)
{
Console.Error.WriteLine($"Failed to write stack trace to log file: {writeEx.Message}");
}
}
}
}
/// <summary>
/// Global logger singleton for production use
/// </summary>
public static class Log
{
private static Logger? _instance;
public static void Initialize(LoggerConfig? config = null, LogSettingsManager? settings = null)
{
_instance = new Logger(config, settings);
}
public static Logger Instance =>
_instance ?? throw new InvalidOperationException("Logger not initialized. Call Initialize() first.");
// Convenience methods that delegate to the singleton
public static void Debug(string message, LogOptions? options = null)
=> Instance.Debug(message, options);
public static void Debug(string message, string source)
=> Instance.Debug(message, source);
public static void Info(string message, LogOptions? options = null)
=> Instance.Info(message, options);
public static void Info(string message, string source)
=> Instance.Info(message, source);
public static void Warn(string message, LogOptions? options = null)
=> Instance.Warn(message, options);
public static void Warn(string message, string source)
=> Instance.Warn(message, source);
public static void Error(string message, LogOptions? options = null)
=> Instance.Error(message, options);
public static void Error(string message, string source)
=> Instance.Error(message, source);
public static void Error(string message, Exception ex, LogOptions? options = null)
=> Instance.Error(message, ex, options);
}

View File

@@ -0,0 +1,92 @@
namespace Parser.Logging;
/// <summary>
/// Startup logging utilities
/// </summary>
public static class Startup
{
/// <summary>
/// Check if running inside a Docker container
/// </summary>
public static bool IsDocker()
{
// Check for .dockerenv file (most reliable)
if (File.Exists("/.dockerenv"))
return true;
// Check for docker in cgroup (fallback)
try
{
var cgroup = File.ReadAllText("/proc/1/cgroup");
return cgroup.Contains("docker");
}
catch
{
return false;
}
}
/// <summary>
/// Log container configuration (only when running in Docker)
/// </summary>
public static void LogContainerConfig()
{
if (!IsDocker()) return;
Log.Info("Container initialized", new LogOptions
{
Source = "Docker",
Meta = new
{
puid = Environment.GetEnvironmentVariable("PUID") ?? "1000",
pgid = Environment.GetEnvironmentVariable("PGID") ?? "1000",
umask = Environment.GetEnvironmentVariable("UMASK") ?? "022",
tz = Environment.GetEnvironmentVariable("TZ") ?? "UTC"
}
});
}
/// <summary>
/// Server information record
/// </summary>
public record ServerInfo(
string Version,
string Environment,
string Timezone,
string Hostname
);
/// <summary>
/// Get server information
/// </summary>
public static ServerInfo GetServerInfo(string version)
{
return new ServerInfo(
Version: version,
Environment: System.Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production",
Timezone: TimeZoneInfo.Local.Id,
Hostname: System.Environment.MachineName
);
}
/// <summary>
/// Log server startup information
/// </summary>
public static void LogServerInfo(string version, string url)
{
var info = GetServerInfo(version);
Log.Info($"Parser service started", new LogOptions
{
Source = "Startup",
Meta = new
{
version = info.Version,
url,
environment = info.Environment,
timezone = info.Timezone,
hostname = info.Hostname
}
});
}
}

View File

@@ -0,0 +1,71 @@
namespace Parser.Logging;
/// <summary>
/// Log severity levels (DEBUG -> INFO -> WARN -> ERROR)
/// </summary>
public enum LogLevel
{
DEBUG,
INFO,
WARN,
ERROR
}
/// <summary>
/// Options for a single log call
/// </summary>
public class LogOptions
{
/// <summary>
/// Optional source/context tag (e.g., "Parser", "Quality", "Language")
/// </summary>
public string? Source { get; set; }
/// <summary>
/// Optional metadata to include with the log
/// </summary>
public object? Meta { get; set; }
}
/// <summary>
/// A single log entry (used for JSON file output)
/// </summary>
public class LogEntry
{
public required string Timestamp { get; set; }
public required string Level { get; set; }
public required string Message { get; set; }
public string? Source { get; set; }
public object? Meta { get; set; }
}
/// <summary>
/// Logger configuration
/// </summary>
public class LoggerConfig
{
/// <summary>
/// Directory where log files will be written
/// </summary>
public string LogsDir { get; set; } = "/tmp/logs";
/// <summary>
/// Master toggle for all logging
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Enable file logging
/// </summary>
public bool FileLogging { get; set; } = true;
/// <summary>
/// Enable console logging
/// </summary>
public bool ConsoleLogging { get; set; } = true;
/// <summary>
/// Minimum log level to output
/// </summary>
public LogLevel MinLevel { get; set; } = LogLevel.INFO;
}

View File

@@ -1,4 +1,4 @@
namespace Parser.Core;
namespace Parser.Models;
public enum Language
{

View File

@@ -0,0 +1,7 @@
namespace Parser.Models;
public record ParseRequest(string Title, string? Type);
public record MatchRequest(string Text, List<string> Patterns);
public record BatchMatchRequest(List<string> Texts, List<string> Patterns);

View File

@@ -0,0 +1,53 @@
namespace Parser.Models;
public record ParseResponse
{
public string Title { get; init; } = "";
public string Type { get; init; } = "";
public string Source { get; init; } = "";
public int Resolution { get; init; }
public string Modifier { get; init; } = "";
public RevisionResponse Revision { get; init; } = new();
public List<string> Languages { get; init; } = new();
public string? ReleaseGroup { get; init; }
public List<string> MovieTitles { get; init; } = new();
public int Year { get; init; }
public string? Edition { get; init; }
public string? ImdbId { get; init; }
public int TmdbId { get; init; }
public string? HardcodedSubs { get; init; }
public string? ReleaseHash { get; init; }
public EpisodeResponse? Episode { get; init; }
}
public record RevisionResponse
{
public int Version { get; init; } = 1;
public int Real { get; init; }
public bool IsRepack { get; init; }
}
public record EpisodeResponse
{
public string? SeriesTitle { get; init; }
public int SeasonNumber { get; init; }
public List<int> EpisodeNumbers { get; init; } = new();
public List<int> AbsoluteEpisodeNumbers { get; init; } = new();
public string? AirDate { get; init; }
public bool FullSeason { get; init; }
public bool IsPartialSeason { get; init; }
public bool IsMultiSeason { get; init; }
public bool IsMiniSeries { get; init; }
public bool Special { get; init; }
public string ReleaseType { get; init; } = "Unknown";
}
public record MatchResponse
{
public Dictionary<string, bool> Results { get; init; } = new();
}
public record BatchMatchResponse
{
public Dictionary<string, Dictionary<string, bool>> Results { get; init; } = new();
}

View File

@@ -1,4 +1,4 @@
namespace Parser.Core;
namespace Parser.Models;
public enum QualitySource
{

View File

@@ -4,6 +4,13 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Parser</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Content Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace Parser.Core;
namespace Parser.Parsers.Common;
internal static class ParserCommon
{

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace Parser.Core;
namespace Parser.Parsers.Common;
public class RegexReplace
{

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Parser.Parsers.Common;
namespace Parser.Core;
namespace Parser.Parsers;
public enum ReleaseType
{

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Parser.Models;
namespace Parser.Core;
namespace Parser.Parsers;
public static class LanguageParser
{

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Parser.Models;
namespace Parser.Core;
namespace Parser.Parsers;
public static class QualityParser
{

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Parser.Parsers.Common;
namespace Parser.Core;
namespace Parser.Parsers;
public static class ReleaseGroupParser
{

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using Parser.Parsers.Common;
namespace Parser.Core;
namespace Parser.Parsers;
public class ParsedMovieInfo
{

View File

@@ -1,259 +1,32 @@
using Parser.Core;
// Bump this version when parser logic changes (regex patterns, parsing behavior, etc.)
// This invalidates the parse result cache in Profilarr
const string ParserVersion = "1.0.0";
using Parser.Endpoints;
using Parser.Logging;
var builder = WebApplication.CreateBuilder(args);
// Load configuration
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
// Get parser version from config
var parserVersion = builder.Configuration["Parser:Version"] ?? "1.0.0";
// Initialize logging
LogSettings.Initialize(builder.Configuration);
Log.Initialize(settings: LogSettings.Instance);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
app.MapPost("/parse", (ParseRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Title))
{
return Results.BadRequest(new { error = "Title is required" });
}
// Get the URL the server will listen on
var urls = app.Urls.Any() ? app.Urls.First() : "http://localhost:5000";
if (string.IsNullOrWhiteSpace(request.Type) ||
(request.Type != "movie" && request.Type != "series"))
{
return Results.BadRequest(new { error = "Type is required and must be 'movie' or 'series'" });
}
// Log startup info
Startup.LogContainerConfig();
Startup.LogServerInfo(parserVersion, urls);
var qualityResult = QualityParser.ParseQuality(request.Title);
var languages = LanguageParser.ParseLanguages(request.Title);
var releaseGroup = ReleaseGroupParser.ParseReleaseGroup(request.Title);
if (request.Type == "movie")
{
var titleInfo = TitleParser.ParseMovieTitle(request.Title);
return Results.Ok(new ParseResponse
{
Title = request.Title,
Type = "movie",
Source = qualityResult.Source.ToString(),
Resolution = (int)qualityResult.Resolution,
Modifier = qualityResult.Modifier.ToString(),
Revision = new RevisionResponse
{
Version = qualityResult.Revision.Version,
Real = qualityResult.Revision.Real,
IsRepack = qualityResult.Revision.IsRepack
},
Languages = languages.Select(l => l.ToString()).ToList(),
ReleaseGroup = releaseGroup,
MovieTitles = titleInfo?.MovieTitles ?? new List<string>(),
Year = titleInfo?.Year ?? 0,
Edition = titleInfo?.Edition,
ImdbId = titleInfo?.ImdbId,
TmdbId = titleInfo?.TmdbId ?? 0,
HardcodedSubs = titleInfo?.HardcodedSubs,
ReleaseHash = titleInfo?.ReleaseHash,
Episode = null
});
}
else // series
{
var episodeInfo = EpisodeParser.ParseTitle(request.Title);
return Results.Ok(new ParseResponse
{
Title = request.Title,
Type = "series",
Source = qualityResult.Source.ToString(),
Resolution = (int)qualityResult.Resolution,
Modifier = qualityResult.Modifier.ToString(),
Revision = new RevisionResponse
{
Version = qualityResult.Revision.Version,
Real = qualityResult.Revision.Real,
IsRepack = qualityResult.Revision.IsRepack
},
Languages = languages.Select(l => l.ToString()).ToList(),
ReleaseGroup = releaseGroup,
MovieTitles = new List<string>(),
Year = 0,
Edition = null,
ImdbId = null,
TmdbId = 0,
HardcodedSubs = null,
ReleaseHash = null,
Episode = episodeInfo != null ? new EpisodeResponse
{
SeriesTitle = episodeInfo.SeriesTitle,
SeasonNumber = episodeInfo.SeasonNumber,
EpisodeNumbers = episodeInfo.EpisodeNumbers.ToList(),
AbsoluteEpisodeNumbers = episodeInfo.AbsoluteEpisodeNumbers.ToList(),
AirDate = episodeInfo.AirDate,
FullSeason = episodeInfo.FullSeason,
IsPartialSeason = episodeInfo.IsPartialSeason,
IsMultiSeason = episodeInfo.IsMultiSeason,
IsMiniSeries = episodeInfo.IsMiniSeries,
Special = episodeInfo.Special,
ReleaseType = episodeInfo.ReleaseType.ToString()
} : null
});
}
});
app.MapGet("/health", () => Results.Ok(new { status = "healthy", version = ParserVersion }));
app.MapPost("/match", (MatchRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Text))
{
return Results.BadRequest(new { error = "Text is required" });
}
if (request.Patterns == null || request.Patterns.Count == 0)
{
return Results.BadRequest(new { error = "At least one pattern is required" });
}
var results = new Dictionary<string, bool>();
foreach (var pattern in request.Patterns)
{
try
{
var regex = new System.Text.RegularExpressions.Regex(
pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100) // Timeout to prevent ReDoS
);
results[pattern] = regex.IsMatch(request.Text);
}
catch (System.Text.RegularExpressions.RegexMatchTimeoutException)
{
// Pattern took too long, treat as no match
results[pattern] = false;
}
catch (System.ArgumentException)
{
// Invalid regex pattern
results[pattern] = false;
}
}
return Results.Ok(new MatchResponse { Results = results });
});
app.MapPost("/match/batch", (BatchMatchRequest request) =>
{
if (request.Texts == null || request.Texts.Count == 0)
{
return Results.BadRequest(new { error = "At least one text is required" });
}
if (request.Patterns == null || request.Patterns.Count == 0)
{
return Results.BadRequest(new { error = "At least one pattern is required" });
}
// Pre-compile all regexes once
var compiledPatterns = new Dictionary<string, System.Text.RegularExpressions.Regex?>();
foreach (var pattern in request.Patterns)
{
try
{
compiledPatterns[pattern] = new System.Text.RegularExpressions.Regex(
pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100)
);
}
catch (System.ArgumentException)
{
compiledPatterns[pattern] = null; // Invalid pattern
}
}
// Process texts in parallel for better performance
var results = new System.Collections.Concurrent.ConcurrentDictionary<string, Dictionary<string, bool>>();
System.Threading.Tasks.Parallel.ForEach(request.Texts, text =>
{
var textResults = new Dictionary<string, bool>();
foreach (var (pattern, regex) in compiledPatterns)
{
if (regex == null)
{
textResults[pattern] = false;
continue;
}
try
{
textResults[pattern] = regex.IsMatch(text);
}
catch (System.Text.RegularExpressions.RegexMatchTimeoutException)
{
textResults[pattern] = false;
}
}
results[text] = textResults;
});
return Results.Ok(new BatchMatchResponse { Results = results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) });
});
// Map endpoints
ParseEndpoints.Map(app);
MatchEndpoints.Map(app);
HealthEndpoints.Map(app, parserVersion);
app.Run();
public record ParseRequest(string Title, string? Type);
public record ParseResponse
{
public string Title { get; init; } = "";
public string Type { get; init; } = "";
public string Source { get; init; } = "";
public int Resolution { get; init; }
public string Modifier { get; init; } = "";
public RevisionResponse Revision { get; init; } = new();
public List<string> Languages { get; init; } = new();
public string? ReleaseGroup { get; init; }
public List<string> MovieTitles { get; init; } = new();
public int Year { get; init; }
public string? Edition { get; init; }
public string? ImdbId { get; init; }
public int TmdbId { get; init; }
public string? HardcodedSubs { get; init; }
public string? ReleaseHash { get; init; }
public EpisodeResponse? Episode { get; init; }
}
public record RevisionResponse
{
public int Version { get; init; } = 1;
public int Real { get; init; }
public bool IsRepack { get; init; }
}
public record EpisodeResponse
{
public string? SeriesTitle { get; init; }
public int SeasonNumber { get; init; }
public List<int> EpisodeNumbers { get; init; } = new();
public List<int> AbsoluteEpisodeNumbers { get; init; } = new();
public string? AirDate { get; init; }
public bool FullSeason { get; init; }
public bool IsPartialSeason { get; init; }
public bool IsMultiSeason { get; init; }
public bool IsMiniSeries { get; init; }
public bool Special { get; init; }
public string ReleaseType { get; init; } = "Unknown";
}
public record MatchRequest(string Text, List<string> Patterns);
public record MatchResponse
{
public Dictionary<string, bool> Results { get; init; } = new();
}
public record BatchMatchRequest(List<string> Texts, List<string> Patterns);
public record BatchMatchResponse
{
public Dictionary<string, Dictionary<string, bool>> Results { get; init; } = new();
}

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "None",
"Microsoft": "None"
}
},
"ParserLogging": {
"Enabled": true,
"ConsoleLogging": true,
"FileLogging": false,
"MinLevel": "INFO"
},
"Parser": {
"Version": "1.0.0"
}
}