Compare commits
3
Commits
8fef257c86
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab59fc1bb | ||
|
|
e76ac39f99 | ||
|
|
c59b36ebf7 |
No files matched your search
@@ -0,0 +1,357 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs up the database, and optionally mirrors the media library.
|
||||||
|
*
|
||||||
|
* Three things make this a backup rather than a file copy:
|
||||||
|
*
|
||||||
|
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
|
||||||
|
* checkpoint, so the artifact is a coherent point-in-time copy even while
|
||||||
|
* the app is writing. Copying the file by hand mid-write is not.
|
||||||
|
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
|
||||||
|
* back before it counts as a success. An unverified backup is a rumour.
|
||||||
|
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
|
||||||
|
* not a dead disk, so a configured remote disk receives a copy and the
|
||||||
|
* command warns loudly on every run where none is set.
|
||||||
|
*
|
||||||
|
* The media library is deliberately NOT part of the default run. It is
|
||||||
|
* gigabytes of immutable uploads; archiving it nightly would produce hours of
|
||||||
|
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
|
||||||
|
* incrementally with rsync instead.
|
||||||
|
*
|
||||||
|
* Exits non-zero on any failure so a cron surfaces the problem instead of
|
||||||
|
* quietly producing corrupt files.
|
||||||
|
*
|
||||||
|
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
|
||||||
|
* up the same way and restored the same way.
|
||||||
|
*/
|
||||||
|
class BackupCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'takeone:backup
|
||||||
|
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
|
||||||
|
{--media-only : Mirror media and skip the database}
|
||||||
|
{--keep-local : Do not delete the local copy after an off-server upload}';
|
||||||
|
|
||||||
|
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
|
||||||
|
|
||||||
|
private array $artifacts = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dir = (string) config('backup.path');
|
||||||
|
|
||||||
|
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
|
||||||
|
$this->error("Cannot create backup directory: {$dir}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = now()->format('Ymd-His');
|
||||||
|
$failed = false;
|
||||||
|
|
||||||
|
if (! $this->option('media-only')) {
|
||||||
|
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('media') || $this->option('media-only')) {
|
||||||
|
$failed = ! $this->mirrorMedia() || $failed;
|
||||||
|
} else {
|
||||||
|
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->shipOffServer();
|
||||||
|
$this->prune($dir);
|
||||||
|
|
||||||
|
if ($failed) {
|
||||||
|
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
|
||||||
|
$this->error('Backup FAILED — see the errors above.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Backup complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Database ---------------- */
|
||||||
|
|
||||||
|
private function backupDatabase(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$connection = config('database.default');
|
||||||
|
$driver = config("database.connections.{$connection}.driver");
|
||||||
|
|
||||||
|
return match ($driver) {
|
||||||
|
'sqlite' => $this->backupSqlite($dir, $stamp),
|
||||||
|
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
|
||||||
|
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
|
||||||
|
private function backupSqlite(string $dir, string $stamp): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sqlite";
|
||||||
|
|
||||||
|
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
|
||||||
|
// something cryptic, say so plainly — a backup that quietly produces
|
||||||
|
// nothing is the worst possible outcome.
|
||||||
|
if (DB::transactionLevel() > 0) {
|
||||||
|
$this->error('Cannot snapshot the database inside an open transaction.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Harmless when the database is not in WAL mode.
|
||||||
|
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Database snapshot failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verifySqlite($target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the snapshot back. A file that exists but cannot be opened, or that
|
||||||
|
* holds no migrations and no videos, is not a backup worth trusting.
|
||||||
|
*/
|
||||||
|
private function verifySqlite(string $path): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = new PDO('sqlite:'.$path);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
|
||||||
|
$this->error("Snapshot failed integrity check: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
|
||||||
|
|
||||||
|
if ($migrations < 1) {
|
||||||
|
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a real application table too. `migrations` alone would still
|
||||||
|
// be present in a snapshot taken of an empty database.
|
||||||
|
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
|
||||||
|
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Snapshot could not be verified: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backupMysql(string $dir, string $stamp, string $connection): bool
|
||||||
|
{
|
||||||
|
$target = $dir."/db-{$stamp}.sql.gz";
|
||||||
|
$c = config("database.connections.{$connection}");
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
|
||||||
|
escapeshellarg((string) $c['host']),
|
||||||
|
escapeshellarg((string) $c['port']),
|
||||||
|
escapeshellarg((string) $c['username']),
|
||||||
|
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
|
||||||
|
escapeshellarg((string) $c['database']),
|
||||||
|
escapeshellarg($target),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
|
||||||
|
$this->error('mysqldump failed (exit '.$status.').');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the dump is readable and non-trivial rather than trusting the
|
||||||
|
// exit code — a gzip of an error message also exits 0.
|
||||||
|
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
|
||||||
|
|
||||||
|
if ($verify !== 0) {
|
||||||
|
$this->error('Dump failed its gzip integrity check — discarding.');
|
||||||
|
@unlink($target);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artifacts[] = $target;
|
||||||
|
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Media ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the media library with rsync.
|
||||||
|
*
|
||||||
|
* Uploaded video cannot be regenerated from anything — losing it is the
|
||||||
|
* one unrecoverable outcome on this platform. rsync is used rather than an
|
||||||
|
* archive because the library is large and almost entirely immutable, so
|
||||||
|
* each run copies only what is new.
|
||||||
|
*/
|
||||||
|
private function mirrorMedia(): bool
|
||||||
|
{
|
||||||
|
$destination = (string) config('backup.media_destination');
|
||||||
|
|
||||||
|
if ($destination === '') {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
|
||||||
|
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
|
||||||
|
$this->error("Media destination is not reachable: {$destination}");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
|
||||||
|
|
||||||
|
if (! $paths) {
|
||||||
|
$this->line(' <comment>–</comment> media nothing to mirror');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --delete is opt-in and off by default: a mirror that deletes is one
|
||||||
|
// wrong path away from erasing the copy it exists to protect.
|
||||||
|
$flags = '-a --partial --info=stats2';
|
||||||
|
|
||||||
|
if (config('backup.media_delete')) {
|
||||||
|
$flags .= ' --delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = true;
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
// Trailing slash on the source, basename on the destination, so
|
||||||
|
// each folder lands in its own directory rather than merging.
|
||||||
|
$command = sprintf(
|
||||||
|
'rsync %s %s %s 2>&1',
|
||||||
|
$flags,
|
||||||
|
escapeshellarg(rtrim($path, '/').'/'),
|
||||||
|
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $out, $status);
|
||||||
|
|
||||||
|
if ($status !== 0) {
|
||||||
|
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
|
||||||
|
$ok = false;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Off-server ---------------- */
|
||||||
|
|
||||||
|
private function shipOffServer(): void
|
||||||
|
{
|
||||||
|
$disk = config('backup.disk');
|
||||||
|
|
||||||
|
if (! $disk) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
|
||||||
|
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->artifacts as $artifact) {
|
||||||
|
try {
|
||||||
|
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
|
||||||
|
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
|
||||||
|
|
||||||
|
if (! $this->option('keep-local')) {
|
||||||
|
@unlink($artifact);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never delete the local copy when the upload failed — it is
|
||||||
|
// the only copy left.
|
||||||
|
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Rotation ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete artifacts past the retention window, but always keep the newest
|
||||||
|
* few — a box that sat idle for a month must still have something to
|
||||||
|
* restore from.
|
||||||
|
*/
|
||||||
|
private function prune(string $dir): void
|
||||||
|
{
|
||||||
|
$days = (int) config('backup.retain_days', 14);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = max(0, (int) config('backup.keep_minimum', 3));
|
||||||
|
$cutoff = now()->subDays($days)->getTimestamp();
|
||||||
|
$removed = 0;
|
||||||
|
|
||||||
|
$files = glob($dir.'/db-*') ?: [];
|
||||||
|
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
|
||||||
|
|
||||||
|
foreach (array_slice($files, $keep) as $file) {
|
||||||
|
if (filemtime($file) < $cutoff && @unlink($file)) {
|
||||||
|
$removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removed) {
|
||||||
|
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function humanSize(string $path): string
|
||||||
|
{
|
||||||
|
$bytes = (int) @filesize($path);
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
|
||||||
|
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes, 1).' '.$units[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
|||||||
->weeklyOn(1, '09:00')
|
->weeklyOn(1, '09:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
|
||||||
|
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
|
||||||
|
// and (when BACKUP_DISK is set) ships a copy off-server.
|
||||||
|
$schedule->command('takeone:backup')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Backup
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for `php artisan takeone:backup`.
|
||||||
|
|
|
||||||
|
| The database and the media library are backed up by DIFFERENT mechanisms on
|
||||||
|
| purpose. The database is small and must be captured as a consistent
|
||||||
|
| point-in-time snapshot, so it is copied in full on every run. The media
|
||||||
|
| library is gigabytes of immutable uploads, so it is mirrored incrementally
|
||||||
|
| and only when asked — tarring it nightly would burn hours of CPU and fill
|
||||||
|
| the disk with near-identical archives.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// Where snapshots are written. Lives under /data, which is git-ignored, so
|
||||||
|
// a backup can never be committed by accident.
|
||||||
|
'path' => env('BACKUP_PATH', base_path('data/backups')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retention
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
|
||||||
|
| are ALWAYS kept regardless of age — a box that sat idle for a month must
|
||||||
|
| still have something to restore from.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
|
||||||
|
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Off-server copy
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
|
||||||
|
| data survives a bad migration but not a dead disk, so set this to a
|
||||||
|
| remote disk. The command warns on every run while it is unset.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'disk' => env('BACKUP_DISK'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Media library
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
|
||||||
|
| from anything. It is mirrored with rsync (incremental, resumable) to
|
||||||
|
| media_destination, which should be a NAS mount or another machine.
|
||||||
|
|
|
||||||
|
| Only runs when --media is passed AND a destination is set.
|
||||||
|
|
|
||||||
|
| media_delete stays false by default: a mirror that deletes is one bad
|
||||||
|
| path away from erasing the copy it was meant to protect.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'media_paths' => [
|
||||||
|
base_path('data/app/users'),
|
||||||
|
base_path('data/app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'media_destination' => env('BACKUP_MEDIA_DEST'),
|
||||||
|
|
||||||
|
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -64,6 +64,20 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Poster shim shown behind the <video> (and visible on inactive slots).
|
||||||
|
Keeps the slot filled with content when the shared player node has
|
||||||
|
moved to a different slot. */
|
||||||
|
.short-poster {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
/* Hidden preloader elements that fetch metadata for the next 1-2 shorts
|
||||||
|
without rendering anything visible. preload="metadata" is the
|
||||||
|
smallest-possible warmup; the browser pulls the moov atom + first
|
||||||
|
keyframe, enough that the switch to playback is instant. */
|
||||||
|
.short-preloader { display: none; }
|
||||||
|
|
||||||
/* ── Tap-to-pause icon ── */
|
/* ── Tap-to-pause icon ── */
|
||||||
.short-pause-icon {
|
.short-pause-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -337,18 +351,21 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Video player --}}
|
{{-- Video player wrap. The actual <video> element is a SINGLE shared
|
||||||
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
|
node (defined once at the bottom of the feed) that gets moved
|
||||||
|
between slots as the active short changes. Each slot carries its
|
||||||
|
own src + poster on data- attributes and the slot's inner div
|
||||||
|
shows the poster until the player docks here. --}}
|
||||||
|
<div class="short-player-wrap" id="wrap-{{ $index }}"
|
||||||
|
data-src="{{ $src }}"
|
||||||
|
data-poster="{{ $thumb }}"
|
||||||
|
onclick="toggleShortPlay({{ $index }})">
|
||||||
|
|
||||||
<video
|
{{-- Poster shim: covers the slot before the shared <video> moves in.
|
||||||
id="sv-{{ $index }}"
|
Also stays visible on inactive slots so the viewport doesn't
|
||||||
src="{{ $src }}"
|
go blank when the user is between shorts. --}}
|
||||||
poster="{{ $thumb }}"
|
<div class="short-poster"
|
||||||
loop
|
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
|
||||||
playsinline
|
|
||||||
preload="{{ $loop->first ? 'auto' : 'none' }}"
|
|
||||||
style="width:100%;height:100%;object-fit:cover;">
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{{-- Pause icon --}}
|
{{-- Pause icon --}}
|
||||||
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
||||||
@@ -429,6 +446,21 @@
|
|||||||
|
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
||||||
|
{{-- Single shared <video> moved between slots as the active short changes.
|
||||||
|
Starts out detached (parked here in the DOM); the IntersectionObserver
|
||||||
|
relocates it into .short-player-wrap of the active slot. --}}
|
||||||
|
<video id="svGlobal"
|
||||||
|
loop playsinline
|
||||||
|
preload="metadata"
|
||||||
|
style="width:100%;height:100%;object-fit:cover;display:block;position:absolute;inset:0;">
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{{-- Preloaders for the next 1-2 shorts. Hidden; preload="metadata" is
|
||||||
|
enough that switching to playback is instant. We assign srcs
|
||||||
|
dynamically — defining them here keeps the DOM shape stable. --}}
|
||||||
|
<video id="svPre1" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
<video id="svPre2" class="short-preloader" preload="metadata" muted></video>
|
||||||
|
|
||||||
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
||||||
Populated via fetch of the regular watch page's #ytcSection block so
|
Populated via fetch of the regular watch page's #ytcSection block so
|
||||||
all the comment features (likes, replies, translate, heart/pin) work
|
all the comment features (likes, replies, translate, heart/pin) work
|
||||||
@@ -496,13 +528,21 @@
|
|||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const main = document.getElementById('main');
|
const main = document.getElementById('main');
|
||||||
|
const video = document.getElementById('svGlobal');
|
||||||
|
const pre1 = document.getElementById('svPre1');
|
||||||
|
const pre2 = document.getElementById('svPre2');
|
||||||
// Persist the mute state across sessions. Default to muted so autoplay
|
// Persist the mute state across sessions. Default to muted so autoplay
|
||||||
// policies don't silently fail on first load; the user can unmute once
|
// policies don't silently fail on first load; the user can unmute once
|
||||||
// and we remember it from then on.
|
// and we remember it from then on.
|
||||||
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
||||||
let current = -1;
|
let current = -1;
|
||||||
|
|
||||||
|
// Build an ordered list of slot wraps so neighbor lookup is a plain
|
||||||
|
// array index — we need to know "the slot at position N+1" to know
|
||||||
|
// which short to preload next.
|
||||||
|
const wraps = Array.from(document.querySelectorAll('.short-player-wrap'));
|
||||||
|
|
||||||
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
||||||
// before the observer starts, so autoplay fires on the target short
|
// before the observer starts, so autoplay fires on the target short
|
||||||
// rather than the first in document order. ────────────────────
|
// rather than the first in document order. ────────────────────
|
||||||
@@ -513,27 +553,62 @@
|
|||||||
})();
|
})();
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
function _dockVideoIn(wrap) {
|
||||||
|
// Move the shared <video> element into the active wrap. The browser
|
||||||
|
// keeps the active media context across the move, so playback
|
||||||
|
// doesn't restart just because we re-parented the node.
|
||||||
|
if (video.parentElement !== wrap) {
|
||||||
|
// Append after the poster so the poster acts as background
|
||||||
|
// while the video decodes its first frame.
|
||||||
|
wrap.appendChild(video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _primePreloaders(idx) {
|
||||||
|
// Preload metadata for the next 1-2 shorts so switching feels
|
||||||
|
// instant. preload="metadata" grabs just the moov atom + first
|
||||||
|
// keyframe; the browser doesn't fetch the whole file.
|
||||||
|
[pre1, pre2].forEach((pre, i) => {
|
||||||
|
const target = wraps[idx + 1 + i];
|
||||||
|
const nextSrc = target?.dataset.src || '';
|
||||||
|
if (pre.src !== nextSrc) pre.src = nextSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseShort(idx) {
|
||||||
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
|
if (video && video.parentElement === wrap) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
}
|
||||||
|
if (wrap) wrap.classList.remove('paused');
|
||||||
|
const prog = document.getElementById('prog-' + idx);
|
||||||
|
if (prog) prog.style.width = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
// ── IntersectionObserver — auto-play visible short ─────────
|
// ── IntersectionObserver — auto-play visible short ─────────
|
||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const item = entry.target;
|
const item = entry.target;
|
||||||
const idx = parseInt(item.dataset.index);
|
const idx = parseInt(item.dataset.index);
|
||||||
const video = document.getElementById('sv-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||||
if (current !== -1 && current !== idx) pauseShort(current);
|
if (current !== -1 && current !== idx) pauseShort(current);
|
||||||
current = idx;
|
current = idx;
|
||||||
|
|
||||||
// ── Reflect the active short in the URL (no history entry) ──
|
// ── Reflect the active short in the URL (no history entry) ──
|
||||||
// Users can copy-paste the address bar and get back here; the
|
|
||||||
// back button still exits the feed instead of walking through
|
|
||||||
// every short they viewed.
|
|
||||||
try {
|
try {
|
||||||
const vid = item.dataset.id;
|
const vid = item.dataset.id;
|
||||||
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
_dockVideoIn(wrap);
|
||||||
|
const nextSrc = wrap.dataset.src || '';
|
||||||
|
if (video.src !== nextSrc) video.src = nextSrc;
|
||||||
|
video.poster = wrap.dataset.poster || '';
|
||||||
|
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
video.volume = 1;
|
video.volume = 1;
|
||||||
video.play().catch(() => {
|
video.play().catch(() => {
|
||||||
@@ -544,7 +619,9 @@
|
|||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
document.getElementById('wrap-' + idx).classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
|
|
||||||
|
_primePreloaders(idx);
|
||||||
} else {
|
} else {
|
||||||
pauseShort(idx);
|
pauseShort(idx);
|
||||||
}
|
}
|
||||||
@@ -555,9 +632,8 @@
|
|||||||
|
|
||||||
// ── Play / pause on tap ────────────────────────────────────
|
// ── Play / pause on tap ────────────────────────────────────
|
||||||
window.toggleShortPlay = function(idx) {
|
window.toggleShortPlay = function(idx) {
|
||||||
const video = document.getElementById('sv-' + idx);
|
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
const wrap = document.getElementById('wrap-' + idx);
|
||||||
if (!video) return;
|
if (!wrap || video.parentElement !== wrap) return;
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
wrap.classList.remove('paused');
|
wrap.classList.remove('paused');
|
||||||
@@ -567,31 +643,23 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function pauseShort(idx) {
|
// ── Progress bar — a single listener on the shared video updates the
|
||||||
const video = document.getElementById('sv-' + idx);
|
// bar in whichever slot it's currently docked. --
|
||||||
const wrap = document.getElementById('wrap-' + idx);
|
video.addEventListener('timeupdate', () => {
|
||||||
if (video) { video.pause(); video.currentTime = 0; }
|
if (!video.duration) return;
|
||||||
if (wrap) wrap.classList.remove('paused');
|
const wrap = video.parentElement;
|
||||||
|
if (!wrap || !wrap.classList.contains('short-player-wrap')) return;
|
||||||
|
const idx = wrap.id.replace('wrap-', '');
|
||||||
const prog = document.getElementById('prog-' + idx);
|
const prog = document.getElementById('prog-' + idx);
|
||||||
if (prog) prog.style.width = '0%';
|
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress bars ──────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.short-item video').forEach(video => {
|
|
||||||
const idx = video.id.replace('sv-', '');
|
|
||||||
video.addEventListener('timeupdate', () => {
|
|
||||||
if (!video.duration) return;
|
|
||||||
const prog = document.getElementById('prog-' + idx);
|
|
||||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Mute toggle ────────────────────────────────────────────
|
// ── Mute toggle ────────────────────────────────────────────
|
||||||
window.toggleMute = function(idx) {
|
window.toggleMute = function(idx) {
|
||||||
muted = !muted;
|
muted = !muted;
|
||||||
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||||
const video = document.getElementById('sv-' + idx);
|
// The shared video is the only one playing; mute it directly.
|
||||||
if (video) video.muted = muted;
|
video.muted = muted;
|
||||||
updateMuteIcon(idx);
|
updateMuteIcon(idx);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -317,14 +317,12 @@
|
|||||||
|
|
||||||
/* No rotation animation */
|
/* No rotation animation */
|
||||||
|
|
||||||
/* Hide sidebar by default */
|
{{-- Dead-code removal (2026-10-21 audit): the previous `.events-sidebar
|
||||||
.events-sidebar {
|
{ display: none }` + `.show { display: flex }` pair here was
|
||||||
display: none;
|
overridden further down (search for the `display: none !important`
|
||||||
}
|
definition under /* ===== Match Events Sidebar ===== */). The
|
||||||
|
!important forms silently won every cascade; this pair never
|
||||||
.events-sidebar.show {
|
rendered. --}}
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Playlist Navigation Controls ===== */
|
/* ===== Playlist Navigation Controls ===== */
|
||||||
.playlist-controls {
|
.playlist-controls {
|
||||||
@@ -2009,15 +2007,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1300px) {
|
{{-- Dead-code removal (2026-10-21 audit): a prior
|
||||||
.yt-sidebar-container {
|
@media (max-width: 1300px) block sized .yt-sidebar-container +
|
||||||
width: 300px;
|
.events-sidebar to 300 px. The immediately-following
|
||||||
}
|
@media (max-width: 1300px) rule overrides both to 100% with
|
||||||
|
!important as part of the stacking layout, so the 300 px pair
|
||||||
.events-sidebar {
|
never applied. --}}
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Narrow desktop: too little room for a side rail ──────────────
|
/* ── Narrow desktop: too little room for a side rail ──────────────
|
||||||
Between 992px and 1300px the left nav (240px) and the 300px rail
|
Between 992px and 1300px the left nav (240px) and the 300px rail
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorts: single reused <video> element + 1-2 next preloaded.
|
||||||
|
*
|
||||||
|
* Validates that the shorts feed markup matches the architecture:
|
||||||
|
* - a single #svGlobal <video> rendered once at the end of the forelse,
|
||||||
|
* - two preloader placeholders (#svPre1 / #svPre2),
|
||||||
|
* - no per-short <video> elements inside .short-player-wrap,
|
||||||
|
* - each slot carries its own data-src + data-poster so the shared player
|
||||||
|
* can swap into position without a full DOM rebuild.
|
||||||
|
*/
|
||||||
|
class ShortsReusedVideoTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shorts_feed_renders_a_single_shared_video_element(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create([
|
||||||
|
'username' => 'sh-'.substr(bin2hex(random_bytes(3)), 0, 6),
|
||||||
|
]);
|
||||||
|
// Three shorts so we can prove only ONE <video id="svGlobal"> is
|
||||||
|
// emitted no matter how many shorts there are.
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
Video::create([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'Short '.$i,
|
||||||
|
'description' => '',
|
||||||
|
'filename' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'path' => "users/x/videos/y{$i}/video.mp4",
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'is_shorts' => true,
|
||||||
|
'duration' => 45,
|
||||||
|
'orientation' => 'portrait',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = $this->get(route('videos.shorts'))->assertOk()->getContent();
|
||||||
|
|
||||||
|
// Exactly one shared video element and two preloaders.
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
substr_count($html, 'id="svGlobal"'),
|
||||||
|
'Shorts feed must emit exactly one shared <video id="svGlobal">.'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString('id="svPre1"', $html);
|
||||||
|
$this->assertStringContainsString('id="svPre2"', $html);
|
||||||
|
|
||||||
|
// No per-short video elements inside the slot wraps.
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/short-player-wrap[^>]*>[^<]*<video id="sv-\d+/',
|
||||||
|
$html,
|
||||||
|
'Per-short <video id="sv-{idx}"> elements should be gone.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each slot still carries its own src + poster for the shared
|
||||||
|
// player to pick up on dock.
|
||||||
|
$this->assertStringContainsString('data-src=', $html);
|
||||||
|
$this->assertStringContainsString('data-poster=', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user