Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Subtitle Edit Changelog
A summary of the changes since 5.1.0:

New features:
* Add API-Route to Auto-translate - thx DennyHo0917
* Assisted split and Assisted move - ranked one-click suggestions with full previews
* Chapter editor with Matroska/MP4/OGM chapter formats (Video > More > Chapters)
* Edit original - edit the original reference rows in place, open a non-matching original as a read-only reference, and show the original subtitle on the waveform
Expand Down
7 changes: 7 additions & 0 deletions src/libse/Settings/ToolsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ public class ToolsSettings
public string AvalAiPrompt { get; set; }
public string AvalAiApiKey { get; set; }
public string AvalAiModel { get; set; }
public string ApiRouteUrl { get; set; }
public string ApiRoutePrompt { get; set; }
public string ApiRouteApiKey { get; set; }
public string ApiRouteModel { get; set; }

public string PerplexityUrl { get; set; }
public string PerplexityPrompt { get; set; }
Expand Down Expand Up @@ -167,6 +171,9 @@ public ToolsSettings()
AvalAiUrl = "https://api.avalai.ir/v1/chat/completions";
AvalAiPrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
AvalAiModel = "gpt-5.6-sol"; // AvalAi.Models[0] in LibUiLogic
ApiRouteUrl = "https://global.api-route.com/v1/chat/completions";
ApiRoutePrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
ApiRouteModel = "gpt-5.6-sol"; // ApiRouteTranslate.Models[0] in LibUiLogic; keep in sync with API-Route pricing
OpenRouterUrl = "https://openrouter.ai/api/v1/chat/completions";
OpenRouterPrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
OpenRouterModel = "openai/gpt-5.6-sol"; // OpenRouterTranslate.Models[0] in LibUiLogic
Expand Down
163 changes: 163 additions & 0 deletions src/libuilogic/AutoTranslate/ApiRouteTranslate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using Nikse.SubtitleEdit.Core.Common;
using Nikse.SubtitleEdit.Core.Settings;
using Nikse.SubtitleEdit.Core.SubtitleFormats;
using Nikse.SubtitleEdit.UiLogic.Translate;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Nikse.SubtitleEdit.UiLogic.Http;

namespace Nikse.SubtitleEdit.UiLogic.AutoTranslate
{
public class ApiRouteTranslate : IAutoTranslator, IDisposable
{
private HttpClient _httpClient = null!;

public static string StaticName { get; set; } = "API-Route";
public override string ToString() => StaticName;
public string Name => StaticName;
public string Url => "https://www.api-route.com";
public string Error { get; set; } = string.Empty;
public int MaxCharacters => 1500;

/// <summary>
/// See https://www.api-route.com
/// </summary>
/// <summary>
/// Current model suggestions from API-Route's pricing catalogue. The service documents
/// that availability changes over time, so the UI keeps the model field editable and this
/// list must not be treated as an exhaustive capability contract.
/// </summary>
public static string[] Models =>
[
"gpt-5.6-sol",
"claude-sonnet-4-6",
"gemini-3.1-pro",
"deepseek-v4-pro",
"grok-4.5",
"qwen/qwen3.5-plus-20260420",
];

/// <summary>
/// Endpoint used when the url in settings is only the service base - see <see cref="AutoTranslateUrl"/>.
/// </summary>
public const string DefaultUrl = "https://global.api-route.com/v1/chat/completions";

public void Initialize()
{
_httpClient?.Dispose();
_httpClient = HttpClientFactoryWithProxy.CreateHttpClientWithProxy();
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("accept", "application/json");
_httpClient.BaseAddress = new Uri(AutoTranslateUrl.Complete(Configuration.Settings.Tools.ApiRouteUrl, DefaultUrl));
_httpClient.Timeout = TimeSpan.FromMinutes(15);

if (!string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRouteApiKey))
{
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "Bearer " + Configuration.Settings.Tools.ApiRouteApiKey);
}
}

public List<TranslationPair> GetSupportedSourceLanguages()
{
return ListLanguages();
}

public List<TranslationPair> GetSupportedTargetLanguages()
{
return ListLanguages();
}

