feat: add API key color assignment, auto-persist, AGENTS.md
This commit is contained in:
@@ -14,6 +14,7 @@ using System.Collections;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.IO;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace URLNotesGrabberCORE
|
||||
{
|
||||
@@ -1556,6 +1557,8 @@ namespace URLNotesGrabberCORE
|
||||
public string OAuthToken { get; set; } = string.Empty;
|
||||
public string OAuthTokenSecret { get; set; } = string.Empty;
|
||||
public bool PoolEnabled { get; set; }
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
public ConsoleColor ParsedColor { get; set; } = ConsoleColor.White;
|
||||
}
|
||||
|
||||
internal class ApiKeyPool
|
||||
@@ -1565,13 +1568,22 @@ namespace URLNotesGrabberCORE
|
||||
private static bool _usePool = false;
|
||||
private static int _currentIndex = 0;
|
||||
private static string _dbPath = string.Empty;
|
||||
private static ApiKeyConfig? _activeKey = null;
|
||||
private static readonly string[] DefaultAutoColors =
|
||||
{
|
||||
"Cyan", "Yellow", "Green", "Magenta", "Blue", "Red",
|
||||
"DarkCyan", "DarkYellow", "DarkGreen", "DarkMagenta"
|
||||
};
|
||||
private static int _nextAutoColorIndex = 0;
|
||||
private static string _configFilePath = string.Empty;
|
||||
|
||||
public static bool IsPoolActive => _usePool;
|
||||
public static List<ApiKeyConfig> Keys => _keys;
|
||||
|
||||
public static void Initialize(IConfiguration config, string? dbPath, string? overrideSection = null)
|
||||
public static void Initialize(IConfiguration config, string? dbPath, string configFilePath, string? overrideSection = null)
|
||||
{
|
||||
_dbPath = dbPath ?? "..\\..\\..\\tl.db";
|
||||
_configFilePath = configFilePath;
|
||||
EnsureStateTableExists();
|
||||
|
||||
if (!string.IsNullOrEmpty(overrideSection))
|
||||
@@ -1580,6 +1592,9 @@ namespace URLNotesGrabberCORE
|
||||
if (section["ConsumerKey"] == null)
|
||||
throw new InvalidOperationException($"API section '{overrideSection}' not found in appsettings.json");
|
||||
|
||||
var colorName = section["Color"] ?? string.Empty;
|
||||
var parsedColor = ParseColor(colorName);
|
||||
|
||||
_overrideKey = new ApiKeyConfig
|
||||
{
|
||||
SectionName = overrideSection,
|
||||
@@ -1588,10 +1603,12 @@ namespace URLNotesGrabberCORE
|
||||
ConsumerSecret = section["ConsumerSecret"]!,
|
||||
OAuthToken = section["OAuthToken"]!,
|
||||
OAuthTokenSecret = section["OAuthTokenSecret"]!,
|
||||
PoolEnabled = true
|
||||
PoolEnabled = true,
|
||||
ColorName = colorName,
|
||||
ParsedColor = parsedColor
|
||||
};
|
||||
_usePool = false;
|
||||
Console.WriteLine($"[Pool] Single-key mode: {overrideSection}");
|
||||
Console.WriteLine($"[Pool] Single-key mode: {overrideSection} (Color: {_overrideKey.ParsedColor})");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1601,6 +1618,8 @@ namespace URLNotesGrabberCORE
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var autoAssignedColors = new List<(string sectionName, string color)>();
|
||||
|
||||
int keyNum = 1;
|
||||
foreach (var sectionName in root.OrderBy(s => s))
|
||||
{
|
||||
@@ -1610,6 +1629,16 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
if (poolEnabled)
|
||||
{
|
||||
var colorName = section["Color"] ?? string.Empty;
|
||||
var parsedColor = ParseColor(colorName);
|
||||
|
||||
if (string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
colorName = GetNextAutoColor();
|
||||
parsedColor = ParseColor(colorName);
|
||||
autoAssignedColors.Add((sectionName, colorName));
|
||||
}
|
||||
|
||||
_keys.Add(new ApiKeyConfig
|
||||
{
|
||||
SectionName = sectionName,
|
||||
@@ -1618,7 +1647,9 @@ namespace URLNotesGrabberCORE
|
||||
ConsumerSecret = section["ConsumerSecret"]!,
|
||||
OAuthToken = section["OAuthToken"]!,
|
||||
OAuthTokenSecret = section["OAuthTokenSecret"]!,
|
||||
PoolEnabled = true
|
||||
PoolEnabled = true,
|
||||
ColorName = colorName,
|
||||
ParsedColor = parsedColor
|
||||
});
|
||||
keyNum++;
|
||||
}
|
||||
@@ -1629,6 +1660,16 @@ namespace URLNotesGrabberCORE
|
||||
var fallback = config.GetSection("TumblrApi");
|
||||
if (fallback["ConsumerKey"] != null)
|
||||
{
|
||||
var colorName = fallback["Color"] ?? string.Empty;
|
||||
var parsedColor = ParseColor(colorName);
|
||||
|
||||
if (string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
colorName = GetNextAutoColor();
|
||||
parsedColor = ParseColor(colorName);
|
||||
autoAssignedColors.Add(("TumblrApi", colorName));
|
||||
}
|
||||
|
||||
_keys.Add(new ApiKeyConfig
|
||||
{
|
||||
SectionName = "TumblrApi",
|
||||
@@ -1637,19 +1678,92 @@ namespace URLNotesGrabberCORE
|
||||
ConsumerSecret = fallback["ConsumerSecret"]!,
|
||||
OAuthToken = fallback["OAuthToken"]!,
|
||||
OAuthTokenSecret = fallback["OAuthTokenSecret"]!,
|
||||
PoolEnabled = true
|
||||
PoolEnabled = true,
|
||||
ColorName = colorName,
|
||||
ParsedColor = parsedColor
|
||||
});
|
||||
Console.WriteLine("[Pool] No keys with PoolEnabled, falling back to TumblrApi");
|
||||
}
|
||||
}
|
||||
|
||||
if (autoAssignedColors.Count > 0)
|
||||
{
|
||||
PersistAutoAssignedColors(autoAssignedColors);
|
||||
}
|
||||
|
||||
_usePool = true;
|
||||
LoadState();
|
||||
|
||||
var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName}"));
|
||||
var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName} (Color: {k.ParsedColor})"));
|
||||
Console.WriteLine($"[Pool] Active keys: {mapping}");
|
||||
}
|
||||
|
||||
private static string GetNextAutoColor()
|
||||
{
|
||||
if (_nextAutoColorIndex < DefaultAutoColors.Length)
|
||||
return DefaultAutoColors[_nextAutoColorIndex++];
|
||||
return "White";
|
||||
}
|
||||
|
||||
private static ConsoleColor ParseColor(string colorName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(colorName))
|
||||
return ConsoleColor.White;
|
||||
if (Enum.TryParse<ConsoleColor>(colorName, out var color))
|
||||
return color;
|
||||
Console.WriteLine($"[Pool] Warning: Invalid color '{colorName}', using White");
|
||||
return ConsoleColor.White;
|
||||
}
|
||||
|
||||
private static void PersistAutoAssignedColors(List<(string sectionName, string color)> assignments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_configFilePath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||
|
||||
writer.WriteStartObject();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
writer.WritePropertyName(prop.Name);
|
||||
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var subProp in prop.Value.EnumerateObject())
|
||||
{
|
||||
writer.WritePropertyName(subProp.Name);
|
||||
subProp.Value.WriteTo(writer);
|
||||
}
|
||||
|
||||
var match = assignments.FirstOrDefault(a => a.sectionName == prop.Name);
|
||||
if (!string.IsNullOrEmpty(match.sectionName))
|
||||
{
|
||||
writer.WriteString("Color", match.color);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
else
|
||||
{
|
||||
prop.Value.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
File.WriteAllText(_configFilePath, Encoding.UTF8.GetString(stream.ToArray()));
|
||||
Console.WriteLine($"[Pool] Auto-assigned colors persisted to config: {string.Join(", ", assignments.Select(a => $"{a.sectionName}={a.color}"))}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Pool] Warning: Could not persist auto-assigned colors to config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureStateTableExists()
|
||||
{
|
||||
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
|
||||
@@ -1685,11 +1799,17 @@ namespace URLNotesGrabberCORE
|
||||
if (retryTs > now)
|
||||
{
|
||||
var remaining = retryTs - now;
|
||||
var prevColor = Console.ForegroundColor;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, clears in {remaining}s");
|
||||
Console.ForegroundColor = prevColor;
|
||||
}
|
||||
else if (retryTs > 0)
|
||||
{
|
||||
var prevColor = Console.ForegroundColor;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared on startup");
|
||||
Console.ForegroundColor = prevColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1697,39 +1817,47 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
public static ApiKeyConfig GetCurrentKey()
|
||||
{
|
||||
ApiKeyConfig key;
|
||||
|
||||
if (!_usePool || _overrideKey != null)
|
||||
return _overrideKey!;
|
||||
|
||||
if (_keys.Count == 1)
|
||||
return _keys[0];
|
||||
|
||||
int attempts = 0;
|
||||
|
||||
while (attempts < _keys.Count)
|
||||
key = _overrideKey!;
|
||||
else if (_keys.Count == 1)
|
||||
key = _keys[0];
|
||||
else
|
||||
{
|
||||
var key = _keys[_currentIndex % _keys.Count];
|
||||
_currentIndex = (_currentIndex + 1) % _keys.Count;
|
||||
int attempts = 0;
|
||||
|
||||
if (IsKeyAvailable(key))
|
||||
while (attempts < _keys.Count)
|
||||
{
|
||||
SaveState();
|
||||
return key;
|
||||
key = _keys[_currentIndex % _keys.Count];
|
||||
_currentIndex = (_currentIndex + 1) % _keys.Count;
|
||||
|
||||
if (IsKeyAvailable(key))
|
||||
{
|
||||
SaveState();
|
||||
_activeKey = key;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
return key;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
var earliest = _keys
|
||||
.Select(k => new { Key = k, RetryUntil = GetRetryUntil(k) })
|
||||
.OrderBy(x => x.RetryUntil)
|
||||
.First();
|
||||
|
||||
Console.WriteLine($"[Pool] All keys rate-limited, using earliest: Key#{earliest.Key.KeyNumber} ({earliest.Key.SectionName}, clears in {Math.Max(0, earliest.RetryUntil - DateTimeOffset.UtcNow.ToUnixTimeSeconds())}s)");
|
||||
|
||||
key = earliest.Key;
|
||||
_currentIndex = (_keys.IndexOf(key) + 1) % _keys.Count;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
var earliest = _keys
|
||||
.Select(k => new { Key = k, RetryUntil = GetRetryUntil(k) })
|
||||
.OrderBy(x => x.RetryUntil)
|
||||
.First();
|
||||
|
||||
Console.WriteLine($"[Pool] All keys rate-limited, using earliest: Key#{earliest.Key.KeyNumber} ({earliest.Key.SectionName}, clears in {Math.Max(0, earliest.RetryUntil - DateTimeOffset.UtcNow.ToUnixTimeSeconds())}s)");
|
||||
|
||||
var chosen = earliest.Key;
|
||||
_currentIndex = (_keys.IndexOf(chosen) + 1) % _keys.Count;
|
||||
SaveState();
|
||||
return chosen;
|
||||
_activeKey = key;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
return key;
|
||||
}
|
||||
|
||||
private static bool IsKeyAvailable(ApiKeyConfig key)
|
||||
@@ -1760,7 +1888,10 @@ namespace URLNotesGrabberCORE
|
||||
cmd.Parameters.AddWithValue("@until", retryUntil);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
var prevColor = Console.ForegroundColor;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, retry in {retryInSeconds}s (until {DateTimeOffset.FromUnixTimeSeconds(retryUntil).LocalDateTime:HH:mm:ss})");
|
||||
Console.ForegroundColor = prevColor;
|
||||
}
|
||||
|
||||
public static void MarkAvailable(ApiKeyConfig key)
|
||||
@@ -1771,6 +1902,11 @@ namespace URLNotesGrabberCORE
|
||||
"INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, 0)", conn);
|
||||
cmd.Parameters.AddWithValue("@name", key.SectionName);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
var prevColor = Console.ForegroundColor;
|
||||
Console.ForegroundColor = key.ParsedColor;
|
||||
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared");
|
||||
Console.ForegroundColor = prevColor;
|
||||
}
|
||||
|
||||
public static bool IsAllRateLimited(out int minRetrySeconds)
|
||||
|
||||
Reference in New Issue
Block a user