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:
1 parent
20c8fdcdf3
commit
2d33e39b9b
8 files changed
+749
-22
No files matched your search
@@ -8,6 +8,7 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +16,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,6 +35,29 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after an
|
||||
// SPA swap, fast double-click, a flaky connection) can POST the identical
|
||||
// body twice. Treat an identical comment from the same user to the same
|
||||
// video within 5 seconds as the same submission and return the first one.
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
@@ -89,6 +113,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user