public async Task<string> Translate(string text, string sourceLanguageCode, string targetLanguageCode, CancellationToken cancellationToken)
{
var model = Configuration.Settings.Tools.ApiRouteModel;
if (string.IsNullOrEmpty(model))
{
model = Models[0];
Configuration.Settings.Tools.ApiRouteModel = model;
}

if (string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRoutePrompt))
{
Configuration.Settings.Tools.ApiRoutePrompt = new ToolsSettings().ApiRoutePrompt;
}
var prompt = string.Format(Configuration.Settings.Tools.ApiRoutePrompt, sourceLanguageCode, targetLanguageCode);
var input = BuildRequestBody(model, prompt, text);

int[] retryDelays = { 2555, 5007, 9013 };
HttpResponseMessage result = null!;
var json = string.Empty;
for (var attempt = 0; attempt <= retryDelays.Length; attempt++)
{
var content = new StringContent(input, Encoding.UTF8);
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
result = await _httpClient.PostAsync(string.Empty, content, cancellationToken);
var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken);
json = Encoding.UTF8.GetString(bytes).Trim();

if (!DeepLTranslate.ShouldRetry(result, json) || attempt == retryDelays.Length)
{
break;
}

await Task.Delay(retryDelays[attempt], cancellationToken);
}

if (!result.IsSuccessStatusCode)
{
Error = json;
SeLogger.Error("API-Route Translate failed calling API: Status code=" + result.StatusCode + Environment.NewLine + json);
}

result.EnsureSuccessStatusCode();

var parser = new SeJsonParser();
var resultText = parser.GetFirstObject(json, "content");
if (resultText == null)
{
return string.Empty;
}

var outputText = Json.DecodeJsonText(resultText).Trim();
if (outputText.StartsWith('"') && outputText.EndsWith('"') && !text.StartsWith('"'))
{
outputText = outputText.Trim('"').Trim();
}

outputText = ChatGptTranslate.FixNewLines(outputText);
outputText = ChatGptTranslate.RemovePreamble(text, outputText);
outputText = ChatGptTranslate.DecodeUnicodeEscapes(outputText);
return outputText.Trim();
}

internal static string BuildRequestBody(string model, string prompt, string text)
{
return System.Text.Json.JsonSerializer.Serialize(new
{
model,
messages = new[]
{
new
{
role = "user",
content = prompt + "\n\n" + text.Trim(),
},
},
});
}

public static List<TranslationPair> ListLanguages()
{
return ChatGptTranslate.ListLanguages();
}

public void Dispose()
{
_httpClient?.Dispose();
}
}
}
38 changes: 38 additions & 0 deletions src/ui/Features/Translate/AutoTranslateViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ public AutoTranslateViewModel(IWindowService windowService, IFolderHelper folder
new AnthropicTranslate(),
new GroqTranslate(),
new OpenRouterTranslate(),
new ApiRouteTranslate(),
new LaraTranslate(),
new PerplexityTranslate(),
new GeminiTranslate(),
Expand Down Expand Up @@ -214,6 +215,11 @@ private void LoadSettings()
// the url box and the translator fell back to the built-in endpoint on the next start.
Configuration.Settings.Tools.OpenRouterUrl = Se.Settings.AutoTranslate.OpenRouterUrl;

Configuration.Settings.Tools.ApiRouteApiKey = Se.Settings.AutoTranslate.ApiRouteApiKey;
Configuration.Settings.Tools.ApiRouteUrl = Se.Settings.AutoTranslate.ApiRouteUrl;
Configuration.Settings.Tools.ApiRouteModel = Se.Settings.AutoTranslate.ApiRouteModel;
Configuration.Settings.Tools.ApiRoutePrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;

Configuration.Settings.Tools.ChatGptApiKey = Se.Settings.AutoTranslate.ChatGptApiKey;
Configuration.Settings.Tools.ChatGptUrl = Se.Settings.AutoTranslate.ChatGptUrl;
Configuration.Settings.Tools.ChatGptModel = Se.Settings.AutoTranslate.ChatGptModel;
Expand Down Expand Up @@ -468,6 +474,14 @@ public void SaveSettings()
Se.Settings.AutoTranslate.OpenRouterUrl = apiUrl.Trim();
}

