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:
1 parent
6aae6f86b6
commit
73527f3781
61 files changed
+4014
-636
No files matched your search
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user