399
app/Actions/Plays/BuildPlaysReport.php
Executable file
399
app/Actions/Plays/BuildPlaysReport.php
Executable file
@@ -0,0 +1,399 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Plays;
|
||||
|
||||
use App\Actions\Album;
|
||||
use App\Actions\TrackPlay;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Movie;
|
||||
use App\Models\Season;
|
||||
use App\Models\Series;
|
||||
use App\Models\Title;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoPlay;
|
||||
use Common\Core\Values\ValueLists;
|
||||
use Common\Database\Metrics\MetricDateRange;
|
||||
use Common\Database\Metrics\Partition;
|
||||
use Common\Database\Metrics\Trend;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BuildPlaysReport
|
||||
{
|
||||
protected Builder $builder;
|
||||
protected array $params = [];
|
||||
protected MetricDateRange $dateRange;
|
||||
protected int $modelId;
|
||||
|
||||
public function execute(array $params): array
|
||||
{
|
||||
$this->params = $params;
|
||||
$this->builder = $this->createBuilder();
|
||||
|
||||
$this->dateRange = new MetricDateRange(
|
||||
start: $this->params['startDate'] ?? null,
|
||||
end: $this->params['endDate'] ?? null,
|
||||
timezone: $this->params['timezone'] ?? null,
|
||||
);
|
||||
|
||||
$metrics = explode(',', Arr::get($params, 'metrics', 'plays'));
|
||||
|
||||
return collect($metrics)
|
||||
->mapWithKeys(function ($metric) {
|
||||
if ($metric === 'movies') {
|
||||
return ['movies' => $this->getTitlesMetric(false)];
|
||||
} elseif ($metric === 'series') {
|
||||
return ['series' => $this->getTitlesMetric(true)];
|
||||
} else {
|
||||
$method = sprintf('get%sMetric', ucfirst($metric));
|
||||
if (method_exists($this, $method)) {
|
||||
return [$metric => $this->$method()];
|
||||
}
|
||||
return [$metric => []];
|
||||
}
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function createBuilder(): Builder
|
||||
{
|
||||
$model = Arr::get($this->params, 'model', '');
|
||||
$parts = explode('=', $model);
|
||||
|
||||
// might send track_play=0, check if variable is set, instead of being truthy
|
||||
if (!isset($parts[0]) || !isset($parts[1])) {
|
||||
$parts = ['video_play', 0];
|
||||
}
|
||||
|
||||
$model = modelTypeToNamespace($parts[0]);
|
||||
$this->modelId = (int) $parts[1];
|
||||
|
||||
switch ($model) {
|
||||
case VideoPlay::class:
|
||||
// all plays, not scoped to any resource (for admin area)
|
||||
Gate::authorize('admin.access');
|
||||
$builder = VideoPlay::query();
|
||||
break;
|
||||
case Video::class:
|
||||
$video = Video::findOrFail($this->modelId);
|
||||
Gate::authorize('update', $video);
|
||||
$builder = $video->plays()->getQuery();
|
||||
break;
|
||||
case Movie::class:
|
||||
case Series::class:
|
||||
case Title::class:
|
||||
$title = Title::findOrFail($this->modelId);
|
||||
Gate::authorize('update', $title);
|
||||
$builder = $title->plays()->getQuery();
|
||||
break;
|
||||
case Season::class:
|
||||
$season = Season::with(['title'])->findOrFail($this->modelId);
|
||||
Gate::authorize('update', $season->title);
|
||||
$builder = VideoPlay::join(
|
||||
'videos',
|
||||
'video_plays.video_id',
|
||||
'=',
|
||||
'videos.id',
|
||||
)
|
||||
->join('titles', 'videos.title_id', '=', 'titles.id')
|
||||
->where('titles.id', $season->title_id)
|
||||
->where('videos.season_num', $season->number);
|
||||
break;
|
||||
case Episode::class:
|
||||
$episode = Episode::with(['title'])->findOrFail($this->modelId);
|
||||
Gate::authorize('update', $episode->title);
|
||||
$builder = $episode->plays()->getQuery();
|
||||
break;
|
||||
default:
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
protected function getPlaysMetric(): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->playsTrend(
|
||||
$this->builder,
|
||||
$this->dateRange,
|
||||
);
|
||||
} else {
|
||||
$data = (new Trend(
|
||||
$this->builder,
|
||||
dateRange: $this->dateRange,
|
||||
))->count();
|
||||
}
|
||||
|
||||
return [
|
||||
'granularity' => $this->dateRange->granularity,
|
||||
'total' => array_sum(Arr::pluck($data, 'value')),
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDevicesMetric(): array
|
||||
{
|
||||
return $this->getPartitionMetric('device', 5);
|
||||
}
|
||||
|
||||
protected function getBrowsersMetric(): array
|
||||
{
|
||||
return $this->getPartitionMetric('browser', 8);
|
||||
}
|
||||
|
||||
protected function getPlatformsMetric(): array
|
||||
{
|
||||
return $this->getPartitionMetric('platform', 5);
|
||||
}
|
||||
|
||||
protected function getTitlesMetric(bool $isSeries = null): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->titles($isSeries);
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder
|
||||
->join('videos', 'video_plays.video_id', '=', 'videos.id')
|
||||
->join('titles', 'videos.title_id', '=', 'titles.id')
|
||||
->when(
|
||||
!is_null($isSeries),
|
||||
fn($query) => $query->where('is_series', $isSeries),
|
||||
)
|
||||
->orderBy('aggregate', 'desc'),
|
||||
groupBy: 'title_id',
|
||||
dateRange: $this->dateRange,
|
||||
limit: 30,
|
||||
))->count();
|
||||
|
||||
$titles = Title::whereIn('id', Arr::pluck($data, 'label'))
|
||||
->compact()
|
||||
->get();
|
||||
|
||||
$data = array_map(function ($item) use ($titles) {
|
||||
$title = $titles->firstWhere('id', $item['label']);
|
||||
$item['model'] = $title;
|
||||
$item['label'] = $title->name;
|
||||
return $item;
|
||||
}, $data);
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSeasonsMetric(): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->seasons($this->modelId);
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder
|
||||
->whereNotNull('season_num')
|
||||
->orderBy('aggregate', 'desc'),
|
||||
groupBy: 'season_num',
|
||||
dateRange: $this->dateRange,
|
||||
limit: 30,
|
||||
))->count();
|
||||
|
||||
$seasons = Season::where('title_id', $this->modelId)
|
||||
->whereIn('number', Arr::pluck($data, 'label'))
|
||||
->with(['title' => fn($query) => $query->compact()])
|
||||
->get();
|
||||
|
||||
$data = array_map(function ($item) use ($seasons) {
|
||||
$season = $seasons->firstWhere('number', (int) $item['label']);
|
||||
$item['model'] = $season;
|
||||
$item['label'] = __('Season :number', [
|
||||
'number' => $season->number,
|
||||
]);
|
||||
return $item;
|
||||
}, $data);
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getEpisodesMetric(): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->episodes($this->modelId);
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder
|
||||
->whereNotNull('episode_id')
|
||||
->orderBy('aggregate', 'desc'),
|
||||
groupBy: 'episode_id',
|
||||
dateRange: $this->dateRange,
|
||||
limit: 30,
|
||||
))->count();
|
||||
|
||||
$episodes = Episode::whereIn('id', Arr::pluck($data, 'label'))
|
||||
->with(['title' => fn($query) => $query->compact()])
|
||||
->get();
|
||||
|
||||
$data = array_map(function ($item) use ($episodes) {
|
||||
$episode = $episodes->firstWhere('id', (int) $item['label']);
|
||||
$item['model'] = $episode;
|
||||
$item['label'] = __('Season :s, Episode :e', [
|
||||
's' => $episode->season_number,
|
||||
'e' => $episode->number,
|
||||
]);
|
||||
return $item;
|
||||
}, $data);
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getVideosMetric(): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->videos();
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder->orderBy('aggregate', 'desc'),
|
||||
groupBy: 'video_id',
|
||||
dateRange: $this->dateRange,
|
||||
limit: 30,
|
||||
))->count();
|
||||
|
||||
$videos = Video::whereIn('id', Arr::pluck($data, 'label'))
|
||||
->with(['title' => fn($query) => $query->compact()])
|
||||
->get();
|
||||
|
||||
$data = array_map(function ($item) use ($videos) {
|
||||
$video = $videos->firstWhere('id', $item['label']);
|
||||
$item['model'] = $video;
|
||||
$item['label'] = $video?->name;
|
||||
return $item;
|
||||
}, $data);
|
||||
}
|
||||
|
||||
$data = array_values(array_filter($data, fn($item) => $item['label']));
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getUsersMetric(): array
|
||||
{
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->users();
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder->orderBy('aggregate', 'desc'),
|
||||
groupBy: 'user_id',
|
||||
dateRange: $this->dateRange,
|
||||
limit: 30,
|
||||
))->count();
|
||||
|
||||
$userIds = collect($data)
|
||||
->pluck('label')
|
||||
->filter()
|
||||
->unique();
|
||||
$users = User::whereIn('id', $userIds)->get();
|
||||
|
||||
$data = array_map(function ($item) use ($users) {
|
||||
$user =
|
||||
$users->firstWhere('id', $item['label']) ??
|
||||
new User(['first_name' => __('Guest user')]);
|
||||
$item['model'] = $user;
|
||||
$item['label'] = $user->display_name;
|
||||
return $item;
|
||||
}, $data);
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getLocationsMetric(): array
|
||||
{
|
||||
$metric = $this->getPartitionMetric('location');
|
||||
|
||||
$countries = app(ValueLists::class)->countries();
|
||||
|
||||
$metric['datasets'][0]['data'] = array_map(function ($location) use (
|
||||
$countries,
|
||||
$metric,
|
||||
) {
|
||||
// only short country code is stored in DB, get and return full country name as well
|
||||
$location['code'] = strtolower($location['label']);
|
||||
$location['label'] =
|
||||
Arr::first(
|
||||
$countries,
|
||||
fn($country) => strtolower($country['code']) ===
|
||||
strtolower($location['code']),
|
||||
)['name'] ?? $location['label'];
|
||||
return $location;
|
||||
}, $metric['datasets'][0]['data']);
|
||||
|
||||
return $metric;
|
||||
}
|
||||
|
||||
protected function getPartitionMetric(
|
||||
string $groupBy,
|
||||
int $limit = 10,
|
||||
): array {
|
||||
if (config('common.site.fake_plays_data')) {
|
||||
$data = (new GenerateFakePlaysData())->partitionMetric($groupBy);
|
||||
} else {
|
||||
$data = (new Partition(
|
||||
$this->builder,
|
||||
groupBy: $groupBy,
|
||||
dateRange: $this->dateRange,
|
||||
limit: $limit,
|
||||
))->count();
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => __('Plays'),
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Actions/Plays/GenerateFakePlaysData.php
Executable file
137
app/Actions/Plays/GenerateFakePlaysData.php
Executable file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Plays;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Season;
|
||||
use App\Models\Title;
|
||||
use App\Models\User;
|
||||
use Common\Admin\Analytics\Actions\BuildDemoAnalyticsReport;
|
||||
use Common\Admin\Analytics\Actions\DemoTrend;
|
||||
use Common\Database\Metrics\MetricDateRange;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateFakePlaysData
|
||||
{
|
||||
public function playsTrend(Builder $builder, MetricDateRange $range): array
|
||||
{
|
||||
return (new DemoTrend($builder, dateRange: $range))->count();
|
||||
}
|
||||
|
||||
public function partitionMetric(string $groupBy): array
|
||||
{
|
||||
$method =
|
||||
'build' .
|
||||
Str::of($groupBy)
|
||||
->ucfirst()
|
||||
->plural()
|
||||
->toString() .
|
||||
'Metric';
|
||||
return (new BuildDemoAnalyticsReport())->$method();
|
||||
}
|
||||
|
||||
public function titles(bool $isSeries = null): array
|
||||
{
|
||||
return Title::orderBy('popularity', 'desc')
|
||||
->when(
|
||||
!is_null($isSeries),
|
||||
fn($query) => $query->where('is_series', $isSeries),
|
||||
)
|
||||
->where('language', 'en')
|
||||
->limit(30)
|
||||
->compact()
|
||||
->get()
|
||||
->map(
|
||||
fn(Title $title) => [
|
||||
'label' => $title->name,
|
||||
'value' => random_int(50, 1654),
|
||||
'percentage' => random_int(1, 100),
|
||||
'model' => $title,
|
||||
],
|
||||
)
|
||||
->sortByDesc('value')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function seasons(int $titleId): array
|
||||
{
|
||||
return Season::where('title_id', $titleId)
|
||||
->with(['title' => fn($query) => $query->compact()])
|
||||
->get()
|
||||
->map(function (Season $season) {
|
||||
return [
|
||||
'label' => $season['name'],
|
||||
'value' => random_int(50, 1654),
|
||||
'percentage' => random_int(1, 100),
|
||||
'model' => $season,
|
||||
];
|
||||
})
|
||||
->sortByDesc('value')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function episodes(int $titleId): array
|
||||
{
|
||||
return Episode::where('title_id', $titleId)
|
||||
->with(['title' => fn($query) => $query->compact()])
|
||||
->get()
|
||||
->map(function (Episode $episode) {
|
||||
return [
|
||||
'label' => $episode['name'],
|
||||
'value' => random_int(50, 1654),
|
||||
'percentage' => random_int(1, 100),
|
||||
'model' => $episode,
|
||||
];
|
||||
})
|
||||
->sortByDesc('value')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function videos(): array
|
||||
{
|
||||
return Title::orderBy('popularity', 'desc')
|
||||
->where('language', 'en')
|
||||
->limit(30)
|
||||
->compact()
|
||||
->with('videos')
|
||||
->get()
|
||||
->filter(fn(Title $title) => $title->videos->isNotEmpty())
|
||||
->map(function (Title $title) {
|
||||
$video = $title->videos->random()->toArray();
|
||||
$title->unsetRelation('videos');
|
||||
$video['title'] = $title->toArray();
|
||||
return [
|
||||
'label' => $video['name'],
|
||||
'value' => random_int(50, 1654),
|
||||
'percentage' => random_int(1, 100),
|
||||
'model' => $video,
|
||||
];
|
||||
})
|
||||
->sortByDesc('value')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function users(): array
|
||||
{
|
||||
return User::inRandomOrder()
|
||||
->limit(30)
|
||||
->compact()
|
||||
->get()
|
||||
->map(
|
||||
fn(User $user) => [
|
||||
'label' => $user->display_name,
|
||||
'value' => random_int(50, 1654),
|
||||
'percentage' => random_int(1, 100),
|
||||
'model' => $user,
|
||||
],
|
||||
)
|
||||
->sortByDesc('value')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
92
app/Actions/Plays/LogVideoPlay.php
Executable file
92
app/Actions/Plays/LogVideoPlay.php
Executable file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Plays;
|
||||
|
||||
use App\Models\Video;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Jenssegers\Agent\Facades\Agent;
|
||||
|
||||
class LogVideoPlay
|
||||
{
|
||||
public function execute(Video $video, array $params = []): void
|
||||
{
|
||||
if (isset($params['currentTime'])) {
|
||||
$this->updateTimeWatched($video, $params);
|
||||
} else {
|
||||
$this->logVideoPlay($video);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateTimeWatched(Video $video, array $params): void
|
||||
{
|
||||
$lastPlay = $video
|
||||
->plays()
|
||||
->forCurrentUser()
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
$timeWatched = round($params['currentTime']);
|
||||
$duration = round($params['duration']);
|
||||
|
||||
// if user watched over 95%, we can assume video is fully watched
|
||||
$fullyWatched = $timeWatched >= (95 / 100) * $duration;
|
||||
|
||||
// if fully watched or watched less than 60 seconds, set time watched to 0
|
||||
if ($fullyWatched || $timeWatched < 60) {
|
||||
$timeWatched = 0;
|
||||
}
|
||||
|
||||
if ($lastPlay) {
|
||||
$lastPlay
|
||||
->fill([
|
||||
'time_watched' => $timeWatched,
|
||||
'duration' => round($params['duration']),
|
||||
])
|
||||
->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function logVideoPlay(Video $video): void
|
||||
{
|
||||
if (!$this->alreadyLoggedToday($video)) {
|
||||
$ip = getIp();
|
||||
$video->plays()->create([
|
||||
'location' => $this->getLocation($ip),
|
||||
'platform' => strtolower(Agent::platform()),
|
||||
'device' => $this->getDevice(),
|
||||
'browser' => strtolower(Agent::browser()),
|
||||
'user_id' => Auth::id(),
|
||||
'ip' => $ip,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function alreadyLoggedToday(Video $video): bool
|
||||
{
|
||||
return $video
|
||||
->plays()
|
||||
->forCurrentUser()
|
||||
->whereBetween('created_at', [
|
||||
Carbon::now()->subDay(),
|
||||
Carbon::now(),
|
||||
])
|
||||
->exists();
|
||||
}
|
||||
|
||||
protected function getDevice(): string
|
||||
{
|
||||
if (Agent::isMobile()) {
|
||||
return 'mobile';
|
||||
} elseif (Agent::isTablet()) {
|
||||
return 'tablet';
|
||||
} else {
|
||||
return 'desktop';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLocation(string $ip): string
|
||||
{
|
||||
return strtolower(geoip($ip)['iso_code']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user