if (engineType == typeof(ApiRouteTranslate))
{
Configuration.Settings.Tools.ApiRouteApiKey = apiKey.Trim();
Configuration.Settings.Tools.ApiRouteModel = apiModel.Trim();
Configuration.Settings.Tools.ApiRouteUrl = apiUrl.Trim();
Se.Settings.AutoTranslate.ApiRouteUrl = apiUrl.Trim();
}

if (engineType == typeof(GeminiTranslate))
{
Configuration.Settings.Tools.GeminiProApiKey = apiKey.Trim();
Expand Down Expand Up @@ -520,6 +534,11 @@ public void SaveSettings()
Se.Settings.AutoTranslate.OpenRouterModel = Configuration.Settings.Tools.OpenRouterModel;
Se.Settings.AutoTranslate.OpenRouterPrompt = Configuration.Settings.Tools.OpenRouterPrompt;

Se.Settings.AutoTranslate.ApiRouteApiKey = Configuration.Settings.Tools.ApiRouteApiKey;
Se.Settings.AutoTranslate.ApiRouteUrl = Configuration.Settings.Tools.ApiRouteUrl;
Se.Settings.AutoTranslate.ApiRouteModel = Configuration.Settings.Tools.ApiRouteModel;
Se.Settings.AutoTranslate.ApiRoutePrompt = Configuration.Settings.Tools.ApiRoutePrompt;

Se.Settings.AutoTranslate.ChatGptApiKey = Configuration.Settings.Tools.ChatGptApiKey;
Se.Settings.AutoTranslate.ChatGptUrl = Configuration.Settings.Tools.ChatGptUrl;
Se.Settings.AutoTranslate.ChatGptModel = Configuration.Settings.Tools.ChatGptModel;
Expand Down Expand Up @@ -1751,6 +1770,7 @@ private static string GetConfiguredApiUrl(IAutoTranslator translator)
AnthropicTranslate => settings.AnthropicApiUrl,
GroqTranslate => settings.GroqUrl,
OpenRouterTranslate => settings.OpenRouterUrl,
ApiRouteTranslate => settings.ApiRouteUrl,
LaraTranslate => settings.LaraUrl,
PerplexityTranslate => settings.PerplexityUrl,
NvidiaTranslate => settings.NvidiaUrl,
Expand Down Expand Up @@ -2294,6 +2314,24 @@ private void SetAutoTranslatorEngine(IAutoTranslator translator)
return;
}

if (engineType == typeof(ApiRouteTranslate))
{
FillUrls(new List<string>
{
Configuration.Settings.Tools.ApiRouteUrl,
});

ApiKeyText = Configuration.Settings.Tools.ApiRouteApiKey;
ApiKeyIsVisible = true;

_apiModels = ApiRouteTranslate.Models.ToList();
ModelIsVisible = true;
ButtonModelIsVisible = true;
ModelText = string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRouteModel) ? _apiModels[0] : Configuration.Settings.Tools.ApiRouteModel;

return;
}

if (engineType == typeof(GeminiTranslate))
{
ApiKeyText = Configuration.Settings.Tools.GeminiProApiKey;
Expand Down
11 changes: 11 additions & 0 deletions src/ui/Features/Translate/TranslateSettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ private async Task Ok()
engineType == typeof(PerplexityTranslate) ||
engineType == typeof(GroqTranslate) ||
engineType == typeof(OpenRouterTranslate) ||
engineType == typeof(ApiRouteTranslate) ||
engineType == typeof(NvidiaTranslate) ||
engineType == typeof(MistralTranslate) ||
engineType == typeof(GeminiTranslate) ||
Expand Down Expand Up @@ -167,6 +168,11 @@ public void SaveValues()
Se.Settings.AutoTranslate.OpenRouterPrompt = PromptText;
Configuration.Settings.Tools.OpenRouterPrompt = PromptText;
}
else if (engineType == typeof(ApiRouteTranslate))
{
Se.Settings.AutoTranslate.ApiRoutePrompt = PromptText;
Configuration.Settings.Tools.ApiRoutePrompt = PromptText;
}
else if (engineType == typeof(NvidiaTranslate))
{
Se.Settings.AutoTranslate.NvidiaPrompt = PromptText;
Expand Down Expand Up @@ -280,6 +286,11 @@ public void LoadValues(IAutoTranslator translator)
return settings.OpenRouterPrompt;
}

