Add AI translation for comments and video descriptions

Comments get a Translate button that calls the active LLM provider in Admin → AI
settings. Each commenter shows a small country flag inferred from their
video_views + profile_visits history (falls back to users.nationality); that
inferred country drives the language hint passed to the LLM so Portuguese
"Linda, linda, linda" translates to "Beautiful" instead of pass-through.

Description boxes get a line-paired translation grid: a shared Translate button
flips #vdbDescSplit into a two-column CSS grid where every source line sits
next to its translation on the same row. Backend splits by newline and asks
the LLM for a JSON map keyed by index so counts always match.

LlmLyricsService now has translate() + translateLines() with echo-retry (if the
model returns the input unchanged while source != target, retry once with a
stronger directive) and keeps the Ollama model resident via keep_alive=1h.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:20 +03:00
1 parent 20c8fdcdf3
commit 2d33e39b9b
8 files changed
+749 -22

No files matched your search

+48 -1
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']);
}
public function index()
@@ -913,6 +913,53 @@ class VideoController extends Controller
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$html = (string) $video->description;
if (trim($html) === '') {
return response()->json(['error' => 'Nothing to translate'], 400);
}
// Flatten the rich description → plain text, keeping paragraph breaks
// so the client can render original and translation line-by-line.
$plain = trim(html_entity_decode(strip_tags(
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$lines = preg_split('/\R/u', $plain);
$target = (string) $request->input('target', 'en');
// Source language inferred from the owner's access-history country,
// falling back to users.nationality.
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
$sourceCountry = $topCountries[$video->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
if (! $translatedLines || count($translatedLines) !== count($lines)) {
return response()->json(['error' => 'Translation failed'], 502);
}
$segments = [];
foreach ($lines as $i => $src) {
$segments[] = [
'original' => (string) $src,
'translated' => (string) ($translatedLines[$i] ?? ''),
];
}
return response()->json([
'success' => true,
'segments' => $segments,
'target' => $target,
'source_hint' => $sourceLang,
'author_country' => $sourceCountry,
]);
}
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {