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

+19
View File
@@ -190,6 +190,25 @@ class MediaController extends Controller
return $this->fileResponse($local);
}
public function sportsImage(string $filename, NasSyncService $nas): Response
{
// Format: "users/{slug}/sports/{matchId}/{key}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
abort(404);
}
// ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response
+124 -1
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -14,7 +15,7 @@ class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
}
// List user's playlists
@@ -119,6 +120,23 @@ class PlaylistController extends Controller
]);
}
// Serve the playlist's own OG metadata to social-media crawlers so previews
// show the playlist's picture and name — not the first video's. Humans still
// get redirected to the first track for one-tap playback.
$ua = (string) $request->userAgent();
$isCrawler = (bool) preg_match(
'/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i',
$ua
);
if ($isCrawler) {
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return response()
->view('playlists.show', compact('playlist', 'videos'))
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@@ -495,4 +513,109 @@ class PlaylistController extends Controller
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {}
}
// Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG
// (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card.
public function ogImage(Playlist $playlist, NasSyncService $nas)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404);
}
if ($playlist->thumbnail) {
$local = storage_path('app/' . $playlist->thumbnail);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$nas->ensureLocalAsset($local, $playlist->thumbnail);
}
if (file_exists($local)) {
$ext = strtolower(pathinfo($local, PATHINFO_EXTENSION));
$src = match ($ext) {
'png' => @imagecreatefrompng($local),
'webp' => @imagecreatefromwebp($local),
'gif' => @imagecreatefromgif($local),
default => @imagecreatefromjpeg($local),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Always output an exact 1200x630 canvas (cover-crop, no letterbox)
// so the served image matches the og:image:width/height we declare —
// a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail.
$cw = 1200; $ch = 630;
$dst = imagecreatetruecolor($cw, $ch);
// Cover: scale so the image fills the whole canvas, center-crop overflow
$scale = max($cw / $ow, $ch / $oh);
$sw = (int) round($cw / $scale);
$sh = (int) round($ch / $scale);
$sx = (int) round(($ow - $sw) / 2);
$sy = (int) round(($oh - $sh) / 2);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Branded fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
imagefill($img, 0, 0, $cBg);
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
$tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
$title = $playlist->name ?: 'Playlist';
$maxChars = 42;
$lines = [];
if (mb_strlen($title) > $maxChars) {
$words = explode(' ', $title); $line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; }
else { $line .= ($line ? ' ' : '') . $word; }
}
if ($line) $lines[] = trim($line);
} else { $lines = [$title]; }
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
$meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
$bbox = imagettfbbox(16, 0, $fontNormal, $meta);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
@@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers;
use App\Models\SportsMatch;
use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class SportsMatchController extends Controller
{
/** Single-image fields stored under the `media` JSON group. */
private const IMAGE_FIELDS = [
'media_participant1_photo' => 'participant1_photo',
'media_participant2_photo' => 'participant2_photo',
'media_referee_photo' => 'referee_photo',
'media_club1_logo' => 'club1_logo',
'media_club2_logo' => 'club2_logo',
'media_event_poster' => 'event_poster',
];
public function store(Request $request, NasSyncService $nas): JsonResponse
{
$data = $request->validate($this->rules());
$match = new SportsMatch();
$match->user_id = Auth::id();
$this->fillFromRequest($match, $request, $data);
$match->save();
$this->handleImages($match, $request, $nas);
$match->save();
return response()->json([
'ok' => true,
'message' => 'Match saved as draft.',
'match' => $this->toEditPayload($match),
]);
}
public function update(Request $request, SportsMatch $sportsMatch, NasSyncService $nas): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
$data = $request->validate($this->rules($sportsMatch));
$this->fillFromRequest($sportsMatch, $request, $data);
$this->handleImages($sportsMatch, $request, $nas);
$sportsMatch->save();
return response()->json([
'ok' => true,
'message' => 'Match updated.',
'match' => $this->toEditPayload($sportsMatch),
]);
}
/** Return the record as JSON so the modal can be re-opened for later editing. */
public function edit(SportsMatch $sportsMatch): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
return response()->json(['match' => $this->toEditPayload($sportsMatch)]);
}
// ── Validation ──────────────────────────────────────────────────────────
private function rules(?SportsMatch $existing = null): array
{
$img = ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'];
return [
// Basic — only these are required for a first (draft) save
'video_id' => ['required', 'integer', Rule::exists('videos', 'id')->where('user_id', Auth::id())],
'status' => ['nullable', Rule::in(['draft', 'published'])],
'title' => ['required', 'string', 'max:255'],
'event_name' => ['nullable', 'string', 'max:255'],
'match_date' => ['nullable', 'date'],
'match_time' => ['nullable', 'date_format:H:i'],
'participant1_name' => ['nullable', 'string', 'max:255'],
'participant2_name' => ['nullable', 'string', 'max:255'],
'referee_name' => ['nullable', 'string', 'max:255'],
// revealed later in edit
'sport' => ['nullable', 'string', 'max:80'],
'match_type' => ['nullable', 'string', 'max:80'],
'venue_name' => ['nullable', 'string', 'max:255'],
// Optional grouped scalars (free-form, kept generic)
'competition' => ['nullable', 'array'],
'participants' => ['nullable', 'array'],
'extra_participants'=> ['nullable', 'array'],
'venue' => ['nullable', 'array'],
'result' => ['nullable', 'array'],
'reviews' => ['nullable', 'array'],
'media' => ['nullable', 'array'],
// Repeatable groups
'officials' => ['nullable', 'array'],
'officials.*.role' => ['nullable', 'string', 'max:80'],
'officials.*.name' => ['nullable', 'string', 'max:255'],
'officials.*.photo' => $img,
'segments' => ['nullable', 'array'],
'segments.*.type' => ['nullable', 'string', 'max:80'],
'segments.*.number' => ['nullable', 'string', 'max:50'],
'segments.*.score' => ['nullable', 'string', 'max:255'],
'segments.*.winner' => ['nullable', 'string', 'max:255'],
'segments.*.notes' => ['nullable', 'string', 'max:2000'],
'statistics' => ['nullable', 'array'],
'statistics.*.name' => ['nullable', 'string', 'max:120'],
'statistics.*.value' => ['nullable', 'string', 'max:120'],
'statistics.*.owner' => ['nullable', 'string', 'max:255'],
'statistics.*.notes' => ['nullable', 'string', 'max:2000'],
// Single image fields
'media_participant1_photo' => $img,
'media_participant2_photo' => $img,
'media_referee_photo' => $img,
'media_club1_logo' => $img,
'media_club2_logo' => $img,
'media_event_poster' => $img,
];
}
// ── Mapping helpers ─────────────────────────────────────────────────────
private function fillFromRequest(SportsMatch $match, Request $request, array $data): void
{
$match->video_id = $data['video_id'];
$match->status = $data['status'] ?? 'draft';
$match->title = $data['title'];
$match->event_name = $data['event_name'] ?? null;
$match->match_date = $data['match_date'] ?? null;
$match->match_time = $data['match_time'] ?? null;
$match->participant1_name = $data['participant1_name'] ?? null;
$match->participant2_name = $data['participant2_name'] ?? null;
$match->referee_name = $data['referee_name'] ?? null;
$match->sport = $data['sport'] ?? null;
$match->match_type = $data['match_type'] ?? null;
$match->venue_name = $data['venue_name'] ?? null;
// Optional scalar groups — keep only non-empty values, drop the JSON if empty.
$match->competition = $this->clean($request->input('competition', []));
$match->venue = $this->clean($request->input('venue', []));
$match->result = $this->clean($request->input('result', []));
$match->reviews = $this->clean($request->input('reviews', []));
// Participants details + any extra participants in one generic structure.
$participants = $this->clean($request->input('participants', []));
$extra = collect($request->input('extra_participants', []))
->map(fn ($p) => $this->clean($p))
->filter()
->values()
->all();
if (! empty($extra)) $participants['extra'] = $extra;
$match->participants = empty($participants) ? null : $participants;
// Repeatable text groups (officials are built in handleImages() since they carry photos).
$match->segments = $this->cleanRows($request->input('segments', []));
$match->statistics = $this->cleanRows($request->input('statistics', []));
// Media text fields (caption/alt/credit/public). Preserve existing image
// paths already on the record; handleImages() overwrites any replaced ones.
$existingMedia = $match->media ?? [];
$mediaText = $this->clean($request->input('media', []));
if (isset($mediaText['public'])) {
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
}
$imagePaths = array_intersect_key($existingMedia, array_flip(self::IMAGE_FIELDS));
$merged = array_merge($imagePaths, $mediaText);
$match->media = empty($merged) ? null : $merged;
}
/** Strip empty values from a flat associative array; return null if nothing left. */
private function clean(?array $arr): ?array
{
if (! is_array($arr)) return null;
$out = array_filter($arr, fn ($v) => $v !== null && $v !== '' && $v !== []);
return empty($out) ? null : $out;
}
/** Clean a list of repeatable rows, dropping rows that are entirely empty. */
private function cleanRows(?array $rows): ?array
{
if (! is_array($rows)) return null;
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) continue;
// photo (file) is handled separately; drop transient hidden keys here
unset($row['photo']);
$clean = $this->clean($row);
if ($clean) $out[] = $clean;
}
return empty($out) ? null : $out;
}
// ── Image handling ──────────────────────────────────────────────────────
private function handleImages(SportsMatch $match, Request $request, NasSyncService $nas): void
{
$slug = $nas->userSlug($match->user ?: Auth::user());
$media = $match->media ?? [];
// Named single images
foreach (self::IMAGE_FIELDS as $input => $key) {
if ($request->hasFile($input)) {
$media[$key] = $this->storeImage($request->file($input), $slug, $match->id, $key, $nas);
}
}
$match->media = empty($media) ? null : $media;
// Officials — built from raw input so file inputs align by index. The
// modal renumbers rows to contiguous indices before submit. A new photo
// file replaces the existing one; otherwise the existing path is kept.
$officials = [];
foreach (array_values($request->input('officials', [])) as $i => $row) {
if (! is_array($row)) continue;
$entry = [];
if (! empty($row['role'])) $entry['role'] = $row['role'];
if (! empty($row['name'])) $entry['name'] = $row['name'];
if ($request->hasFile("officials.$i.photo")) {
$entry['photo'] = $this->storeImage(
$request->file("officials.$i.photo"), $slug, $match->id, "official-$i", $nas
);
} elseif (! empty($row['photo_existing'])) {
$entry['photo'] = $row['photo_existing'];
}
if (! empty($entry)) $officials[] = $entry;
}
$match->officials = empty($officials) ? null : $officials;
}
/**
* Write one uploaded image to the canonical NAS path
* (users/{slug}/sports/{matchId}/{key}.{ext}) and return that relative path.
*/
private function storeImage(UploadedFile $file, string $slug, int $matchId, string $key, NasSyncService $nas): string
{
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$rel = "users/{$slug}/sports/{$matchId}/{$key}.{$ext}";
$localAbs = storage_path('app/' . $rel);
@mkdir(dirname($localAbs), 0755, true);
$file->move(dirname($localAbs), basename($localAbs));
// Push to NAS and drop the local copy when the NAS is reachable.
if ($nas->isEnabled()) {
$nas->mkdirp(dirname($rel));
if ($nas->putFile($localAbs, $rel)) {
@unlink($localAbs);
}
}
return $rel;
}
/** Shape the record for the edit modal (resolves image paths to URLs). */
private function toEditPayload(SportsMatch $match): array
{
$media = $match->media ?? [];
$mediaUrls = [];
// Key the URLs by the form input name (e.g. media_participant1_photo) so the
// modal can match each preview to its field via [data-img].
foreach (self::IMAGE_FIELDS as $input => $key) {
if (! empty($media[$key])) {
$mediaUrls[$input] = route('media.sports-image', $media[$key]);
}
}
$officials = $match->officials ?? [];
foreach ($officials as &$o) {
if (! empty($o['photo'])) $o['photo_url'] = route('media.sports-image', $o['photo']);
}
unset($o);
return [
'id' => $match->id,
'video_id' => $match->video_id,
'video_title' => optional($match->video)->title,
'status' => $match->status,
'sport' => $match->sport,
'title' => $match->title,
'event_name' => $match->event_name,
'match_type' => $match->match_type,
'match_date' => optional($match->match_date)->format('Y-m-d'),
'match_time' => $match->match_time ? substr($match->match_time, 0, 5) : null,
'participant1_name' => $match->participant1_name,
'participant2_name' => $match->participant2_name,
'referee_name' => $match->referee_name,
'venue_name' => $match->venue_name,
'competition' => $match->competition,
'participants' => $match->participants,
'venue' => $match->venue,
'result' => $match->result,
'reviews' => $match->reviews,
'media' => $media,
'media_urls' => $mediaUrls,
'officials' => $officials,
'segments' => $match->segments,
'statistics' => $match->statistics,
];
}
}
+75 -2
View File
@@ -21,6 +21,33 @@ class UserController extends Controller
$this->middleware('auth')->except(['channel']);
}
// Typeahead search for members (used by the "Share to member" picker)
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 1) {
return response()->json(['users' => []]);
}
$users = User::where('id', '!=', Auth::id())
->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('username', 'like', "%{$q}%");
})
->orderBy('name')
->limit(8)
->get(['id', 'name', 'username', 'avatar']);
return response()->json([
'users' => $users->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'channel' => $u->channel,
'avatar' => $u->avatar_url,
]),
]);
}
// Profile page - personal overview for the authenticated user
public function profile()
{
@@ -378,7 +405,49 @@ class UserController extends Controller
]);
}
public function toggleSubscribe(User $user)
public function recordProfileVisit(Request $request, User $user)
{
// Don't record self-visits or repeated visits from the same person in the last 30 minutes
$visitorId = Auth::id();
$deviceId = $request->cookie('_did');
if ($visitorId && $visitorId === $user->id) {
return response()->json(['ok' => true, 'skipped' => 'self']);
}
$sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$dedup = \App\Models\ProfileVisit::where('profile_user_id', $user->id)
->where('created_at', '>=', now()->subMinutes(30))
->when($visitorId, fn ($q) => $q->where('visitor_user_id', $visitorId))
->when(! $visitorId && $deviceId, fn ($q) => $q->whereNull('visitor_user_id')->where('device_id', $deviceId))
->when($sourceVideoId, fn ($q) => $q->where('source_video_id', $sourceVideoId))
->exists();
if ($dedup) {
return response()->json(['ok' => true, 'skipped' => 'dedup']);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\App\Models\ProfileVisit::create([
'profile_user_id' => $user->id,
'visitor_user_id' => $visitorId,
'device_id' => $deviceId,
'source_video_id' => $sourceVideoId,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
public function toggleSubscribe(Request $request, User $user)
{
$me = Auth::user();
@@ -390,7 +459,11 @@ class UserController extends Controller
$me->subscriptions()->detach($user->id);
$subscribed = false;
} else {
$me->subscriptions()->attach($user->id);
$sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! \App\Models\Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$me->subscriptions()->attach($user->id, ['source_video_id' => $sourceVideoId]);
$subscribed = true;
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
}
+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,
]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProfileVisit extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'profile_user_id',
'visitor_user_id',
'device_id',
'source_video_id',
'ip_address',
'country',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function profileUser(): BelongsTo
{
return $this->belongsTo(User::class, 'profile_user_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(User::class, 'visitor_user_id');
}
public function sourceVideo(): BelongsTo
{
return $this->belongsTo(Video::class, 'source_video_id');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SportsMatch extends Model
{
use HasFactory;
protected $table = 'sports_matches';
protected $fillable = [
'video_id', 'user_id', 'status',
'sport', 'title', 'event_name', 'match_type',
'match_date', 'match_time',
'participant1_name', 'participant2_name', 'referee_name', 'venue_name',
'competition', 'participants', 'media', 'officials',
'venue', 'result', 'segments', 'statistics', 'reviews',
];
protected $casts = [
'match_date' => 'date',
'competition' => 'array',
'participants' => 'array',
'media' => 'array',
'officials' => 'array',
'venue' => 'array',
'result' => 'array',
'segments' => 'array',
'statistics' => 'array',
'reviews' => 'array',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
}
+2 -2
View File
@@ -205,7 +205,7 @@ class User extends Authenticatable implements MustVerifyEmail
'user_subscriptions',
'channel_id',
'subscriber_id'
)->withPivot('created_at');
)->withPivot(['created_at', 'source_video_id']);
}
// Channels this user subscribes to
@@ -216,7 +216,7 @@ class User extends Authenticatable implements MustVerifyEmail
'user_subscriptions',
'subscriber_id',
'channel_id'
)->withPivot('created_at');
)->withPivot(['created_at', 'source_video_id']);
}
public function isSubscribedTo(User $channel): bool
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VideoSharedWithUser extends Notification
{
public function __construct(
public Video $video,
public User $sharer,
public ?string $message,
public string $shareUrl,
) {}
/**
* A direct, person-to-person share is transactional — always deliver both
* the in-app notification and the email (not gated by subscription prefs).
*/
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'video_shared',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->sharer->id,
'actor_name' => $this->sharer->name,
'actor_avatar' => $this->sharer->avatar_url,
'message' => $this->message,
];
}
public function toMail(object $notifiable): MailMessage
{
$isSong = $this->video->isAudioOnly() || $this->video->type === 'music';
$noun = $isSong ? 'song' : ($this->video->type === 'match' ? 'match' : 'video');
return (new MailMessage)
->subject($this->sharer->name . ' shared a ' . $noun . ' with you')
->view('emails.video-shared', [
'video' => $this->video,
'sender' => $this->sharer,
'shareUrl' => $this->shareUrl,
'personalMessage' => $this->message,
'shareTitle' => $this->video->title,
]);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Support;
use App\Models\Video;
use App\Services\NasSyncService;
/**
* Resolves a video's thumbnail to a local absolute file path so it can be
* embedded inline (CID) in transactional emails.
*
* Remote mail clients (notably Gmail's image proxy) cannot reliably fetch the
* dynamic /media/thumbnails route — it sits behind Cloudflare and PHP, so the
* proxy may be challenged or time out, leaving a broken image. Embedding the
* bytes directly in the message removes that external dependency entirely.
*/
class EmailThumbnail
{
public static function localPath(?Video $video): ?string
{
if (! $video || ! $video->thumbnail) {
return null;
}
$thumb = $video->thumbnail;
$nas = app(NasSyncService::class);
// NAS-mirrored path format: "users/{slug}/videos/{song}/…"
if (str_starts_with($thumb, 'users/')) {
$local = storage_path('app/' . $thumb);
if (file_exists($local)) {
return $local;
}
@mkdir(dirname($local), 0755, true);
// The DB path mirrors the NAS path exactly — try it directly first.
if ($nas->ensureLocalAsset($local, $thumb)) {
return $local;
}
// Extension may differ on NAS (e.g. canonical thumb.webp).
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($thumb, PATHINFO_EXTENSION) ?: 'jpg';
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) {
return $local;
}
}
return file_exists($local) ? $local : null;
}
// Legacy flat filename format.
$local = storage_path('app/public/thumbnails/' . $thumb);
return file_exists($local) ? $local : null;
}
}