if (engineType == typeof(ApiRouteTranslate))
{
return settings.ApiRoutePrompt;
}

if (engineType == typeof(NvidiaTranslate))
{
return settings.NvidiaPrompt;
Expand Down
8 changes: 8 additions & 0 deletions src/ui/Logic/Config/SeAutoTranslate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ public class SeAutoTranslate
public string AvalAiPrompt { get; set; }
public string AvalAiApiKey { get; set; }
public string AvalAiModel { get; set; }
public string ApiRouteUrl { get; set; }
public string ApiRoutePrompt { get; set; }
public string ApiRouteApiKey { get; set; }
public string ApiRouteModel { get; set; }

public string PerplexityUrl { get; set; }
public string PerplexityPrompt { get; set; }
Expand Down Expand Up @@ -127,6 +131,10 @@ public SeAutoTranslate()
AvalAiModel = AvalAi.Models[0];
AvalAiPrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments:";
AvalAiUrl = AvalAi.DefaultUrl;
ApiRouteApiKey = string.Empty;
ApiRouteModel = ApiRouteTranslate.Models[0];
ApiRoutePrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
ApiRouteUrl = ApiRouteTranslate.DefaultUrl;
PerplexityApiKey = string.Empty;
PerplexityModel = PerplexityTranslate.Models[0];
PerplexityPrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments:";
Expand Down
4 changes: 4 additions & 0 deletions src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ public static void ApplyAutoTranslate(Se4SettingsFile file)
yield return ("AvalAiPrompt", v => t.AvalAiPrompt = v);
yield return ("AvalAiApiKey", v => t.AvalAiApiKey = v);
yield return ("AvalAiModel", v => t.AvalAiModel = v);
yield return ("ApiRouteUrl", v => t.ApiRouteUrl = v);
yield return ("ApiRoutePrompt", v => t.ApiRoutePrompt = v);
yield return ("ApiRouteApiKey", v => t.ApiRouteApiKey = v);
yield return ("ApiRouteModel", v => t.ApiRouteModel = v);
yield return ("OpenRouterUrl", v => t.OpenRouterUrl = v);
yield return ("OpenRouterPrompt", v => t.OpenRouterPrompt = v);
yield return ("OpenRouterApiKey", v => t.OpenRouterApiKey = v);
Expand Down
36 changes: 36 additions & 0 deletions tests/UI/Features/TranslateSettingsResetPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Nikse.SubtitleEdit.Core.Common;
using Nikse.SubtitleEdit.Features.Translate;
using Nikse.SubtitleEdit.Logic.Config;
using Nikse.SubtitleEdit.UiLogic.AutoTranslate;
Expand Down Expand Up @@ -99,4 +100,39 @@ public void TranslateSettingsWindow_HidesResetPromptButtonForEngineWithoutPrompt
Assert.NotNull(button);
Assert.False(button!.IsEffectivelyVisible);
}
[AvaloniaFact]
public void ApiRoutePrompt_IsVisibleResettableAndSaved()
{
var savedPrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;
var savedCorePrompt = Configuration.Settings.Tools.ApiRoutePrompt;
try
{
Se.Settings.AutoTranslate.ApiRoutePrompt = "My API-Route prompt from {0} to {1}";
Configuration.Settings.Tools.ApiRoutePrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;

var vm = new TranslateSettingsViewModel();
vm.LoadValues(new ApiRouteTranslate());

Assert.True(vm.PromptIsVisible);
Assert.Equal("My API-Route prompt from {0} to {1}", vm.PromptText);

vm.ResetPromptCommand.Execute(null);

var builtIn = new SeAutoTranslate().ApiRoutePrompt;
Assert.Contains("keep line breaks exactly the same", builtIn);
Assert.Equal(builtIn, vm.PromptText);

vm.PromptText = "Saved API-Route prompt from {0} to {1}";
vm.SaveValues();

Assert.Equal(vm.PromptText, Se.Settings.AutoTranslate.ApiRoutePrompt);
Assert.Equal(vm.PromptText, Configuration.Settings.Tools.ApiRoutePrompt);
}
finally
{
Se.Settings.AutoTranslate.ApiRoutePrompt = savedPrompt;
Configuration.Settings.Tools.ApiRoutePrompt = savedCorePrompt;
}
}

}
Loading
Loading