From ff6a9be8c167e7bc25c3127999e6efa4dafe489d Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Tue, 20 Jan 2026 18:40:28 +1030 Subject: [PATCH] refactor(parser): restructure service with proper logging and sepofcon --- src/services/parser/Directory.Build.props | 7 + .../parser/Endpoints/HealthEndpoints.cs | 15 + .../parser/Endpoints/MatchEndpoints.cs | 123 ++++++++ .../parser/Endpoints/ParseEndpoints.cs | 136 +++++++++ src/services/parser/Logging/Colors.cs | 14 + src/services/parser/Logging/LogSettings.cs | 139 +++++++++ src/services/parser/Logging/Logger.cs | 241 ++++++++++++++++ src/services/parser/Logging/Startup.cs | 92 ++++++ src/services/parser/Logging/Types.cs | 71 +++++ .../parser/{Core => Models}/Language.cs | 2 +- src/services/parser/Models/Requests.cs | 7 + src/services/parser/Models/Responses.cs | 53 ++++ src/services/parser/{Core => Models}/Types.cs | 2 +- src/services/parser/Parser.csproj | 7 + .../{Core => Parsers/Common}/ParserCommon.cs | 2 +- .../{Core => Parsers/Common}/RegexReplace.cs | 2 +- .../parser/{Core => Parsers}/EpisodeParser.cs | 3 +- .../{Core => Parsers}/LanguageParser.cs | 3 +- .../parser/{Core => Parsers}/QualityParser.cs | 3 +- .../{Core => Parsers}/ReleaseGroupParser.cs | 3 +- .../parser/{Core => Parsers}/TitleParser.cs | 3 +- src/services/parser/Program.cs | 271 ++---------------- src/services/parser/appsettings.json | 17 ++ 23 files changed, 958 insertions(+), 258 deletions(-) create mode 100644 src/services/parser/Directory.Build.props create mode 100644 src/services/parser/Endpoints/HealthEndpoints.cs create mode 100644 src/services/parser/Endpoints/MatchEndpoints.cs create mode 100644 src/services/parser/Endpoints/ParseEndpoints.cs create mode 100644 src/services/parser/Logging/Colors.cs create mode 100644 src/services/parser/Logging/LogSettings.cs create mode 100644 src/services/parser/Logging/Logger.cs create mode 100644 src/services/parser/Logging/Startup.cs create mode 100644 src/services/parser/Logging/Types.cs rename src/services/parser/{Core => Models}/Language.cs (97%) create mode 100644 src/services/parser/Models/Requests.cs create mode 100644 src/services/parser/Models/Responses.cs rename src/services/parser/{Core => Models}/Types.cs (97%) rename src/services/parser/{Core => Parsers/Common}/ParserCommon.cs (98%) rename src/services/parser/{Core => Parsers/Common}/RegexReplace.cs (95%) rename src/services/parser/{Core => Parsers}/EpisodeParser.cs (99%) rename src/services/parser/{Core => Parsers}/LanguageParser.cs (99%) rename src/services/parser/{Core => Parsers}/QualityParser.cs (99%) rename src/services/parser/{Core => Parsers}/ReleaseGroupParser.cs (98%) rename src/services/parser/{Core => Parsers}/TitleParser.cs (99%) create mode 100644 src/services/parser/appsettings.json diff --git a/src/services/parser/Directory.Build.props b/src/services/parser/Directory.Build.props new file mode 100644 index 0000000..97243ff --- /dev/null +++ b/src/services/parser/Directory.Build.props @@ -0,0 +1,7 @@ + + + + $(MSBuildThisFileDirectory)..\..\..\dist\parser\ + $(MSBuildThisFileDirectory)..\..\..\dist\parser\obj\ + + diff --git a/src/services/parser/Endpoints/HealthEndpoints.cs b/src/services/parser/Endpoints/HealthEndpoints.cs new file mode 100644 index 0000000..8e592ec --- /dev/null +++ b/src/services/parser/Endpoints/HealthEndpoints.cs @@ -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 }); + }); + } +} diff --git a/src/services/parser/Endpoints/MatchEndpoints.cs b/src/services/parser/Endpoints/MatchEndpoints.cs new file mode 100644 index 0000000..692ff41 --- /dev/null +++ b/src/services/parser/Endpoints/MatchEndpoints.cs @@ -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(); + + 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(); + 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>(); + + Parallel.ForEach(request.Texts, text => + { + var textResults = new Dictionary(); + 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) }); + } +} diff --git a/src/services/parser/Endpoints/ParseEndpoints.cs b/src/services/parser/Endpoints/ParseEndpoints.cs new file mode 100644 index 0000000..bc3f138 --- /dev/null +++ b/src/services/parser/Endpoints/ParseEndpoints.cs @@ -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(), + 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(), + 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); + } + } +} diff --git a/src/services/parser/Logging/Colors.cs b/src/services/parser/Logging/Colors.cs new file mode 100644 index 0000000..b084628 --- /dev/null +++ b/src/services/parser/Logging/Colors.cs @@ -0,0 +1,14 @@ +namespace Parser.Logging; + +/// +/// ANSI color codes for terminal output +/// +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"; +} diff --git a/src/services/parser/Logging/LogSettings.cs b/src/services/parser/Logging/LogSettings.cs new file mode 100644 index 0000000..8522f23 --- /dev/null +++ b/src/services/parser/Logging/LogSettings.cs @@ -0,0 +1,139 @@ +using Microsoft.Extensions.Configuration; + +namespace Parser.Logging; + +/// +/// Log settings manager +/// Loads configuration from appsettings.json and environment variables +/// +public class LogSettingsManager +{ + private LoggerConfig _config; + private readonly IConfiguration? _configuration; + + public LogSettingsManager(IConfiguration? configuration = null) + { + _configuration = configuration; + _config = LoadConfig(); + } + + /// + /// Load settings from configuration + /// + 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; + } + + /// + /// Reload settings from configuration + /// + public void Reload() + { + _config = LoadConfig(); + } + + /// + /// Get current configuration + /// + public LoggerConfig Get() => _config; + + /// + /// Check if logging is enabled + /// + public bool IsEnabled() => _config.Enabled; + + /// + /// Check if file logging is enabled + /// + public bool IsFileLoggingEnabled() => _config.FileLogging; + + /// + /// Check if console logging is enabled + /// + public bool IsConsoleLoggingEnabled() => _config.ConsoleLogging; + + /// + /// Get minimum log level + /// + public LogLevel GetMinLevel() => _config.MinLevel; + + /// + /// Check if a log level should be logged based on minimum level + /// + 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(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(value, true, out var level) ? level : defaultValue; + } +} + +/// +/// Singleton instance for global access +/// +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."); +} diff --git a/src/services/parser/Logging/Logger.cs b/src/services/parser/Logging/Logger.cs new file mode 100644 index 0000000..0e99a2a --- /dev/null +++ b/src/services/parser/Logging/Logger.cs @@ -0,0 +1,241 @@ +using System.Text.Json; + +namespace Parser.Logging; + +/// +/// Logger with console and file output +/// Supports configurable settings and daily rotation +/// +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}"; + } + + /// + /// Get log file path with daily rotation (YYYY-MM-DD.log) + /// + 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 + { + 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}"); + } + } + } +} + +/// +/// Global logger singleton for production use +/// +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); +} diff --git a/src/services/parser/Logging/Startup.cs b/src/services/parser/Logging/Startup.cs new file mode 100644 index 0000000..0a55313 --- /dev/null +++ b/src/services/parser/Logging/Startup.cs @@ -0,0 +1,92 @@ +namespace Parser.Logging; + +/// +/// Startup logging utilities +/// +public static class Startup +{ + /// + /// Check if running inside a Docker container + /// + 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; + } + } + + /// + /// Log container configuration (only when running in Docker) + /// + 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" + } + }); + } + + /// + /// Server information record + /// + public record ServerInfo( + string Version, + string Environment, + string Timezone, + string Hostname + ); + + /// + /// Get server information + /// + 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 + ); + } + + /// + /// Log server startup information + /// + 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 + } + }); + } +} diff --git a/src/services/parser/Logging/Types.cs b/src/services/parser/Logging/Types.cs new file mode 100644 index 0000000..41b8daf --- /dev/null +++ b/src/services/parser/Logging/Types.cs @@ -0,0 +1,71 @@ +namespace Parser.Logging; + +/// +/// Log severity levels (DEBUG -> INFO -> WARN -> ERROR) +/// +public enum LogLevel +{ + DEBUG, + INFO, + WARN, + ERROR +} + +/// +/// Options for a single log call +/// +public class LogOptions +{ + /// + /// Optional source/context tag (e.g., "Parser", "Quality", "Language") + /// + public string? Source { get; set; } + + /// + /// Optional metadata to include with the log + /// + public object? Meta { get; set; } +} + +/// +/// A single log entry (used for JSON file output) +/// +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; } +} + +/// +/// Logger configuration +/// +public class LoggerConfig +{ + /// + /// Directory where log files will be written + /// + public string LogsDir { get; set; } = "/tmp/logs"; + + /// + /// Master toggle for all logging + /// + public bool Enabled { get; set; } = true; + + /// + /// Enable file logging + /// + public bool FileLogging { get; set; } = true; + + /// + /// Enable console logging + /// + public bool ConsoleLogging { get; set; } = true; + + /// + /// Minimum log level to output + /// + public LogLevel MinLevel { get; set; } = LogLevel.INFO; +} diff --git a/src/services/parser/Core/Language.cs b/src/services/parser/Models/Language.cs similarity index 97% rename from src/services/parser/Core/Language.cs rename to src/services/parser/Models/Language.cs index 939f15b..79dcd94 100644 --- a/src/services/parser/Core/Language.cs +++ b/src/services/parser/Models/Language.cs @@ -1,4 +1,4 @@ -namespace Parser.Core; +namespace Parser.Models; public enum Language { diff --git a/src/services/parser/Models/Requests.cs b/src/services/parser/Models/Requests.cs new file mode 100644 index 0000000..0a06dc2 --- /dev/null +++ b/src/services/parser/Models/Requests.cs @@ -0,0 +1,7 @@ +namespace Parser.Models; + +public record ParseRequest(string Title, string? Type); + +public record MatchRequest(string Text, List Patterns); + +public record BatchMatchRequest(List Texts, List Patterns); diff --git a/src/services/parser/Models/Responses.cs b/src/services/parser/Models/Responses.cs new file mode 100644 index 0000000..1284779 --- /dev/null +++ b/src/services/parser/Models/Responses.cs @@ -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 Languages { get; init; } = new(); + public string? ReleaseGroup { get; init; } + public List 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 EpisodeNumbers { get; init; } = new(); + public List 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 Results { get; init; } = new(); +} + +public record BatchMatchResponse +{ + public Dictionary> Results { get; init; } = new(); +} diff --git a/src/services/parser/Core/Types.cs b/src/services/parser/Models/Types.cs similarity index 97% rename from src/services/parser/Core/Types.cs rename to src/services/parser/Models/Types.cs index c25076c..8c925f0 100644 --- a/src/services/parser/Core/Types.cs +++ b/src/services/parser/Models/Types.cs @@ -1,4 +1,4 @@ -namespace Parser.Core; +namespace Parser.Models; public enum QualitySource { diff --git a/src/services/parser/Parser.csproj b/src/services/parser/Parser.csproj index 1b28a01..6f504fc 100644 --- a/src/services/parser/Parser.csproj +++ b/src/services/parser/Parser.csproj @@ -4,6 +4,13 @@ net8.0 enable enable + Parser + + + PreserveNewest + + + diff --git a/src/services/parser/Core/ParserCommon.cs b/src/services/parser/Parsers/Common/ParserCommon.cs similarity index 98% rename from src/services/parser/Core/ParserCommon.cs rename to src/services/parser/Parsers/Common/ParserCommon.cs index f8d8fc8..f6b1fa2 100644 --- a/src/services/parser/Core/ParserCommon.cs +++ b/src/services/parser/Parsers/Common/ParserCommon.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Parser.Core; +namespace Parser.Parsers.Common; internal static class ParserCommon { diff --git a/src/services/parser/Core/RegexReplace.cs b/src/services/parser/Parsers/Common/RegexReplace.cs similarity index 95% rename from src/services/parser/Core/RegexReplace.cs rename to src/services/parser/Parsers/Common/RegexReplace.cs index de95d6b..2e231d7 100644 --- a/src/services/parser/Core/RegexReplace.cs +++ b/src/services/parser/Parsers/Common/RegexReplace.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Parser.Core; +namespace Parser.Parsers.Common; public class RegexReplace { diff --git a/src/services/parser/Core/EpisodeParser.cs b/src/services/parser/Parsers/EpisodeParser.cs similarity index 99% rename from src/services/parser/Core/EpisodeParser.cs rename to src/services/parser/Parsers/EpisodeParser.cs index 3d18745..6918934 100644 --- a/src/services/parser/Core/EpisodeParser.cs +++ b/src/services/parser/Parsers/EpisodeParser.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; +using Parser.Parsers.Common; -namespace Parser.Core; +namespace Parser.Parsers; public enum ReleaseType { diff --git a/src/services/parser/Core/LanguageParser.cs b/src/services/parser/Parsers/LanguageParser.cs similarity index 99% rename from src/services/parser/Core/LanguageParser.cs rename to src/services/parser/Parsers/LanguageParser.cs index 6a5a644..851b1af 100644 --- a/src/services/parser/Core/LanguageParser.cs +++ b/src/services/parser/Parsers/LanguageParser.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; +using Parser.Models; -namespace Parser.Core; +namespace Parser.Parsers; public static class LanguageParser { diff --git a/src/services/parser/Core/QualityParser.cs b/src/services/parser/Parsers/QualityParser.cs similarity index 99% rename from src/services/parser/Core/QualityParser.cs rename to src/services/parser/Parsers/QualityParser.cs index 844d606..3f967bb 100644 --- a/src/services/parser/Core/QualityParser.cs +++ b/src/services/parser/Parsers/QualityParser.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; +using Parser.Models; -namespace Parser.Core; +namespace Parser.Parsers; public static class QualityParser { diff --git a/src/services/parser/Core/ReleaseGroupParser.cs b/src/services/parser/Parsers/ReleaseGroupParser.cs similarity index 98% rename from src/services/parser/Core/ReleaseGroupParser.cs rename to src/services/parser/Parsers/ReleaseGroupParser.cs index 2fd539e..6c7166a 100644 --- a/src/services/parser/Core/ReleaseGroupParser.cs +++ b/src/services/parser/Parsers/ReleaseGroupParser.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; +using Parser.Parsers.Common; -namespace Parser.Core; +namespace Parser.Parsers; public static class ReleaseGroupParser { diff --git a/src/services/parser/Core/TitleParser.cs b/src/services/parser/Parsers/TitleParser.cs similarity index 99% rename from src/services/parser/Core/TitleParser.cs rename to src/services/parser/Parsers/TitleParser.cs index 9d7184a..ef76683 100644 --- a/src/services/parser/Core/TitleParser.cs +++ b/src/services/parser/Parsers/TitleParser.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; +using Parser.Parsers.Common; -namespace Parser.Core; +namespace Parser.Parsers; public class ParsedMovieInfo { diff --git a/src/services/parser/Program.cs b/src/services/parser/Program.cs index 3da4842..d732824 100644 --- a/src/services/parser/Program.cs +++ b/src/services/parser/Program.cs @@ -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(), - 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(), - 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(); - - 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(); - 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>(); - - System.Threading.Tasks.Parallel.ForEach(request.Texts, text => - { - var textResults = new Dictionary(); - 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 Languages { get; init; } = new(); - public string? ReleaseGroup { get; init; } - public List 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 EpisodeNumbers { get; init; } = new(); - public List 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 Patterns); - -public record MatchResponse -{ - public Dictionary Results { get; init; } = new(); -} - -public record BatchMatchRequest(List Texts, List Patterns); - -public record BatchMatchResponse -{ - public Dictionary> Results { get; init; } = new(); -} diff --git a/src/services/parser/appsettings.json b/src/services/parser/appsettings.json new file mode 100644 index 0000000..d5202c8 --- /dev/null +++ b/src/services/parser/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "None", + "Microsoft": "None" + } + }, + "ParserLogging": { + "Enabled": true, + "ConsoleLogging": true, + "FileLogging": false, + "MinLevel": "INFO" + }, + "Parser": { + "Version": "1.0.0" + } +}