Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2978,25 +2978,30 @@ class StreamRepository @Inject constructor(
onPendingAddons: ((List<String>) -> Unit)? = null
): List<Subtitle> = withContext(Dispatchers.IO) {
val allAddons = installedAddons.first()
// Include:
// - Addons classified as AddonType.SUBTITLE (OpenSubtitles and, going forward,
// any user-added pure-subtitle addon like Wizdom/Ktuvit now that addCustomAddon
// classifies them correctly).
// - Addons classified as CUSTOM whose manifest declares a `subtitles` resource.
// This covers two cases: (a) addons installed before the classification fix
// landed, which are still stored as CUSTOM; (b) hybrid addons that provide
// both streams and subtitles. Both should be queried for subtitles.
// Selection is by CAPABILITY, not by AddonType. The old gate accepted only SUBTITLE, or
// CUSTOM-with-a-`subtitles`-manifest, and silently dropped everything else — which made
// whole addons invisible depending on *how they were installed*:
// - the web installer stamps type=COMMUNITY on anything that isn't subtitle-only
// (web/lib/addons.ts), and COMMUNITY was never queried at all;
// - a cloud/legacy payload with no cached manifest parses back as CUSTOM + manifest=null
// (parseAddons defaults the type), which failed the manifest check.
// Either way the symptom is identical and confusing: the addon is installed and enabled,
// shows in the addon list, and yet contributes zero subtitles — while OpenSubtitles
// (hardcoded SUBTITLE) keeps working, so it looks like "only OpenSubtitles loads".
// Fixes issue #80.
val speculativeAddonIds = mutableSetOf<String>()
val subtitleAddons = allAddons.filter { addon ->
if (!addon.isInstalled || !addon.isEnabled) return@filter false
if (addon.type == AddonType.SUBTITLE) return@filter true
if (addon.type == AddonType.CUSTOM) {
val declaresSubtitles = addon.manifest?.resources?.any { res ->
res.name.equals("subtitles", ignoreCase = true)
} == true
return@filter declaresSubtitles
val declared = addon.manifest?.resources.orEmpty()
if (declared.isNotEmpty()) {
return@filter declared.any { res -> res.name.equals("subtitles", ignoreCase = true) }
}
false
// No manifest cached — capability unknown. Ask anyway (a stream-only addon just 404s
// or returns an empty list) rather than dropping a possibly-working subtitle provider.
// These are marked speculative so they don't pay the cold-start retry below.
speculativeAddonIds += addon.id
addon.type == AddonType.CUSTOM || addon.type == AddonType.COMMUNITY
}

val videoHash = stream?.behaviorHints?.videoHash?.trim().takeUnless { it.isNullOrBlank() }
Expand Down Expand Up @@ -3052,8 +3057,10 @@ class StreamRepository @Inject constructor(

// Aggregator endpoints (AIOStreams) often hang or 502 on the first (cold) hit and
// succeed once their upstream fan-out is warm — one retry recovers those.
// Speculative addons (queried without a manifest, capability unknown) are expected
// to come back empty, so they don't get to spend another 1.5s proving it.
var subs = attempt()
if (subs.isEmpty()) {
if (subs.isEmpty() && addon.id !in speculativeAddonIds) {
delay(1_500)
subs = attempt()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.arflix.tv.ui.screens.player

// Both names are historical: the enum value is persisted in DataStore and cloud backups, so
// renaming either one breaks restore on other devices. They mean "the Groq model" and "the Gemini
// model" — the actual model ids live in SubtitleTranslationService.
enum class SubtitleAiModel {
// Now maps to openai/gpt-oss-120b (llama-3.3-70b decommissioned August 2026).
GROQ_LLAMA_70B,
// Now maps to gemini-3.5-flash-lite (2.5 retired July 2026). Name kept: the enum value is
// persisted in DataStore and cloud backups — renaming breaks restore on other devices.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@ private val BLOCKED_FINISH_REASONS = setOf(
"PROHIBITED_CONTENT", "SAFETY", "RECITATION", "BLOCKLIST", "SPII"
)

private const val GROQ_MODEL_ID = "llama-3.3-70b-versatile"
// llama-3.3-70b-versatile was decommissioned by Groq (August 16, 2026). gpt-oss-120b is Groq's
// recommended replacement: ~500 tok/s (vs 280), same 30 RPM free tier, 2x the daily token budget.
// It is a reasoning model — see GROQ_REASONING_EFFORT below.
private const val GROQ_MODEL_ID = "openai/gpt-oss-120b"
private const val GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
// gpt-oss cannot disable reasoning entirely (unlike Gemini's thinkingLevel=minimal or Qwen's
// reasoning_effort=none) — "low" is the floor. Anything higher spends seconds of thinking on what
// is a mechanical translation, which subtitles can't wait for. "hidden" keeps the chain-of-thought
// out of message.content so extractJsonArray() doesn't have to parse around it.
private const val GROQ_REASONING_EFFORT = "low"
private const val GROQ_REASONING_FORMAT = "hidden"
// gemini-2.5-flash was retired by Google (HTTP 404 "no longer available", July 2026).
// gemini-3.5-flash-lite: ~2x throughput (350 vs 165 tok/s) and ~3.5x cheaper than 3.5-flash,
// positioned by Google for high-volume translation. Same v1beta API + thinkingLevel field.
Expand Down Expand Up @@ -172,6 +181,8 @@ class SubtitleTranslationService(
val body = JSONObject().apply {
put("model", GROQ_MODEL_ID)
put("temperature", 0.1)
put("reasoning_effort", GROQ_REASONING_EFFORT)
put("reasoning_format", GROQ_REASONING_FORMAT)
put("messages", JSONArray().apply {
put(JSONObject().apply {
put("role", "system")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4102,7 +4102,7 @@ private fun MobileSettingsSubPage(
title = stringResource(R.string.ai_model_title),
subtitle = stringResource(R.string.ai_model_desc),
value = when (uiState.subtitleAiModel) {
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq - Llama 3.3 70B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq - GPT-OSS 120B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google - Gemini 3.5 Flash Lite"
},
isFocused = false,
Expand Down Expand Up @@ -5397,7 +5397,7 @@ private fun TvGeneralSettingsRows(
title = stringResource(R.string.ai_model_title),
subtitle = stringResource(R.string.ai_model_desc),
value = when (subtitleAiModel) {
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq - Llama 3.3 70B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq - GPT-OSS 120B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google - Gemini 3.5 Flash Lite"
},
isFocused = focusedIndex == localIndex,
Expand Down Expand Up @@ -5863,7 +5863,7 @@ private fun GeneralSettings(
title = stringResource(R.string.ai_model_title),
subtitle = stringResource(R.string.ai_model_desc),
value = when (subtitleAiModel) {
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – Llama 3.3 70B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – GPT-OSS 120B"
com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google – Gemini 3.5 Flash Lite"
},
isFocused = focusedIndex == 29,
Expand Down Expand Up @@ -5945,7 +5945,7 @@ private fun AiModelDialog(
) {
val isMobile = LocalDeviceType.current.isTouchDevice()
val options = listOf(
Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – Llama 3.3 70B", stringResource(R.string.ai_groq_model_note)),
Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – GPT-OSS 120B", stringResource(R.string.ai_groq_model_note)),
Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25, "Google – Gemini 3.5 Flash Lite", stringResource(R.string.ai_gemini_model_note))
)
BackHandler { onDismiss() }
Expand Down
2 changes: 1 addition & 1 deletion docs/subtitle-auto-match.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ context.
| Auto Find Best Subtitle Match | `subtitle_ai_find_best_match` | `false` | Auto-runs the scan on playback. **AI-independent** (works with no API key). Off by default — users opt in. Key name kept for backward compat. |
| AI subtitle translation (master) | `subtitle_ai_enabled` | `false` | Enables AI features: translation option in menu, AI interim during scans, hearing fallback. |
| Auto-Select AI Translation | `subtitle_ai_auto_select` | `false` | Allows AI translation to activate **automatically** (incl. as the scan's on-screen interim). |
| Model | `subtitle_ai_model` | `GROQ_LLAMA_70B` | Groq or Gemini for batch translation. **Hearing requires Gemini.** |
| Model | `subtitle_ai_model` | `GROQ_LLAMA_70B` | Groq (`openai/gpt-oss-120b` — the enum name is historical) or Gemini for batch translation. **Hearing requires Gemini.** |
| API key | `subtitle_ai_api_key` (global) | — | One key used for both batch translation and Gemini Live. |

All of the above (except profile-scoped language) sync via `CloudSyncRepository`
Expand Down
2 changes: 1 addition & 1 deletion netlify-arvio-tv-site/companion/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ async function renderAI() {
<label class="model-card ${model==='GROQ_LLAMA_70B'?'selected':''}">
<input type="radio" name="ai-model" value="GROQ_LLAMA_70B" ${model==='GROQ_LLAMA_70B'?'checked':''} onchange="updateAISetting('subtitleAiModel',this.value)">
<div>
<div class="model-name">⚡ Groq Llama 70B <span class="badge badge-gold" style="font-size:10px">${t('ai_recommended')}</span></div>
<div class="model-name">⚡ Groq GPT-OSS 120B <span class="badge badge-gold" style="font-size:10px">${t('ai_recommended')}</span></div>
<div class="model-desc">${t('ai_groq_desc')}</div>
</div>
</label>
Expand Down
2 changes: 1 addition & 1 deletion netlify-arvio-tv-site/companion/mock-preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ <h2 class="sub">Scrapers</h2>
<h2 class="sub">Select AI Model</h2>
<div style="max-width:560px">
<div class="model-option selected">
<div class="model-name">Groq — Llama 3.3 70B <span class="badge green" style="margin-right:8px">Recommended</span></div>
<div class="model-name">Groq — GPT-OSS 120B <span class="badge green" style="margin-right:8px">Recommended</span></div>
<div class="model-desc">Fastest · Free · Great quality for subtitles</div>
</div>
<div class="model-option">
Expand Down
13 changes: 10 additions & 3 deletions web/lib/subtitleAi.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
// AI subtitle translation — web port of the Android app's
// SubtitleTranslationService/Manager: same providers (Groq llama-3.3-70b /
// Gemini 2.5 Flash), same prompt, same ⏎ line-break sentinel, batched with a
// SubtitleTranslationService/Manager: same providers (Groq gpt-oss-120b /
// Gemini), same prompt, same ⏎ line-break sentinel, batched with a
// short window, cached per cue text, 5s backoff on 429.

const GROQ_MODEL_ID = "llama-3.3-70b-versatile";
// llama-3.3-70b-versatile was decommissioned by Groq (August 16, 2026); gpt-oss-120b is the
// recommended replacement — faster, same 30 RPM free tier. It always reasons: "low" is the floor,
// and "hidden" keeps the chain-of-thought out of message.content. Mirrors the Android service.
const GROQ_MODEL_ID = "openai/gpt-oss-120b";
const GROQ_REASONING_EFFORT = "low";
const GROQ_REASONING_FORMAT = "hidden";
const GROQ_URL = "https://api.groq.com/openai/v1/chat/completions";
const GEMINI_MODEL_ID = "gemini-2.5-flash";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL_ID}:generateContent`;
Expand Down Expand Up @@ -118,6 +123,8 @@ export class SubtitleTranslator {
body: JSON.stringify({
model: GROQ_MODEL_ID,
temperature: 0.1,
reasoning_effort: GROQ_REASONING_EFFORT,
reasoning_format: GROQ_REASONING_FORMAT,
messages: [
{ role: "system", content: systemPrompt(this.targetLanguage) },
{ role: "user", content: JSON.stringify(lines) }
Expand Down
Loading