Add sports-match type, device tracking, profile visits, and share refactor

- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
This commit is contained in:
ghassan committed 2026-05-29 01:50:28 +03:00
1 parent 6aae6f86b6
commit 73527f3781
61 files changed
+4014 -636

No files matched your search

+599 -70
View File
@@ -428,6 +428,7 @@ class VideoController extends Controller
return response()->json([
'success' => true,
'video_id' => $video->id,
'redirect' => route('videos.show', $video),
]);
} catch (\Throwable $e) {
@@ -462,6 +463,16 @@ class VideoController extends Controller
?? $request->ip();
$geo = GeoIpService::lookup($ip);
// Persistent client-side device ID (set in the response cookie below).
// Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
$viewDid = $request->cookie('_did') ?: (string) Str::uuid();
// Device fingerprint hash (set client-side by /fp.js after first paint).
// Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
// Null on the very first visit; the JS will call /identify to backfill it.
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
if (Auth::check()) {
$exists = \DB::table('video_views')
->where('user_id', Auth::id())
@@ -476,16 +487,22 @@ class VideoController extends Controller
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
} else {
// Guest: deduplicate by IP within the last hour
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
$exists = \DB::table('video_views')
->whereNull('user_id')
->where('video_id', $video->id)
->where('ip_address', $ip)
->where('watched_at', '>', now()->subHour())
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
})
->exists();
if (! $exists) {
@@ -495,6 +512,9 @@ class VideoController extends Controller
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(),
]);
}
@@ -542,13 +562,11 @@ class VideoController extends Controller
default => 'videos.types.generic',
};
// Set persistent device-ID cookie used for share-link dedup
$did = $request->cookie('_did') ?: (string) Str::uuid();
// Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
->withCookie(cookie('_did', $viewDid, 60 * 24 * 365 * 5));
}
public function playerData(Video $video, Request $request)
@@ -2133,12 +2151,17 @@ class VideoController extends Controller
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('video_downloads')->insert([
'video_id' => $video->id,
'user_id' => $userId,
'ip_address' => $ip,
'country' => $geo['country'],
'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'type' => $type,
'downloaded_at' => now(),
]);
@@ -2217,6 +2240,44 @@ class VideoController extends Controller
return response()->json(['success' => true]);
}
/**
* Share a video directly with selected members. Each recipient gets an in-app
* notification (clicking it opens the video) and an email.
*/
public function shareWithMembers(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'user_ids' => 'required|array|min:1|max:30',
'user_ids.*' => 'integer|exists:users,id',
'message' => 'nullable|string|max:500',
]);
$sharer = Auth::user();
$message = $data['message'] ?? null;
$recipients = \App\Models\User::whereIn('id', $data['user_ids'])
->where('id', '!=', $sharer->id)
->get();
foreach ($recipients as $member) {
try {
$member->notify(new \App\Notifications\VideoSharedWithUser(
$video, $sharer, $message, $video->share_url
));
} catch (\Throwable $e) {
\Log::error('Share-to-member failed: ' . $e->getMessage(), [
'video_id' => $video->id, 'member_id' => $member->id,
]);
}
}
return response()->json(['success' => true, 'count' => $recipients->count()]);
}
public function accessShare(Request $request, string $token)
{
$share = \DB::table('video_shares')->where('token', $token)->first();
@@ -2244,12 +2305,17 @@ class VideoController extends Controller
?? $request->ip();
$geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name'=> $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'accessed_at' => now(),
]);
}
@@ -2393,6 +2459,48 @@ class VideoController extends Controller
]);
}
public function trackProgress(Request $request, Video $video)
{
$seconds = max(0, (int) $request->input('watched_seconds', 0));
$completed = (bool) $request->boolean('completed');
$viewDid = $request->cookie('_did');
$query = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDay())
->orderByDesc('id')
->limit(1);
if (Auth::check()) {
$query->where('user_id', Auth::id());
} else {
$query->whereNull('user_id');
if ($viewDid) {
$query->where('device_id', $viewDid);
} else {
return response()->json(['ok' => false], 204);
}
}
$view = $query->first();
if (! $view) {
return response()->json(['ok' => false], 204);
}
$updates = [];
if ($seconds > (int) $view->watched_seconds) {
$updates['watched_seconds'] = $seconds;
}
if ($completed && ! $view->completed) {
$updates['completed'] = true;
}
if ($updates) {
\DB::table('video_views')->where('id', $view->id)->update($updates);
}
return response()->json(['ok' => true]);
}
public function insights(Video $video)
{
if (Auth::id() !== $video->user_id) {
@@ -2432,23 +2540,34 @@ class VideoController extends Controller
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
: ($viewsThisWeek > 0 ? 100 : 0);
// 14-day daily breakdown
// 14-day daily breakdown — segmented by viewer category (male / female / other-or-guest)
$rawDaily = \DB::table('video_views')
->selectRaw("date(watched_at) as day, count(*) as cnt")
->where('video_id', $id)
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(watched_at)")
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("date(video_views.watched_at) as day,
sum(case when users.gender = 'male' then 1 else 0 end) as male_cnt,
sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
count(*) as cnt")
->where('video_views.video_id', $id)
->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(video_views.watched_at)")
->orderBy('day')
->pluck('cnt', 'day');
->get()
->keyBy('day');
$daily = [];
for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i);
$d = $now->copy()->subDays($i);
$key = $d->format('Y-m-d');
$row = $rawDaily->get($key);
$daily[] = [
'date' => $d->format('Y-m-d'),
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0),
'date' => $key,
'label' => $d->format('M d'),
'short' => $d->format('D'),
'count' => $row ? (int) $row->cnt : 0,
'male' => $row ? (int) $row->male_cnt : 0,
'female' => $row ? (int) $row->female_cnt : 0,
'other' => $row ? (int) $row->other_cnt : 0,
];
}
@@ -2481,8 +2600,8 @@ class VideoController extends Controller
->first();
$peakHour = $peakRow ? (int) $peakRow->hr : null;
// Top registered viewers (by view count)
$topViewers = \DB::table('video_views')
// Top registered viewers (by view count) — also surface device/browser of their latest visit
$topViewersRaw = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id)
@@ -2490,8 +2609,25 @@ class VideoController extends Controller
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
->get();
// One extra query: most-recent user_agent per top viewer (small, indexed lookup)
$topViewerIds = $topViewersRaw->pluck('id')->all();
$latestUaByUid = [];
if ($topViewerIds) {
$latestRows = \DB::table('video_views as v1')
->whereIn('v1.user_id', $topViewerIds)
->where('v1.video_id', $id)
->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
->get(['v1.user_id', 'v1.user_agent']);
foreach ($latestRows as $row) {
$latestUaByUid[$row->user_id] = $row->user_agent;
}
}
$topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
$ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'channel' => $u->username,
'name' => $u->name,
@@ -2500,19 +2636,30 @@ class VideoController extends Controller
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$guestViews = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->count();
// 10 most recent views
$recentViewers = \DB::table('video_views')
// Recent activity — grouped by viewer.
// Registered users group by user_id. For guests we layer three signals,
// strongest first: device_hash (multi-signal browser fingerprint) → device_id
// cookie → ip_address (legacy / cookie-less). This survives cookie clears,
// incognito mode, browser swaps, and VPN / country hops.
$recentRaw = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select(
'video_views.watched_at',
'video_views.country',
'video_views.ip_address',
'video_views.device_id',
'video_views.device_hash',
'video_views.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar',
@@ -2520,20 +2667,40 @@ class VideoController extends Controller
)
->where('video_views.video_id', $id)
->orderByDesc('video_views.watched_at')
->limit(10)
->get()
->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
->limit(500)
->get();
$groups = [];
foreach ($recentRaw as $r) {
if ($r->user_id) {
$key = 'u:' . $r->user_id;
} else {
$guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
$key = 'g:' . $guestKey;
}
if (! isset($groups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$groups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->watched_at,
];
}
$groups[$key]['count']++;
// first row is the most recent (orderByDesc) so device/browser reflect their latest visit
}
$recentViewers = array_values($groups);
// Download details
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
@@ -2545,8 +2712,8 @@ class VideoController extends Controller
->groupBy('type')
->pluck('cnt', 'type');
// Per-user download counts (top 20 logged-in users)
$dlUsers = \DB::table('video_downloads')
// Per-user download counts (top 20 logged-in users) + device/browser of their latest download
$dlUsersRaw = \DB::table('video_downloads')
->join('users', 'users.id', '=', 'video_downloads.user_id')
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
->where('video_downloads.video_id', $id)
@@ -2554,8 +2721,24 @@ class VideoController extends Controller
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($u) => [
->get();
$dlUserIds = $dlUsersRaw->pluck('id')->all();
$dlLatestUaByUid = [];
if ($dlUserIds) {
$latestDl = \DB::table('video_downloads as d1')
->whereIn('d1.user_id', $dlUserIds)
->where('d1.video_id', $id)
->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
->get(['d1.user_id', 'd1.user_agent']);
foreach ($latestDl as $row) {
$dlLatestUaByUid[$row->user_id] = $row->user_agent;
}
}
$dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
$ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
return [
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
@@ -2563,43 +2746,62 @@ class VideoController extends Controller
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
]);
'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$dlGuests = \DB::table('video_downloads')
->where('video_id', $id)
->whereNull('user_id')
->count();
// 10 most recent downloads
$dlRecent = \DB::table('video_downloads')
// Recent download activity — grouped by downloader.
// Guests are keyed by ip_address (video_downloads doesn't track device_id);
// device/browser comes from the most-recent download's user_agent.
$dlRecentRaw = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select(
'video_downloads.id',
'video_downloads.type',
'video_downloads.downloaded_at',
'video_downloads.country',
'video_downloads.country_name',
'video_downloads.ip_address',
'video_downloads.user_agent',
'users.id as user_id',
'users.name as user_name',
'users.avatar as user_avatar'
)
->where('video_downloads.video_id', $id)
->orderByDesc('video_downloads.downloaded_at')
->limit(10)
->get()
->map(fn ($r) => [
'type' => $r->type,
'at' => $r->downloaded_at,
'country' => $r->country,
'country_name'=> $r->country_name,
'user_id' => $r->user_id,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
->limit(500)
->get();
$dlGroups = [];
foreach ($dlRecentRaw as $r) {
$key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
if (! isset($dlGroups[$key])) {
$ua = $this->parseUserAgent($r->user_agent);
$dlGroups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'type' => $r->type,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->downloaded_at,
];
}
$dlGroups[$key]['count']++;
}
$dlRecent = array_values($dlGroups);
// Gender breakdown (authenticated viewers only)
$genderRows = \DB::table('video_views')
@@ -2625,13 +2827,17 @@ class VideoController extends Controller
$rawAges = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("{$ageExpr} as age")
->selectRaw("{$ageExpr} as age, users.gender as gender")
->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday')
->get();
$ageBuckets = ['Under 13' => 0, '13–17' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55–64' => 0, '65+' => 0];
$blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
$ageBuckets = [
'Under 13' => $blank, '13–17' => $blank, '18–24' => $blank, '25–34' => $blank,
'35–44' => $blank, '45–54' => $blank, '55–64' => $blank, '65+' => $blank,
];
foreach ($rawAges as $row) {
$age = (int) $row->age;
$bucket = match (true) {
@@ -2644,16 +2850,22 @@ class VideoController extends Controller
$age <= 64 => '55–64',
default => '65+',
};
$ageBuckets[$bucket]++;
$ageBuckets[$bucket]['count']++;
if ($row->gender === 'male') $ageBuckets[$bucket]['male']++;
elseif ($row->gender === 'female') $ageBuckets[$bucket]['female']++;
else $ageBuckets[$bucket]['other']++;
}
$totalAge = array_sum($ageBuckets);
$totalAge = array_sum(array_column($ageBuckets, 'count'));
$ageGroups = collect($ageBuckets)
->filter(fn ($cnt) => $cnt > 0)
->map(fn ($cnt, $label) => [
'label' => $label,
'count' => $cnt,
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
->filter(fn ($b) => $b['count'] > 0)
->map(fn ($b, $label) => [
'label' => $label,
'count' => $b['count'],
'male' => $b['male'],
'female' => $b['female'],
'other' => $b['other'],
'pct' => $totalAge > 0 ? round($b['count'] / $totalAge * 100) : 0,
])->values();
// Who liked this video
@@ -2717,9 +2929,70 @@ class VideoController extends Controller
];
})->sortByDesc('reach')->values()->take(10);
// ── Skip rate ──────────────────────────────────────────────
// Skipped = watched_seconds < max(10, 10% of duration)
$duration = (int) ($video->duration ?? 0);
$skipThreshold = max(10, (int) floor($duration * 0.10));
$skippedViews = \DB::table('video_views')
->where('video_id', $id)
->where('watched_seconds', '<', $skipThreshold)
->count();
$skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;
// ── Save rate ──────────────────────────────────────────────
// Distinct users who added this video to a playlist (excluding the uploader's own playlists)
$saveCount = \DB::table('playlist_videos')
->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
->where('playlist_videos.video_id', $id)
->where('playlists.user_id', '!=', $video->user_id)
->distinct('playlists.user_id')
->count('playlists.user_id');
$uniqueViewersAll = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;
// ── Profile (wall) visits originating from this video ───────
$profileVisits = \DB::table('profile_visits')
->where('profile_user_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── New subscribers driven by this video ───────────────────
$newSubscribers = \DB::table('user_subscriptions')
->where('channel_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── Comments (including replies) ───────────────────────────
$commentsCount = \DB::table('comments')->where('video_id', $id)->count();
// ── Accounts reached (distinct users + distinct guest devices) ──
$reachedUsers = $uniqueViewersAll;
$reachedGuests = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->whereNotNull('device_id')
->distinct('device_id')
->count('device_id');
$accountsReached = $reachedUsers + $reachedGuests;
return response()->json([
'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers,
'skip_rate' => $skipRate,
'skipped_views' => $skippedViews,
'skip_threshold' => $skipThreshold,
'save_count' => $saveCount,
'save_rate' => $saveRate,
'profile_visits' => $profileVisits,
'new_subscribers' => $newSubscribers,
'comments_count' => $commentsCount,
'accounts_reached' => $accountsReached,
'reached_users' => $reachedUsers,
'reached_guests' => $reachedGuests,
'top_viewers' => $topViewers,
'guest_views' => $guestViews,
'recent_viewers' => $recentViewers,
@@ -2913,5 +3186,261 @@ class VideoController extends Controller
'records' => $records,
]);
}
// ── Backfill a device fingerprint after the JS computes it ────────────
// On the very first visit the cookie isn't there yet, so the view row is
// inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
// ms later with the freshly-computed hash so we can stamp it onto the row
// that was just inserted (and the future cookie does the rest).
public function identify(Request $request, Video $video)
{
$hash = (string) $request->input('hash', '');
if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
return response()->json(['ok' => false], 422);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
// Update the latest matching view row for this caller (user OR cookie OR IP)
// within a short window so we don't accidentally stamp someone else's row.
$q = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(10))
->whereNull('device_hash');
if (Auth::check()) {
$q->where('user_id', Auth::id());
} else {
$did = $request->cookie('_did');
$q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
if ($did) $w->where('device_id', $did);
$w->orWhere('ip_address', $ip);
});
}
$q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);
return response()->json(['ok' => true])
->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
}
// ── User-agent → coarse device / browser / OS labels ──────────────────
// Shared by every insights endpoint so labels stay consistent.
private function parseUserAgent(?string $ua): array
{
if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];
// Device family
$device = 'Desktop';
if (preg_match('/iPad/i', $ua)) $device = 'iPad';
elseif (preg_match('/Tablet/i', $ua)) $device = 'Tablet';
elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
elseif (preg_match('/Android/i', $ua)) $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
elseif (preg_match('/Mobile/i', $ua)) $device = 'Mobile';
// OS
$os = 'Unknown';
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows 10/11';
elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m)) $os = 'macOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m)) $os = 'Android ' . $m[1];
elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m)) $os = 'iOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m)) $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
// Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
$browser = 'Unknown';
if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m)) $browser = 'Edge ' . $m[1];
elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m)) $browser = 'Opera ' . $m[1];
elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m)) $browser = 'Firefox ' . $m[1];
elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m)) $browser = 'Chrome ' . $m[1];
elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m)) $browser = 'Safari ' . $m[1];
elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m)) $browser = 'Safari ' . $m[1];
return ['device' => $device, 'os' => $os, 'browser' => $browser];
}
// ── Drill-down: detail for one share link ─────────────────────────────
public function insightsShare(Video $video, string $token)
{
if (Auth::id() !== $video->user_id) abort(403);
$share = \DB::table('video_shares')
->where('video_id', $video->id)
->where('token', $token)
->first();
if (! $share) abort(404);
// Sharer profile
$sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
if ($share->user_id) {
$u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
if ($u) {
$sharer = [
'is_guest' => false,
'id' => $u->id,
'name' => $u->name,
'channel' => $u->username,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
];
}
}
$accesses = \DB::table('share_accesses')
->where('share_id', $share->id)
->orderByDesc('accessed_at')
->get();
// Country / device / browser / os aggregation
$countries = [];
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($accesses as $a) {
$code = $a->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
$p = $this->parseUserAgent($a->user_agent ?? null);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
$recent = $accesses->take(50)->map(function ($a) {
$p = $this->parseUserAgent($a->user_agent ?? null);
return [
'at' => $a->accessed_at,
'country' => $a->country,
'device' => $p['device'],
'browser' => $p['browser'],
];
})->values();
return response()->json([
'token' => $share->token,
'created_at' => $share->created_at,
'sharer' => $sharer,
'reach' => $accesses->count(),
'countries' => array_values($countries),
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
// ── Drill-down: one viewer's full view history on this video ─────────
// $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
public function insightsViewer(Video $video, string $who)
{
if (Auth::id() !== $video->user_id) abort(403);
$who = substr($who, 0, 80);
$isUser = str_starts_with($who, 'u:');
$key = substr($who, 2);
$q = \DB::table('video_views')->where('video_id', $video->id);
if ($isUser) {
$q->where('user_id', (int) $key);
} else {
// Match across all three guest signals: fingerprint hash > device cookie > legacy IP
$q->whereNull('user_id')->where(function ($w) use ($key) {
$w->where('device_hash', $key)
->orWhere('device_id', $key)
->orWhere('ip_address', $key);
});
}
$rows = $q->orderByDesc('watched_at')
->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);
if ($rows->isEmpty()) abort(404);
// Build identity block
$identity = [
'is_guest' => ! $isUser,
'name' => 'Guest',
'avatar' => null,
'channel' => null,
];
if ($isUser) {
$user = \App\Models\User::find((int) $key);
if ($user) {
$identity = [
'is_guest' => false,
'id' => $user->id,
'name' => $user->name,
'channel' => $user->username,
'avatar' => $user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id,
];
}
} else {
$identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
}
// Country aggregation
$countries = [];
foreach ($rows as $r) {
$code = $r->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
// Device / browser / OS — parse on the fly from user_agent
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($rows as $r) {
$p = $this->parseUserAgent($r->user_agent);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
// Recent timestamps (cap 50 for the modal)
$recent = $rows->take(50)->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
]);
return response()->json([
'identity' => $identity,
'total' => $rows->count(),
'first_at' => $rows->last()->watched_at,
'last_at' => $rows->first()->watched_at,
'countries' => $countries,
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
}