Skip to content

Commit 09018e7

Browse files
committed
Code review on the changes.
1 parent b3bcf1c commit 09018e7

21 files changed

Lines changed: 227 additions & 285 deletions

src/M3Undle.Cli/CliApp.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public async Task<int> RunAsync(string[] args, CancellationToken cancellationTok
9393
UsagePrinter.PrintUsage(_stdout);
9494
return ExitCodes.ConfigError;
9595
}
96-
catch (CliException ex)
96+
catch (CoreException ex)
9797
{
9898
await _stderr.WriteLineAsync(ex.Message);
9999
return ex.ExitCode;

src/M3Undle.Cli/Commands/GroupsCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public async Task<int> ExecuteAsync(CommandContext context, CancellationToken ca
3939
{
4040
if (string.IsNullOrEmpty(context.PlaylistSource))
4141
{
42-
throw new CliException("Missing required: --playlist-url or --config with playlist", ExitCodes.ConfigError);
42+
throw new CoreException("Missing required: --playlist-url or --config with playlist", ExitCodes.ConfigError);
4343
}
4444

4545
var fetcher = new SourceFetcher(_httpClient, _diagnostics);

src/M3Undle.Cli/Commands/InteractiveSourceFetcher.cs

Lines changed: 36 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using M3Undle.Core;
21
using M3Undle.Core.Net;
32
using Spectre.Console;
43

@@ -8,161 +7,65 @@ internal sealed class InteractiveSourceFetcher
87
{
98
private readonly HttpClient _httpClient;
109
private readonly TextWriter _diagnostics;
10+
private readonly SourceFetcher _sourceFetcher;
1111

1212
public InteractiveSourceFetcher(HttpClient httpClient, TextWriter diagnostics)
1313
{
1414
_httpClient = httpClient;
1515
_diagnostics = diagnostics;
16+
_sourceFetcher = new SourceFetcher(httpClient, diagnostics);
1617
}
1718

1819
public async Task<string> GetStringWithProgressAsync(string source, IAnsiConsole console, CancellationToken cancellationToken)
1920
{
20-
if (string.IsNullOrWhiteSpace(source))
21-
{
22-
throw new CliException("Playlist URL was not provided.", ExitCodes.ConfigError);
23-
}
24-
2521
if (Uri.TryCreate(source, UriKind.Absolute, out var uri) &&
2622
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
2723
{
28-
try
29-
{
30-
if (_diagnostics != TextWriter.Null)
31-
{
32-
await _diagnostics.WriteLineAsync($"Downloading {UrlRedactor.RedactUrl(uri)}...");
33-
}
34-
35-
using var response = await _httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
36-
37-
if (_diagnostics != TextWriter.Null)
24+
using var response = await HttpFetcher.SendAndValidateAsync(
25+
_httpClient, uri, _diagnostics, cancellationToken,
26+
HttpCompletionOption.ResponseHeadersRead);
27+
28+
var total = response.Content.Headers.ContentLength ?? -1L;
29+
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
30+
using var ms = new MemoryStream();
31+
var buffer = new byte[8192];
32+
long read = 0;
33+
34+
var result = string.Empty;
35+
await console.Progress()
36+
.AutoClear(true)
37+
.Columns(CreateColumns(total))
38+
.StartAsync(async ctx =>
3839
{
39-
await _diagnostics.WriteLineAsync($"Response status: {(int)response.StatusCode} {response.ReasonPhrase}");
40-
await _diagnostics.WriteLineAsync($"Content-Type: {response.Content.Headers.ContentType}");
41-
await _diagnostics.WriteLineAsync($"Content-Length: {response.Content.Headers.ContentLength?.ToString() ?? "unknown"}");
42-
}
43-
44-
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
45-
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
46-
{
47-
throw new CliException($"Authentication failed when requesting {UrlRedactor.RedactUrl(uri)}", ExitCodes.AuthError);
48-
}
49-
50-
if (!response.IsSuccessStatusCode)
51-
{
52-
var errorBody = string.Empty;
53-
try
54-
{
55-
errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
56-
57-
if (_diagnostics != TextWriter.Null && !string.IsNullOrWhiteSpace(errorBody))
58-
{
59-
await _diagnostics.WriteLineAsync("=== Server Error Response Body ===");
60-
await _diagnostics.WriteLineAsync(errorBody);
61-
await _diagnostics.WriteLineAsync("=== End Server Error Response ===");
62-
}
63-
64-
if (!string.IsNullOrWhiteSpace(errorBody) && errorBody.Length > 500)
65-
{
66-
errorBody = errorBody[..500] + "...";
67-
}
68-
}
69-
catch (Exception ex)
40+
var task = ctx.AddTask("Downloading", maxValue: total > 0 ? total : double.MaxValue);
41+
if (total <= 0)
7042
{
71-
if (_diagnostics != TextWriter.Null)
72-
{
73-
await _diagnostics.WriteLineAsync($"Failed to read error response body: {ex.Message}");
74-
}
43+
task.IsIndeterminate = true;
44+
task.Description = "Downloading (0 B)";
7545
}
7646

77-
var errorMessage = $"Request to {UrlRedactor.RedactUrl(uri)} failed with status {(int)response.StatusCode} ({response.ReasonPhrase}).";
78-
if (!string.IsNullOrWhiteSpace(errorBody))
47+
int bytesRead;
48+
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
7949
{
80-
errorMessage += $"\nServer response: {errorBody}";
81-
}
50+
ms.Write(buffer, 0, bytesRead);
51+
read += bytesRead;
8252

83-
throw new CliException(errorMessage, ExitCodes.NetworkError);
84-
}
53+
task.Increment(bytesRead);
8554

86-
var total = response.Content.Headers.ContentLength ?? -1L;
87-
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
88-
using var ms = new MemoryStream();
89-
var buffer = new byte[8192];
90-
long read = 0;
55+
if (total > 0)
56+
task.Value = read;
57+
else
58+
task.Description = $"Downloading ({FormatBytes(read)})";
59+
}
9160

92-
var result = string.Empty;
93-
await console.Progress()
94-
.AutoClear(true)
95-
.Columns(CreateColumns(total))
96-
.StartAsync(async ctx =>
97-
{
98-
var task = ctx.AddTask("Downloading", maxValue: total > 0 ? total : double.MaxValue);
99-
if (total <= 0)
100-
{
101-
task.IsIndeterminate = true;
102-
task.Description = "Downloading (0 B)";
103-
}
104-
105-
int bytesRead;
106-
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
107-
{
108-
ms.Write(buffer, 0, bytesRead);
109-
read += bytesRead;
110-
111-
task.Increment(bytesRead);
112-
113-
if (total > 0)
114-
{
115-
task.Value = read;
116-
}
117-
else
118-
{
119-
task.Description = $"Downloading ({FormatBytes(read)})";
120-
}
121-
}
122-
123-
task.StopTask();
124-
result = System.Text.Encoding.UTF8.GetString(ms.ToArray());
125-
});
126-
127-
return result;
128-
}
129-
catch (CliException)
130-
{
131-
throw;
132-
}
133-
catch (TaskCanceledException ex)
134-
{
135-
if (_diagnostics != TextWriter.Null)
136-
{
137-
await _diagnostics.WriteLineAsync($"Request timed out: {ex}");
138-
}
139-
140-
throw new CliException($"Request to {UrlRedactor.RedactUrl(uri)} timed out: {ex.Message}", ExitCodes.NetworkError);
141-
}
142-
catch (HttpRequestException ex)
143-
{
144-
if (_diagnostics != TextWriter.Null)
145-
{
146-
await _diagnostics.WriteLineAsync($"Request failed: {ex}");
147-
}
61+
task.StopTask();
62+
result = System.Text.Encoding.UTF8.GetString(ms.ToArray());
63+
});
14864

149-
throw new CliException($"Request to {UrlRedactor.RedactUrl(uri)} failed: {ex.Message}", ExitCodes.NetworkError);
150-
}
65+
return result;
15166
}
15267

153-
try
154-
{
155-
if (_diagnostics != TextWriter.Null)
156-
{
157-
await _diagnostics.WriteLineAsync($"Reading file {source}...");
158-
}
159-
160-
return await File.ReadAllTextAsync(source, cancellationToken);
161-
}
162-
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
163-
{
164-
throw new CliException($"Failed to read file {source}: {ex.Message}", ExitCodes.IoError);
165-
}
68+
return await _sourceFetcher.GetStringAsync(source, cancellationToken);
16669
}
16770

16871
private static ProgressColumn[] CreateColumns(long total)
@@ -190,9 +93,7 @@ private static ProgressColumn[] CreateColumns(long total)
19093
private static string FormatBytes(long bytes)
19194
{
19295
if (bytes < 1024)
193-
{
19496
return $"{bytes} B";
195-
}
19697

19798
string[] units = ["KB", "MB", "GB", "TB"];
19899
double value = bytes;

src/M3Undle.Cli/Commands/RunCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public async Task<int> ExecuteAsync(CommandContext context, CancellationToken ca
4141
{
4242
if (string.IsNullOrEmpty(context.PlaylistSource))
4343
{
44-
throw new CliException("Missing required: --playlist-url or --config with playlist", ExitCodes.ConfigError);
44+
throw new CoreException("Missing required: --playlist-url or --config with playlist", ExitCodes.ConfigError);
4545
}
4646

4747
var epgRequested = !string.IsNullOrEmpty(context.EpgSource);
@@ -50,7 +50,7 @@ public async Task<int> ExecuteAsync(CommandContext context, CancellationToken ca
5050

5151
if (epgRequested && string.IsNullOrEmpty(playlistOut) && string.IsNullOrEmpty(epgOut))
5252
{
53-
throw new CliException("When an EPG is requested you must provide --out-playlist, --out-epg, or use '-' for stdout.", ExitCodes.ConfigError);
53+
throw new CoreException("When an EPG is requested you must provide --out-playlist, --out-epg, or use '-' for stdout.", ExitCodes.ConfigError);
5454
}
5555

5656
var interactive = ShouldUseInteractiveConsole(playlistOut, epgOut, epgRequested);

src/M3Undle.Core/CliException.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/M3Undle.Core/Configuration/ConfigLoader.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,20 @@ public static class ConfigLoader
1414
{
1515
if (!File.Exists(configPath))
1616
{
17-
throw new CliException($"Config file not found: {configPath}", ExitCodes.ConfigError);
17+
throw new CoreException($"Config file not found: {configPath}", ExitCodes.ConfigError);
1818
}
1919

2020
var text = await File.ReadAllTextAsync(configPath, cancellationToken);
2121
if (string.IsNullOrWhiteSpace(text))
2222
{
23-
throw new CliException($"Config file is empty: {configPath}", ExitCodes.ConfigError);
23+
throw new CoreException($"Config file is empty: {configPath}", ExitCodes.ConfigError);
2424
}
2525

2626
var config = ParseConfiguration(configPath, text);
2727

2828
if (!config.Profiles.TryGetValue(profileName, out var profile))
2929
{
30-
throw new CliException($"Profile '{profileName}' not found in config.", ExitCodes.ConfigError);
30+
throw new CoreException($"Profile '{profileName}' not found in config.", ExitCodes.ConfigError);
3131
}
3232

3333
var configDir = Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? Environment.CurrentDirectory;
@@ -59,7 +59,7 @@ private static MediaConfig ParseYaml(string yaml)
5959
}
6060
catch (Exception ex)
6161
{
62-
throw new CliException($"Failed to parse YAML config: {ex.Message}", ExitCodes.ConfigError);
62+
throw new CoreException($"Failed to parse YAML config: {ex.Message}", ExitCodes.ConfigError);
6363
}
6464
}
6565

@@ -72,7 +72,7 @@ private static MediaConfig ParseJson(string json)
7272
}
7373
catch (Exception ex)
7474
{
75-
throw new CliException($"Failed to parse JSON config: {ex.Message}", ExitCodes.ConfigError);
75+
throw new CoreException($"Failed to parse JSON config: {ex.Message}", ExitCodes.ConfigError);
7676
}
7777
}
7878

src/M3Undle.Core/CoreException.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace M3Undle.Core;
2+
3+
public sealed class CoreException : Exception
4+
{
5+
public int ExitCode { get; }
6+
7+
public CoreException(string message, int exitCode) : base(message)
8+
{
9+
ExitCode = exitCode;
10+
}
11+
}

src/M3Undle.Core/Env/EnvFileLoader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public static IReadOnlyDictionary<string, string> LoadFromDirectory(string direc
4141
}
4242
catch (Exception ex)
4343
{
44-
throw new CliException($"Failed to read .env file: {ex.Message}", ExitCodes.ConfigError);
44+
throw new CoreException($"Failed to read .env file: {ex.Message}", ExitCodes.ConfigError);
4545
}
4646
}
4747
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
namespace M3Undle.Core.Epg;
2+
3+
/// <summary>
4+
/// Pre-built lookup index over a single EPG source's channel list.
5+
/// Build once per source catalogue, then call FindBestMatch for each candidate channel
6+
/// to avoid rebuilding lookup dictionaries on every call.
7+
/// </summary>
8+
public sealed class EpgChannelIndex
9+
{
10+
private readonly Dictionary<string, EpgChannelRecord> _byExactId;
11+
private readonly Dictionary<string, EpgChannelRecord> _byNormName;
12+
private readonly IReadOnlyList<EpgChannelRecord> _all;
13+
14+
public EpgChannelIndex(IReadOnlyList<EpgChannelRecord> channels)
15+
{
16+
ArgumentNullException.ThrowIfNull(channels);
17+
18+
_all = channels;
19+
_byExactId = channels
20+
.GroupBy(c => c.XmltvChannelId, StringComparer.OrdinalIgnoreCase)
21+
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
22+
_byNormName = channels
23+
.GroupBy(c => EpgChannelMatcher.NormalizeName(c.DisplayName), StringComparer.OrdinalIgnoreCase)
24+
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
25+
}
26+
27+
public EpgChannelMatch? FindBestMatch(EpgChannelMatchCandidate channel)
28+
{
29+
ArgumentNullException.ThrowIfNull(channel);
30+
return EpgChannelMatcher.FindBestMatch(channel, _byExactId, _byNormName, _all);
31+
}
32+
}

src/M3Undle.Core/Epg/EpgChannelMatcher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public static IReadOnlyList<string> Tokenize(string? value)
6161
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
6262
}
6363

64-
private static EpgChannelMatch? FindBestMatch(
64+
internal static EpgChannelMatch? FindBestMatch(
6565
EpgChannelMatchCandidate channel,
6666
Dictionary<string, EpgChannelRecord> byExactId,
6767
Dictionary<string, EpgChannelRecord> byNormName,

0 commit comments

Comments
 (0)