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]>
361 lines
11 KiB
PHP
Executable File
361 lines
11 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Notifications\VerifyEmail;
|
|
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;
|
|
|
|
class User extends Authenticatable implements MustVerifyEmail
|
|
{
|
|
use HasApiTokens, HasFactory, Notifiable;
|
|
|
|
protected $fillable = [
|
|
'name',
|
|
'username',
|
|
'email',
|
|
'password',
|
|
'avatar',
|
|
'role',
|
|
'bio',
|
|
'website',
|
|
'twitter',
|
|
'instagram',
|
|
'facebook',
|
|
'youtube',
|
|
'linkedin',
|
|
'tiktok',
|
|
'birthday',
|
|
'location',
|
|
'gender',
|
|
'nationality',
|
|
'phone_code',
|
|
'phone_number',
|
|
'timezone',
|
|
'whatsapp',
|
|
'google_location',
|
|
'social_phone',
|
|
'social_email',
|
|
'two_factor_secret',
|
|
'two_factor_enabled',
|
|
'banner',
|
|
'notification_preferences',
|
|
];
|
|
|
|
protected $hidden = [
|
|
'password',
|
|
'remember_token',
|
|
];
|
|
|
|
protected $casts = [
|
|
'email_verified_at' => 'datetime',
|
|
'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,
|
|
'notif_new_reply' => true,
|
|
'notif_comment_like' => true,
|
|
'notif_video_like' => true,
|
|
'notif_new_subscriber' => true,
|
|
'notif_new_video_from_sub' => true,
|
|
'notif_new_post_from_sub' => true,
|
|
'notif_new_user_reg' => true,
|
|
'email_new_comment' => true,
|
|
'email_new_reply' => true,
|
|
'email_comment_like' => false,
|
|
'email_video_like' => false,
|
|
'email_new_subscriber' => true,
|
|
'email_new_video_from_sub' => true,
|
|
'email_new_post_from_sub' => false,
|
|
'email_video_processed' => true,
|
|
'email_new_user_reg' => true,
|
|
'email_weekly_digest' => true,
|
|
];
|
|
|
|
public function notificationPref(string $key): bool
|
|
{
|
|
$prefs = $this->notification_preferences ?? [];
|
|
$default = self::NOTIF_DEFAULTS[$key] ?? true;
|
|
return isset($prefs[$key]) ? (bool) $prefs[$key] : $default;
|
|
}
|
|
|
|
public static function notifDefaults(): array
|
|
{
|
|
return self::NOTIF_DEFAULTS;
|
|
}
|
|
|
|
protected $appends = ['avatar_url', 'banner_url'];
|
|
|
|
// Auto-generate a unique slug-based username when creating a user without one
|
|
protected static function boot(): void
|
|
{
|
|
parent::boot();
|
|
static::creating(function (User $user) {
|
|
if (empty($user->username)) {
|
|
$user->username = static::generateUniqueUsername($user->name);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
if ($base === '') {
|
|
$base = 'user';
|
|
}
|
|
do {
|
|
$slug = $base . '-' . Str::lower(Str::random(6));
|
|
} while (static::where('username', $slug)->exists());
|
|
|
|
return $slug;
|
|
}
|
|
|
|
// Returns the channel URL slug — the username, auto-generated and saved if missing
|
|
public function getChannelAttribute(): string
|
|
{
|
|
if (empty($this->username)) {
|
|
$this->username = static::generateUniqueUsername($this->name);
|
|
$this->saveQuietly();
|
|
}
|
|
return $this->username;
|
|
}
|
|
|
|
// Relationships
|
|
public function videos()
|
|
{
|
|
return $this->hasMany(Video::class);
|
|
}
|
|
|
|
public function likes()
|
|
{
|
|
return $this->belongsToMany(Video::class, 'video_likes')->withTimestamps();
|
|
}
|
|
|
|
public function views()
|
|
{
|
|
return $this->belongsToMany(Video::class, 'video_views')->withTimestamps();
|
|
}
|
|
|
|
public function comments()
|
|
{
|
|
return $this->hasMany(Comment::class);
|
|
}
|
|
|
|
public function playlists()
|
|
{
|
|
return $this->hasMany(Playlist::class);
|
|
}
|
|
|
|
public function posts()
|
|
{
|
|
return $this->hasMany(\App\Models\Post::class);
|
|
}
|
|
|
|
public function getAvatarUrlAttribute(): string
|
|
{
|
|
if ($this->avatar) {
|
|
// ?v busts the browser cache when the avatar is replaced. The path is
|
|
// always .../profile/avatar.<ext>, so without a version every page would
|
|
// keep showing the previously cached image after an upload.
|
|
return route('media.avatar', $this->avatar).'?v='.($this->updated_at?->timestamp ?? '0');
|
|
}
|
|
|
|
return asset('images/default-avatar.svg');
|
|
}
|
|
|
|
public function getBannerUrlAttribute(): ?string
|
|
{
|
|
if ($this->banner) {
|
|
return route('media.banner', $this->banner).'?v='.($this->updated_at?->timestamp ?? '0');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function sendEmailVerificationNotification(): void
|
|
{
|
|
$this->notify(new VerifyEmail);
|
|
}
|
|
|
|
// Role helper methods
|
|
public function isSuperAdmin()
|
|
{
|
|
return $this->role === 'super_admin';
|
|
}
|
|
|
|
public function isAdmin()
|
|
{
|
|
return in_array($this->role, ['admin', 'super_admin']);
|
|
}
|
|
|
|
public function isUser()
|
|
{
|
|
return $this->role === 'user' || $this->role === null;
|
|
}
|
|
|
|
// Users who subscribe TO this channel
|
|
public function subscribers()
|
|
{
|
|
return $this->belongsToMany(
|
|
User::class,
|
|
'user_subscriptions',
|
|
'channel_id',
|
|
'subscriber_id'
|
|
)->withPivot(['created_at', 'source_video_id']);
|
|
}
|
|
|
|
// Channels this user subscribes to
|
|
public function subscriptions()
|
|
{
|
|
return $this->belongsToMany(
|
|
User::class,
|
|
'user_subscriptions',
|
|
'subscriber_id',
|
|
'channel_id'
|
|
)->withPivot(['created_at', 'source_video_id']);
|
|
}
|
|
|
|
public function isSubscribedTo(User $channel): bool
|
|
{
|
|
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
|
|
}
|
|
|
|
public function getSubscriberCountAttribute(): int
|
|
{
|
|
return $this->subscribers()->count();
|
|
}
|
|
|
|
// Check if user has any social links
|
|
public function socialLinks()
|
|
{
|
|
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
|
|
}
|
|
|
|
public function hasSocialLinks()
|
|
{
|
|
return $this->socialLinks()->exists();
|
|
}
|
|
|
|
// Get formatted website URL
|
|
public function getWebsiteUrlAttribute()
|
|
{
|
|
if (! $this->website) {
|
|
return null;
|
|
}
|
|
|
|
// Add https:// if no protocol is specified
|
|
if (! preg_match('/^https?:\/\//', $this->website)) {
|
|
return 'https://'.$this->website;
|
|
}
|
|
|
|
return $this->website;
|
|
}
|
|
}
|