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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user