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

+100
View File
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
// TAKEONE account link. The token is a live credential for that
// platform, so it is encrypted at rest and never sent back to a browser.
'takeone_token' => 'encrypted',
'takeone_connected_at' => 'datetime',
];
/**
* Can this user run TAKEONE lookups?
*
* A super admin always can: the platform holds a TAKEONE super-admin token
* on their behalf, so they are connected with full reach without ever
* pasting anything. Everyone else links their own account, and sees only
* what that account may see.
*/
public function takeoneLinked(): bool
{
return $this->takeoneToken() !== null;
}
/** True when the connection is the platform's, not one this user pasted. */
public function takeoneManagedLink(): bool
{
$own = $this->takeone_token;
return ($own === null || $own === '')
&& $this->isSuperAdmin()
&& ! empty(config('services.takeone.admin_token'));
}
/**
* The token to talk to TAKEONE with. A personally linked account always
* wins, so a super admin who deliberately links their own identity is not
* silently upgraded back to the platform credential.
*/
public function takeoneToken(): ?string
{
$own = $this->takeone_token;
if ($own !== null && $own !== '') {
return $own;
}
if ($this->isSuperAdmin()) {
$platform = config('services.takeone.admin_token');
return ! empty($platform) ? $platform : null;
}
return null;
}
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
});
}
/**
* Resolve the most-frequent ISO2 country for each user id, aggregating
* video_views + profile_visits and falling back to users.nationality.
* Returns [user_id => 'BH', …]; keys without data are omitted.
*/
public static function topCountriesFor(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
if (! $userIds) return [];
$tally = [];
$rows = DB::table('video_views')
->selectRaw('user_id, country, COUNT(*) as c')
->whereIn('user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
if (Schema::hasTable('profile_visits')) {
$rows = DB::table('profile_visits')
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
->whereIn('visitor_user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('visitor_user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
}
$out = [];
foreach ($tally as $uid => $counts) {
arsort($counts);
$out[$uid] = array_key_first($counts);
}
// Fallback to users.nationality for anyone still unresolved.
$missing = array_values(array_diff($userIds, array_keys($out)));
if ($missing) {
$nats = static::whereIn('id', $missing)
->whereNotNull('nationality')->where('nationality', '!=', '')
->pluck('nationality', 'id');
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
}
return $out;
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);