first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

18
.editorconfig Executable file
View File

@@ -0,0 +1,18 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
quote_type = single
max_line_length = 80
[*.md]
max_line_length = off
trim_trailing_whitespace = false
[*.php]
indent_size = 4

59
.env.example Executable file
View File

@@ -0,0 +1,59 @@
APP_NAME=MTDb
APP_ENV=local
APP_KEY=base64:NE5wdzVEQm9CeUVMTGRtczNEZXRuVjFaeVQ0QmJTQUc=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_URL=http://localhost
APP_TIMEZONE=UTC
APP_LOCALE=english
APP_VERSION=4.0.4
DISABLE_UPDATE_AUTH=true
BILLING_ENABLED=true
INSTALLED=false
LOCAL_SEARCH_MODE=fulltext
ENABLE_CONTACT_PAGE=true
NOTIFICATIONS_ENABLED=true
SCOUT_DRIVER=mysql
SCOUT_MYSQL_MODE=extended
SCOUT_PREFIX=mtdb_
API_INTEGRATED=true
CLOCKWORK_ENABLED=false
PULSE_ENABLED=false
DB_CONNECTION=mysql
DB_HOST=172.28.2.1
DB_PORT=3306
DB_DATABASE=mtdb
DB_USERNAME=root
DB_PASSWORD=root
DB_STRICT_MODE=false
DB_PREFIX=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_APP_ID=
PUSHER_APP_KEY=
SENTRY_DSN=null
RATING_COLUMN=tmdb_vote_average
STATIC_FILE_DELIVERY=null
PUBLIC_DISK_DRIVER=local
PAYPAL_PRODUCT_ID=b808bbf3-7372-4e57-b6fd-7672a546bd61
PAYPAL_PRODUCT_NAME="MTDb Subscription"

64
.eslintrc.json Executable file
View File

@@ -0,0 +1,64 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 13,
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "@typescript-eslint"],
"ignorePatterns": ["*.config.*", "*.d.ts"],
"rules": {
"react/destructuring-assignment": "off",
"@typescript-eslint/naming-convention": "off",
"react-hooks/rules-of-hooks": "error",
"react/no-array-index-key": "off",
"react/jsx-filename-extension": [
1,
{
"extensions": [".tsx"]
}
],
"import/prefer-default-export": "off",
"prefer-destructuring": "off",
"radix": ["error", "as-needed"],
"prefer-const": [
"error",
{
"destructuring": "all"
}
],
"no-use-before-define": "off",
"no-plusplus": "off",
"arrow-body-style": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/lines-between-class-members": "off",
"no-unused-expressions": "off",
"@typescript-eslint/no-unused-expressions": "error",
"no-param-reassign": "off",
"class-methods-use-this": "off",
"consistent-return": "off",
"default-case": "off",
"no-return-assign": ["error", "except-parens"],
"react/react-in-jsx-scope": "off",
"react/require-default-props": "off",
"react/prop-types": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-fragments": "off",
"react/function-component-definition": "off",
"import/no-extraneous-dependencies": "off",
"react/display-name": "off",
"react/no-unstable-nested-components": ["error", {"allowAsProps": true}]
}
}

5
.gitattributes vendored Executable file
View File

@@ -0,0 +1,5 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
CHANGELOG.md export-ignore

33
.github/workflows/build.yaml vendored Executable file
View File

@@ -0,0 +1,33 @@
name: Build
on: [push]
jobs:
run:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}
steps:
- uses: actions/checkout@v3
with:
submodules: 'true'
token: ${{ secrets.GHUB_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Build client
run: npm run build
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET }}
aws-region: us-west-1
- name: Deploy build assets to S3 bucket
run: aws s3 sync ./public/build/ s3://vebto-assets/mtdb/build --delete
#- name: Deploy vendor assets to S3 bucket
#run: aws s3 sync ./public/vendor/ s3://vebto-assets/mtdb/vendor --delete

50
.gitignore vendored Executable file
View File

@@ -0,0 +1,50 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
vendor
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# misc
/storage/tntsearch/*.index
/storage/framework/maintenance.php
.htaccess
public/.htaccess
public/favicon
public/favicon.ico
install_files
/public/hot
/storage/*.key
Homestead.json
Homestead.yaml
.env
data.ms
/test-results/
/playwright-report/
/playwright/.cache/
storageState.json
# translation files (include default "en"), will be created during installation
resources/lang/*.json
# Sentry Auth Token
.env.sentry-build-plugin

3
.gitmodules vendored Executable file
View File

@@ -0,0 +1,3 @@
[submodule "common"]
path = common
url = git@github.com:RamunasO/common-new.git

2
.prettierignore Executable file
View File

@@ -0,0 +1,2 @@
**/routes/*.php
*.min.css

19
.prettierrc.yaml Executable file
View File

@@ -0,0 +1,19 @@
arrowParens: avoid
bracketSpacing: false
phpVersion: "8.0"
singleQuote: true
plugins:
- "@prettier/plugin-php"
- "prettier-plugin-tailwindcss"
- "prettier-plugin-blade"
overrides:
- files: "*.html"
options:
printWidth: 100
tabWidth: 2
singleQuote: false
- files: "*.php"
options:
tabWidth: 4

193
app/Actions/AppValueLists.php Executable file
View File

@@ -0,0 +1,193 @@
<?php
namespace App\Actions;
use App\Models\Genre;
use App\Models\Keyword;
use App\Models\ProductionCountry;
use App\Models\Title;
use Common\Core\Values\ValueLists;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class AppValueLists extends ValueLists
{
public function titleFilterLanguages($params = [])
{
$allLanguages = json_decode(
$this->fs->get(base_path('resources/lists/tmdb-languages.json')),
true,
);
$usedLanguages = Title::withoutGlobalScope('adult')
->select('language')
->groupBy('language')
->select([DB::raw('count(*) as total'), 'language'])
->pluck('total', 'language')
->toArray();
return collect($allLanguages)
->filter(fn($language) => isset($usedLanguages[$language['code']]))
->map(
fn($language) => [
'value' => $language['code'],
'name' => $language['name'],
'total' => $usedLanguages[$language['code']] ?? 0,
],
)
->sortByDesc('total')
->values();
}
public function productionCountries($params = []): Collection
{
return ProductionCountry::get(['id', 'name', 'display_name'])->map(
fn($country) => [
'value' => $country->id,
'name' => $country->display_name,
],
);
}
public function genres($params = []): Collection
{
if (Arr::get($params, 'type') === 'tmdb') {
$genres = json_decode(
$this->fs->get(resource_path('lists/tmdb-genres.json')),
true,
);
return collect($genres)->map(
fn($genre) => [
'value' => $genre['id'],
'name' => $genre['name'],
],
);
} else {
return Genre::get(['id', 'name', 'display_name'])->map(
fn($genre) => [
'value' => $genre->id,
'name' => $genre->display_name,
],
);
}
}
public function languages($params = [])
{
if (Arr::get($params, 'type') === 'tmdb') {
return collect(
json_decode(
$this->fs->get(resource_path('lists/tmdb-languages.json')),
true,
),
);
} else {
return parent::languages();
}
}
public function countries($params = [])
{
if (Arr::get($params, 'type') === 'tmdb') {
return collect(
json_decode(
$this->fs->get(resource_path('lists/tmdb-countries.json')),
true,
),
);
} else {
return parent::countries();
}
}
public function keywords($params = [])
{
if (Arr::get($params, 'type') === 'tmdb') {
$allKeywords = collect(
json_decode(
$this->fs->get(resource_path('lists/tmdb-keywords.json')),
true,
),
);
if (isset($params['searchQuery'])) {
$allKeywords = $allKeywords
->filter(
fn($keyword) => str_contains(
strtolower($keyword['name']),
strtolower($params['searchQuery']),
),
)
->values();
}
$keywords = $allKeywords->slice(0, 50)->map(
fn($keyword) => [
'value' => $keyword['id'],
'name' => $keyword['name'],
],
);
if ($selectedValue = Arr::get($params, 'selectedValue')) {
$selectedKeyword = $allKeywords->firstWhere(
'id',
$selectedValue,
);
if ($selectedKeyword) {
$keywords->prepend([
'value' => $selectedKeyword['id'],
'name' => $selectedKeyword['name'],
]);
}
}
} else {
$query = isset($params['searchQuery'])
? Keyword::search($params['searchQuery'])
: Keyword::query();
$keywords = $query
->take(50)
->get()
->map(
fn($keyword) => [
'value' => $keyword->id,
'name' => $keyword->display_name,
],
);
if ($selectedValue = Arr::get($params, 'selectedValue')) {
$selectedKeyword = Keyword::find($selectedValue);
if ($selectedKeyword) {
$keywords->prepend([
'value' => $selectedKeyword->id,
'name' => $selectedKeyword->display_name,
]);
}
}
}
return $keywords;
}
public function titleFilterAgeRatings($params = []): Collection
{
return Title::withoutGlobalScope('adult')
->select('certification')
->groupBy('certification')
->select([DB::raw('count(*) as total'), 'certification'])
->orderBy('total', 'desc')
->pluck('certification')
->filter(
fn($certification) => !!$certification &&
$certification !== '-',
)
->map(
fn($certification) => [
'value' => $certification,
'name' => strtoupper($certification),
],
)
->values();
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Actions\Channels;
use App\Models\Episode;
use App\Models\Genre;
use App\Models\Keyword;
use App\Models\Person;
use App\Models\ProductionCountry;
use App\Models\Title;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class FetchContentFromLocalDatabase
{
public function execute(
string $method,
string $modelType,
?array $filters = [],
): Collection {
$isSeries = $modelType === Title::SERIES_TYPE;
if ($method === 'trendingPeople') {
return Person::orderBy('popularity', 'desc')
->limit(20)
->get();
} elseif ($method === 'mostPopular') {
return $this->getTitleBuilder($filters)
->orderBy('popularity', 'desc')
->limit(20)
->where('is_series', $isSeries)
->get();
} elseif ($method === 'topRated') {
return $this->getTopRatedTitles(
$this->getTitleBuilder($filters),
$isSeries,
);
} elseif ($method === 'upcoming') {
return $this->getMoviesReleasingBetween(
$this->getTitleBuilder($filters),
Carbon::now(),
Carbon::now()->addWeek(),
);
} elseif ($method === 'nowPlaying') {
return $this->getMoviesReleasingBetween(
$this->getTitleBuilder($filters),
Carbon::now(),
Carbon::now()->subWeek(),
);
} elseif ($method === 'onTheAir') {
$this->getSeriesAiringBetween(
$this->getTitleBuilder($filters),
Carbon::now(),
Carbon::now()->addWeek(),
);
} elseif ($method === 'airingToday') {
return $this->getSeriesAiringBetween(
$this->getTitleBuilder($filters),
Carbon::now(),
Carbon::now()->addDay(),
);
} elseif ($method === 'latestVideos') {
return $this->getTitleBuilder($filters)
->join('videos', 'titles.id', '=', 'videos.title_id')
->where('videos.origin', 'local')
->where('approved', true)
->orderBy('videos.created_at', 'desc')
->select('titles.*')
->distinct()
->limit(20)
->get();
} elseif ($method === 'lastAdded') {
return $this->getTitleBuilder($filters)
::where('is_series', $isSeries)
->orderBy('created_at', 'desc')
->limit(20)
->get();
}
}
private function getTopRatedTitles(mixed $builder, $isSeries): Collection
{
$ratingCol = config('common.site.rating_column');
$query = $builder->where('is_series', $isSeries);
if (Str::contains($ratingCol, 'tmdb_vote_average')) {
$query->orderBy(DB::raw('tmdb_vote_count > 100'), 'desc');
}
return $query
->orderBy($ratingCol, 'desc')
->limit(20)
->get();
}
private function getMoviesReleasingBetween(
mixed $builder,
$from,
$to,
): Collection {
return $builder
->whereBetween('release_date', [$from, $to])
->orderBy('popularity', 'desc')
->limit(20)
->where('is_series', false)
->get(['id', 'name']);
}
private function getSeriesAiringBetween(
mixed $builder,
$from,
$to,
): Collection {
$titleIds = Episode::whereBetween('release_date', [$from, $to])
->limit(300)
->get(['title_id'])
->pluck('title_id')
->unique()
->slice(0, 20);
return $builder->whereIn('id', $titleIds)->get(['id', 'name']);
}
private function getTitleBuilder(?array $filters = []): mixed
{
if (isset($filters['keyword'])) {
return Keyword::findOrFail($filters['keyword'])->titles();
} elseif (isset($filters['genre'])) {
return Genre::findOrFail($filters['genre'])->titles();
} elseif (isset($filters['country'])) {
return ProductionCountry::findOrFail($filters['country'])->titles();
} else {
return Title::query();
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Actions\Channels;
use App\Models\Person;
use App\Models\Title;
use App\Services\Data\Tmdb\TmdbApi;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class FetchContentFromTmdb
{
public function execute(
string $method,
string $modelType,
?array $filters = [],
): Collection {
Title::disableSearchSyncing();
Person::disableSearchSyncing();
if ($modelType === Person::MODEL_TYPE) {
return $this->importPeople($method);
} else {
return $this->ImportTitles($method, $modelType, $filters);
}
}
protected function importPeople(string $method)
{
if ($method === 'trendingPeople') {
$people = app(TmdbApi::class)->trendingPeople();
return $people
->map(
fn($personData) => Person::firstOrCreateFromEncodedTmdbId(
$personData['id'],
)->maybeUpdateFromExternal([
'forceAutomation' => true,
]),
)
->filter();
}
}
protected function ImportTitles(
string $method,
string $modelType,
?array $extraFilters = [],
) {
$filters = $this->buildFilters($method, $modelType);
if (count($extraFilters)) {
if (isset($extraFilters['keyword'])) {
$filters['with_keywords'] = $extraFilters['keyword'];
}
if (isset($extraFilters['genre'])) {
$filters['with_genres'] = $extraFilters['genre'];
}
if (isset($extraFilters['country'])) {
$filters['with_origin_country'] = $extraFilters['country'];
}
if (isset($extraFilters['language'])) {
$filters['with_original_language'] = $extraFilters['language'];
}
}
// fetch from tmdb
$titles = app(TmdbApi::class)->browse(1, $modelType, $filters)[
'results'
];
// store in local db
return $titles
->map(function ($titleData) {
return Title::firstOrCreateFromEncodedTmdbId(
$titleData['id'],
)->maybeUpdateFromExternal([
'forceAutomation' => true,
//'updateLast3Seasons' => true,
]);
})
->filter();
}
protected function buildFilters(string $method, string $modelType): array
{
$type = "{$modelType}.{$method}";
switch ($type) {
case 'movie.mostPopular':
$from = Carbon::now()
->subMonths(6)
->format('Y-m-d');
return [
'sort_by' => 'popularity.desc',
'primary_release_date.gte' => $from,
];
case 'movie.topRated':
case 'series.topRated':
return [
'sort_by' => 'vote_average.desc',
'vote_count.gte' => 600,
];
case 'movie.upcoming':
$from = Carbon::now()
->addDay()
->format('Y-m-d');
$to = Carbon::now()
->addMonth()
->format('Y-m-d');
return [
'sort_by' => 'popularity.desc',
'with_release_type' => '2|3',
'primary_release_date.gte' => $from,
'primary_release_date.lte' => $to,
];
case 'movie.nowPlaying':
$from = Carbon::now()
->subMonths(2)
->format('Y-m-d');
$to = Carbon::now()
->subDays(2)
->format('Y-m-d');
return [
'sort_by' => 'popularity.desc',
'with_release_type' => '2|3',
'primary_release_date.gte' => $from,
'primary_release_date.lte' => $to,
];
case 'series.mostPopular':
return [
'sort_by' => 'popularity.desc',
];
case 'series.airingThisWeek':
$from = Carbon::now()
->startOfDay()
->format('Y-m-d');
$to = Carbon::now()
->startOfDay()
->addDays(6)
->format('Y-m-d');
return [
'sort_by' => 'popularity.desc',
'air_date.gte' => $from,
'air_date.lte' => $to,
];
case 'series.airingToday':
$from = Carbon::now()
->startOfDay()
->format('Y-m-d');
$to = Carbon::now()
->endOfDay()
->format('Y-m-d');
return [
'sort_by' => 'popularity.desc',
'air_date.gte' => $from,
'air_date.lte' => $to,
];
default:
return [];
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Actions\Demo;
use App\Models\Episode;
use App\Models\Title;
use App\Models\Video;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoAnimeVideos
{
public function execute(): void
{
$output = new ConsoleOutput();
$output->write('Generating demo anime videos... ', true);
$this->createTitleLinks();
$this->createEpisodeLinks();
}
private function createTitleLinks(): void
{
Title::where('is_series', false)
->select('id')
->whereDoesntHave('videos', function ($query) {
$query->where('category', 'full')->where('origin', 'local');
})
->chunkById(100, function (Collection $titles) {
$titleVideos = $titles
->pluck('id')
->map(fn($titleId) => $this->getVideosData($titleId))
->flatten(1);
Video::insert($titleVideos->toArray());
});
}
private function createEpisodeLinks(): void
{
Episode::whereDoesntHave('videos', function ($query) {
$query->where('category', 'full')->where('origin', 'local');
})->chunkById(100, function (Collection $episodes) {
$episodeVideos = $episodes
->map(
fn(Episode $episode) => $this->getVideosData(
$episode->title_id,
$episode,
),
)
->flatten(1);
Video::insert($episodeVideos->toArray());
});
}
private function getVideosData($titleId, Episode $episode = null): array
{
$sharedData = [
'category' => 'full',
'title_id' => $titleId,
'season_num' => $episode?->season_number,
'episode_num' => $episode?->episode_number,
'episode_id' => $episode?->id,
'origin' => 'local',
'approved' => true,
'updated_at' => Carbon::now(),
'user_id' => 1,
];
$urls = [
'https://www.youtube.com/embed/ByXuk9QqQkk',
'https://player.vimeo.com/video/208890816',
'https://www.dailymotion.com/embed/video/x4qi23d',
'https://www.youtube.com/embed/xU47nhruN-Q',
'https://google.com',
];
$languages = ['en', 'ru', 'fr', 'en', 'en'];
$videos = [];
for ($i = 0; $i <= 4; $i++) {
$num = $i + 1;
$videos[] = array_merge($sharedData, [
'name' => "Mirror $num",
'src' => $urls[$i],
'quality' => $i === 3 ? '4k' : 'hd',
'language' => $languages[$i],
'type' => $i === 4 ? 'external' : 'embed',
'upvotes' => rand(1, 200),
'downvotes' => rand(1, 30),
'created_at' => Carbon::now()->subDay(),
]);
}
return $videos;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Actions\Demo;
use App\Models\Episode;
use App\Models\Title;
use App\Models\User;
use Common\Files\Traits\HandlesEntryPaths;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoComments
{
use HandlesEntryPaths;
private Collection $users;
private int $currentId = 1;
public function execute(string $variant = null): void
{
DB::table('comment_reports')->truncate();
DB::table('comment_votes')->truncate();
$this->currentId = DB::table('comments')->max('id') + 1;
$demoEmails = collect(
json_decode(
file_get_contents(app_path('Actions/Demo/demo-users.json')),
true,
),
)->pluck('email');
$this->users = User::whereIn('email', $demoEmails)->get();
// movies
$this->generateFor(
Title::where('release_date', '<', now()->subDays(6))
->where('is_series', false)
->where('popularity', '>=', 10),
$variant === 'anime'
? app_path('Actions/Demo/demo-anime-comments.json')
: app_path('Actions/Demo/demo-movie-comments.json'),
);
// series
$this->generateFor(
Title::where('release_date', '<', now()->subDays(6))
->withCount('episodes')
->where('is_series', true)
->where('popularity', '>=', 10)
->has('episodes', '<', 400),
$variant === 'anime'
? app_path('Actions/Demo/demo-anime-comments.json')
: app_path('Actions/Demo/demo-series-comments.json'),
);
// episodes
$this->generateFor(
Episode::where('release_date', '<', now()->subDays(6))->whereHas(
'title',
function ($query) {
$query->where('popularity', '>=', 1);
},
),
$variant === 'anime'
? app_path('Actions/Demo/demo-anime-comments.json')
: app_path('Actions/Demo/demo-episode-comments.json'),
);
}
protected function generateFor(Builder $builder, string $path): void
{
$query = $builder->has('comments', '<', 40)->select('id');
$count = $query->count();
if (!$count) {
return;
}
$output = new ConsoleOutput();
$progressBar = new ProgressBar($output, $count);
$progressBar->start();
$output->write(
"Generating comments for {$builder->getModel()->getTable()}",
true,
);
$userSequence = new Sequence(...$this->users->pluck('id')->toArray());
$data = json_decode(file_get_contents($path), true);
$itemComments = collect($data)
->slice(0, rand(40, 118))
->map(function ($comment) use ($userSequence) {
$date = now()
->subMonth(rand(0, 2))
->subDays(rand(1, 12));
return [
'user_id' => $userSequence(),
'content' => $comment['comment'],
'upvotes' => rand(5, 200),
'downvotes' => rand(0, 20),
'reports_count' => rand(0, 3),
'created_at' => $date,
'updated_at' => $date,
];
});
$query
->lazyById(100)
->each(function ($item) use ($progressBar, $itemComments) {
$payload = $itemComments->map(function ($comment) use ($item) {
$data = array_merge($comment, [
'id' => $this->currentId,
'path' => $this->encodePath($this->currentId),
'commentable_id' => $item->id,
'commentable_type' => $item->getMorphClass(),
]);
$this->currentId++;
return $data;
});
DB::table('comments')->insert($payload->toArray());
$progressBar->advance();
});
$progressBar->finish();
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Actions\Demo;
use App\Models\Title;
use App\Models\User;
use Common\Files\Traits\HandlesEntryPaths;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoReviews
{
use HandlesEntryPaths;
private Collection $users;
public function execute(): void
{
DB::table('review_reports')->truncate();
DB::table('review_feedback')->truncate();
$demoEmails = collect(
json_decode(
file_get_contents(app_path('Actions/Demo/demo-users.json')),
true,
),
)->pluck('email');
$this->users = User::whereIn('email', $demoEmails)->get();
$this->generateFor('movie');
$this->generateFor('series');
}
protected function generateFor(string $type): void
{
$query = Title::where('is_series', $type == 'series')
->where('popularity', '>=', 10)
->where('release_date', '<', now()->subDays(6))
->has('reviews', '<', 40)
->select('id');
$count = $query->count();
if ($count === 0) {
return;
}
$output = new ConsoleOutput();
$progressBar = new ProgressBar($output, $count);
$progressBar->start();
$output->write(
"Generating reviews for {$query->getModel()->getTable()}",
true,
);
$userSequence = new Sequence(...$this->users->pluck('id')->toArray());
$data = json_decode(
file_get_contents(app_path("Actions/Demo/demo-$type-reviews.json")),
true,
);
$movieReviews = collect($data)
->slice(0, rand(40, $this->users->count()))
->map(function ($review) use ($userSequence) {
$date = now()
->subMonth(rand(0, 2))
->subDays(rand(1, 12));
return [
'user_id' => $userSequence(),
'title' => $review['title'],
'body' => $review['body'],
'score' => $review['score'],
'has_text' => true,
'helpful_count' => rand(10, 200),
'not_helpful_count' => rand(0, 20),
'created_at' => $date,
'updated_at' => $date,
];
});
$query
->lazyById(100)
->each(function ($movie) use ($movieReviews, $progressBar) {
$payload = $movieReviews->map(
fn($review) => array_merge($review, [
'reviewable_id' => $movie->id,
'reviewable_type' => Title::MODEL_TYPE,
]),
);
DB::table('reviews')->insert($payload->toArray());
$progressBar->advance();
});
$progressBar->finish();
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Actions\Demo;
use App\Models\Episode;
use App\Models\Title;
use App\Models\Video;
use App\Models\VideoCaption;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoStreamVideos
{
protected array $demoVideos = [
[
'name' => 'Demo HLS',
'src' =>
'https://stream.mux.com/buWV5Srtc9vUQTWmgpTFkDNlA3zImB7C9CQ2CVgkZpI.m3u8',
'type' => 'stream',
'quality' => '1080p',
],
[
'name' => 'Demo HLS',
'src' =>
'https://stream.mux.com/Rus46I1a5LSATgJ6YDhbDo024F7qHEYZBTbOxRZJdIn4.m3u8',
'type' => 'stream',
'quality' => '1080p',
],
[
'name' => 'Demo HLS',
'src' =>
'https://stream.mux.com/E7hs5dNds85023rGvL4rrAjPI02BpRE2024XyFnjx007oj4.m3u8',
'type' => 'stream',
'quality' => '1080p',
],
[
'name' => 'Demo HLS',
'src' =>
'https://stream.mux.com/Q20238p74cLNL4Bm2ivdjGnSozST6Gec2Ym1EuDnCMJI.m3u8',
'type' => 'stream',
'quality' => '1080p',
],
[
'name' => 'Demo HLS',
'src' =>
'https://stream.mux.com/spmRS3NLqWHkkBGEYM4KRhj6Hytnat00AnPS8v3hj9qA.m3u8',
'type' => 'stream',
'quality' => '1080p',
],
[
'name' => 'Demo WebM',
'src' => 'storage/demo-videos/sprite-fright/sprite-fright.webm',
'type' => 'video',
'quality' => '1080p',
'captions' => [
[
'name' => 'English',
'language' => 'en',
'url' =>
'storage/demo-videos/sprite-fright/subs/sprite-fright.en.vtt',
],
[
'name' => 'Russian',
'language' => 'ru',
'url' =>
'storage/demo-videos/sprite-fright/subs/sprite-fright.ru.vtt',
],
[
'name' => 'German',
'language' => 'de',
'url' =>
'storage/demo-videos/sprite-fright/subs/sprite-fright.de.vtt',
],
[
'name' => 'Italian',
'language' => 'it',
'url' =>
'storage/demo-videos/sprite-fright/subs/sprite-fright.it.vtt',
],
],
],
];
protected Sequence $demoVideoSequence;
public function execute(): void
{
$this->demoVideoSequence = new Sequence(...$this->demoVideos);
$output = new ConsoleOutput();
$output->write('Generating demo stream videos... ', true);
$this->createVideosForAllMovies();
$this->createEpisodeVideos();
$this->createCaptions();
}
private function createVideosForAllMovies(): void
{
Title::where('is_series', false)
->whereDoesntHave(
'videos',
fn($query) => $query
->where('origin', 'local')
->where('category', 'full'),
)
->select('id')
->chunkById(100, function (Collection $titles) {
$titleVideos = $titles->map(
fn($title) => $this->getVideoData($title->id),
);
Video::insert($titleVideos->toArray());
});
}
private function createCaptions(): void
{
collect($this->demoVideos)
->filter(fn($video) => isset($video['captions']))
->each(function ($data) {
Video::where('origin', 'local')
->where('category', 'full')
->where('name', $data['name'])
->whereDoesntHave('captions')
->chunkById(100, function (Collection $videos) use ($data) {
$captionPayload = $videos->flatMap(
fn(Video $video) => array_map(
fn($captionData) => [
'name' => $captionData['name'],
'language' => $captionData['language'],
'url' => $captionData['url'],
'video_id' => $video->id,
'created_at' => now(),
'updated_at' => now(),
],
$data['captions'],
),
);
VideoCaption::insert($captionPayload->toArray());
});
});
}
private function createEpisodeVideos(): void
{
Episode::whereDoesntHave(
'videos',
fn($query) => $query
->where('origin', 'local')
->where('category', 'full'),
)->chunkById(100, function (Collection $episodes) {
$episodeVideos = $episodes->map(
fn(Episode $episode) => $this->getVideoData(
$episode->title_id,
$episode,
),
);
Video::insert($episodeVideos->toArray());
});
}
private function getVideoData(int $titleId, Episode $episode = null): array
{
$sequence = $this->demoVideoSequence;
$data = $sequence();
return [
'name' => $data['name'],
'src' => $data['src'],
'type' => $data['type'],
'category' => 'full',
'quality' => $data['quality'],
'title_id' => $titleId,
'season_num' => $episode?->season_number,
'episode_num' => $episode?->episode_number,
'episode_id' => $episode?->id,
'origin' => 'local',
'approved' => true,
'created_at' => now(),
'updated_at' => now(),
'language' => 'en',
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Actions\Demo;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Str;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoUsers
{
public function execute(): void
{
$data = collect(
json_decode(
file_get_contents(
base_path('app/Actions/Demo/demo-users.json'),
),
true,
),
)->unique('email');
$existing = User::whereIn('email', $data->pluck('email'))->get();
$unique = $data->reject(
fn($user) => $existing->contains('email', $user['email']),
);
if ($unique->isEmpty()) {
return;
}
$output = new ConsoleOutput();
$output->write('Generating demo users... ', true);
$avatarSequence = new Sequence(...range(1, 75));
$users = $unique->map(function ($user) use ($avatarSequence) {
$number = $avatarSequence();
$gender = strtolower($user['gender']);
return [
'email' => $user['email'],
'first_name' => $user['first_name'],
'last_name' => $user['last_name'],
'gender' => $gender,
'password' => Str::random(),
'email_verified_at' => now(),
'avatar' => "https://xsgames.co/randomusers/assets/avatars/$gender/$number.jpg",
];
});
User::insert($users->toArray());
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Actions\Demo;
use App\Models\Video;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\ConsoleOutput;
class GenerateDemoVideoVotes
{
public function execute(): void
{
// delete old votes
DB::table('video_votes')->truncate();
$count = Video::where('upvotes', '<', 60)->count();
if ($count == 0) {
return;
}
$output = new ConsoleOutput();
$output->write('Generating video votes... ', true);
$progressBar = new ProgressBar($output, $count);
$progressBar->start();
Video::select('id')
->where('upvotes', '<', 60)
->lazyById(100)
->each(function (Video $video) use ($progressBar) {
$video->update([
'upvotes' => rand(60, 2100),
'downvotes' => rand(50, 150),
]);
$progressBar->advance();
});
$progressBar->finish();
}
}

View File

@@ -0,0 +1,161 @@
[
{
"comment": "OMG, this anime had me on the edge of my seat! The action scenes were so freaking epic, and the plot was just mind-blowing."
},
{
"comment": "Dude, I can't get enough of the awesome animation in this series. It's like a visual feast, man!"
},
{
"comment": "This anime is a total blast, yo! It's got a bit of everything laughs, romance, and crazy adventures."
},
{
"comment": "Bro, just when I thought I had it all figured out, this anime threw me for a loop! Loved all those surprises, man."
},
{
"comment": "The character development in this anime is freaking awesome! I got super attached to the main crew and their struggles."
},
{
"comment": "No lie, I binge-watched this entire series in one sitting. It's hella addictive, worth staying up all night for!"
},
{
"comment": "If you want a feel-good anime with a positive message, this one's a winner. Left me with a big ol' smile, man!"
},
{
"comment": "The voice acting in this anime is off the charts, bro! The emotions hit you right in the gut."
},
{
"comment": "As an anime fanatic, I'm telling you, this one's the real deal. A hidden gem that needs more love, dude!"
},
{
"comment": "The world-building in this anime is sick, man! I got lost in that crazy universe, couldn't get enough of it!"
},
{
"comment": "Bro, the art style is out of this world! Every frame is like a work of art, totally blows your mind!"
},
{
"comment": "Ngl, I haven't cried this much over an anime in forever. The feels hit you right in the heart, dude!"
},
{
"comment": "This anime took me on an emotional rollercoaster, man! I laughed, I cried, I freaking loved it all!"
},
{
"comment": "The fight scenes are straight-up epic! They get your blood pumping, and you can't take your eyes off 'em."
},
{
"comment": "Man, I'm jamming to the sick tunes in this anime. The soundtrack is fire, sets the perfect mood!"
},
{
"comment": "If you're into mind-bending plots and crazy twists, you're gonna be mind-blown by this anime, bro!"
},
{
"comment": "The supporting characters in this anime are just as rad as the main crew. I dig 'em all!"
},
{
"comment": "This anime knows how to balance humor and serious stuff. Keeps you entertained the whole time!"
},
{
"comment": "This anime ain't afraid to tackle deep questions, man. Makes you think about life and stuff, you know?"
},
{
"comment": "Big props to this anime for showing respect to different cultures. It feels genuine, man."
},
{
"comment": "The pacing in this anime is on point, dude. It keeps the story moving without dragging it out."
},
{
"comment": "This anime's got a kickass mythology and lore. It adds a whole new dimension to the story!"
},
{
"comment": "The friendships in this anime are so heartwarming, man. It's all about the bonds, and it hits you in the feels!"
},
{
"comment": "Yo, I've been recommending this anime to everyone I know. It's a must-watch for any anime fan, no doubt!"
},
{
"comment": "OMG, the humor in this anime had me cracking up big time! Laughed my guts out in every episode!"
},
{
"comment": "I'm obsessed with the crazy character designs, man. Each one is so unique and stands out!"
},
{
"comment": "This anime is full of epic quotes that stick with you, dude. You'll be saying 'em all day long!"
},
{
"comment": "I wish I could live in the world of this anime, man. The universe is so freaking cool!"
},
{
"comment": "The character arcs in this anime are next-level, bro. You see 'em grow and change, and it's awesome!"
},
{
"comment": "The action sequences are out of this world, man. They get your heart racing, no lie!"
},
{
"comment": "Yo, this anime left me with a rollercoaster of emotions. Happy, sad, everything in between!"
},
{
"comment": "I can't get enough of the catchy opening and ending themes, man. They get stuck in your head!"
},
{
"comment": "The attention to detail in the animation is sick. It's all about those little things that make it pop!"
},
{
"comment": "The villain in this anime is wicked! Love to hate 'em, bro!"
},
{
"comment": "The character relationships feel so real and relatable, man. You get invested in their lives!"
},
{
"comment": "OMG, the plot twists had me shook, bro. I never saw 'em coming, that's for sure!"
},
{
"comment": "This anime dives deep into life's big questions, man. Makes you ponder and stuff."
},
{
"comment": "The art and animation quality never disappoint. It's top-notch all the way, dude!"
},
{
"comment": "The emotional depth of this anime hits you hard, man. It's a rollercoaster ride!"
},
{
"comment": "I couldn't stop watching once I started, dude. It's totally addicting!"
},
{
"comment": "This anime strikes the perfect balance between laughs and heartwarming moments. So darn enjoyable!"
},
{
"comment": "The character backstories are on point, bro. It adds layers of awesomeness to the story!"
},
{
"comment": "Yo, I'm impressed by how this anime respects different cultures. It's all about that diversity, man!"
},
{
"comment": "The world-building in this anime is so freaking cool. It's like a whole new universe to explore!"
},
{
"comment": "This anime's all about friendship, love, and courage, man. Hits you right in the feels!"
},
{
"comment": "The plot keeps you guessing, bro. It's like a wild ride with twists at every turn!"
},
{
"comment": "The character dynamics and chemistry are fire, man. It's a joy to watch 'em interact!"
},
{
"comment": "OMG, the art style is mind-blowing! It's like a feast for the eyes, dude!"
},
{
"comment": "I appreciate how this anime tackles real-world issues while still being hella entertaining."
},
{
"comment": "The soundtrack adds so much to the anime, bro. It sets the mood perfectly!"
},
{
"comment": "This anime got me right in the feels, man. Laughed, cried, and cheered for the characters!"
},
{
"comment": "The world-building and lore in this anime are insane, dude. It's a whole new dimension!"
},
{
"comment": "This anime left me with a mix of emotions from laughter to tears. Freaking unforgettable!"
}
]

View File

@@ -0,0 +1,365 @@
[
{
"comment": "OMG, this episode blew my mind! So many unexpected twists!"
},
{
"comment": "Just watched this episode and I'm speechless. It's a game-changer!"
},
{
"comment": "The suspense in this episode is unreal. Kept me on the edge of my seat!"
},
{
"comment": "This episode had me laughing out loud. So hilarious!"
},
{
"comment": "The emotional depth in this episode is incredible. It hit me right in the feels!"
},
{
"comment": "Just finished watching this episode and I'm in awe. So well-crafted!"
},
{
"comment": "The acting in this episode is phenomenal. Each performance is top-notch!"
},
{
"comment": "This episode had me hooked from start to finish. So captivating!"
},
{
"comment": "The plot development in this episode is mind-blowing. It's building up to something big!"
},
{
"comment": "Just witnessed the biggest cliffhanger in this episode. I need to know what happens next!"
},
{
"comment": "The chemistry between the characters in this episode is electric. I love their interactions!"
},
{
"comment": "This episode had me on an emotional rollercoaster. I laughed, I cried, I felt it all!"
},
{
"comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!"
},
{
"comment": "This episode is a rollercoaster ride of emotions. It's intense and heart-wrenching!"
},
{
"comment": "The dialogue in this episode is sharp and impactful. It kept me engaged!"
},
{
"comment": "This episode is a masterpiece. The writing, the acting, everything is on point!"
},
{
"comment": "The character growth in this episode is remarkable. They're evolving so beautifully!"
},
{
"comment": "This episode left me with so many questions. I can't wait for the next one!"
},
{
"comment": "The chemistry between the actors in this episode is undeniable. They have great synergy!"
},
{
"comment": "This episode is a rollercoaster of emotions. It had me on the edge of my seat!"
},
{
"comment": "The cinematography in this episode is stunning. It adds another layer of depth!"
},
{
"comment": "This episode is a turning point in the series. It's a game-changer!"
},
{
"comment": "The performances in this episode are outstanding. The actors brought their A-game!"
},
{
"comment": "This episode kept me guessing till the very end. So many surprises!"
},
{
"comment": "The writing in this episode is brilliant. The storytelling is top-notch!"
},
{
"comment": "This episode had me on an emotional rollercoaster. I'm still recovering!"
},
{
"comment": "The intensity in this episode is off the charts. It had me at the edge of my seat!"
},
{
"comment": "Just watched this episode and I'm shook. It took the series to a whole new level!"
},
{
"comment": "The cinematography in this episode is breathtaking. Each shot is a work of art!"
},
{
"comment": "This episode had me hooked from the first minute. Such a gripping storyline!"
},
{
"comment": "The character dynamics in this episode are so well-executed. I love seeing their relationships!"
},
{
"comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!"
},
{
"comment": "The plot twists in this episode are mind-blowing. They kept me on my toes!"
},
{
"comment": "This episode left me speechless. It's a game-changer!"
},
{
"comment": "The acting in this episode is exceptional. The performances are so powerful!"
},
{
"comment": "This episode had me at the edge of my seat. So intense and suspenseful!"
},
{
"comment": "The storytelling in this episode is masterful. It's a captivating journey!"
},
{
"comment": "This episode had me in tears. It touched my heart in ways I didn't expect!"
},
{
"comment": "The chemistry between the characters in this episode is fire. I ship them so hard!"
},
{
"comment": "This episode is a rollercoaster ride. It had me gasping for breath!"
},
{
"comment": "The cinematography in this episode is stunning. Each frame is like a painting!"
},
{
"comment": "This episode is a game-changer. It took the series to a whole new level!"
},
{
"comment": "The performances in this episode are outstanding. The actors brought so much depth!"
},
{
"comment": "This episode had me at the edge of my seat. I couldn't look away!"
},
{
"comment": "The writing in this episode is exceptional. It's smart and thought-provoking!"
},
{
"comment": "This episode had me in tears. It's an emotional rollercoaster!"
},
{
"comment": "The tension in this episode is palpable. It kept me on the edge of my seat!"
},
{
"comment": "Just watched this episode and I'm in awe. It's a game-changer!"
},
{
"comment": "The cinematography in this episode is breathtaking. Each shot is visually stunning!"
},
{
"comment": "This episode had me hooked from the start. Such a compelling story!"
},
{
"comment": "The character development in this episode is remarkable. I love seeing their growth!"
},
{
"comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!"
},
{
"comment": "The plot twists in this episode blew my mind. I didn't see them coming!"
},
{
"comment": "This episode left me speechless. It's a turning point in the series!"
},
{
"comment": "The acting in this episode is outstanding. The performances are so powerful!"
},
{
"comment": "This episode had me on the edge of my seat. It's so suspenseful!"
},
{
"comment": "The storytelling in this episode is brilliant. It kept me engaged throughout!"
},
{
"comment": "This episode touched my heart. It's emotional and impactful!"
},
{
"comment": "The chemistry between the characters in this episode is electric. Their interactions are captivating!"
},
{
"comment": "This episode is a rollercoaster ride. It had me on an emotional high!"
},
{
"comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!"
},
{
"comment": "This episode is a game-changer. It took the series to a whole new level!"
},
{
"comment": "The performances in this episode are outstanding. The actors delivered powerful portrayals!"
},
{
"comment": "This episode had me at the edge of my seat. I couldn't look away!"
},
{
"comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!"
},
{
"comment": "This episode had me in tears. It's an emotional rollercoaster!"
},
{
"comment": "The tension in this episode is palpable. It kept me on the edge of my seat!"
},
{
"comment": "Just watched this episode and my mind is blown. It's a game-changer!"
},
{
"comment": "The suspense in this episode is unreal. Kept me on the edge of my seat!"
},
{
"comment": "This episode had me laughing out loud. So hilarious!"
},
{
"comment": "The emotional depth in this episode is incredible. It hit me right in the feels!"
},
{
"comment": "Just finished watching this episode and I'm in awe. So well-crafted!"
},
{
"comment": "The acting in this episode is phenomenal. Each performance is top-notch!"
},
{
"comment": "This episode had me hooked from start to finish. So captivating!"
},
{
"comment": "The plot development in this episode is mind-blowing. It's building up to something big!"
},
{
"comment": "Just witnessed the biggest cliffhanger in this episode. I need to know what happens next!"
},
{
"comment": "The chemistry between the characters in this episode is electric. I love their interactions!"
},
{
"comment": "This episode had me on an emotional rollercoaster. I laughed, I cried, I felt it all!"
},
{
"comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!"
},
{
"comment": "This episode is a rollercoaster ride of emotions. It's intense and heart-wrenching!"
},
{
"comment": "The dialogue in this episode is sharp and impactful. It kept me engaged!"
},
{
"comment": "This episode is a masterpiece. The writing, the acting, everything is on point!"
},
{
"comment": "The character growth in this episode is remarkable. They're evolving so beautifully!"
},
{
"comment": "This episode left me with so many questions. I can't wait for the next one!"
},
{
"comment": "The chemistry between the actors in this episode is undeniable. They have great synergy!"
},
{
"comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!"
},
{
"comment": "The plot twists in this episode are mind-blowing. They kept me on my toes!"
},
{
"comment": "This episode left me speechless. It's a game-changer!"
},
{
"comment": "The acting in this episode is exceptional. The performances are so powerful!"
},
{
"comment": "This episode had me at the edge of my seat. So intense and suspenseful!"
},
{
"comment": "The storytelling in this episode is masterful. It's a captivating journey!"
},
{
"comment": "This episode had me in tears. It touched my heart in ways I didn't expect!"
},
{
"comment": "The chemistry between the characters in this episode is fire. I ship them so hard!"
},
{
"comment": "This episode is a rollercoaster ride. It had me gasping for breath!"
},
{
"comment": "The cinematography in this episode is stunning. Each frame is like a painting!"
},
{
"comment": "This episode is a game-changer. It took the series to a whole new level!"
},
{
"comment": "The performances in this episode are outstanding. The actors brought so much depth!"
},
{
"comment": "This episode had me on the edge of my seat. I couldn't look away!"
},
{
"comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!"
},
{
"comment": "This episode had me in tears. It's an emotional rollercoaster!"
},
{
"comment": "The tension in this episode is palpable. It kept me on the edge of my seat!"
},
{
"comment": "Just watched this episode and I'm in awe. It's a game-changer!"
},
{
"comment": "The cinematography in this episode is breathtaking. Each shot is visually stunning!"
},
{
"comment": "This episode had me hooked from the start. Such a compelling story!"
},
{
"comment": "The character development in this episode is remarkable. I love seeing their growth!"
},
{
"comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!"
},
{
"comment": "The plot twists in this episode blew my mind. I didn't see them coming!"
},
{
"comment": "This episode left me speechless. It's a turning point in the series!"
},
{
"comment": "The acting in this episode is outstanding. The performances are so powerful!"
},
{
"comment": "This episode had me on the edge of my seat. It's so suspenseful!"
},
{
"comment": "The storytelling in this episode is brilliant. It kept me engaged throughout!"
},
{
"comment": "This episode touched my heart. It's emotional and impactful!"
},
{
"comment": "The chemistry between the characters in this episode is electric. Their interactions are captivating!"
},
{
"comment": "This episode is a rollercoaster ride. It had me on an emotional high!"
},
{
"comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!"
},
{
"comment": "This episode is a game-changer. It took the series to a whole new level!"
},
{
"comment": "The performances in this episode are outstanding. The actors delivered powerful portrayals!"
},
{
"comment": "This episode had me at the edge of my seat. I couldn't look away!"
},
{
"comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!"
},
{
"comment": "This episode had me in tears. It's an emotional rollercoaster!"
},
{
"comment": "The tension in this episode is palpable. It kept me on the edge of my seat!"
}
]

View File

@@ -0,0 +1,356 @@
[
{
"comment": "OMG, this movie is mind-blowing! Couldn't take my eyes off the screen!"
},
{
"comment": "Just watched this film and it's a total rollercoaster ride. So intense!"
},
{
"comment": "The visuals in this movie are insane. Such stunning cinematography!"
},
{
"comment": "Lead actor in this one is straight-up killing it. Their performance was on point!"
},
{
"comment": "This movie had me on the edge of my seat the whole time. Non-stop excitement!"
},
{
"comment": "The cast in this film is awesome. They brought so much energy to the story!"
},
{
"comment": "Laughed my head off watching this comedy flick. It's hilarious from start to finish!"
},
{
"comment": "Action scenes in this movie are epic. Heart-pounding and adrenaline-fueled!"
},
{
"comment": "This film had me in tears one moment and then cheering like crazy. So many emotions!"
},
{
"comment": "The soundtrack in this movie is fire. Can't get enough of those awesome tunes!"
},
{
"comment": "Just saw this movie and it blew my mind! So much suspense and unexpected twists."
},
{
"comment": "The setting in this film is breathtaking. It transports you to another world."
},
{
"comment": "The chemistry between the actors in this movie is off the charts. They were perfect together!"
},
{
"comment": "This movie had me hooked from the very beginning. Such an engaging storyline!"
},
{
"comment": "The humor in this film is on point. I was laughing throughout!"
},
{
"comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!"
},
{
"comment": "The performances in this film are outstanding. The actors truly brought their characters to life."
},
{
"comment": "I can't get over the incredible plot twists in this movie. Kept me guessing till the end!"
},
{
"comment": "This movie touched my heart. I felt all the emotions deeply."
},
{
"comment": "The action sequences in this film are like nothing I've seen before. Jaw-dropping!"
},
{
"comment": "Just watched this movie and it exceeded all my expectations. Highly recommended!"
},
{
"comment": "The cinematography in this film is stunning. Every frame is a work of art."
},
{
"comment": "The performances in this movie blew me away. So raw and powerful!"
},
{
"comment": "This movie is a rollercoaster of emotions. Laughter, tears, and everything in between!"
},
{
"comment": "The chemistry between the lead actors is off the charts. Their on-screen presence is electrifying!"
},
{
"comment": "Couldn't stop talking about this movie after watching it. It left a lasting impression!"
},
{
"comment": "The dialogue in this film is so witty and clever. I was quoting lines for days!"
},
{
"comment": "This movie had me at the edge of my seat throughout. Suspenseful and thrilling!"
},
{
"comment": "The soundtrack in this movie is pure perfection. It sets the mood so well!"
},
{
"comment": "Just watched this movie and I'm in awe. It's a masterpiece in every aspect!"
},
{
"comment": "The storytelling in this film is top-notch. It captivated me from start to finish."
},
{
"comment": "The performances in this movie are Oscar-worthy. So much talent on screen!"
},
{
"comment": "This movie is a total crowd-pleaser. Everyone should watch it!"
},
{
"comment": "The suspense in this film had me on the edge of my seat. Nerve-wracking and intense!"
},
{
"comment": "The cinematography in this movie is breathtaking. Each shot is visually stunning!"
},
{
"comment": "The characters in this film are so relatable. I felt like I knew them personally."
},
{
"comment": "This movie kept me guessing till the very end. So many unexpected twists!"
},
{
"comment": "The performances in this film are exceptional. I was completely immersed in the story."
},
{
"comment": "Just watched this movie and I'm speechless. It's a cinematic masterpiece!"
},
{
"comment": "The humor in this film had me laughing out loud. Pure comedic genius!"
},
{
"comment": "This movie is a visual feast. The production design is incredible!"
},
{
"comment": "The chemistry between the lead actors is undeniable. Their interactions are electric!"
},
{
"comment": "Couldn't get enough of this movie. I wish it never ended!"
},
{
"comment": "The cinematography in this film is stunning. It captured the essence of the story perfectly."
},
{
"comment": "The performances in this movie are outstanding. Each actor brought something unique to their role."
},
{
"comment": "This movie took me on an emotional rollercoaster. I laughed, I cried, and everything in between!"
},
{
"comment": "The action sequences in this film are jaw-dropping. It's like an adrenaline rush!"
},
{
"comment": "Just finished watching this movie and I'm blown away. It's a must-see!"
},
{
"comment": "The visuals in this film are stunning. I was in awe of the breathtaking cinematography!"
},
{
"comment": "The performances in this movie are phenomenal. The actors gave it their all!"
},
{
"comment": "This movie had me hooked from the beginning till the end. Such a gripping storyline!"
},
{
"comment": "The humor in this film is spot on. I couldn't stop laughing!"
},
{
"comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!"
},
{
"comment": "The cast in this movie is fantastic. Each actor brought depth and charisma to their character."
},
{
"comment": "This movie kept me on the edge of my seat. So many unexpected twists and turns!"
},
{
"comment": "The emotions in this film hit me hard. I laughed, I cried, and I felt everything in between."
},
{
"comment": "The action sequences in this movie are absolutely thrilling. It's a wild ride from start to finish!"
},
{
"comment": "Just watched this movie and it blew my mind! The story, the performances, everything was incredible."
},
{
"comment": "The cinematography in this film is breathtaking. Every shot is like a work of art!"
},
{
"comment": "The performances in this movie are outstanding. The actors brought so much depth to their characters!"
},
{
"comment": "This movie had me completely engrossed. I couldn't take my eyes off the screen!"
},
{
"comment": "The humor in this film is on point. I couldn't stop laughing!"
},
{
"comment": "The special effects in this movie are mind-blowing. They added an extra layer of awesomeness!"
},
{
"comment": "The cast in this movie is stellar. Each actor brought their A-game!"
},
{
"comment": "This movie had me guessing till the end. So many plot twists and surprises!"
},
{
"comment": "The performances in this film are outstanding. Each actor brought their character to life in a remarkable way."
},
{
"comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!"
},
{
"comment": "The comedy in this film had me laughing so hard. It's pure comedic gold!"
},
{
"comment": "This movie is visually stunning. The cinematography is breathtaking!"
},
{
"comment": "The chemistry between the actors in this film is electric. They had amazing on-screen dynamics!"
},
{
"comment": "Couldn't stop thinking about this movie after watching it. It left a lasting impression!"
},
{
"comment": "The dialogue in this film is so witty and clever. It had me laughing out loud!"
},
{
"comment": "This movie had me on the edge of my seat the whole time. Gripping and suspenseful!"
},
{
"comment": "The soundtrack in this movie is perfect. It sets the mood and enhances every scene!"
},
{
"comment": "Just watched this movie and I'm blown away. It's a must-see!"
},
{
"comment": "The storytelling in this film is captivating. It kept me engaged from start to finish."
},
{
"comment": "The performances in this movie are exceptional. Each actor delivered a powerful portrayal!"
},
{
"comment": "This movie is a crowd-pleaser. It has something for everyone!"
},
{
"comment": "The suspense in this film had me on the edge of my seat. It kept me guessing till the end!"
},
{
"comment": "The cinematography in this movie is breathtaking. Each shot is visually stunning!"
},
{
"comment": "The characters in this film are so relatable. I felt like I knew them personally."
},
{
"comment": "This movie kept me on the edge of my seat. So many unexpected twists and turns!"
},
{
"comment": "The performances in this film are exceptional. I was completely immersed in the story."
},
{
"comment": "Just watched this movie and I'm speechless. It's a cinematic masterpiece!"
},
{
"comment": "The humor in this film had me laughing out loud. Pure comedic genius!"
},
{
"comment": "This movie is a visual feast. The production design is incredible!"
},
{
"comment": "The chemistry between the lead actors is undeniable. Their on-screen interactions are electric!"
},
{
"comment": "Couldn't get enough of this movie. I wish it never ended!"
},
{
"comment": "The cinematography in this film is stunning. It captured the essence of the story perfectly."
},
{
"comment": "The performances in this movie are outstanding. Each actor brought something unique to their role."
},
{
"comment": "This movie took me on an emotional rollercoaster. I laughed, I cried, and everything in between!"
},
{
"comment": "The action sequences in this film are jaw-dropping. It's like an adrenaline rush!"
},
{
"comment": "Just finished watching this movie and I'm blown away. It's a must-see!"
},
{
"comment": "The visuals in this film are stunning. I was in awe of the breathtaking cinematography!"
},
{
"comment": "The performances in this movie are phenomenal. The actors gave it their all!"
},
{
"comment": "This movie had me hooked from the beginning till the end. Such a gripping storyline!"
},
{
"comment": "The humor in this film is spot on. I couldn't stop laughing!"
},
{
"comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!"
},
{
"comment": "The cast in this movie is fantastic. Each actor brought depth and charisma to their character."
},
{
"comment": "This movie kept me guessing till the very end. So many unexpected twists!"
},
{
"comment": "The performances in this film are exceptional. I was completely immersed in the story."
},
{
"comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!"
},
{
"comment": "The cinematography in this film is breathtaking. Every shot is like a work of art!"
},
{
"comment": "The performances in this movie are outstanding. Each actor brought so much depth to their characters!"
},
{
"comment": "This movie had me completely engrossed. I couldn't take my eyes off the screen!"
},
{
"comment": "The humor in this film is on point. I couldn't stop laughing!"
},
{
"comment": "The special effects in this movie are mind-blowing. They added an extra layer of awesomeness!"
},
{
"comment": "The cast in this movie is stellar. Each actor brought their A-game!"
},
{
"comment": "This movie had me guessing till the end. So many plot twists and surprises!"
},
{
"comment": "The performances in this film are outstanding. Each actor brought their character to life in a remarkable way."
},
{
"comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!"
},
{
"comment": "The comedy in this film had me laughing so hard. It's pure comedic gold!"
},
{
"comment": "This movie is visually stunning. The cinematography is breathtaking!"
},
{
"comment": "The chemistry between the actors in this film is electric. They had amazing on-screen dynamics!"
},
{
"comment": "Couldn't stop thinking about this movie after watching it. It left a lasting impression!"
},
{
"comment": "The dialogue in this film is so witty and clever. It had me laughing out loud!"
},
{
"comment": "This movie had me on the edge of my seat the whole time. Gripping and suspenseful!"
},
{
"comment": "The soundtrack in this movie is perfect. It sets the mood and enhances every scene!"
}
]

View File

@@ -0,0 +1,523 @@
[
{
"title": "A Must-Watch!",
"body": "OMG, this movie is a total masterpiece! The storytelling, acting, and visuals are on another level. You gotta see it!",
"score": 9
},
{
"title": "So Thrilling and Action-Packed!",
"body": "Hold onto your seats, folks! This movie is an adrenaline rush from start to finish. The action scenes will blow your mind!",
"score": 8
},
{
"title": "Emotionally Powerful",
"body": "Get ready to feel all the feels! This movie dives deep into emotions and hits you right in the heart. It's a tearjerker, but in a good way!",
"score": 7
},
{
"title": "Laugh-Out-Loud Funny!",
"body": "If you're in need of a good laugh, this movie is the perfect remedy! The jokes are hilarious, and the humor is off the charts!",
"score": 8
},
{
"title": "Visually Jaw-Dropping",
"body": "Prepare to have your mind blown by the stunning visuals in this movie! The cinematography and special effects are out of this world!",
"score": 9
},
{
"title": "Keeps You Guessing!",
"body": "Whoa, this movie is a wild ride! It's full of suspense, unexpected twists, and turns that will leave you guessing until the very end!",
"score": 8
},
{
"title": "Heartwarming and Inspiring",
"body": "This movie will warm your heart and give you all the feels! It's inspiring, uplifting, and leaves you with a big smile on your face!",
"score": 9
},
{
"title": "Gets You Thinking",
"body": "Get ready to have your mind blown! This movie tackles deep themes that make you question everything. It'll keep you pondering for days!",
"score": 7
},
{
"title": "A Thrill Ride to Remember",
"body": "Buckle up, folks! This movie is a non-stop thrill ride that will have you at the edge of your seat, biting your nails, and craving for more!",
"score": 9
},
{
"title": "Mind-Blowing Performances",
"body": "The cast in this movie is absolutely incredible! They deliver mind-blowing performances that will leave you in awe. Kudos to the actors!",
"score": 8
},
{
"title": "Disappointing and Underwhelming",
"body": "I had high hopes for this movie, but it fell short. The plot was predictable, and the performances felt lackluster. Not worth the hype.",
"score": 4
},
{
"title": "Lacks Originality",
"body": "Unfortunately, this movie felt like a rehash of familiar tropes. The story offered nothing new, and I found myself bored and unengaged.",
"score": 3
},
{
"title": "A Missed Opportunity",
"body": "With such potential, this movie failed to deliver. The pacing was off, and the characters lacked depth. It's a forgettable experience.",
"score": 5
},
{
"title": "Impressive Cinematic Experience",
"body": "This movie is a visual feast for the eyes! The stunning cinematography and breathtaking set pieces create an immersive cinematic experience. Highly recommended!",
"score": 9
},
{
"title": "Captivating Storyline",
"body": "From beginning to end, this movie had me hooked with its compelling storyline. The twists and turns kept me engaged, eagerly waiting to see what happens next!",
"score": 8
},
{
"title": "Powerful and Thought-Provoking",
"body": "This movie tackles important themes and leaves a lasting impact. It makes you reflect on society and the human condition. Prepare for a thought-provoking journey!",
"score": 7
},
{
"title": "Hilarious and Heartwarming",
"body": "Laughter and warm fuzzies guaranteed! This movie strikes the perfect balance between comedy and heart, delivering memorable moments that will leave you smiling.",
"score": 8
},
{
"title": "Visually Stunning Masterpiece",
"body": "This movie is a visual marvel. The attention to detail, breathtaking visuals, and stunning cinematography create a feast for the eyes. A true cinematic masterpiece!",
"score": 9
},
{
"title": "Intriguing and Suspenseful",
"body": "Prepare for a nail-biting experience! This movie keeps you on the edge of your seat with its gripping plot and suspenseful sequences. It will leave you guessing until the end!",
"score": 8
},
{
"title": "Inspiring and Uplifting",
"body": "This movie is a feel-good gem that leaves you with a renewed sense of inspiration and hope. It touches your heart and reminds you of the power of dreams!",
"score": 9
},
{
"title": "Mind-Expanding Story",
"body": "This movie expands your mind and challenges your perspective. It delves into deep concepts and takes you on a journey of self-discovery. Prepare to be amazed!",
"score": 7
},
{
"title": "Action-Packed Thrills",
"body": "Hold onto your popcorn! This movie delivers non-stop action and thrilling sequences that will leave you breathless. Get ready for an adrenaline-fueled joyride!",
"score": 9
},
{
"title": "Incredible Performances",
"body": "The performances in this movie are top-notch! The talented cast brings their characters to life, delivering powerful and nuanced portrayals. A true acting masterclass!",
"score": 8
},
{
"title": "Disappointing Execution",
"body": "Despite promising elements, this movie fails to live up to expectations. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.",
"score": 4
},
{
"title": "Lackluster Storytelling",
"body": "The story in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.",
"score": 3
},
{
"title": "Underdeveloped Characters",
"body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.",
"score": 5
},
{
"title": "Visually Mediocre",
"body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.",
"score": 4
},
{
"title": "Predictable and Formulaic",
"body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.",
"score": 3
},
{
"title": "A Forgettable Experience",
"body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.",
"score": 5
},
{
"title": "Engaging and Enthralling",
"body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!",
"score": 8
},
{
"title": "Deeply Moving and Emotional",
"body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!",
"score": 9
},
{
"title": "An Entertaining Delight",
"body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!",
"score": 8
},
{
"title": "Visually Mesmerizing",
"body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!",
"score": 9
},
{
"title": "Suspenseful and Thrilling",
"body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!",
"score": 8
},
{
"title": "An Inspiring Journey",
"body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.",
"score": 9
},
{
"title": "Intellectually Stimulating",
"body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!",
"score": 7
},
{
"title": "Action-Packed and Exciting",
"body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!",
"score": 9
},
{
"title": "Outstanding Performances",
"body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!",
"score": 8
},
{
"title": "Disappointing Plot",
"body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.",
"score": 4
},
{
"title": "Lacks Substance",
"body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.",
"score": 3
},
{
"title": "Underwhelming Characters",
"body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.",
"score": 5
},
{
"title": "Visually Unremarkable",
"body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.",
"score": 4
},
{
"title": "Formulaic and Predictable",
"body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.",
"score": 3
},
{
"title": "A Forgettable Journey",
"body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.",
"score": 5
},
{
"title": "Compelling and Engrossing",
"body": "This movie hooks you from the beginning and keeps you engaged throughout. The captivating story and stellar performances make it a must-watch!",
"score": 8
},
{
"title": "A Rollercoaster of Emotions",
"body": "Get ready to laugh, cry, and everything in between! This movie tugs at your heartstrings and takes you on an emotional rollercoaster. Prepare for a truly moving experience!",
"score": 9
},
{
"title": "Pure Fun and Entertainment",
"body": "This movie is pure entertainment! It's a fun-filled adventure that will keep you entertained from start to finish. Sit back, relax, and enjoy the show!",
"score": 8
},
{
"title": "A Visual Masterpiece",
"body": "Prepare to be visually amazed! This movie boasts breathtaking visuals, stunning cinematography, and a visual style that will leave you in awe.",
"score": 9
},
{
"title": "Thrilling and Suspenseful",
"body": "This movie will keep you on the edge of your seat with its thrilling plot and suspenseful twists. It's a gripping cinematic experience you won't want to miss!",
"score": 8
},
{
"title": "An Inspiring and Motivational Tale",
"body": "This movie inspires you to chase your dreams and overcome obstacles. It's a motivational tale that leaves you feeling empowered and ready to take on the world!",
"score": 9
},
{
"title": "Intellectually Engaging",
"body": "Prepare to have your mind stimulated! This movie challenges your intellect with its thought-provoking concepts and complex narrative. A must-watch for thinkers!",
"score": 7
},
{
"title": "Action-Packed and Thrilling",
"body": "Hold onto your seats! This movie delivers high-octane action and thrilling sequences that will keep you on the edge of your seat. A pulse-pounding experience!",
"score": 9
},
{
"title": "Exceptional Acting",
"body": "The performances in this movie are truly remarkable. The cast delivers nuanced and powerful performances that elevate the storytelling. Bravo!",
"score": 8
},
{
"title": "Disappointing Execution",
"body": "Despite its promising premise, this movie fails to deliver. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.",
"score": 4
},
{
"title": "Lackluster Storytelling",
"body": "The storytelling in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.",
"score": 3
},
{
"title": "Underdeveloped Characters",
"body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.",
"score": 5
},
{
"title": "Visually Mediocre",
"body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.",
"score": 4
},
{
"title": "Predictable and Formulaic",
"body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.",
"score": 3
},
{
"title": "A Forgettable Experience",
"body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.",
"score": 5
},
{
"title": "Engaging and Enthralling",
"body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!",
"score": 8
},
{
"title": "Deeply Moving and Emotional",
"body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!",
"score": 9
},
{
"title": "An Entertaining Delight",
"body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!",
"score": 8
},
{
"title": "Visually Mesmerizing",
"body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!",
"score": 9
},
{
"title": "Suspenseful and Thrilling",
"body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!",
"score": 8
},
{
"title": "An Inspiring Journey",
"body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.",
"score": 9
},
{
"title": "Intellectually Stimulating",
"body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!",
"score": 7
},
{
"title": "Action-Packed and Exciting",
"body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!",
"score": 9
},
{
"title": "Outstanding Performances",
"body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!",
"score": 8
},
{
"title": "Disappointing Plot",
"body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.",
"score": 4
},
{
"title": "Lacks Substance",
"body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.",
"score": 3
},
{
"title": "Underwhelming Characters",
"body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.",
"score": 5
},
{
"title": "Visually Unremarkable",
"body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.",
"score": 4
},
{
"title": "Formulaic and Predictable",
"body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.",
"score": 3
},
{
"title": "A Forgettable Journey",
"body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.",
"score": 5
},
{
"title": "Compelling and Engrossing",
"body": "This movie hooks you from the beginning and keeps you engaged throughout. The captivating story and stellar performances make it a must-watch!",
"score": 8
},
{
"title": "A Rollercoaster of Emotions",
"body": "Get ready to laugh, cry, and everything in between! This movie tugs at your heartstrings and takes you on an emotional rollercoaster. Prepare for a truly moving experience!",
"score": 9
},
{
"title": "Pure Fun and Entertainment",
"body": "This movie is pure entertainment! It's a fun-filled adventure that will keep you entertained from start to finish. Sit back, relax, and enjoy the show!",
"score": 8
},
{
"title": "A Visual Masterpiece",
"body": "Prepare to be visually amazed! This movie boasts breathtaking visuals, stunning cinematography, and a visual style that will leave you in awe.",
"score": 9
},
{
"title": "Thrilling and Suspenseful",
"body": "This movie will keep you on the edge of your seat with its thrilling plot and suspenseful twists. It's a gripping cinematic experience you won't want to miss!",
"score": 8
},
{
"title": "An Inspiring and Motivational Tale",
"body": "This movie inspires you to chase your dreams and overcome obstacles. It's a motivational tale that leaves you feeling empowered and ready to take on the world!",
"score": 9
},
{
"title": "Intellectually Engaging",
"body": "Prepare to have your mind stimulated! This movie challenges your intellect with its thought-provoking concepts and complex narrative. A must-watch for thinkers!",
"score": 7
},
{
"title": "Action-Packed and Thrilling",
"body": "Hold onto your seats! This movie delivers high-octane action and thrilling sequences that will keep you on the edge of your seat. A pulse-pounding experience!",
"score": 9
},
{
"title": "Exceptional Acting",
"body": "The performances in this movie are truly remarkable. The cast delivers nuanced and powerful performances that elevate the storytelling. Bravo!",
"score": 8
},
{
"title": "Disappointing Execution",
"body": "Despite its promising premise, this movie fails to deliver. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.",
"score": 4
},
{
"title": "Lackluster Storytelling",
"body": "The storytelling in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.",
"score": 3
},
{
"title": "Underdeveloped Characters",
"body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.",
"score": 5
},
{
"title": "Visually Mediocre",
"body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.",
"score": 4
},
{
"title": "Predictable and Formulaic",
"body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.",
"score": 3
},
{
"title": "A Forgettable Experience",
"body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.",
"score": 5
},
{
"title": "Engaging and Enthralling",
"body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!",
"score": 8
},
{
"title": "Deeply Moving and Emotional",
"body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!",
"score": 9
},
{
"title": "An Entertaining Delight",
"body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!",
"score": 8
},
{
"title": "Visually Mesmerizing",
"body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!",
"score": 9
},
{
"title": "Suspenseful and Thrilling",
"body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!",
"score": 8
},
{
"title": "An Inspiring Journey",
"body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.",
"score": 9
},
{
"title": "Intellectually Stimulating",
"body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!",
"score": 7
},
{
"title": "Action-Packed and Exciting",
"body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!",
"score": 9
},
{
"title": "Outstanding Performances",
"body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!",
"score": 8
},
{
"title": "Disappointing Plot",
"body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.",
"score": 4
},
{
"title": "Lacks Substance",
"body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.",
"score": 3
},
{
"title": "Underwhelming Characters",
"body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.",
"score": 5
},
{
"title": "Visually Unremarkable",
"body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.",
"score": 4
},
{
"title": "Formulaic and Predictable",
"body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.",
"score": 3
},
{
"title": "A Forgettable Journey",
"body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.",
"score": 5
}
]

View File

@@ -0,0 +1,329 @@
[
{
"comment": "OMG, this series is mind-blowing! I can't stop watching!"
},
{
"comment": "Just binge-watched this series and I'm hooked. It's so addictive!"
},
{
"comment": "The storyline in this series is insane. So many twists and turns!"
},
{
"comment": "The cast in this series is amazing. Each actor brings something unique!"
},
{
"comment": "This series had me on the edge of my seat the whole time. So suspenseful!"
},
{
"comment": "The chemistry between the characters in this series is off the charts. Love it!"
},
{
"comment": "Laughed my heart out watching this comedy series. It's hilarious!"
},
{
"comment": "The drama in this series is so intense. It keeps me wanting more!"
},
{
"comment": "Just finished the latest season of this series. Mind officially blown!"
},
{
"comment": "The soundtrack in this series is fire. Can't get enough of it!"
},
{
"comment": "This series is binge-worthy. I can't stop clicking 'Next Episode'!"
},
{
"comment": "The character development in this series is amazing. I feel so connected to them!"
},
{
"comment": "This series has me emotionally invested. I laugh, I cry, I feel it all!"
},
{
"comment": "The cliffhangers in this series are killing me. I need to know what happens next!"
},
{
"comment": "The acting in this series is top-notch. Such talent on display!"
},
{
"comment": "This series keeps me guessing at every turn. Never a dull moment!"
},
{
"comment": "The relationships in this series are so compelling. I'm rooting for them!"
},
{
"comment": "This series is pure entertainment. It's my guilty pleasure!"
},
{
"comment": "The writing in this series is incredible. So many quotable lines!"
},
{
"comment": "This series has a unique concept. Refreshing and original!"
},
{
"comment": "The suspense in this series is killing me. Can't wait for the next episode!"
},
{
"comment": "The chemistry between the cast members is amazing. They're like a family!"
},
{
"comment": "This series has a great mix of genres. There's something for everyone!"
},
{
"comment": "The plot twists in this series are mind-blowing. I never saw them coming!"
},
{
"comment": "The performances in this series are outstanding. Each actor shines!"
},
{
"comment": "This series is addictive. I'm obsessed with the characters and their stories!"
},
{
"comment": "The humor in this series is on point. I can't stop laughing!"
},
{
"comment": "This series keeps me on the edge of my seat. So many unexpected surprises!"
},
{
"comment": "The soundtrack in this series is a vibe. It sets the perfect mood!"
},
{
"comment": "This series is a rollercoaster of emotions. I'm hooked!"
},
{
"comment": "The world-building in this series is impressive. So immersive!"
},
{
"comment": "This series has strong female characters. They're empowering and inspiring!"
},
{
"comment": "The cinematography in this series is stunning. Every shot is beautiful!"
},
{
"comment": "This series has incredible storytelling. I'm captivated!"
},
{
"comment": "The performances in this series are incredible. Each actor brings depth and nuance!"
},
{
"comment": "This series is an emotional rollercoaster. It tugs at my heartstrings!"
},
{
"comment": "The suspense in this series is intense. I can't stop watching!"
},
{
"comment": "This series has a diverse and inclusive cast. Representation matters!"
},
{
"comment": "The writing in this series is superb. It keeps me engaged!"
},
{
"comment": "This series is my latest obsession. I can't get enough of it!"
},
{
"comment": "The chemistry between the actors in this series is fire. They have amazing on-screen dynamics!"
},
{
"comment": "This series is a rollercoaster ride of emotions. It takes me on a journey!"
},
{
"comment": "The world-building in this series is phenomenal. It feels so real!"
},
{
"comment": "This series tackles important social issues. It's thought-provoking!"
},
{
"comment": "The cinematography in this series is stunning. Each frame is visually captivating!"
},
{
"comment": "This series has a strong ensemble cast. Each actor brings something special!"
},
{
"comment": "The plot twists in this series keep me on my toes. Never a dull moment!"
},
{
"comment": "This series has me emotionally invested. I feel like I'm part of their world!"
},
{
"comment": "The character dynamics in this series are so well-developed. I love their interactions!"
},
{
"comment": "This series has so many memorable moments. It leaves a lasting impression!"
},
{
"comment": "The performances in this series are exceptional. Each actor brings depth to their character!"
},
{
"comment": "This series is addictive. I can't stop watching episode after episode!"
},
{
"comment": "The humor in this series is hilarious. It never fails to make me laugh!"
},
{
"comment": "This series keeps me on the edge of my seat. It's a wild ride!"
},
{
"comment": "The soundtrack in this series is incredible. It enhances the storytelling!"
},
{
"comment": "This series is an emotional rollercoaster. It makes me laugh and cry!"
},
{
"comment": "The attention to detail in this series is impressive. It's a visual feast!"
},
{
"comment": "This series has a diverse cast with amazing performances. It's inclusive and representative!"
},
{
"comment": "The writing in this series is brilliant. The dialogue is sharp and engaging!"
},
{
"comment": "This series has become my new obsession. I can't get enough of it!"
},
{
"comment": "The chemistry between the actors in this series is electric. Their performances are captivating!"
},
{
"comment": "This series is a whirlwind of emotions. It has me invested in every character!"
},
{
"comment": "The world-building in this series is rich and immersive. It feels like a whole new universe!"
},
{
"comment": "This series tackles relevant and timely themes. It's thought-provoking and impactful!"
},
{
"comment": "The cinematography in this series is stunning. It adds another layer of beauty to the storytelling!"
},
{
"comment": "This series has an incredible ensemble cast. The chemistry between the actors is incredible!"
},
{
"comment": "The plot twists in this series are mind-bending. It keeps me guessing all the time!"
},
{
"comment": "This series has a strong emotional core. It touches my heart and leaves a lasting impact!"
},
{
"comment": "The character development in this series is exceptional. I love seeing their growth!"
},
{
"comment": "This series is so addictive. I can't stop watching. It's my latest obsession!"
},
{
"comment": "The humor in this series is top-notch. It's a perfect balance of comedy and heart!"
},
{
"comment": "This series is full of surprises. It keeps me guessing and on the edge of my seat!"
},
{
"comment": "The soundtrack in this series is phenomenal. It adds so much to the atmosphere!"
},
{
"comment": "This series is an emotional rollercoaster. It takes me through highs and lows!"
},
{
"comment": "The attention to detail in this series is incredible. The production value is top-notch!"
},
{
"comment": "This series has a diverse cast that brings authenticity and representation. It's amazing!"
},
{
"comment": "The writing in this series is exceptional. It keeps me engaged and invested in the story!"
},
{
"comment": "This series has become my new addiction. I can't get enough of it!"
},
{
"comment": "The chemistry between the actors in this series is off the charts. They have incredible synergy!"
},
{
"comment": "This series is an emotional rollercoaster. It makes me feel all the feels!"
},
{
"comment": "The world-building in this series is immersive and captivating. It feels like a whole new reality!"
},
{
"comment": "This series tackles important issues with sensitivity and depth. It's thought-provoking!"
},
{
"comment": "The cinematography in this series is stunning. It's visually breathtaking!"
},
{
"comment": "This series has an amazing ensemble cast. Each actor brings their A-game!"
},
{
"comment": "The plot twists in this series are mind-blowing. It keeps me on the edge of my seat!"
},
{
"comment": "This series has characters I can't help but root for. I'm emotionally invested!"
},
{
"comment": "The character dynamics in this series are so well-written. The relationships feel real!"
},
{
"comment": "This series has so many unforgettable moments. It leaves a lasting impact!"
},
{
"comment": "The performances in this series are outstanding. Each actor brings depth to their role!"
},
{
"comment": "Just watched the latest episode of this series and it's mind-blowing! Can't wait for more!"
},
{
"comment": "This series keeps getting better and better with each episode. I'm hooked!"
},
{
"comment": "The latest episode of this series left me speechless. It's a game-changer!"
},
{
"comment": "The character development in this series is outstanding. I love seeing them grow!"
},
{
"comment": "The latest episode of this series had me on the edge of my seat. So thrilling!"
},
{
"comment": "I just can't get enough of this series. Each episode leaves me wanting more!"
},
{
"comment": "The latest episode of this series had me laughing out loud. It's so funny!"
},
{
"comment": "This series knows how to keep me invested. The latest episode was so captivating!"
},
{
"comment": "The latest episode of this series had so many unexpected twists. It's unpredictable!"
},
{
"comment": "I'm completely obsessed with this series. The latest episode blew my mind!"
},
{
"comment": "The latest episode of this series is visually stunning. The production value is incredible!"
},
{
"comment": "This series just keeps raising the bar. The latest episode was exceptional!"
},
{
"comment": "The chemistry between the characters in this series is off the charts. I love their dynamics!"
},
{
"comment": "The latest episode of this series tugged at my heartstrings. It's so emotional!"
},
{
"comment": "I'm always left wanting more after each episode of this series. It's addictive!"
},
{
"comment": "The latest episode of this series had me at the edge of my seat. So intense!"
},
{
"comment": "This series continues to surprise me. The latest episode had an unexpected twist!"
},
{
"comment": "The latest episode of this series had me completely hooked. It's so well-written!"
},
{
"comment": "I just can't get enough of this series. The latest episode was phenomenal!"
},
{
"comment": "The latest episode of this series had me on an emotional rollercoaster. It's powerful!"
}
]

View File

@@ -0,0 +1,507 @@
[
{
"title": "Binge-Worthy Series!",
"body": "I couldn't stop watching this series! Each episode left me wanting more. The storyline and characters are captivating. Highly recommended!",
"score": 9
},
{
"title": "Addictive and Engaging",
"body": "This series had me hooked from the very first episode. The plot twists and character development kept me invested throughout. Can't wait for the next season!",
"score": 8
},
{
"title": "Emotional Rollercoaster",
"body": "This series took me on an emotional journey. I laughed, I cried, and I felt deeply connected to the characters. It's a must-watch!",
"score": 7
},
{
"title": "Hilarious and Heartwarming",
"body": "I couldn't stop laughing while watching this series. The humor is spot-on, and the heartwarming moments tug at your heartstrings. A perfect balance!",
"score": 8
},
{
"title": "Intriguing and Mysterious",
"body": "This series kept me guessing until the very end. The mystery and suspense had me on the edge of my seat. A thrilling ride!",
"score": 9
},
{
"title": "Well-Written and Gripping",
"body": "The writing in this series is exceptional. The dialogue is sharp, and the storylines are compelling. It's a masterclass in storytelling!",
"score": 8
},
{
"title": "Powerful and Thought-Provoking",
"body": "This series tackles important social issues and provokes thought. It sheds light on topics that need to be discussed. A truly impactful watch!",
"score": 7
},
{
"title": "Addictive Plot Twists",
"body": "Just when I thought I had it figured out, this series hit me with unexpected plot twists. It kept me engaged and eagerly anticipating the next episode!",
"score": 8
},
{
"title": "Relatable and Authentic",
"body": "The characters in this series feel like real people. I could relate to their struggles and victories. It's a genuine and authentic portrayal!",
"score": 9
},
{
"title": "Mind-Blowing Performances",
"body": "The cast in this series delivers outstanding performances. Their talent and chemistry bring the characters to life. Bravo!",
"score": 8
},
{
"title": "Lacks Substance",
"body": "Unfortunately, this series lacks depth. The characters are underdeveloped, and the plot feels weak. It didn't leave a lasting impression.",
"score": 4
},
{
"title": "Disappointing Execution",
"body": "I had high expectations for this series, but it fell short. The pacing was off, and the storytelling felt disjointed. A missed opportunity.",
"score": 3
},
{
"title": "Unengaging Storylines",
"body": "The series failed to captivate me. The storylines felt uninteresting, and the episodes dragged on. It didn't hold my attention.",
"score": 5
},
{
"title": "Lackluster Character Development",
"body": "The characters in this series lacked depth and growth. Their arcs felt stagnant, and I struggled to connect with them.",
"score": 4
},
{
"title": "Visually Stunning",
"body": "This series is a visual treat. The cinematography and production design are top-notch. It's a feast for the eyes!",
"score": 9
},
{
"title": "Addictive and Suspenseful",
"body": "I couldn't stop watching this series. Each episode ended with a cliffhanger that left me eagerly waiting for the next. So suspenseful!",
"score": 8
},
{
"title": "Heartwarming and Inspiring",
"body": "This series touched my heart. It's uplifting, inspiring, and reminds us of the power of friendship and resilience. A feel-good watch!",
"score": 9
},
{
"title": "Thought-Provoking and Mind-Bending",
"body": "This series messes with your mind in the best way possible. It raises intriguing questions and challenges your perception of reality.",
"score": 7
},
{
"title": "A Rollercoaster of Emotions",
"body": "This series had me laughing one moment and in tears the next. It's an emotional rollercoaster that leaves a lasting impact.",
"score": 8
},
{
"title": "Captivating Ensemble Cast",
"body": "The chemistry among the cast members is electric. They bring the characters to life with such authenticity and make the series truly engaging.",
"score": 9
},
{
"title": "Disappointing Plot Twists",
"body": "The series relied too heavily on predictable plot twists. It left me underwhelmed and craving for more surprising twists and turns.",
"score": 4
},
{
"title": "Lacks Coherence",
"body": "The series felt disjointed and lacked a cohesive narrative. The storylines didn't seamlessly come together, leaving me confused.",
"score": 3
},
{
"title": "Underdeveloped Supporting Characters",
"body": "While the main characters shined, the supporting cast felt underutilized. Their arcs were shallow and left much to be desired.",
"score": 5
},
{
"title": "Visually Underwhelming",
"body": "The series didn't make good use of its visual potential. The cinematography and production design felt lackluster and uninspired.",
"score": 4
},
{
"title": "Predictable Storylines",
"body": "The series followed familiar tropes and lacked originality. The storylines unfolded exactly as expected, offering little surprises.",
"score": 3
},
{
"title": "A Forgettable Experience",
"body": "Despite its initial promise, this series failed to make a lasting impression. It lacked memorable moments and failed to leave a lasting impact.",
"score": 5
},
{
"title": "Compelling and Addictive",
"body": "This series had me hooked from the first episode. The characters and their complex relationships kept me invested throughout. A must-watch!",
"score": 8
},
{
"title": "Gripping and Intense",
"body": "The series is a thrilling ride from start to finish. The suspense and tension never let up. I couldn't look away!",
"score": 9
},
{
"title": "Relatable and Authentic",
"body": "The characters in this series feel like real people. I could relate to their struggles and triumphs. It's a genuine and authentic portrayal!",
"score": 8
},
{
"title": "Brilliant Ensemble Cast",
"body": "The cast in this series is phenomenal. Each actor brings their A-game, creating a dynamic and captivating ensemble. Brilliant performances!",
"score": 9
},
{
"title": "Underwhelming Plot",
"body": "The series failed to deliver an engaging and cohesive storyline. The plot felt disjointed and left me wanting more depth.",
"score": 4
},
{
"title": "Slow-Paced and Boring",
"body": "The series lacked excitement and dragged on. The slow pace made it difficult to stay engaged. It didn't hold my interest.",
"score": 3
},
{
"title": "Weak Supporting Characters",
"body": "While the main characters were well-developed, the supporting cast felt underdeveloped and served little purpose in the series.",
"score": 5
},
{
"title": "Visually Average",
"body": "The series didn't stand out visually. The cinematography and production design were mediocre, lacking creativity and flair.",
"score": 4
},
{
"title": "Lack of Originality",
"body": "The series felt derivative and failed to bring anything new to the table. It lacked fresh ideas and original storytelling.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.",
"score": 5
},
{
"title": "Compelling and Captivating",
"body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!",
"score": 8
},
{
"title": "Thrilling and Heart-Pounding",
"body": "This series had me on the edge of my seat. The suspense and intense moments kept me glued to the screen. So exhilarating!",
"score": 9
},
{
"title": "Authentic Character Development",
"body": "The series beautifully portrayed the growth and development of its characters. I felt invested in their journeys. Truly compelling!",
"score": 8
},
{
"title": "Ensemble Cast Chemistry",
"body": "The chemistry among the cast members is electric. Their interactions and dynamics add depth and richness to the series. Fantastic ensemble!",
"score": 9
},
{
"title": "Weak Plot Progression",
"body": "The series struggled with pacing and failed to progress the plot effectively. It felt stagnant and lacked momentum.",
"score": 4
},
{
"title": "Lacks Excitement",
"body": "The series failed to deliver thrilling moments or exciting plot developments. It left me wanting more action and intensity.",
"score": 3
},
{
"title": "Underutilized Supporting Characters",
"body": "While the main characters shined, the supporting cast felt underused and lacked substantial storylines. Their potential wasn't fully explored.",
"score": 5
},
{
"title": "Visually Unimpressive",
"body": "The series didn't utilize its visual elements to their full potential. The cinematography and production design were uninspiring.",
"score": 4
},
{
"title": "Unoriginal Storylines",
"body": "The series relied on familiar tropes and predictable plotlines. It lacked innovation and failed to offer any surprises.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial promise, this series didn't leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.",
"score": 5
},
{
"title": "Engrossing and Addictive",
"body": "This series had me captivated from the first episode. The storytelling and character arcs kept me invested throughout. A must-watch!",
"score": 8
},
{
"title": "Nail-Biting and Suspenseful",
"body": "The series had me on the edge of my seat. The suspense and tension were palpable. I couldn't look away!",
"score": 9
},
{
"title": "Well-Developed Characters",
"body": "The series excelled in character development. Each character had depth and their own compelling storylines. Truly engaging!",
"score": 8
},
{
"title": "Chemistry-Driven Ensemble",
"body": "The cast's chemistry is electric. Their interactions and relationships bring an extra layer of authenticity to the series. Outstanding ensemble!",
"score": 9
},
{
"title": "Weak Plot Twists",
"body": "The series failed to deliver impactful plot twists. The twists felt predictable and lacked the wow factor. Disappointing.",
"score": 4
},
{
"title": "Lacks Momentum",
"body": "The series struggled to maintain momentum. It felt slow and didn't build enough excitement or suspense.",
"score": 3
},
{
"title": "Underdeveloped Supporting Cast",
"body": "While the main characters shined, the supporting cast felt underutilized and lacked meaningful character arcs. A missed opportunity.",
"score": 5
},
{
"title": "Visually Unremarkable",
"body": "The series didn't stand out visually. The cinematography and production design were average, lacking innovation and creativity.",
"score": 4
},
{
"title": "Predictable and Clichéd",
"body": "The series followed predictable storylines and clichéd tropes. It failed to bring anything fresh or original to the table.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.",
"score": 5
},
{
"title": "Compelling and Addictive",
"body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!",
"score": 8
},
{
"title": "Thrilling and Heart-Pounding",
"body": "This series had me on the edge of my seat. The suspense and intense moments kept me glued to the screen. So exhilarating!",
"score": 9
},
{
"title": "Authentic Character Development",
"body": "The series beautifully portrayed the growth and development of its characters. I felt invested in their journeys. Truly compelling!",
"score": 8
},
{
"title": "Ensemble Cast Chemistry",
"body": "The chemistry among the cast members is electric. Their interactions and dynamics add depth and richness to the series. Fantastic ensemble!",
"score": 9
},
{
"title": "Weak Plot Progression",
"body": "The series struggled with pacing and failed to progress the plot effectively. It felt stagnant and lacked momentum.",
"score": 4
},
{
"title": "Lacks Excitement",
"body": "The series failed to deliver thrilling moments or exciting plot developments. It left me wanting more action and intensity.",
"score": 3
},
{
"title": "Underutilized Supporting Characters",
"body": "While the main characters shined, the supporting cast felt underused and lacked substantial storylines. Their potential wasn't fully explored.",
"score": 5
},
{
"title": "Visually Unimpressive",
"body": "The series didn't utilize its visual elements to their full potential. The cinematography and production design were uninspiring.",
"score": 4
},
{
"title": "Unoriginal Storylines",
"body": "The series relied on familiar tropes and predictable plotlines. It lacked innovation and failed to offer any surprises.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial promise, this series didn't leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.",
"score": 5
},
{
"title": "Compelling and Captivating",
"body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!",
"score": 8
},
{
"title": "Thrilling and Suspenseful",
"body": "This series kept me on the edge of my seat. The suspense and tension were palpable. I couldn't look away!",
"score": 9
},
{
"title": "Engaging Character Dynamics",
"body": "The series excelled in showcasing the relationships and dynamics between the characters. Their interactions were captivating.",
"score": 8
},
{
"title": "Impressive Ensemble Cast",
"body": "The cast delivered outstanding performances, bringing depth and authenticity to their characters. A stellar ensemble!",
"score": 9
},
{
"title": "Weak Plot Development",
"body": "The series struggled to develop its plot effectively. The storylines felt disjointed and lacked coherence.",
"score": 4
},
{
"title": "Lackluster Excitement",
"body": "The series failed to deliver excitement or thrilling moments. It lacked the necessary suspense and intensity.",
"score": 3
},
{
"title": "Underdeveloped Supporting Cast",
"body": "While the main characters were well-developed, the supporting cast felt underutilized and lacked substantial storylines.",
"score": 5
},
{
"title": "Visually Mediocre",
"body": "The series didn't stand out visually. The cinematography and production design felt average and uninspired.",
"score": 4
},
{
"title": "Predictable and Stale",
"body": "The series followed predictable storylines and failed to offer any surprises. It lacked freshness and originality.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.",
"score": 5
},
{
"title": "Addictive and Riveting",
"body": "This series had me hooked from the first episode. The intriguing storyline and compelling characters kept me engaged throughout. Can't wait for the next season!",
"score": 8
},
{
"title": "Heartwarming and Uplifting",
"body": "The series touched my heart and left me feeling inspired. It's a beautiful portrayal of hope, love, and resilience. Highly recommended!",
"score": 9
},
{
"title": "Laugh-Out-Loud Funny",
"body": "This series had me in stitches from start to finish. The humor is clever and the comedic timing is spot-on. A perfect comedy series!",
"score": 8
},
{
"title": "Mind-Blowing Plot Twists",
"body": "Just when I thought I had it all figured out, this series delivered mind-blowing plot twists that left me in awe. It kept me guessing until the very end!",
"score": 9
},
{
"title": "Engaging and Addictive",
"body": "This series grabbed my attention and didn't let go. The fast-paced storytelling and gripping cliffhangers kept me eagerly anticipating the next episode.",
"score": 8
},
{
"title": "Thought-Provoking and Intelligent",
"body": "The series tackles complex themes and presents them with depth and intelligence. It sparks meaningful discussions and leaves a lasting impact.",
"score": 7
},
{
"title": "Unexpected and Surprising",
"body": "This series kept me on my toes with its unexpected plot developments and surprising character arcs. It defied my expectations in the best way!",
"score": 8
},
{
"title": "Gripping and Suspenseful",
"body": "The series had me on the edge of my seat. The tension and suspense were palpable, making it a thrilling and nail-biting experience.",
"score": 9
},
{
"title": "Immersive and Captivating",
"body": "This series transported me to another world. The immersive storytelling and rich world-building kept me fully engaged. A must-watch for fantasy fans!",
"score": 8
},
{
"title": "Powerful and Impactful",
"body": "This series packs an emotional punch. It explores important themes with depth and authenticity, leaving a lasting impact on the viewer.",
"score": 9
},
{
"title": "Addictive and Thrilling",
"body": "I couldn't stop watching this series. Each episode ended with a cliffhanger that left me craving for more. It's a pulse-pounding thrill ride!",
"score": 8
},
{
"title": "Compelling Character Dynamics",
"body": "The relationships between the characters are the heart of this series. The chemistry and complexity make it a truly engaging watch.",
"score": 9
},
{
"title": "Disappointing Plot Development",
"body": "The series failed to progress the plot effectively. It felt slow and lacked the necessary momentum to keep me fully invested.",
"score": 4
},
{
"title": "Underwhelming and Lackluster",
"body": "This series didn't live up to the hype. It lacked excitement and failed to deliver on its promising premise. Disappointing overall.",
"score": 3
},
{
"title": "Weak Characterization",
"body": "The characters in this series felt one-dimensional and lacked depth. Their arcs and motivations were underdeveloped and left me wanting more.",
"score": 5
},
{
"title": "Visually Stunning",
"body": "The series is a visual feast for the eyes. The stunning cinematography and production design create a mesmerizing and immersive experience.",
"score": 9
},
{
"title": "Emotionally Charged",
"body": "This series tugged at my heartstrings. The emotional depth and raw performances left me in tears. A deeply moving experience!",
"score": 8
},
{
"title": "Smart and Mind-Bending",
"body": "The series challenges your perception and keeps you guessing. It's a smartly crafted and mind-bending journey that will leave you in awe.",
"score": 9
},
{
"title": "Heartfelt and Genuine",
"body": "This series touched my heart with its authenticity and heartfelt storytelling. It's a genuine portrayal of human emotions and relationships.",
"score": 8
},
{
"title": "Disappointing Plot Twists",
"body": "The series relied on predictable and uninspired plot twists. It failed to surprise or engage me. The twists felt forced and lacking impact.",
"score": 4
},
{
"title": "Lacks Momentum and Pacing",
"body": "The series struggled with pacing issues and lacked the necessary momentum to keep me fully engaged. It felt slow and dragged on at times.",
"score": 3
},
{
"title": "Underdeveloped Supporting Characters",
"body": "While the main characters shined, the supporting cast felt underutilized and lacked substantial storylines. Their potential wasn't fully realized.",
"score": 5
},
{
"title": "Visually Mediocre",
"body": "The series didn't impress visually. The cinematography and production design felt average and didn't leave a lasting impact.",
"score": 4
},
{
"title": "Predictable and Formulaic",
"body": "The series followed a predictable formula, leaving little room for surprises. It felt formulaic and failed to offer originality.",
"score": 3
},
{
"title": "Easily Forgettable",
"body": "Despite its initial intrigue, this series failed to leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.",
"score": 5
}
]

686
app/Actions/Demo/demo-users.json Executable file
View File

@@ -0,0 +1,686 @@
[
{
"email": "john.doe@example.com",
"first_name": "John",
"last_name": "Doe",
"gender": "male"
},
{
"email": "jane.smith@example.com",
"first_name": "Jane",
"last_name": "Smith",
"gender": "female"
},
{
"email": "michael.johnson@example.com",
"first_name": "Michael",
"last_name": "Johnson",
"gender": "male"
},
{
"email": "emily.williams@example.com",
"first_name": "Emily",
"last_name": "Williams",
"gender": "female"
},
{
"email": "william.brown@example.com",
"first_name": "William",
"last_name": "Brown",
"gender": "male"
},
{
"email": "sophia.jones@example.com",
"first_name": "Sophia",
"last_name": "Jones",
"gender": "female"
},
{
"email": "jackson.taylor@example.com",
"first_name": "Jackson",
"last_name": "Taylor",
"gender": "male"
},
{
"email": "olivia.anderson@example.com",
"first_name": "Olivia",
"last_name": "Anderson",
"gender": "female"
},
{
"email": "matthew.johnson@example.com",
"first_name": "Matthew",
"last_name": "Johnson",
"gender": "male"
},
{
"email": "emma.martin@example.com",
"first_name": "Emma",
"last_name": "Martin",
"gender": "female"
},
{
"email": "liam.thompson@example.com",
"first_name": "Liam",
"last_name": "Thompson",
"gender": "male"
},
{
"email": "ava.hernandez@example.com",
"first_name": "Ava",
"last_name": "Hernandez",
"gender": "female"
},
{
"email": "noah.white@example.com",
"first_name": "Noah",
"last_name": "White",
"gender": "male"
},
{
"email": "isabella.moore@example.com",
"first_name": "Isabella",
"last_name": "Moore",
"gender": "female"
},
{
"email": "ethan.martin@example.com",
"first_name": "Ethan",
"last_name": "Martin",
"gender": "male"
},
{
"email": "mia.lewis@example.com",
"first_name": "Mia",
"last_name": "Lewis",
"gender": "female"
},
{
"email": "mason.hill@example.com",
"first_name": "Mason",
"last_name": "Hill",
"gender": "male"
},
{
"email": "amelia.wilson@example.com",
"first_name": "Amelia",
"last_name": "Wilson",
"gender": "female"
},
{
"email": "logan.clark@example.com",
"first_name": "Logan",
"last_name": "Clark",
"gender": "male"
},
{
"email": "harper.robinson@example.com",
"first_name": "Harper",
"last_name": "Robinson",
"gender": "female"
},
{
"email": "oliver.walker@example.com",
"first_name": "Oliver",
"last_name": "Walker",
"gender": "male"
},
{
"email": "evelyn.cooper@example.com",
"first_name": "Evelyn",
"last_name": "Cooper",
"gender": "female"
},
{
"email": "lucas.peterson@example.com",
"first_name": "Lucas",
"last_name": "Peterson",
"gender": "male"
},
{
"email": "abigail.kelly@example.com",
"first_name": "Abigail",
"last_name": "Kelly",
"gender": "female"
},
{
"email": "aiden.richardson@example.com",
"first_name": "Aiden",
"last_name": "Richardson",
"gender": "male"
},
{
"email": "elizabeth.cook@example.com",
"first_name": "Elizabeth",
"last_name": "Cook",
"gender": "female"
},
{
"email": "michael.ross@example.com",
"first_name": "Michael",
"last_name": "Ross",
"gender": "male"
},
{
"email": "sofia.bennett@example.com",
"first_name": "Sofia",
"last_name": "Bennett",
"gender": "female"
},
{
"email": "alexander.brooks@example.com",
"first_name": "Alexander",
"last_name": "Brooks",
"gender": "male"
},
{
"email": "charlotte.bell@example.com",
"first_name": "Charlotte",
"last_name": "Bell",
"gender": "female"
},
{
"email": "ethan.hall@example.com",
"first_name": "Ethan",
"last_name": "Hall",
"gender": "male"
},
{
"email": "grace.perez@example.com",
"first_name": "Grace",
"last_name": "Perez",
"gender": "female"
},
{
"email": "daniel.baker@example.com",
"first_name": "Daniel",
"last_name": "Baker",
"gender": "male"
},
{
"email": "hannah.bailey@example.com",
"first_name": "Hannah",
"last_name": "Bailey",
"gender": "female"
},
{
"email": "matthew.rogers@example.com",
"first_name": "Matthew",
"last_name": "Rogers",
"gender": "male"
},
{
"email": "ella.gomez@example.com",
"first_name": "Ella",
"last_name": "Gomez",
"gender": "female"
},
{
"email": "joseph.morris@example.com",
"first_name": "Joseph",
"last_name": "Morris",
"gender": "male"
},
{
"email": "chloe.bailey@example.com",
"first_name": "Chloe",
"last_name": "Bailey",
"gender": "female"
},
{
"email": "william.sanchez@example.com",
"first_name": "William",
"last_name": "Sanchez",
"gender": "male"
},
{
"email": "zoey.harris@example.com",
"first_name": "Zoey",
"last_name": "Harris",
"gender": "female"
},
{
"email": "christopher.foster@example.com",
"first_name": "Christopher",
"last_name": "Foster",
"gender": "male"
},
{
"email": "aubrey.patterson@example.com",
"first_name": "Aubrey",
"last_name": "Patterson",
"gender": "female"
},
{
"email": "ryan.bell@example.com",
"first_name": "Ryan",
"last_name": "Bell",
"gender": "male"
},
{
"email": "avery.sullivan@example.com",
"first_name": "Avery",
"last_name": "Sullivan",
"gender": "female"
},
{
"email": "noah.washington@example.com",
"first_name": "Noah",
"last_name": "Washington",
"gender": "male"
},
{
"email": "addison.roberts@example.com",
"first_name": "Addison",
"last_name": "Roberts",
"gender": "female"
},
{
"email": "david.hughes@example.com",
"first_name": "David",
"last_name": "Hughes",
"gender": "male"
},
{
"email": "madison.murphy@example.com",
"first_name": "Madison",
"last_name": "Murphy",
"gender": "female"
},
{
"email": "jacob.edwards@example.com",
"first_name": "Jacob",
"last_name": "Edwards",
"gender": "male"
},
{
"email": "scarlett.watson@example.com",
"first_name": "Scarlett",
"last_name": "Watson",
"gender": "female"
},
{
"email": "michael.russell@example.com",
"first_name": "Michael",
"last_name": "Russell",
"gender": "male"
},
{
"email": "grace.hill@example.com",
"first_name": "Grace",
"last_name": "Hill",
"gender": "female"
},
{
"email": "logan.mitchell@example.com",
"first_name": "Logan",
"last_name": "Mitchell",
"gender": "male"
},
{
"email": "sophia.myers@example.com",
"first_name": "Sophia",
"last_name": "Myers",
"gender": "female"
},
{
"email": "john.richardson@example.com",
"first_name": "John",
"last_name": "Richardson",
"gender": "male"
},
{
"email": "abigail.wilson@example.com",
"first_name": "Abigail",
"last_name": "Wilson",
"gender": "female"
},
{
"email": "william.sanders@example.com",
"first_name": "William",
"last_name": "Sanders",
"gender": "male"
},
{
"email": "lily.howard@example.com",
"first_name": "Lily",
"last_name": "Howard",
"gender": "female"
},
{
"email": "james.hall@example.com",
"first_name": "James",
"last_name": "Hall",
"gender": "male"
},
{
"email": "sophia.gonzalez@example.com",
"first_name": "Sophia",
"last_name": "Gonzalez",
"gender": "female"
},
{
"email": "joseph.russell@example.com",
"first_name": "Joseph",
"last_name": "Russell",
"gender": "male"
},
{
"email": "mia.allen@example.com",
"first_name": "Mia",
"last_name": "Allen",
"gender": "female"
},
{
"email": "alexander.peterson@example.com",
"first_name": "Alexander",
"last_name": "Peterson",
"gender": "male"
},
{
"email": "emily.brooks@example.com",
"first_name": "Emily",
"last_name": "Brooks",
"gender": "female"
},
{
"email": "daniel.stewart@example.com",
"first_name": "Daniel",
"last_name": "Stewart",
"gender": "male"
},
{
"email": "chloe.bennett@example.com",
"first_name": "Chloe",
"last_name": "Bennett",
"gender": "female"
},
{
"email": "lucas.rogers@example.com",
"first_name": "Lucas",
"last_name": "Rogers",
"gender": "male"
},
{
"email": "ava.watson@example.com",
"first_name": "Ava",
"last_name": "Watson",
"gender": "female"
},
{
"email": "mason.howard@example.com",
"first_name": "Mason",
"last_name": "Howard",
"gender": "male"
},
{
"email": "zoey.gonzalez@example.com",
"first_name": "Zoey",
"last_name": "Gonzalez",
"gender": "female"
},
{
"email": "andrew.wood@example.com",
"first_name": "Andrew",
"last_name": "Wood",
"gender": "male"
},
{
"email": "grace.patterson@example.com",
"first_name": "Grace",
"last_name": "Patterson",
"gender": "female"
},
{
"email": "william.perez@example.com",
"first_name": "William",
"last_name": "Perez",
"gender": "male"
},
{
"email": "scarlett.bailey@example.com",
"first_name": "Scarlett",
"last_name": "Bailey",
"gender": "female"
},
{
"email": "ryan.sanchez@example.com",
"first_name": "Ryan",
"last_name": "Sanchez",
"gender": "male"
},
{
"email": "olivia.harris@example.com",
"first_name": "Olivia",
"last_name": "Harris",
"gender": "female"
},
{
"email": "alexander.kelly@example.com",
"first_name": "Alexander",
"last_name": "Kelly",
"gender": "male"
},
{
"email": "aubrey.richardson@example.com",
"first_name": "Aubrey",
"last_name": "Richardson",
"gender": "female"
},
{
"email": "noah.cook@example.com",
"first_name": "Noah",
"last_name": "Cook",
"gender": "male"
},
{
"email": "david.morris@example.com",
"first_name": "David",
"last_name": "Morris",
"gender": "female"
},
{
"email": "amelia.walker@example.com",
"first_name": "Amelia",
"last_name": "Walker",
"gender": "male"
},
{
"email": "jacob.richardson@example.com",
"first_name": "Jacob",
"last_name": "Richardson",
"gender": "female"
},
{
"email": "michael.hill@example.com",
"first_name": "Michael",
"last_name": "Hill",
"gender": "male"
},
{
"email": "sophia.peterson@example.com",
"first_name": "Sophia",
"last_name": "Peterson",
"gender": "female"
},
{
"email": "william.bell@example.com",
"first_name": "William",
"last_name": "Bell",
"gender": "male"
},
{
"email": "zoey.hall@example.com",
"first_name": "Zoey",
"last_name": "Hall",
"gender": "female"
},
{
"email": "christopher.murphy@example.com",
"first_name": "Christopher",
"last_name": "Murphy",
"gender": "male"
},
{
"email": "aubrey.myers@example.com",
"first_name": "Aubrey",
"last_name": "Myers",
"gender": "female"
},
{
"email": "ryan.richardson@example.com",
"first_name": "Ryan",
"last_name": "Richardson",
"gender": "male"
},
{
"email": "addison.wilson@example.com",
"first_name": "Addison",
"last_name": "Wilson",
"gender": "female"
},
{
"email": "david.sanders@example.com",
"first_name": "David",
"last_name": "Sanders",
"gender": "male"
},
{
"email": "madison.hill@example.com",
"first_name": "Madison",
"last_name": "Hill",
"gender": "female"
},
{
"email": "jacob.peterson@example.com",
"first_name": "Jacob",
"last_name": "Peterson",
"gender": "male"
},
{
"email": "scarlett.brooks@example.com",
"first_name": "Scarlett",
"last_name": "Brooks",
"gender": "female"
},
{
"email": "michael.stewart@example.com",
"first_name": "Michael",
"last_name": "Stewart",
"gender": "male"
},
{
"email": "grace.bennett@example.com",
"first_name": "Grace",
"last_name": "Bennett",
"gender": "female"
},
{
"email": "logan.roberts@example.com",
"first_name": "Logan",
"last_name": "Roberts",
"gender": "male"
},
{
"email": "sophia.baker@example.com",
"first_name": "Sophia",
"last_name": "Baker",
"gender": "female"
},
{
"email": "john.gomez@example.com",
"first_name": "John",
"last_name": "Gomez",
"gender": "male"
},
{
"email": "abigail.wood@example.com",
"first_name": "Abigail",
"last_name": "Wood",
"gender": "female"
},
{
"email": "william.patterson@example.com",
"first_name": "William",
"last_name": "Patterson",
"gender": "male"
},
{
"email": "lily.perez@example.com",
"first_name": "Lily",
"last_name": "Perez",
"gender": "female"
},
{
"email": "james.hughes@example.com",
"first_name": "James",
"last_name": "Hughes",
"gender": "male"
},
{
"email": "sophia.murphy@example.com",
"first_name": "Sophia",
"last_name": "Murphy",
"gender": "female"
},
{
"email": "joseph.edwards@example.com",
"first_name": "Joseph",
"last_name": "Edwards",
"gender": "male"
},
{
"email": "mia.washington@example.com",
"first_name": "Mia",
"last_name": "Washington",
"gender": "female"
},
{
"email": "alexander.rogers@example.com",
"first_name": "Alexander",
"last_name": "Rogers",
"gender": "male"
},
{
"email": "emily.gonzalez@example.com",
"first_name": "Emily",
"last_name": "Gonzalez",
"gender": "female"
},
{
"email": "daniel.morris@example.com",
"first_name": "Daniel",
"last_name": "Morris",
"gender": "male"
},
{
"email": "chloe.walker@example.com",
"first_name": "Chloe",
"last_name": "Walker",
"gender": "female"
},
{
"email": "lucas.peterson@example.com",
"first_name": "Lucas",
"last_name": "Peterson",
"gender": "male"
},
{
"email": "ava.watson@example.com",
"first_name": "Ava",
"last_name": "Watson",
"gender": "female"
},
{
"email": "mason.howard@example.com",
"first_name": "Mason",
"last_name": "Howard",
"gender": "male"
},
{
"email": "zoey.gonzalez@example.com",
"first_name": "Zoey",
"last_name": "Gonzalez",
"gender": "female"
}
]

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Lists;
use App\Models\Channel;
use App\Models\User;
use Common\Database\Datasource\Datasource;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class ListsLoader
{
public function forUser(User $user, array $params): AbstractPaginator
{
$builder = $user->lists()->where('internal', false);
if (Auth::id() !== $user->id) {
$builder->where('public', true);
}
$pagination = (new Datasource($builder, $params))->paginate();
$pagination
->loadCount('items')
->load([
'items' => fn($query) => $query
->orderBy('order')
->where('order', '<=', 4)
->compact(),
])
->transform(function (Channel $list) {
$list->description = Str::limit($list->description, 80);
return $list;
});
return $pagination;
}
public function allLists(array $params): AbstractPaginator
{
$builder = Channel::where('type', 'list')
->where('internal', false)
->with('user');
$pagination = (new Datasource($builder, $params))->paginate();
$pagination->loadCount('items');
$pagination->transform(function (Channel $list) {
$list->description = Str::limit($list->description, 80);
return $list;
});
return $pagination;
}
}

41
app/Actions/LocalSearch.php Executable file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Actions;
use App\Actions\People\LoadPrimaryCredit;
use App\Models\Person;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class LocalSearch
{
public function execute(string $query, array $params = []): Collection
{
$titles = collect();
$people = collect();
if (Arr::get($params, 'type') !== 'person') {
$titles = Title::search($query)
->take(20)
->get();
if ($with = Arr::get($params, 'with')) {
$with = array_filter(explode(',', $with));
$titles->load($with);
}
}
if (Arr::get($params, 'type') !== 'title') {
$people = Person::search($query)
->take(20)
->get();
app(LoadPrimaryCredit::class)->execute($people);
}
return $titles
->concat($people)
->slice(0, Arr::get($params, 'limit', 8))
->values();
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Actions\News;
use App\Models\NewsArticle;
use App\Models\Person;
use App\Models\Title;
use App\Services\Data\News\ImdbNewsProvider;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ImportNewsFromRemoteProvider
{
public function execute(): void
{
$newArticles = collect(
app(ImdbNewsProvider::class)->getArticles(),
)->map(function ($article) {
$article['slug'] = slugify(Str::limit($article['title'], 50));
return $article;
});
$existing = NewsArticle::whereIn(
'slug',
$newArticles->pluck('slug'),
)->get();
// filter out already existing articles
$newArticles = $newArticles
->filter(
fn($newArticle) => !$existing->first(
fn($existingArticle) => $existingArticle['title'] ===
$newArticle['title'] ||
$existingArticle['slug'] === $newArticle['slug'],
),
)
->unique('slug');
$articlesPayload = $newArticles->map(function ($article, $index) {
$date = isset($article['date'])
? Carbon::parse($article['date'])
: Carbon::now();
return [
'title' => $article['title'],
'body' => $article['body'],
'slug' => $article['slug'],
'image' => $article['image'] ?? null,
'source' => $article['source'] ?? null,
'source_url' => $article['source_url'] ?? null,
'byline' => $article['byline'] ?? null,
'created_at' => $date,
'updated_at' => $date,
];
});
NewsArticle::insert($articlesPayload->toArray());
$this->attachArticlesToRelatedModels($newArticles);
}
protected function attachArticlesToRelatedModels(Collection $articles): void
{
// fetch inserted articles from db
$dbArticles = NewsArticle::whereIn(
'slug',
$articles->pluck('slug'),
)->get(['id', 'slug']);
$titleImdbIds = [];
$personImdbIds = [];
// generate [article_slug => [imdb_id, imdb_id, ...]] arrays
foreach ($articles as $article) {
if (isset($article['imdb_title_ids'])) {
foreach ($article['imdb_title_ids'] as $imdbTitleId) {
$titleImdbIds[$article['slug']][] = $imdbTitleId;
}
}
if (isset($article['imdb_person_ids'])) {
foreach ($article['imdb_person_ids'] as $imdbPersonId) {
$personImdbIds[$article['slug']][] = $imdbPersonId;
}
}
}
// fetch titles and people by imdb_id as [imdb_id => id] arrays
$titles = Title::whereIn(
'imdb_id',
collect($titleImdbIds)
->values()
->flatten(),
)
->pluck('id', 'imdb_id')
->toArray();
$people = Person::whereIn(
'imdb_id',
collect($personImdbIds)
->values()
->flatten(),
)
->pluck('id', 'imdb_id')
->toArray();
// generate titles payload for pivot table
$payload = [];
foreach ($titleImdbIds as $slug => $imdbIds) {
foreach ($imdbIds as $imdbId) {
if (isset($titles[$imdbId])) {
$payload[] = [
'article_id' => $dbArticles->firstWhere('slug', $slug)
->id,
'model_id' => $titles[$imdbId],
'model_type' => Title::MODEL_TYPE,
];
}
}
}
// generate people payload for pivot table
foreach ($personImdbIds as $slug => $imdbIds) {
foreach ($imdbIds as $imdbId) {
if (isset($people[$imdbId])) {
$payload[] = [
'article_id' => $dbArticles->firstWhere('slug', $slug)
->id,
'model_id' => $people[$imdbId],
'model_type' => Person::class,
];
}
}
}
DB::table('news_article_models')->insert($payload);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Actions\People;
use App\Models\Person;
use Illuminate\Support\Facades\DB;
class DeletePeople
{
public function execute(array $ids): void
{
Person::withoutGlobalScope('adult')
->whereIn('id', $ids)
->delete();
DB::table('creditables')
->whereIn('person_id', $ids)
->delete();
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace App\Actions\People;
use App\Models\Person;
use App\Models\Title;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class GetPersonCredits
{
private ?int $titleId;
public function execute(Person $person, $options = []): array
{
$this->titleId = Arr::get($options, 'titleId');
$credits = $this->titleId ? [] : $this->getTitleCredits($person);
$seasonCredits = $this->getSeasonCredits($person);
$episodeCredits = $this->getEpisodeCredits($person);
$mergedCredits = $this->mergeCredits(
Arr::get($credits, 'all', []),
$seasonCredits,
$episodeCredits,
);
$mergedCredits = $this->separateSelfCreditsAndSort($mergedCredits);
return [
'credits' => $mergedCredits,
'knownFor' => Arr::get($credits, 'knownFor', []),
'total_credits_count' => array_reduce(
$mergedCredits,
fn($carry, $item) => $carry + count($item),
0,
),
];
}
private function mergeCredits($credits1, $credits2, $credits3): array
{
$mergedCredits = array_merge_recursive($credits1, $credits2, $credits3);
return array_map(function ($titles) {
// sort titles by year
usort($titles, fn($a, $b) => $b['year'] - $a['year']);
$unique = [];
// if this title already exists and existing
// title has episodes property, continue,
// otherwise push title into 'unique' array
foreach ($titles as $title) {
$existing = Arr::get($unique, $title['id']);
if ($existing) {
$existing['credited_episode_count'] =
Arr::get($existing, 'credited_episode_count', 0) +
Arr::get($title, 'credited_episode_count', 0);
$existing['episodes'] = collect(
array_merge(
Arr::get($existing, 'episodes', []),
Arr::get($title, 'episodes', []),
),
)
->unique('id')
->toArray();
if (!$this->titleId) {
$existing['episodes'] = array_slice(
$existing['episodes'],
0,
5,
);
}
$unique[$title['id']] = $existing;
} else {
$unique[$title['id']] = $title;
}
}
return array_values($unique);
}, $mergedCredits);
}
private function getTitleCredits(Person $person): array
{
$credits = $person->credits()->get();
// generate known for list for actors "known_for" department.
$allKnownFor = $credits
->filter(function (Title $credit) use ($person) {
$knownFor =
strtolower($person->known_for) === 'acting'
? 'actors'
: $person->known_for;
return $credit->pivot->department === strtolower($knownFor);
})
->unique();
$knownFor = $allKnownFor->where('pivot.order', '<', 10);
if ($knownFor->count() < 4) {
$knownFor = $allKnownFor;
}
// sort by person credit "order" for title as well as title popularity
$knownFor = $knownFor
->sortBy(function ($title) {
$order = $title->pivot->order;
$popularity = $title->popularity;
return $order - $popularity;
})
->slice(0, 6)
->values();
// cast to array, so poster/backdrop is not removed later.
$knownFor = $knownFor->load(['primaryVideo'])->toArray();
// remove any data not needed to render person filmography
$credits = $credits
->map(function (Title $credit) {
unset($credit['backdrop']);
return $credit;
})
->groupBy('pivot.department');
return ['all' => $credits->toArray(), 'knownFor' => $knownFor];
}
/**
* Get credits for all series seasons person is attached to.
*/
private function getSeasonCredits(Person $person): array
{
$seasons = $person
->seasonCredits($this->titleId)
->with([
'title' => fn($query) => $query->select(
'id',
'name',
'release_date',
'poster',
),
'episodes' => fn($query) => $query
->select(
'id',
'name',
'release_date',
'season_id',
'season_number',
'episode_number',
'title_id',
)
->orderBy('season_number', 'desc')
->orderBy('episode_number', 'desc'),
])
->get();
// group all seasons by department, for example "production"
$groupedSeasons = $seasons->groupBy('pivot.department');
return $groupedSeasons
->map(function (Collection $departmentGroup) {
$seasonsGroupedByTitle = $departmentGroup->groupBy('title.id');
// attach episodes from all seasons to title
return $seasonsGroupedByTitle
->map(function (Collection $titleSeasons) {
$title = $titleSeasons
->first(fn($s) => $s->title)
->title->toArray();
//get episodes from each season and move season "pivot" data to each episode
$episodesFromAllSeasons = $titleSeasons
->pluck('episodes')
->flatten()
->values()
->map(function ($episode) use ($titleSeasons) {
$episode->pivot = $titleSeasons
->first()
->pivot->toArray();
return $episode;
});
$title[
'credited_episode_count'
] = $episodesFromAllSeasons->count();
if (!$this->titleId) {
$episodesFromAllSeasons = $episodesFromAllSeasons->take(
5,
);
}
$title['episodes'] = $episodesFromAllSeasons->toArray();
return $title;
})
->values();
})
->toArray();
}
/**
* Get all individual episodes person is credited for.
*
* This will return array grouped by department, and
* series with all episodes person is credited for attached
* to that series.
*
* @param Person $person
* @return array
*/
private function getEpisodeCredits(Person $person)
{
$episodes = $person
->episodeCredits($this->titleId)
->with([
'title' => function (BelongsTo $query) {
$query->select('id', 'name', 'release_date', 'poster');
},
])
->get();
$groupedByDep = $episodes->groupBy('pivot.department');
return $groupedByDep
->map(
fn(Collection $episodes) => $episodes
->groupBy('title.id')
->map(function (Collection $episodes) {
if (!$episodes->first()->title) {
return null;
}
$title = $episodes->first()->title->toArray();
$episodes = $episodes->map(function ($episode) {
unset($episode->title);
return $episode;
});
$title['credited_episode_count'] = $episodes->count();
if (!$this->titleId) {
$episodes = $episodes->take(5);
}
$title['episodes'] = $episodes->toArray();
return $title;
})
->filter()
->values(),
)
->toArray();
}
private function separateSelfCreditsAndSort(array $credits): array
{
if (!isset($credits['cast'])) {
return $credits;
}
$cast = [];
$self = [];
foreach ($credits['cast'] as $credit) {
$char = isset($credit['pivot']['character'])
? strtolower($credit['pivot']['character'])
: null;
if (
$char &&
($char === 'self' || Str::contains($char, 'himself'))
) {
$self[] = $credit;
} else {
$cast[] = $credit;
}
}
$credits['cast'] = $cast;
// sort before adding "self" to array as that should be last always
uksort(
$credits,
fn($a, $b) => (is_countable($credits[$b])
? count($credits[$b])
: 0) - (is_countable($credits[$a]) ? count($credits[$a]) : 0),
);
$credits['self'] = $self;
return $credits;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Actions\People;
use App\Models\Title;
use Illuminate\Database\QueryException;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LoadPrimaryCredit
{
public function execute(Collection|AbstractPaginator $people): void
{
$prefix = DB::getTablePrefix();
$titleSelect = Title::select([
'titles.id',
'titles.is_series',
'titles.name',
DB::raw("{$prefix}creditables.person_id as pivot_person_id"),
DB::raw(
"{$prefix}creditables.creditable_id as pivot_creditable_id",
),
DB::raw(
"{$prefix}creditables.creditable_type as pivot_creditable_type",
),
DB::raw("{$prefix}creditables.job as pivot_job"),
DB::raw("{$prefix}creditables.department as pivot_department"),
DB::raw(
"row_number() over (partition by {$prefix}creditables.person_id order by {$prefix}titles.popularity desc) as laravel_row",
),
])
->join('creditables', 'titles.id', '=', 'creditables.creditable_id')
->where('creditables.creditable_type', Title::MODEL_TYPE)
->whereIn('creditables.person_id', $people->pluck('id'))
// this scope will mess up binding merging below
->withoutGlobalScope('adult')
->when(
!config('tmdb.includeAdult'),
fn($q) => $q->where('adult', false),
);
// cache syntax error, if mysql version does not support partition
try {
$items = DB::table(
DB::raw("({$titleSelect->toSql()}) as laravel_table"),
)
->select('*')
->mergeBindings($titleSelect->getQuery())
->where('laravel_row', '<=', 1)
->orderBy('laravel_row')
->get();
} catch (QueryException $e) {
return;
}
$credits = Title::hydrate($items->toArray());
$people->each(function ($person) use ($credits) {
$credit = $credits->first(
fn($credit) => $credit->pivot_person_id === $person->id,
);
if ($credit) {
$person->primary_credit = [
'id' => $credit->id,
'is_series' => $credit->is_series,
'name' => $credit->name,
'year' => $credit->year,
'model_type' => $credit->model_type,
];
}
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Actions\People;
use App\Models\Person;
use Common\Database\Datasource\Datasource;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaginatePeople
{
public function execute(array $params, $builder = null): AbstractPaginator
{
$builder = $builder ?? Person::query();
$isCompact = Arr::get($params, 'compact');
if ($isCompact) {
$builder->select([
'id',
'name',
'birth_date',
'death_date',
'poster',
'popularity',
]);
}
$datasource = new Datasource($builder, $params);
// prevent duplicate items when ordering by columns that are not
// guaranteed to be unique (name, popularity, age etc.)
$datasource->secondaryOrderCol = 'id';
if (!Arr::get($params, 'order') && !Arr::get($params, 'orderBy')) {
$datasource->order = [
'col' => 'popularity',
'dir' => 'desc',
];
}
if (
$datasource->getOrder()['col'] === 'popularity' &&
($min = config('content.people_index_min_popularity'))
) {
$builder->where('popularity', '>', $min);
}
$pagination = $datasource->paginate();
if (!$isCompact) {
$pagination->transform(function (Person $person) {
$person->description = Str::limit($person->description, 500);
return $person;
});
//app(LoadPrimaryCredit::class)->execute($pagination);
}
return $pagination;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Actions\People;
use App\Actions\Titles\Store\StoreCredits;
use App\Actions\Titles\StoresMediaImages;
use App\Models\Person;
class StorePersonData
{
use StoresMediaImages;
private ?Person $person = null;
private ?array $data = null;
public function execute(Person $person, array $data): Person
{
$this->person = $person;
$this->data = $data;
$this->persistData();
$this->persistRelations();
return $this->person;
}
private function persistData(): void
{
$personData = array_filter($this->data, function (
$value, // make sure we don't overwrite existing values with null
) {
if (is_array($value)) {
return false;
}
// if fully_synced is true, override everything and erase any previously set values.
// For example if "death_date" was previously set on a person and tmdb now returns null for "death_date", set "death_date" to null in database.
if (
config('common.site.tmdb_delete_when_sync') &&
$this->data['fully_synced']
) {
return true;
}
// if "tmdb_delete_when_sync" is false, don't clear existing values, as values set from admin manually might be erased
return !is_null($value);
});
$this->person->fill($personData)->save();
}
private function persistRelations(): void
{
$relations = array_filter($this->data, fn($value) => is_array($value));
foreach ($relations as $name => $values) {
switch ($name) {
case 'credits':
app(StoreCredits::class)->execute($this->person, $values);
break;
case 'images':
$this->storeImages($values, $this->person);
break;
}
}
}
}

View 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,
],
],
];
}
}

View 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();
}
}

View 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']);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Reviews;
use App\Models\Review;
use Illuminate\Support\Facades\DB;
class UpdateReviewableAverageScore
{
public function execute(int $reviewableId, string $reviewableType): void
{
$votes = app(Review::class)
->where('reviewable_type', $reviewableType)
->where('reviewable_id', $reviewableId)
->select(
DB::raw('avg(`score`) as average'),
DB::raw('count(*) as count'),
)
->first();
$average = number_format((float) $votes['average'], 1);
// title or episode
$model = app(modelTypeToNamespace($reviewableType))->find(
$reviewableId,
);
$model->local_vote_average = $average;
$model->local_vote_count = $votes['count'];
$model->save();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Season;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DeleteSeasons
{
public function execute(array|Collection $seasonIds): void
{
// seasons
DB::table('creditables')
->whereIn('creditable_id', $seasonIds)
->where('creditable_type', Season::MODEL_TYPE)
->delete();
app(Season::class)
->whereIn('id', $seasonIds)
->delete();
// episodes
$episodeIds = app(Episode::class)
->whereIn('season_id', $seasonIds)
->pluck('id');
DB::table('creditables')
->whereIn('creditable_id', $episodeIds)
->where('creditable_type', Episode::MODEL_TYPE)
->delete();
app(Episode::class)
->whereIn('id', $episodeIds)
->delete();
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Actions\Titles;
use App\Models\Image;
use App\Models\Listable;
use App\Models\Review;
use App\Models\Season;
use App\Models\Title;
use App\Models\Video;
use Common\Comments\Comment;
use Illuminate\Support\Facades\DB;
class DeleteTitles
{
public function execute(array $titleIds): void
{
$seasonIds = app(Season::class)
->whereIn('title_id', $titleIds)
->pluck('id');
app(DeleteSeasons::class)->execute($seasonIds);
// credits
DB::table('creditables')
->whereIn('creditable_id', $titleIds)
->where('creditable_type', Title::MODEL_TYPE)
->delete();
// images
app(Image::class)
->whereIn('model_id', $titleIds)
->where('model_type', Title::MODEL_TYPE)
->delete();
// list items
app(Listable::class)
->whereIn('listable_id', $titleIds)
->where('listable_type', Title::MODEL_TYPE)
->delete();
// channel items
DB::table('channelables')
->whereIn('channelable_id', $titleIds)
->where('channelable_type', Title::MODEL_TYPE)
->delete();
// reviews
Review::whereIn('reviewable_id', $titleIds)
->where('reviewable_type', Title::MODEL_TYPE)
->delete();
// comments
Comment::whereIn('commentable_id', $titleIds)
->where('commentable_type', Title::MODEL_TYPE)
->delete();
// tags
DB::table('taggables')
->whereIn('taggable_id', $titleIds)
->where('taggable_type', Title::MODEL_TYPE)
->delete();
// keywords
DB::table('keyword_title')
->whereIn('title_id', $titleIds)
->delete();
// countries
DB::table('country_title')
->whereIn('title_id', $titleIds)
->delete();
// genres
DB::table('genre_title')
->whereIn('title_id', $titleIds)
->delete();
// videos
$videoIds = app(Video::class)
->whereIn('title_id', $titleIds)
->pluck('id');
app(Video::class)
->whereIn('id', $videoIds)
->delete();
DB::table('video_votes')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_captions')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_reports')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_plays')
->whereIn('video_id', $videoIds)
->delete();
// titles
Title::withoutGlobalScope('adult')
->whereIn('id', $titleIds)
->delete();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Titles;
trait HandlesEncodedTmdbId
{
public static function encodeTmdbId(
string $provider,
string $type,
string|int $id,
): string {
return base64_encode("$provider|$type|$id");
}
public static function decodeTmdbId($encodedId): array
{
$encodedId = base64_decode($encodedId);
$parts = explode('|', $encodedId);
if (count($parts) === 3) {
return [
'provider' => $parts[0],
'type' => $parts[1],
'id' => $parts[2],
];
} else {
return ['provider' => null, 'type' => null, 'id' => null];
}
}
public static function decodeTmdbIdOrFail(string $encodedId): array
{
[$provider, $type, $id] = array_values(
static::decodeTmdbId($encodedId),
);
if (!$provider || !$type || !$id) {
abort(404);
}
return [(int) $id, $type];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use DB;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait HasCreditableRelation
{
public function credits(): BelongsToMany
{
$query = $this->morphToMany(Person::class, 'creditable')->withPivot([
'id',
'job',
'department',
'order',
'character',
]);
$query = $query->select(['people.id', 'name', 'poster']);
// order by department first, so we always get director,
// writers and creators, even if limit is applied to this query
$prefix = DB::getTablePrefix();
return $query
->orderBy(
DB::raw(
"FIELD(department, 'directing', 'creators', 'writing', 'actors')",
),
)
// should be "desc" and not "asc" because "minus" is added which will reverse order
->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc');
}
public function updateCredit(int $pivotId, array $payload): void
{
// lowercase payload
$payload = collect($payload)
->mapWithKeys(function ($value, $key) {
if ($key === 'department' || $key === 'job') {
$value = strtolower($value);
}
return [$key => $value];
})
->toArray();
$this->credits()
->newPivotQuery()
->where('id', $pivotId)
->update($payload);
}
public function createCredit(array $payload): void
{
// lowercase payload
$payload = collect($payload)
->mapWithKeys(function ($value, $key) {
if ($key === 'department' || $key === 'job') {
$value = strtolower($value);
}
return [$key => $value];
})
->toArray();
if ($payload['department'] === 'actors') {
$payload['order'] =
$this->credits()
->wherePivot('department', 'actors')
->count() + 1;
}
$this->credits()->attach($payload['person_id'], $payload);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
trait HasVideoRelation
{
public function videos(): HasMany
{
return $this->hasMany(Video::class)->applySelectedSort();
}
public function primaryVideo(): HasOne
{
$preferFull = settings('streaming.prefer_full');
return $this->hasOne(Video::class)
->when($preferFull, function ($query) {
$query->where('category', 'full');
})
->select([
'id',
'title_id',
'name',
'category',
'episode_id',
'season_num',
'episode_num',
])
->when(
// is series or movie and not specific episode
fn() => static::class !== Episode::class && $preferFull,
function ($builder) {
return $builder->where(function ($builder) {
// video attached directly to movie or series
$builder
->where(
fn($builder) => $builder
->whereNull('season_num')
->whereNull('episode_num'),
)
// video attached to first episode of series
->orWhere(
fn($builder) => $builder
->where('season_num', 1)
->where('episode_num', 1),
);
});
},
)
->applySelectedSort();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
trait InsertsTmdbTitleOrPerson
{
public function insertOrRetrieve(Collection $tmdbItems): Collection
{
$tmdbItems = $tmdbItems->map(function ($value) {
unset($value['relation_data']);
unset($value['model_type']);
unset($value['id']);
return $value;
});
$select =
static::class === Person::class
? ['id', 'tmdb_id', 'name']
: ['id', 'tmdb_id', 'name', 'is_series'];
$existing = $this->withoutGlobalScope('adult')
->select($select)
->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id'))
->get()
->mapWithKeys(fn($item) => [$item['tmdb_id'] => $item]);
$new = $tmdbItems
->filter(fn($item) => !isset($existing[$item['tmdb_id']]))
->values();
if ($new->isNotEmpty()) {
$new->transform(function ($item) {
$item['created_at'] = Arr::get(
$item,
'created_at',
Carbon::now(),
);
return $item;
});
$this->insert($new->toArray());
return $this->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id'))
->select($select)
->get();
} else {
return $existing;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Titles;
use Illuminate\Support\Facades\DB;
class LoadSeasonEpisodeNumbers
{
public function execute(int $titleId, int $seasonNumber): array
{
return DB::table('episodes')
->where('title_id', $titleId)
->where('season_number', $seasonNumber)
->orderBy('episode_number', 'asc')
->pluck('episode_number')
->toArray();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class GetRelatedTitles
{
public function execute(Title $title, array $params = []): Collection
{
$titleIds = $this->getByTags($title, $params);
if ($titleIds->isNotEmpty()) {
return Title::whereIn('id', $titleIds)
->with(['primaryVideo'])
->when(isset($params['compact']), fn($q) => $q->compact())
->get();
}
return collect();
}
private function getByTags(Title $title, array $params): Collection
{
$prefix = DB::getTablePrefix();
$keywordIds = $title->keywords->pluck('id');
$genreIds = $title->genres->pluck('id');
return DB::table('titles')
->join('keyword_title as k', 'k.title_id', '=', 'titles.id')
->join('genre_title as g', 'g.title_id', '=', 'titles.id')
->select(
DB::raw(
"{$prefix}titles.id, COUNT({$prefix}k.id) + COUNT({$prefix}g.id) as total_count",
),
)
->whereIn('k.keyword_id', $keywordIds)
->whereIn('g.genre_id', $genreIds)
->when($title->release_date, function ($q) use ($title) {
$q->whereBetween('release_date', [
$title->release_date->subYears(5)->format('Y-m-d'),
$title->release_date->addYears(5)->format('Y-m-d'),
]);
})
->where('titles.id', '!=', $title->id)
->groupBy('titles.id')
->orderBy('total_count', 'desc')
->limit(Arr::get($params, 'limit', 10))
->pluck('titles.id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaginateSeasonEpisodes
{
public function execute(
Title $title,
int $seasonNumber,
array $params = [],
): AbstractPaginator {
$builder = $title->episodes()->where('season_number', $seasonNumber);
$builder->with(['primaryVideo']);
$orderBy = Arr::get($params, 'orderBy', 'episode_number');
$orderDir = Arr::get($params, 'orderDir', 'asc');
$pagination = $builder
->orderBy($orderBy, $orderDir)
->simplePaginate(Arr::get($params, 'perPage', 30));
// only show first paragraph of description
$pagination->through(function ($episode) use ($params) {
$episode->description = explode("\n", $episode->description)[0];
if (Arr::get($params, 'excludeDescription')) {
$episode->description = null;
} elseif (Arr::get($params, 'truncateDescriptions')) {
$episode->description = Str::limit($episode->description, 200);
}
return $episode;
});
return $pagination;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
class PaginateTitleSeasons
{
public function execute(Title $title, array $params = []): AbstractPaginator
{
return $title
->seasons()
->select([
'seasons.id',
'seasons.poster',
'seasons.release_date',
'number',
'title_id',
])
->withCount('episodes')
->orderBy('number', 'desc')
->paginate(Arr::get($params, 'perPage', 8));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Common\Database\Datasource\Datasource;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PaginateTitles
{
public function execute(array $params, $builder = null): AbstractPaginator
{
if (!$builder) {
$builder = Title::query();
}
if ($type = Arr::get($params, 'type')) {
$builder->where('is_series', $type === 'series');
}
$builder->with(['primaryVideo']);
$datasource = new Datasource($builder, $params);
// prevent duplicate items when ordering by columns that are not
// guaranteed to be unique (budget, popularity , revenue etc.)
$datasource->secondaryOrderCol = 'id';
// only show titles with more than 50 votes when filtering by rating
if ($datasource->filters->has('tmdb_vote_average')) {
$builder->where('tmdb_vote_count', '>=', 50);
}
$order = $datasource->getOrder();
$order['col'] = str_replace(
['user_score', 'rating'],
config('common.site.rating_column'),
$order['col'],
);
// show titles with less than 50 votes on tmdb last, regardless of their average
if (Str::contains($order['col'], 'tmdb_vote_average')) {
$builder->orderBy(DB::raw('tmdb_vote_count > 100'), 'desc');
}
$datasource->order = $order;
return $datasource->paginate();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class ShowTitle
{
public function execute(int|string $id, array $params): array
{
if (defined('SHOULD_PRERENDER')) {
$params['skipUpdating'] = true;
$params['load'] =
'images,genres,productionCountries,keywords,videos,primaryVideo,seasons,compactCredits';
$params['loadCount'] = 'seasons';
}
if (is_numeric($id) || ctype_digit($id)) {
$title = Title::findOrFail($id);
} else {
$title = Title::firstOrCreateFromEncodedTmdbId($id);
}
if (!Arr::get($params, 'skipUpdating')) {
$title = $title->maybeUpdateFromExternal();
if (!$title) {
abort(404);
}
}
$response = ['title' => $title->loadCount('seasons')];
foreach (explode(',', Arr::get($params, 'load', '')) as $relation) {
$methodName = sprintf('load%s', Str::camel($relation));
if (method_exists($this, $methodName)) {
$response = $this->$methodName($title, $params, $response);
} elseif (method_exists($title, $relation)) {
$title->load($relation);
}
}
foreach (
explode(',', Arr::get($params, 'loadCount', ''))
as $relation
) {
if (method_exists($title, $relation)) {
$title->loadCount($relation);
}
}
return $response;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Episode;
use App\Models\Person;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StoreCredits
{
public const UNIQUE_KEY = 'tmdb_id';
private Title|Episode|Season|Person|null $model = null;
public function __construct(private Person $person, private Title $title)
{
}
/**
* @param array $originalMediaItems
*/
public function execute(Title|Episode|Season|Person $model, $originalMediaItems)
{
$this->model = $model;
if (empty($originalMediaItems)) return;
// generate records we will insert into "creditables" pivot table
$newPivotRecords = $this->generatePivotRecords($originalMediaItems);
if ($newPivotRecords->isEmpty()) return;
// fetch all existing "creditable" pivot rows for this creditable
$existingPivotRecords = $this->getExistingRecords($newPivotRecords);
// merge new and existing pivot records, so "order" column value is not lost
$mergedPivotRecords = $this->mergeNewAndExistingRecords($existingPivotRecords, $newPivotRecords);
// delete all "creditables" pivot table records for this creditable
$this->detachExistingRecords($existingPivotRecords);
// insert new pivot records
DB::table('creditables')->insert($mergedPivotRecords->toArray());
}
/**
* @param array $originalMediaItems
* @return Collection
*/
private function generatePivotRecords($originalMediaItems)
{
// need to remove series, otherwise it will attach all guest starts to main series, because
// tmdb API does not specify whether credit belongs to series, season or episode.
$originalMediaItems = $this->filterOutSeries($originalMediaItems);
$dbMediaItems = $this->insertOrRetrieveMediaItems($originalMediaItems);
return collect($originalMediaItems)->map(function($originalMediaItem) use($dbMediaItems) {
$creditable = $dbMediaItems->first(fn($item, $uniqueKey) => $uniqueKey === $originalMediaItem[self::UNIQUE_KEY] && $item['is_series'] === Arr::get($originalMediaItem, 'is_series'));
if ( ! $creditable) return null;
// either attaching multiple titles to a person
// or attaching multiple people to title/season/episode
if ($this->model->model_type === Person::MODEL_TYPE) {
$personId = $this->model->id;
$creditableId = $creditable->id;
} else {
$personId = $creditable->id;
$creditableId = $this->model->id;
}
// build relation records for attaching all credits to title
// (same person might be attached to title multiple times)
return [
'id' => null,
'person_id' => $personId,
'creditable_id' => $creditableId,
'creditable_type' => $this->getCreditableType(),
'character' => Arr::get($originalMediaItem, 'relation_data.character'),
'order' => Arr::get($originalMediaItem, 'relation_data.order'),
'department' => Arr::get($originalMediaItem, 'relation_data.department'),
'job' => Arr::get($originalMediaItem, 'relation_data.job'),
];
})->filter()->values();
}
/**
* Merge existing "creditables" pivot table records with the ones
* we are about to insert. This needs to be done because sometimes
* "order" property does not exist on "new" records, but exists on
* "old" ones and since we will delete "old" ones, "order" would be lost.
*
* @param Collection $existingRecords
* @param Collection $newRecords
* @return Collection
*/
private function mergeNewAndExistingRecords($existingRecords, $newRecords)
{
// all of these properties on both arrays must match for them to be considered equal
$matchProps = collect(['person_id', 'creditable_id', 'creditable_type', 'character', 'department', 'job']);
return $newRecords->map(function($newRecord) use($existingRecords, $matchProps) {
$oldRecord = $existingRecords->first(fn($existingRecord) => $matchProps->every(fn($prop) => $existingRecord[$prop] === $newRecord[$prop]), []);
return $this->arrayMergeIfNotNull($newRecord, $oldRecord);
});
}
/**
* @param array $arr1
* @param array $arr2
* @return array
*/
private function arrayMergeIfNotNull($arr1, $arr2) {
foreach($arr2 as $key => $val) {
$is_set_and_not_null = isset($arr1[$key]);
if ( $val == null && $is_set_and_not_null ) {
$arr2[$key] = $arr1[$key];
}
}
return array_merge($arr1, $arr2);
}
/**
* Insert or retrieve titles or people that need to be attached.
*
* @param Collection|(Title|Person)[] $mediaItems
* @return Collection
*/
private function insertOrRetrieveMediaItems(Collection|array $mediaItems)
{
// make sure we only insert person/title once, even
// if they appear multiple time in title credits
$uniqueMediaItems = collect($mediaItems)->unique(self::UNIQUE_KEY)->values();
if ($uniqueMediaItems->isEmpty()) return collect();
if ($uniqueMediaItems[0]['model_type'] === Person::MODEL_TYPE) {
$mediaItems = $this->person->insertOrRetrieve($uniqueMediaItems);
} else {
$mediaItems = $this->title->insertOrRetrieve($uniqueMediaItems);
}
return $mediaItems->mapWithKeys(fn($item) => [$item[self::UNIQUE_KEY] => $item]);
}
/**
* @param Collection $records
* @return Collection
*/
private function getExistingRecords($records)
{
// select only fields needed to do the diff
return DB::table('creditables')
->whereIn('person_id', $records->pluck('person_id'))
->whereIn('job', $records->pluck('job'))
->where('creditable_type', $records[0]['creditable_type'])
->whereIn('creditable_id', $records->pluck('creditable_id'))
->get()
->map(fn($record) => (array) $record);
}
/**
* Delete creditable records that already exist.
*
* @param Collection $records
*/
private function detachExistingRecords($records)
{
if ($records->isEmpty()) return;
// select only fields needed to do the diff
$existingRecords = DB::table('creditables')
->whereIn('person_id', $records->pluck('person_id'))
->whereIn('job', $records->pluck('job'))
->where('creditable_type', $records[0]['creditable_type'])
->whereIn('creditable_id', $records->pluck('creditable_id'))
->get();
DB::table('creditables')->whereIn('id', $existingRecords->pluck('id'))->delete();
}
/**
* Get creditable type for morphToMany or morphedByMany relation.
*/
private function getCreditableType(): string
{
if ($this->model::class === Person::class) {
// won't be attaching seasons or episodes here
// so can just return title type instantly
return Title::MODEL_TYPE;
} else {
return $this->model::MODEL_TYPE;
}
}
/**
* Remove all series and episodes from specified array.
*
* @param array $originalMediaItems
* @return array
*/
private function filterOutSeries($originalMediaItems)
{
return array_filter($originalMediaItems, fn($creditable) => !Arr::get($creditable, 'is_series'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Episode;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Support\Collection;
class StoreEpisodeData
{
public function execute(Title $title, Season $season, $episodes): Title
{
$existingEpisodes = $season->episodes()->get();
foreach ($episodes as $episodeData) {
$episode = $this->storePrimaryData(
$season,
$existingEpisodes,
$episodeData,
);
app(StoreCredits::class)->execute($episode, $episodeData['cast']);
}
return $title;
}
private function storePrimaryData(
Season $season,
Collection $existingEpisodes,
array $episodeData,
): Episode {
$episodeData = array_filter(
$episodeData,
fn($value) => !is_array($value) && $value !== Episode::MODEL_TYPE,
);
$episodeData['title_id'] = $season->title_id;
unset($episodeData['id']);
$existingEpisode = $existingEpisodes->firstWhere(
'episode_number',
$episodeData['episode_number'],
);
if ($existingEpisode) {
$existingEpisode->update($episodeData);
return $existingEpisode;
} else {
return $season->episodes()->create($episodeData);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Season;
use App\Models\Title;
class StoreSeasonData
{
private ?Title $title = null;
public function execute(Title $title, array $data): Title
{
if (empty($data)) {
return $title;
}
$this->title = $title;
$season = $this->persistData($data);
if (isset($data['cast'])) {
app(StoreCredits::class)->execute($season, $data['cast']);
}
if (isset($data['episodes'])) {
app(StoreEpisodeData::class)->execute(
$title,
$season,
$data['episodes'],
);
}
return $this->title;
}
private function persistData(array $data): Season
{
// remove all relation data
$data = array_filter(
$data,
fn($value) => !is_array($value) && $value !== Season::MODEL_TYPE,
);
// if season data did not change then timestamps
// will not be updated because model is not dirty
$data['updated_at'] = now();
return Season::updateOrCreate(
[
'title_id' => $this->title->id,
'number' => $data['number'],
],
$data,
);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Actions\Titles\Store;
use App\Actions\Titles\StoresMediaImages;
use App\Models\Season;
use App\Models\Title;
use App\Models\Video;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class StoreTitleData
{
use StoresMediaImages;
private ?Title $title = null;
private ?array $data = null;
private ?array $options = null;
public function execute(
Title $title,
array $data,
array $options = [],
): Title {
$this->title = $title;
$this->data = $data;
$this->options = $options;
$this->persistData();
$this->persistRelations();
return $this->title;
}
private function persistData(): void
{
$titleData = array_filter(
$this->data,
fn(
$value, // make sure we don't overwrite existing values with null
) => !is_array($value) &&
($this->options['overrideWithEmptyValues'] ?? !is_null($value)),
);
$this->title->fill($titleData)->save();
}
private function persistRelations(): void
{
$relations = array_filter($this->data, fn($value) => is_array($value));
foreach ($relations as $name => $values) {
switch ($name) {
case 'videos':
$this->persistVideos($values);
break;
case 'images':
$this->storeImages($values, $this->title);
break;
case 'genres':
$this->persistTags($values, 'genre');
break;
case 'countries':
$this->persistTags($values, 'production_country');
break;
case 'cast':
app(StoreCredits::class)->execute($this->title, $values);
break;
case 'keywords':
$this->persistTags($values, 'keyword');
break;
case 'seasons':
$this->persistSeasons($values);
}
}
}
private function persistSeasons(array $seasons): void
{
$newSeasons = collect($seasons)
->map(function ($season) {
$season['title_id'] = $this->title->id;
return $season;
})
->filter(
fn($season) => !$this->title->seasons->contains(
'number',
$season['number'],
),
);
if ($newSeasons->isNotEmpty()) {
Season::insert($newSeasons->toArray());
}
}
private function persistTags(array $tags, string $type): void
{
$values = collect($tags)->map(
fn($tag) => [
'name' => $tag['name'],
'display_name' => Arr::get(
$tag,
'display_name',
ucfirst($tag['name']),
),
],
);
$tags = app(modelTypeToNamespace($type))->insertOrRetrieve(
$values,
$type,
);
$relation = $this->title->{Str::camel(Str::plural($type))}();
$relation->syncWithoutDetaching($tags->pluck('id'));
}
private function persistVideos(array $values): void
{
$videos = collect($values)
->unique(fn($v) => strtolower($v['name']))
->values()
->map(function ($value, $i) {
$value['title_id'] = $this->title->id;
$value['order'] = $i + 1;
$value['created_at'] = now();
$value['updated_at'] = now();
return $value;
});
Video::where('origin', '!=', 'local')
->where('title_id', $this->title->id)
->whereNull('episode_num')
->delete();
Video::insert($videos->toArray());
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Actions\Titles;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class StoreMediaImageOnDisk
{
// sizes should be ordered by size (desc), to avoid blurry images
private array $sizes = [
'original' => null,
'large' => 500,
'medium' => 300,
'small' => 92,
];
public function execute(UploadedFile $file): string
{
$hash = Str::random(30);
$manager = new ImageManager(new Driver());
$img = $manager->read($file);
$extension = $file->extension() ?? 'jpeg';
foreach ($this->sizes as $key => $size) {
if ($size) {
$img->scale($size);
}
Storage::disk('public')->put(
"media-images/backdrops/$hash/$key.$extension",
$extension === 'png' ? $img->toPng() : $img->toJpeg(),
);
}
$endpoint = config('common.site.file_preview_endpoint');
$uri = "media-images/backdrops/$hash/original.$extension";
return $endpoint
? "$endpoint/storage/$uri"
: Storage::disk('public')->url($uri);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use App\Models\Title;
trait StoresMediaImages
{
public function storeImages(array $values, Title|Person $model): void
{
$values = array_map(function ($value) use ($model) {
$value['model_id'] = $model->id;
$value['model_type'] = $model->getMorphClass();
return $value;
}, $values);
$model
->images()
->where('source', '!=', 'local')
->delete();
$model->images()->insert($values);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Person;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TitleCredits
{
public function loadCompact(
Title $title,
Season $season = null,
Episode $episode = null,
): Collection {
$builder = $this->getBaseQuery($title, $season, $episode)
->whereIn('creditables.department', [
'actors',
'writing',
'directing',
'creators',
])
->limit(50);
$credits = $this->groupByDepartment($builder);
if (isset($credits['actors'])) {
$credits['actors'] = $credits['actors']->take(15)->values();
}
if (isset($credits['writing'])) {
$credits['writing'] = $credits['writing']->take(3)->values();
}
if (isset($credits['directing'])) {
$credits['directing'] = $credits['directing']
->filter(fn($c) => $c['pivot']['job'] === 'director')
->take(3)
->values();
}
if (isset($credits['creators'])) {
$credits['creators'] = $credits['creators']->take(3)->values();
}
return $credits;
}
public function loadFull(
Title $title,
Season $season = null,
Episode $episode = null,
): Collection {
$builder = $this->getBaseQuery($title, $season, $episode);
return $this->groupByDepartment($builder);
}
protected function getBaseQuery(
Title $title = null,
Season $season = null,
Episode $episode = null,
): Builder {
$prefix = DB::getTablePrefix();
$builder = Person::join(
'creditables',
'people.id',
'=',
'creditables.person_id',
)
->select([
'people.id',
'people.name',
'people.poster',
DB::raw("{$prefix}creditables.id as pivot_id"),
DB::raw(
"{$prefix}creditables.creditable_id as pivot_creditable_id",
),
DB::raw(
"{$prefix}creditables.creditable_type as pivot_creditable_type",
),
DB::raw("{$prefix}creditables.job as pivot_job"),
DB::raw("{$prefix}creditables.department as pivot_department"),
DB::raw("{$prefix}creditables.order as pivot_order"),
DB::raw("{$prefix}creditables.character as pivot_character"),
])
// Need to keep same person in different departments (as actor and producer for example)
// while still removing duplicates when loading credits for title, season and episode
->groupBy(['people.id', 'creditables.department']);
$builder->where(function ($q) use ($title, $season, $episode) {
if ($title) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $title->id)
->where(
'creditables.creditable_type',
$title->getMorphClass(),
),
);
}
if ($episode) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $episode->id)
->where(
'creditables.creditable_type',
$episode->getMorphClass(),
),
);
}
if ($season) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $season->id)
->where(
'creditables.creditable_type',
$season->getMorphClass(),
),
);
}
});
// order by department first, so we always get director,
// writers and creators, even if limit is applied to this query
$prefix = DB::getTablePrefix();
return $builder
->orderBy(
DB::raw(
"FIELD(department, 'directing', 'creators', 'writing', 'actors')",
),
)
// should be "desc" and not "asc" because "minus" is added which will reverse order
->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc');
}
protected function groupByDepartment(Builder $builder): Collection
{
$credits = $builder->get()->map(function ($credit) {
$credit->pivot = [
'id' => $credit->pivot_id,
'job' => $credit->pivot_job,
'department' => $credit->pivot_department,
'character' => $credit->pivot_character,
];
unset(
$credit->pivot_id,
$credit->pivot_creditable_id,
$credit->pivot_creditable_type,
$credit->pivot_job,
$credit->pivot_department,
$credit->pivot_order,
$credit->pivot_character,
);
return $credit;
});
return $credits->groupBy('pivot.department');
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Actions\Videos;
use App\Models\Episode;
use App\Models\Video;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class CrupdateVideo
{
public function execute(array $params, int $videoId = null): Video
{
if (Arr::get($params, 'episode_num')) {
$episode = Episode::where('title_id', $params['title_id'])
->where('episode_number', $params['episode_num'])
->where('season_number', $params['season_num'])
->firstOrFail();
$params['episode_id'] = $episode->id;
}
$params['positive_votes'] = 0;
$params['negative_votes'] = 0;
$params['origin'] = 'local';
$captions = Arr::pull($params, 'captions');
if ($videoId) {
$video = Video::findOrFail($videoId);
$video->fill($params)->save();
} else {
$params['approved'] = $this->shouldAutoApprove();
$params['user_id'] = Auth::id();
$video = Video::create($params);
}
if (isset($captions)) {
$this->syncCaptions($video, $captions);
}
return $video;
}
private function shouldAutoApprove(): bool
{
return settings('streaming.auto_approve') ||
Auth::user()->hasPermission('admin');
}
protected function syncCaptions(Video $video, array $captions): void
{
$captions = collect($captions);
// delete captions that were removed
$captionIds = $captions->pluck('id')->filter();
$video
->captions()
->whereNotIn('id', $captionIds)
->delete();
$captions->each(function ($caption, $index) use ($video) {
if (isset($caption['id'])) {
$video
->captions()
->where('id', $caption['id'])
->update([
'name' => $caption['name'],
'language' => $caption['language'],
'url' => $caption['url'],
'order' => $index,
]);
} else {
$caption['user_id'] = Auth::id();
$caption['order'] = $index;
$video->captions()->create($caption);
}
});
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Common\Auth\Permissions\Permission;
use Common\Auth\Permissions\Traits\SyncsPermissions;
use Common\Database\Seeds\DefaultPagesSeeder;
use Common\Localizations\Localization;
use Common\Pages\CustomPage;
use Hash;
use Illuminate\Console\Command;
class CleanDemoSite extends Command
{
use SyncsPermissions;
protected $signature = 'demo:clean';
public function handle(): void
{
// reset admin user
$this->cleanAdminUser('admin@admin.com');
// delete localizations
Localization::get()->each(function (Localization $localization) {
if (strtolower($localization->name) !== 'english') {
$localization->delete();
}
});
// delete custom pages
CustomPage::truncate();
app(DefaultPagesSeeder::class)->run();
}
private function cleanAdminUser($email): void
{
$admin = User::where('email', $email)->first();
if (!$admin) {
$admin = User::create([
'email' => $email,
]);
}
$admin->avatar = null;
$admin->username = 'admin';
$admin->first_name = 'Demo';
$admin->last_name = 'Admin';
$admin->password = 'admin';
$admin->email_verified_at = now()->subDays(10);
$admin->save();
$adminPermission = app(Permission::class)
->where('name', 'admin')
->first();
$this->syncPermissions($admin, [$adminPermission]);
}
}

View File

@@ -0,0 +1,269 @@
<?php
namespace App\Console\Commands;
use App\Actions\Demo\GenerateDemoAnimeVideos;
use App\Actions\Demo\GenerateDemoComments;
use App\Actions\Demo\GenerateDemoReviews;
use App\Actions\Demo\GenerateDemoStreamVideos;
use App\Actions\Demo\GenerateDemoUsers;
use App\Actions\Demo\GenerateDemoVideoVotes;
use App\Models\Channel;
use Common\Admin\Appearance\Themes\CssTheme;
use Common\Channels\GenerateChannelsFromConfig;
use Common\Channels\UpdateAllChannelsContent;
use Common\Settings\DotEnvEditor;
use Common\Settings\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class GenerateDemoDataCommand extends Command
{
protected $signature = 'demo:generate {variant} {--truncate}';
public function handle(): void
{
@set_time_limit(0);
@ini_set('memory_limit', '200M');
Artisan::call('down');
$variant = $this->argument('variant');
if ($variant === 'database') {
$this->databaseVariant();
} elseif ($variant === 'streaming') {
$this->streamingVariant();
} elseif ($variant === 'anime') {
$this->animeVariant();
} else {
$this->error('Invalid variant');
}
$this->info('Demo data generated', true);
Artisan::call('up');
}
protected function databaseVariant(): void
{
$this->overridePrimaryMenu([
[
'id' => 'cVKgbI',
'type' => 'channels',
'label' => 'Movies',
'action' => '/movies',
],
[
'id' => 'nVKg4v',
'type' => 'channels',
'label' => 'Series',
'action' => '/series',
],
[
'id' => 'nVKg1Ix',
'type' => 'channels',
'label' => 'People',
'action' => '/people',
],
[
'id' => 'nVKxdI',
'type' => 'channels',
'label' => 'News',
'action' => '/latest-news',
],
]);
$homepageChannel = $this->generateChannels([
resource_path('defaults/channels/shared-channels.json'),
resource_path('defaults/channels/default-channels.json'),
]);
settings()->save([
'homepage.type' => 'channels',
'homepage.value' => $homepageChannel->id,
'streaming.video_panel_content' => 'all',
'streaming.prefer_full' => false,
'streaming.show_video_selector' => false,
'streaming.show_header_play' => true,
'content.search_provider' => 'tmdb',
'content.title_provider' => 'tmdb',
'content.people_provider' => 'tmdb',
'content.automate_filmography' => true,
'title_page.sections' => json_encode([
'seasons',
'videos',
'images',
'reviews',
'cast',
'related',
]),
]);
Artisan::call(UpdateAllChannelsContent::class);
(new GenerateDemoUsers())->execute();
(new GenerateDemoVideoVotes())->execute();
(new GenerateDemoComments())->execute();
(new GenerateDemoReviews())->execute();
}
protected function streamingVariant(): void
{
if ($this->option('truncate')) {
Artisan::call(TruncateTitleData::class);
}
$this->overridePrimaryMenu([
[
'id' => 'cVKg0I',
'type' => 'route',
'label' => 'Movies',
'action' => '/movies',
],
[
'id' => 'nVKg0v',
'type' => 'route',
'label' => 'TV shows',
'action' => '/series',
],
[
'id' => 'nVKg0Ix',
'type' => 'route',
'label' => 'Watchlist',
'action' => '/watchlist',
],
]);
$homepageChannel = $this->generateChannels([
resource_path('defaults/channels/shared-channels.json'),
resource_path('defaults/channels/streaming-channels.json'),
]);
$darkTheme = CssTheme::where('default_dark', true)->first();
settings()->save([
'themes.default_id' => $darkTheme->id,
'homepage.type' => 'landingPage',
'homepage.value' => $homepageChannel->id,
'streaming.video_panel_content' => 'full',
'streaming.prefer_full' => true,
'streaming.show_video_selector' => false,
'streaming.show_header_play' => true,
'content.search_provider' => 'local',
'content.title_provider' => 'tmdb',
'content.people_provider' => 'tmdb',
'content.automate_filmography' => false,
'title_page.sections' => json_encode([
'seasons',
'images',
'reviews',
'cast',
'related',
]),
]);
app(DotEnvEditor::class)->write([
'scout_driver' => 'meilisearch',
]);
$this->info('Updating channels');
Artisan::call(UpdateAllChannelsContent::class);
$this->info('Channels updated.');
$this->info('Updating seasons');
Artisan::call(UpdateSeasonsFromRemote::class);
$this->info('Seasons updated.');
(new GenerateDemoUsers())->execute();
(new GenerateDemoStreamVideos())->execute();
(new GenerateDemoVideoVotes())->execute();
(new GenerateDemoComments())->execute();
(new GenerateDemoReviews())->execute();
}
protected function animeVariant(): void
{
if ($this->option('truncate')) {
Artisan::call(TruncateTitleData::class);
}
$this->overridePrimaryMenu([
[
'id' => 'cVKg0I',
'type' => 'route',
'label' => 'Movies',
'action' => '/movies',
],
[
'id' => 'nVKg0v',
'type' => 'route',
'label' => 'TV shows',
'action' => '/series',
],
[
'id' => 'nVKg0Ix',
'type' => 'route',
'label' => 'Watchlist',
'action' => '/watchlist',
],
]);
$homepageChannel = $this->generateChannels([
resource_path('defaults/channels/shared-channels.json'),
resource_path('defaults/channels/anime-channels.json'),
]);
settings()->save([
'homepage.type' => 'channels',
'homepage.value' => $homepageChannel->id,
'streaming.video_panel_content' => 'full',
'streaming.prefer_full' => true,
'streaming.show_video_selector' => true,
'streaming.show_header_play' => true,
'content.search_provider' => 'local',
'content.title_provider' => 'tmdb',
'content.people_provider' => 'tmdb',
'content.automate_filmography' => false,
'title_page.sections' => json_encode([
'episodes',
'images',
'reviews',
'cast',
'related',
]),
]);
app(DotEnvEditor::class)->write([
'scout_driver' => 'meilisearch',
]);
Artisan::call(UpdateAllChannelsContent::class);
(new GenerateDemoUsers())->execute();
(new GenerateDemoAnimeVideos())->execute();
(new GenerateDemoVideoVotes())->execute();
(new GenerateDemoComments())->execute('anime');
(new GenerateDemoReviews())->execute();
}
protected function overridePrimaryMenu(array $items): void
{
$menus = Setting::where('name', 'menus')->first()->value;
$index = array_search('primary', array_column($menus, 'name'));
$menus[$index]['items'] = $items;
Setting::where('name', 'menus')->update([
'value' => json_encode($menus),
]);
}
protected function generateChannels(array $paths): ?Channel
{
$ids = Channel::where('type', 'channel')->pluck('id');
DB::table('channelables')
->whereIn('channel_id', $ids)
->delete();
Channel::whereIn('id', $ids)->delete();
return (new GenerateChannelsFromConfig())->execute($paths);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Console\Commands;
use App\Services\SitemapGenerator;
use Illuminate\Console\Command;
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
protected $description = 'Generate sitemaps for all site resources.';
public function handle(): void
{
app(SitemapGenerator::class)->generate();
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\Data\Tmdb\TmdbApi;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class SyncTmdbValueLists extends Command
{
protected $signature = 'tmdb:syncValueLists';
public function handle(): int
{
$this->syncGenres();
$this->syncCountries();
$this->syncDepartments();
$this->syncLanguages();
$this->syncKeywords();
return Command::SUCCESS;
}
protected function syncKeywords(): void
{
$downloadUrl =
'http://files.tmdb.org/p/exports/keyword_ids_07_08_2023.json.gz';
$content = gzdecode(file_get_contents($downloadUrl));
$content = str_replace('"}', '"},', $content);
$content = rtrim(trim($content), ',');
file_put_contents(resource_path('lists/tmdb-keywords.json'), '[' . $content . ']');
}
protected function syncGenres(): void
{
$movieGenres = Http::get(TmdbApi::TMDB_BASE . 'genre/movie/list', [
'api_key' => config('services.tmdb.key'),
'language' => 'en-US',
])->json()['genres'];
$tvGenres = Http::get(TmdbApi::TMDB_BASE . 'genre/tv/list', [
'api_key' => config('services.tmdb.key'),
'language' => 'en-US',
])->json()['genres'];
$mergedGenres = collect($movieGenres)
->merge($tvGenres)
->unique('id')
->values()
->toArray();
file_put_contents(
resource_path('lists/tmdb-genres.json'),
json_encode($mergedGenres),
);
}
private function syncLanguages(): void
{
$languages = Http::get(TmdbApi::TMDB_BASE . 'configuration/languages', [
'api_key' => config('services.tmdb.key'),
])->json();
$languages = array_map(fn($language) => [
'code' => $language['iso_639_1'],
'name' => $language['english_name'],
], $languages);
file_put_contents(
resource_path('lists/tmdb-languages.json'),
json_encode($languages),
);
}
protected function syncDepartments(): void
{
$departments = Http::get(TmdbApi::TMDB_BASE . 'configuration/jobs', [
'api_key' => config('services.tmdb.key'),
])->json();
file_put_contents(
resource_path('lists/tmdb-departments.json'),
json_encode($departments),
);
}
protected function syncCountries(): void
{
$countries = Http::get(TmdbApi::TMDB_BASE . 'configuration/countries', [
'api_key' => config('services.tmdb.key'),
])->json();
$countries = array_map(fn($country) => [
'code' => $country['iso_3166_1'],
'name' => $country['english_name'],
], $countries);
file_put_contents(
resource_path('lists/tmdb-countries.json'),
json_encode($countries),
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use DB;
use Illuminate\Console\Command;
class TruncateTitleData extends Command
{
protected $signature = 'titles:truncate';
protected $description = 'Truncate all title related database tables.';
public function handle()
{
DB::table('channelables')->truncate();
DB::table('channels')->truncate();
DB::table('comment_reports')->truncate();
DB::table('comment_votes')->truncate();
DB::table('comments')->truncate();
DB::table('country_title')->truncate();
DB::table('creditables')->truncate();
DB::table('episodes')->truncate();
DB::table('genre_title')->truncate();
DB::table('genres')->truncate();
DB::table('images')->truncate();
DB::table('keyword_title')->truncate();
DB::table('keywords')->truncate();
DB::table('production_countries')->truncate();
DB::table('review_feedback')->truncate();
DB::table('review_reports')->truncate();
DB::table('reviews')->truncate();
DB::table('seasons')->truncate();
DB::table('tags')->truncate();
DB::table('taggables')->truncate();
DB::table('people')->truncate();
DB::table('titles')->truncate();
DB::table('videos')->truncate();
DB::table('video_captions')->truncate();
DB::table('video_reports')->truncate();
DB::table('video_plays')->truncate();
DB::table('video_votes')->truncate();
DB::table('news_article_models')->truncate();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use App\Actions\News\ImportNewsFromRemoteProvider;
use Illuminate\Console\Command;
class UpdateNewsFromRemote extends Command
{
protected $signature = 'news:update';
protected $description = 'Update news from currently selected 3rd party site.';
public function handle(): void
{
app(ImportNewsFromRemoteProvider::class)->execute();
$this->info('News updated.');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Models\Season;
use Illuminate\Console\Command;
class UpdateSeasonsFromRemote extends Command
{
protected $signature = 'seasons:update';
public function handle(): void
{
$seasons = Season::orderBy('updated_at', 'asc')
->with('title')
->limit(50)
->get();
$this->withProgressBar($seasons, function (Season $season) {
if ($season->title) {
$season->maybeUpdateFromExternal($season->title);
}
});
$this->info('Seasons updated');
}
}

45
app/Console/Kernel.php Executable file
View File

@@ -0,0 +1,45 @@
<?php
namespace App\Console;
use App\Console\Commands\CleanDemoSite;
use App\Console\Commands\UpdateNewsFromRemote;
use App\Console\Commands\UpdateSeasonsFromRemote;
use Common\Channels\UpdateAllChannelsContent;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected $commands = [UpdateAllChannelsContent::class];
protected function schedule(Schedule $schedule): void
{
if (settings('news.auto_update')) {
$schedule->command(UpdateNewsFromRemote::class)->daily();
}
if (
config('services.tmdb.key') &&
(settings('content.force_season_update') ||
settings('content.title_provider') === 'tmdb')
) {
$schedule
->command(UpdateSeasonsFromRemote::class)
->everyFourHours();
}
if (config('common.site.demo')) {
$schedule->command(CleanDemoSite::class)->daily();
}
$schedule->command(UpdateAllChannelsContent::class)->daily();
}
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}

10
app/Exceptions/Handler.php Executable file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Common\Core\Exceptions\BaseExceptionHandler;
class Handler extends BaseExceptionHandler
{
//
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
class AdminTitleTagsController extends BaseController
{
public function index(string $tagType)
{
$this->authorize('index', Title::class);
$builder = app(modelTypeToNamespace($tagType))->newQuery();
$dataSource = new Datasource($builder, request()->all());
$pagination = $dataSource->paginate();
return $this->success(['pagination' => $pagination]);
}
public function store(string $type) {
$this->authorize('store', Title::class);
$data = $this->validate(request(), [
'name' => 'required|string',
'display_name' => 'string',
]);
$tag = app(modelTypeToNamespace($type))->create($data);
return $this->success(['tag' => $tag]);
}
public function update(string $type, int $tagId) {
$this->authorize('update', Title::class);
$data = $this->validate(request(), [
'name' => 'string',
'display_name' => 'string',
]);
$tag = app(modelTypeToNamespace($type))->findOrFail($tagId);
$tag->update($data);
return $this->success(['tag' => $tag]);
}
public function destroy($type, string $ids)
{
$tagIds = explode(',', $ids);
$this->authorize('destroy', Title::class);
foreach ($tagIds as $tagId) {
$tag = app(modelTypeToNamespace($type))->findOrFail($tagId);
$tag->titles()->detach();
$tag->delete();
}
return $this->success();
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace App\Http\Controllers;
use Common\Core\BaseController;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;
use App\Models\Genre;
use App\Models\ProductionCountry;
use App\Models\Title;
use App\Models\Image;
use App\Models\Keyword;
use App\Models\Person;
use App\Models\Video;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use Illuminate\Support\Facades\DB;
class ApiTmdbController extends BaseController
{
protected $baseUrl = 'https://api.themoviedb.org/3';
protected $token = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJjYTJiNzZmNDkyZWQxNGFmMmU2Njk4N2E2YmRjZDY0ZiIsIm5iZiI6MTc0OTQ2MzY4NC41NjksInN1YiI6IjY4NDZiMjg0NDQ5MDEyOGMxMzNmYzk1NiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.PXMuq0fxXaC2kR1qrd-LDeezz0HAhL8o3-4QGaik0D4";
private array $sizes = [
'original' => null,
'large' => 500,
'medium' => 300,
'small' => 92,
];
public function index()
{
$movieId = request('movieId');
//$movieId = 1284120;
/****************************************** */
$responseMovie = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/movie/$movieId", [
'language' => 'fr-FR'
]);
$movie = $responseMovie->successful() ? $responseMovie->json() : [];
$title = Title::create([
'imdb_id' => $movie['id'],
'backdrop' => "https://image.tmdb.org/t/p/original".$movie['backdrop_path'],
'poster' => "https://image.tmdb.org/t/p/original".$movie['poster_path'],
'name' => $movie['title'],
'original_title' => $movie['original_title'],
'language' => $movie['original_language'],
'is_series' => false,
'release_date' => $movie['release_date'],
'tagline' => $movie['tagline'],
'description' => $movie['overview'],
'runtime' => $movie['runtime'],
//////////////////'certification' => $movie[''],
'budget' => $movie['budget'],
'revenue' => $movie['revenue'],
'popularity' => $movie['popularity'],
'language' => $movie['original_language'],
'adult' => $movie['adult'],
'type' => 'movie',
'tmdb_vote_average' => $movie['vote_average']
]);
$genres = $countrys = [];
foreach($movie['production_countries'] as $country)
{
$productionCountry = ProductionCountry::updateOrCreate(
['name' => $country['iso_3166_1']],
['name' => $country['iso_3166_1'], 'display_name' => $country['name']
]);
$countrys[] = $productionCountry->id;
}
foreach($movie['genres'] as $genre)
{
$genre = Genre::updateOrCreate(
['name' => $genre['name']],
[
'name' => $genre['name'],
'display_name' => $genre['name']
]);
$genres[] = $genre->id;
}
$title->productionCountries()->attach($countrys);
$title->genres()->attach($genres);
/*********************************************** */
$responseMovieKeyword = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/movie/$movieId/keywords", [
'language' => 'fr-FR'
]);
$movieKeywords = $responseMovieKeyword->successful() ? $responseMovieKeyword->json() : [];
$keywords = [];
foreach($movieKeywords['keywords'] as $valKey)
{
$keyword = Keyword::updateOrCreate(
['name' => $valKey['name']],
[
'name' => $valKey['name'],
'display_name' => $valKey['name']
]);
$keywords[] = $keyword->id;
}
$title->keywords()->attach($keywords);
/************************************************/
$responseMovieCredits = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/movie/$movieId/credits", [
'language' => 'fr-FR'
]);
$movieCredits = $responseMovieCredits->successful() ? $responseMovieCredits->json() : [];
foreach($movieCredits['cast'] as $cast)
{
$person_id = $this->getPeopleByTMDBid($cast['id']);
$title->createCredit([
'person_id'=>$person_id,
'department'=> "actors",
'job'=>"actor",
'order'=>$cast['order'],
'character'=>$cast['character'],
]);
}
foreach($movieCredits['crew'] as $cast)
{
$person_id = $this->getPeopleByTMDBid($cast['id']);
$title->createCredit([
'person_id'=>$person_id,
'department'=> $cast['department'],
'job'=>$cast['job'],
]);
}
/********************* Videos *******************************/
$responseMovieVideos = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/movie/$movieId/videos", [
'language' => 'fr-FR'
]);
$MovieVideos = $responseMovieVideos->successful() ? $responseMovieVideos->json() : [];
foreach($MovieVideos['results'] as $keyV =>$video)
{
if( ($video['site'] == "YouTube") && ($video['type'] == "Trailer") )
{
Video::create([
'name'=>$video['name'],
'src'=>"https://youtube.com/embed/".$video['key'],
'type'=>'embed',
'quality'=> ($video['size']==1080)? "1080p" : null,
'title_id'=>$title->id,
'origin'=>"local",
'downvotes'=>0,
'upvotes'=>0,
'approved'=>1,
'order'=>$keyV,
'user_id'=>auth()->id(),
'language'=>$video['iso_639_1'],
'category'=>'trailer',
]);
}
}
/***********************Images***************************/
$responseMovieImages = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/movie/$movieId/images", []);
$movieImages = $responseMovieImages->successful() ? $responseMovieImages->json() : [];
for($i=0; $i<=5; $i++)
{
$nameFileImage = isset($movieImages['backdrops'][$i]['file_path'])? $movieImages['backdrops'][$i]['file_path'] : null;
if($nameFileImage)
{
$url = $this->saveImage("https://image.tmdb.org/t/p/original$nameFileImage");
Image::where('model_type', Title::MODEL_TYPE)
->where('model_id', $title->id)
->increment('order');
Image::create([
'url' => $url,
'type' => 'backdrop',
'source' => 'local',
'model_type' => Title::MODEL_TYPE,
'model_id' => $title->id,
'order' => 0
]);
}
}
}
function getPeopleByTMDBid($tmdb_id)
{
$person = DB::table('people')->where('tmdb_id', $tmdb_id)->first();
if(!$person)
{
$responsePeople = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json',
])->get("$this->baseUrl/person/$tmdb_id", [
'language' => 'fr-FR'
]);
$people = $responsePeople->successful() ? $responsePeople->json() : [];
$person = Person::create([
'tmdb_id' => $people['id'],
'name' => $people['name'],
'description'=>$people['biography'],
'gender'=>$people['gender'],
'birth_date'=>$people['birthday'],
'birth_place'=>$people['place_of_birth'],
'poster'=>"https://image.tmdb.org/t/p/w500".$people['profile_path'],
'imdb_id'=>$people['imdb_id'],
'known_for'=>$people['known_for_department'],
'popularity'=>$people['popularity'],
'death_date'=>$people['deathday'],
'adult'=>$people['adult'],
]);
}
return $person->id ;
}
public function saveImage(string $urlImage): string
{
$hash = Str::random(30);
$manager = new ImageManager(new Driver());
// Télécharger l'image depuis l'URL
$imageContent = file_get_contents($urlImage);
$tempFile = tempnam(sys_get_temp_dir(), 'img');
file_put_contents($tempFile, $imageContent);
try {
$img = $manager->read($tempFile);
// Déterminer l'extension à partir de l'URL ou utiliser jpeg par défaut
$pathInfo = pathinfo(parse_url($urlImage, PHP_URL_PATH));
$extension = $pathInfo['extension'] ?? 'jpeg';
$extension = in_array(strtolower($extension), ['jpg', 'jpeg', 'png']) ? $extension : 'jpeg';
foreach ($this->sizes as $key => $size) {
if ($size) {
$img->scale($size);
}
Storage::disk('public')->put(
"media-images/backdrops/$hash/$key.$extension",
$extension === 'png' ? $img->toPng() : $img->toJpeg(),
);
}
$endpoint = config('common.site.file_preview_endpoint');
$uri = "media-images/backdrops/$hash/original.$extension";
return $endpoint
? "$endpoint/storage/$uri"
: Storage::disk('public')->url($uri);
} finally {
// Nettoyer le fichier temporaire
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Auth;
use Common\Billing\Models\Product;
use Common\Core\BaseController;
class AppHomeController extends BaseController
{
public function __invoke()
{
if (
settings('homepage.type') === 'channels' ||
(Auth::check() && settings('homepage.type') === 'landingPage')
) {
return app(FallbackRouteController::class)->renderChannel(
settings('homepage.value'),
);
} else {
return $this->renderClientOrApi([
'pageName' => 'landing-page',
'data' => [
'loader' => 'landingPage',
'products' => Product::with(['permissions', 'prices'])
->limit(15)
->orderBy('position', 'asc')
->get(),
'trendingTitles' => Title::orderBy('popularity', 'desc')
->compact()
->limit(6)
->get(),
],
]);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Common\Core\BaseController;
use Illuminate\Support\Str;
class ChannelItemController extends BaseController
{
public function add(Channel $channel)
{
$this->authorize('update', $channel);
$data = $this->validate(request(), [
'itemId' => 'required|integer',
'itemType' => 'required|string',
]);
$relationName = Str::plural($data['itemType']);
$channel->$relationName()->sync(
[
$data['itemId'] => [
'order' => $channel->$relationName()->count() + 1,
],
],
false,
);
$channel->touch();
return $this->success(['channel' => $channel]);
}
public function remove(Channel $channel)
{
$this->authorize('update', $channel);
$data = $this->validate(request(), [
'itemId' => 'required|integer',
'itemType' => 'required|string',
]);
$relationName = Str::plural($data['itemType']);
$channel->$relationName()->detach($data['itemId']);
$channel->touch();
return $this->success(['channel' => $channel]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Loaders\EpisodeLoader;
use App\Models\Episode;
use App\Models\Title;
use Common\Core\BaseController;
use Illuminate\Database\Query\Builder;
use Illuminate\Validation\Rule;
class EpisodeController extends BaseController
{
public function show()
{
$data = (new EpisodeLoader())->loadData(request('loader'));
$this->authorize('show', $data['title']);
return $this->renderClientOrApi([
'data' => $data,
'pageName' => 'episode-page',
]);
}
public function update(Title $title, int $seasonNumber, int $episodeNumber)
{
$this->authorize('update', $title);
$episode = $title
->episodes()
->where('season_number', $seasonNumber)
->where('episode_number', $episodeNumber)
->firstOrFail();
$this->validate(request(), [
'episode_number' => [
'integer',
Rule::unique('episodes')
->ignore($episode->episode_number, 'episode_number')
->where(function (Builder $query) use ($episode) {
$query
->where('season_number', $episode->season_number)
->where('title_id', $episode->title_id);
}),
],
]);
$episode->fill(request()->all())->save();
return $this->success(['episode' => $episode]);
}
public function store(Title $title, int $seasonNumber)
{
$this->authorize('update', $title);
$season = $title->findSeason($seasonNumber)->loadCount('episodes');
$this->validate(request(), [
'episode_number' => [
'integer',
Rule::unique('episodes')->where(function (Builder $query) use (
$season,
) {
$query
->where('season_number', $season->number)
->where('title_id', $season->title_id);
}),
],
]);
$epNum = request('episode_number');
if (!$epNum) {
$epNum =
$season
->episodes()
->orderBy('episode_number', 'desc')
->value('episode_number') + 1;
}
$episode = Episode::create(
array_merge(request()->all(), [
'season_number' => $season->number,
'episode_number' => $epNum,
'season_id' => $season->id,
'title_id' => $season->title_id,
]),
);
return $this->success(['episode' => $episode]);
}
public function destroy(int $id)
{
$this->authorize('destroy', Title::class);
$episode = Episode::findOrFail($id);
$episode->credits()->detach();
$episode->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\Channel;
use Common\Channels\ChannelController;
use Common\Core\Controllers\HomeController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class FallbackRouteController
{
static array $defaultRoutes = ['lists', 'titles', 'search', 'news', 'user'];
public function __invoke(string $path)
{
$parts = explode('/', $path);
if (
count($parts) > 2 ||
count($parts) < 1 ||
(count($parts) === 1 && in_array($parts[0], self::$defaultRoutes))
) {
return $this->renderClient();
}
// first try to match a channel, if none is found, fallback to rendering client side app
try {
if ($parts[0] === 'lists' && isset($parts[1])) {
request()->merge(['channelType' => 'list']);
$parts[0] = $parts[1];
$parts[1] = null;
}
if ($parts[0] === 'channel' && isset($parts[1])) {
$parts[0] = $parts[1];
$parts[1] = null;
}
$slugOrId = $parts[0];
$restriction = $parts[1] ?? null;
return $this->renderChannel($slugOrId, $restriction);
} catch (ModelNotFoundException) {
return $this->renderClient();
}
}
public function renderChannel(string $slugOrId, ?string $restriction = null)
{
$channel = app(Channel::class)->resolveRouteBinding($slugOrId);
if ($restriction) {
request()->merge(['restriction' => $restriction]);
}
return app(ChannelController::class)->show($channel);
}
public function renderClient()
{
// no need to prerender channels here, use base HomeController
request()->route()->action['uses'] = HomeController::class . '@show';
return app(HomeController::class)->show();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Common\Core\BaseController;
use DB;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ImageOrderController extends BaseController
{
public function __construct(private Title $title, private Request $request)
{
}
/**
* @param int $titleId
* @return JsonResponse
*/
public function changeOrder($titleId) {
$title = $this->title->findOrFail($titleId);
$this->authorize('update', $title);
$this->validate($this->request, [
'ids' => 'array|min:1',
'ids.*' => 'integer'
]);
$queryPart = '';
foreach($this->request->get('ids') as $order => $id) {
$queryPart .= " when id=$id then $order";
}
DB::table('images')
->whereIn('id', $this->request->get('ids'))
->update(['order' => DB::raw("(case $queryPart end)")]);
return $this->success();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\StoreMediaImageOnDisk;
use App\Models\Image;
use App\Models\Title;
use Common\Core\BaseController;
use Storage;
class ImagesController extends BaseController
{
public function store()
{
$titleId = request('titleId');
$model = app(Title::class)->findOrFail($titleId);
$this->authorize('store', $model);
$this->validate(request(), [
'file' => 'required|image|max:10240',
'titleId' => 'required|integer',
]);
$url = app(StoreMediaImageOnDisk::class)->execute(
request()->file('file'),
);
// put new image at the start of the list when sorted by "order"
Image::where('model_type', Title::MODEL_TYPE)
->where('model_id', $titleId)
->increment('order');
$image = Image::create([
'url' => $url,
'type' => 'backdrop',
'source' => 'local',
'model_type' => Title::MODEL_TYPE,
'model_id' => $titleId,
'order' => 0,
]);
return $this->success(['image' => $image]);
}
public function destroy(int $id)
{
$img = Image::findOrFail($id);
$model = app(modelTypeToNamespace($img->model_type))->findOrFail(
$img->model_id,
);
$this->authorize('destroy', $model);
if ($img->source === 'local') {
// storage/media-images/backdrops/kw4q4eg5g8q4eq6/original.jpg
$dir = str_replace('storage/', '', dirname($img->url));
if (Storage::disk('public')->exists($dir)) {
Storage::disk('public')->deleteDirectory($dir);
}
}
$img->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Person;
use App\Models\Title;
use App\Services\Data\Tmdb\TmdbApi;
use Carbon\Carbon;
use Common\Core\BaseController;
class ImportMediaController extends BaseController
{
public function importMediaItem()
{
$this->authorize('store', Title::class);
$data = $this->validate(request(), [
'media_type' => 'required|string',
'tmdb_id' => 'required|integer',
]);
if ($data['media_type'] === Person::MODEL_TYPE) {
$mediaItem = Person::withoutGlobalScope('adult')
->firstOrCreate([
'tmdb_id' => $data['tmdb_id'],
])
->maybeUpdateFromExternal([
'forceAutomation' => true,
'ignoreLastUpdate' => true,
]);
} else {
$mediaItem = Title::withoutGlobalScope('adult')
->firstOrCreate([
'tmdb_id' => $data['tmdb_id'],
'is_series' => $data['media_type'] === Title::SERIES_TYPE,
])
->maybeUpdateFromExternal([
'forceAutomation' => true,
'updateLast3Seasons' => true,
'ignoreLastUpdate' => true,
]);
}
if (!$mediaItem) {
abort(404);
}
return ['mediaItem' => $mediaItem];
}
public function importViaBrowse()
{
$this->authorize('store', Title::class);
if (!config('services.tmdb.key')) {
abort(
403,
'Enter your Themoviedb API key in settings page before importing titles.',
);
}
@set_time_limit(0);
@ini_set('memory_limit', '200M');
$tmdbParams = [
'with_release_type' => '2|3',
];
if (request('country')) {
$tmdbParams['with_origin_country'] = strtolower(request('country'));
}
if (request('language')) {
$tmdbParams['with_original_language'] = request('language');
}
if (request('min_rating')) {
$tmdbParams['vote_average.gte'] = request('min_rating');
}
if (request('max_rating')) {
$tmdbParams['vote_average.lte'] = request('max_rating');
}
if (request('genres')) {
$tmdbParams['with_genres'] = request('genres');
}
if (request('keywords')) {
$tmdbParams['with_keywords'] = request('keywords');
}
if (request('start_date') && request('end_date')) {
$tmdbParams['release_date.gte'] = Carbon::parse(
request('start_date'),
)->format('Y-m-d');
$tmdbParams['release_date.lte'] = Carbon::parse(
request('end_date'),
)->format('Y-m-d');
}
$response = app(TmdbApi::class)->browse(
request('current_page', 1),
request('type', 'movie'),
$tmdbParams,
);
$titles = $response['results']
->map(function ($result) {
return Title::withoutGlobalScope('adult')
->firstOrCreate([
'tmdb_id' => $result['tmdb_id'],
'is_series' => $result['is_series'],
])
->maybeUpdateFromExternal([
'forceAutomation' => true,
]);
})
->filter()
->values();
return [
'titles' => $titles,
'total_pages' => $response['total_pages'],
'current_page' => request('current_page', 1),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Plays\BuildPlaysReport;
use Common\Core\BaseController;
class InsightsReportController extends BaseController
{
public function __construct()
{
// will authorize based on specified model in "BuildInsightsReport"
$this->middleware('auth');
}
public function __invoke()
{
$report = app(BuildPlaysReport::class)->execute(
request()->all(),
);
return $this->success(['report' => $report]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Lists\ListsLoader;
use App\Models\Channel;
use Common\Core\BaseController;
class ListsController extends BaseController
{
public function index()
{
$this->authorize('index', [Channel::class, 'list']);
$pagination = (new ListsLoader())->allLists(request()->all());
return $this->success(['pagination' => $pagination]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Actions\News\ImportNewsFromRemoteProvider;
use App\Models\NewsArticle;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Support\Str;
class NewsController extends BaseController
{
public function index()
{
$this->authorize('show', NewsArticle::class);
$paginator = new Datasource(NewsArticle::query(), request()->all());
$pagination = $paginator->paginate();
if (request('stripHtml') || request('truncateBody')) {
$pagination
->map(function (NewsArticle $article) {
if (request('stripHtml')) {
// remove html tags
$article->body = strip_tags($article->body);
// remove last "...see full article"
$parts = explode('...', $article->body);
if (
count($parts) > 1 &&
Str::contains(last($parts), 'See full article')
) {
array_pop($parts);
}
$article->body = implode('', $parts);
}
if ($newLength = (int) request('truncateBody')) {
$article->body = Str::limit($article->body, $newLength);
}
return $article;
})
->values();
}
return $this->success(['pagination' => $pagination]);
}
public function show($slugOrId)
{
$article = NewsArticle::where('id', $slugOrId)
->orWhere('slug', $slugOrId)
->firstOrFail();
$this->authorize('show', $article);
$data = [
'article' => $article,
'related' => NewsArticle::compact()
->where('id', '!=', $article->id)
->orderBy('created_at', 'desc')
->limit(10)
->get(),
'loader' => 'newsArticlePage',
];
return $this->renderClientOrApi([
'pageName' => 'news-article-page',
'data' => $data,
]);
}
public function update($id)
{
$article = NewsArticle::findOrFail($id);
$this->authorize('update', $article);
$data = $this->validate(request(), [
'title' => 'min:5|max:250',
'body' => 'min:5',
'image' => 'string',
'slug' => 'string',
]);
$article->fill($data)->save();
return $this->success(['article' => $article]);
}
public function store()
{
$this->authorize('store', NewsArticle::class);
$data = $this->validate(request(), [
'title' => 'required|min:5|max:250',
'body' => 'required|min:5',
'image' => 'string',
'slug' => 'required|string',
]);
$article = NewsArticle::create($data);
return $this->success(['article' => $article]);
}
public function destroy(string $ids)
{
$ids = explode(',', $ids);
$this->authorize('destroy', NewsArticle::class);
NewsArticle::whereIn('id', $ids)->delete();
return $this->success();
}
public function importFromRemoteProvider()
{
$this->authorize('store', NewsArticle::class);
app(ImportNewsFromRemoteProvider::class)->execute();
return $this->success();
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Actions\People\DeletePeople;
use App\Actions\People\GetPersonCredits;
use App\Actions\People\PaginatePeople;
use App\Jobs\IncrementModelViews;
use App\Models\Person;
use Common\Core\BaseController;
use Illuminate\Support\Arr;
class PersonController extends BaseController
{
public function index()
{
$this->authorize('index', Person::class);
$pagination = (new PaginatePeople())->execute(request()->all());
return $this->success(['pagination' => $pagination]);
}
public function show($id, $name = null)
{
$this->authorize('show', Person::class);
$loader = request('loader', 'personPage');
if (is_numeric($id) || ctype_digit($id)) {
$person = Person::findOrFail($id);
} else {
$person = Person::firstOrCreateFromEncodedTmdbId($id);
}
if ($loader === 'personPage' && requestIsFromFrontend()) {
$person->maybeUpdateFromExternal();
}
$data = array_merge(
['person' => $person, 'loader' => $loader],
app(GetPersonCredits::class)->execute($person),
);
(new IncrementModelViews())->execute($data['person']);
return $this->renderClientOrApi([
'data' => $data,
'pageName' => 'person-page',
]);
}
public function store()
{
$this->authorize('store', Person::class);
$data = request()->all();
$data['popularity'] = Arr::get($data, 'popularity') ?: 50;
$person = Person::create($data);
return $this->success(['person' => $person]);
}
public function update($id)
{
$this->authorize('update', Person::class);
$person = Person::findOrFail($id);
$data = request()->all();
$data['popularity'] = Arr::get($data, 'popularity') ?: 50;
$person->fill($data)->save();
return $this->success(['person' => $person]);
}
public function destroy(string $ids)
{
$ids = explode(',', $ids);
$this->authorize('destroy', Person::class);
app(DeletePeople::class)->execute($ids);
return $this->success();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Actions\People\GetPersonCredits;
use App\Models\Person;
use Common\Core\BaseController;
use Illuminate\Support\Arr;
class PersonCreditsController extends BaseController
{
public function fullTitleCredits(
Person $person,
int $titleId,
string $department,
) {
$this->authorize('show', Person::class);
$credits = app(GetPersonCredits::class)->execute($person, [
'titleId' => $titleId,
]);
$title = Arr::first(
$credits['credits'][$department],
fn($title) => $title['id'] === (int) $titleId,
);
return $this->success(['credits' => $title['episodes']]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\Retrieve\GetRelatedTitles;
use App\Models\Title;
use Common\Core\BaseController;
class RelatedTitlesController extends BaseController
{
public function index(int $id)
{
$this->authorize('index', Title::class);
$title = Title::with('keywords', 'genres')->findOrFail($id);
$related = app(GetRelatedTitles::class)->execute(
$title,
request()->all(),
);
return $this->success(['titles' => $related]);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Reviews\UpdateReviewableAverageScore;
use App\Models\Review;
use Auth;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Support\Str;
class ReviewController extends BaseController
{
public function index()
{
$this->authorize('index', Review::class);
$builder = Review::withCount('reports')->withTextOnly();
// will need to specify this outside of filters on edit title reviews page
if (request('reviewable_id') && request('reviewable_type')) {
$builder->where([
'reviewable_id' => request('reviewable_id'),
'reviewable_type' => request('reviewable_type'),
]);
}
$datasource = new Datasource($builder, request()->all());
$order = $datasource->getOrder();
if (Str::endsWith($order['col'], 'mostHelpful')) {
$datasource->order = false;
$builder->orderByMostHelpful();
}
$pagination = $datasource->paginate()->through(function ($review) {
if ($review->relationLoaded('reviewable') && $review->reviewable) {
$normalized = $review->reviewable->toNormalizedArray();
$review->unsetRelation('reviewable');
$review->setAttribute('reviewable', $normalized);
}
return $review;
});
return $this->success(['pagination' => $pagination]);
}
public function update($id)
{
$review = Review::findOrFail($id);
$this->authorize('update', $review);
$data = request()->all();
if (isset($data['body'])) {
$data['has_text'] = true;
}
$review->fill($data)->save();
app(UpdateReviewableAverageScore::class)->execute(
$review->reviewable_id,
$review->reviewable_type,
);
return $this->success(['review' => $review]);
}
public function store()
{
$this->authorize('store', Review::class);
$data = $this->validate(request(), [
'reviewable_id' => 'required|integer',
'reviewable_type' => 'required|string',
'title' => 'string|min:10|max:150',
'body' => 'string|min:100|max:5000',
'score' => 'required|integer|min:1|max:10',
]);
$reviewableId = $data['reviewable_id'];
$reviewableType = $data['reviewable_type'];
$values = [
'score' => request('score'),
];
// don't override review body or title when only score changes
if (request('body')) {
$values['body'] = request('body');
$values['has_text'] = true;
}
if (request('title')) {
$values['title'] = request('title');
}
$review = Review::updateOrCreate(
[
'user_id' => Auth::id(),
'reviewable_type' => $reviewableType,
'reviewable_id' => $reviewableId,
],
$values,
);
$review->load('user');
app(UpdateReviewableAverageScore::class)->execute(
$reviewableId,
$reviewableType,
);
return $this->success(['review' => $review]);
}
public function destroy(string $ids)
{
$reviewIds = explode(',', $ids);
$reviews = Review::whereIn('id', $reviewIds)->get();
$this->authorize('destroy', [Review::class, $reviews]);
$reviews->each(function (Review $review) {
app(UpdateReviewableAverageScore::class)->execute(
$review->reviewable_id,
$review->reviewable_type,
);
});
Review::whereIn('id', $reviews->pluck('id'))->delete();
return $this->success();
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Review;
use Common\Core\BaseController;
class ReviewFeedbackController extends BaseController
{
public function store(Review $review)
{
$this->authorize('show', $review);
$data = $this->validate(request(), [
'is_helpful' => 'required|boolean',
]);
$isHelpful = $data['is_helpful'];
$review->feedback()->updateOrCreate(
[
'user_id' => auth()->id(),
],
[
'is_helpful' => $isHelpful,
],
);
$review->timestamps = false;
if ($isHelpful) {
$review->increment('helpful_count');
if ($review->not_helpful_count > 0) {
$review->decrement('not_helpful_count');
}
} else {
if ($review->helpful_count > 0) {
$review->decrement('helpful_count');
}
$review->increment('not_helpful_count');
}
return $this->success(['review' => $review->load('feedback')]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Loaders\ReviewsLoader;
use Common\Core\BaseController;
class ReviewableController extends BaseController
{
public function index()
{
$data = (new ReviewsLoader())->loadData([
'reviewableType' => request('reviewable_type'),
'reviewableId' => request('reviewable_id'),
'page' => request('page'),
'orderBy' => request('orderBy'),
'orderDir' => request('orderDir'),
'perPage' => request('perPage'),
]);
if (!$data) {
abort(404);
}
$this->authorize('show', $data['reviewable']);
return $this->success($data);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Actions\LocalSearch;
use App\Services\Data\Tmdb\TmdbApi;
use Common\Core\BaseController;
use Illuminate\Support\Collection;
use Str;
class SearchController extends BaseController
{
public function index($query)
{
$dataProvider =
request('provider') ?: settings('content.search_provider');
$results = $this->searchUsing($dataProvider, $query)
->map(function ($result) {
if (isset($result['description'])) {
$result['description'] = Str::limit(
$result['description'],
140,
);
}
return $result;
})
->values();
$data = [
'results' => $results,
'query' => trim(strip_tags($query), '"\''),
'loader' => 'searchPage',
];
return $this->renderClientOrApi([
'pageName' => 'search-page',
'data' => $data,
]);
}
private function searchUsing($provider, $query)
{
$params = request()->all();
$params['limit'] =
request('loader', 'searchPage') === 'searchPage' ? 20 : 8;
if ($provider === 'tmdb') {
return app(TmdbApi::class)->search($query, $params);
}
$results = app(LocalSearch::class)->execute($query, $params);
if ($provider === 'all') {
$tmdb = app(TmdbApi::class)->search($query, $params);
$results = $results
->concat($tmdb)
->unique(
fn($item) => ($item['tmdb_id'] ?: $item['name']) .
$item['model_type'],
)
->groupBy('model_type')
// make sure specified limit is enforced per group
// (title, person) instead of the whole collection
->map(
fn(Collection $group) => $group->slice(0, $params['limit']),
)
->flatten(1)
->sortByDesc('popularity');
}
return $results;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\DeleteSeasons;
use App\Actions\Titles\LoadSeasonEpisodeNumbers;
use App\Loaders\SeasonLoader;
use App\Models\Title;
use Common\Core\BaseController;
class SeasonController extends BaseController
{
public function show()
{
$data = (new SeasonLoader())->loadData(request('loader'));
$this->authorize('show', $data['title']);
return $this->renderClientOrApi([
'pageName' => 'season-page',
'data' => $data,
]);
}
public function store($titleId)
{
$this->authorize('update', Title::class);
$title = Title::withCount('seasons')->findOrFail($titleId);
$season = $title->seasons()->create([
'number' => $title->seasons_count + 1,
]);
return $this->success(['season' => $season]);
}
public function destroy(int $seasonId)
{
$this->authorize('update', Title::class);
app(DeleteSeasons::class)->execute([$seasonId]);
return $this->success();
}
public function episodeNumbers()
{
$titleId = request()->route('titleId');
$seasonNumber = request()->route('seasonNumber');
$episodeNumbers = (new LoadSeasonEpisodeNumbers())->execute(
$titleId,
$seasonNumber,
);
return $this->success(['episodeNumbers' => $episodeNumbers]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\Retrieve\PaginateSeasonEpisodes;
use App\Models\Title;
use Common\Core\BaseController;
class SeasonEpisodesController extends BaseController
{
public function __invoke(Title $title, int $seasonNumber)
{
$this->authorize('show', $title);
$pagination = app(PaginateSeasonEpisodes::class)->execute(
$title,
$seasonNumber,
request()->all(),
);
return $this->success(['pagination' => $pagination]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\LoadSeasonEpisodeNumbers;
use App\Models\Season;
use App\Models\Title;
use Common\Core\BaseController;
class TitleAutocompleteController extends BaseController
{
public function __invoke()
{
$this->authorize('index', Title::class);
$search = request('searchQuery');
$selectedTitleId = request('selectedTitleId');
$seasonNumber = request('seasonNumber');
$builder = app(Title::class);
if ($search) {
$builder = $builder->search($search);
}
$results = $builder
->take(10)
->get(['id', 'name', 'poster', 'release_date']);
$results = $results->map(function (Title $title) {
$normalized = $title->toNormalizedArray();
if ($title->relationLoaded('season')) {
$normalized['episodes_count'] =
$title->season->episodes_count ?? 0;
}
$normalized['seasons_count'] = $title->seasons_count;
return $normalized;
});
if ($selectedTitleId) {
$title = Title::find($selectedTitleId);
if ($title) {
$normalizedTitle = $title->toNormalizedArray();
$normalizedTitle['seasons_count'] = Season::where(
'title_id',
$title->id,
)->count();
if ($seasonNumber) {
$normalizedTitle[
'episode_numbers'
] = (new LoadSeasonEpisodeNumbers())->execute(
$title->id,
$seasonNumber,
);
}
$results->prepend($normalizedTitle);
}
}
return ['titles' => $results];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\DeleteTitles;
use App\Actions\Titles\Retrieve\PaginateTitles;
use App\Actions\Titles\Store\StoreTitleData;
use App\Jobs\IncrementModelViews;
use App\Loaders\TitleLoader;
use App\Models\Title;
use Common\Core\BaseController;
class TitleController extends BaseController
{
public function index()
{
$this->authorize('index', Title::class);
$pagination = app(PaginateTitles::class)->execute(request()->all());
return $this->success(['pagination' => $pagination]);
}
public function show()
{
$data = (new TitleLoader())->loadData(request('loader'));
$this->authorize('show', $data['title']);
(new IncrementModelViews())->execute($data['title']);
return $this->renderClientOrApi([
'pageName' => 'title-page',
'data' => $data,
]);
}
public function update(int $id)
{
$this->authorize('update', Title::class);
$data = request()->all();
$title = Title::findOrFail($id);
$title = app(StoreTitleData::class)->execute($title, $data, [
'overrideWithEmptyValues' => true,
]);
return $this->success(['title' => $title]);
}
public function store()
{
$this->authorize('store', Title::class);
$title = Title::create(request()->all());
return $this->success(['title' => $title]);
}
public function destroy(string $ids)
{
$titleIds = explode(',', $ids);
$this->authorize('destroy', Title::class);
app(DeleteTitles::class)->execute($titleIds);
return $this->success();
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Models\Season;
use App\Models\Title;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class TitleCreditsController extends BaseController
{
public function index(Title $title)
{
$this->authorize('show', $title);
$model = $this->resolveCreditableModel($title, request()->all());
$builder = $model->credits();
if (request('crewOnly')) {
$builder->wherePivot('department', '!=', 'actors');
}
if ($department = request('department')) {
$builder->wherePivot('department', $department);
}
$datasource = new Datasource($builder, request()->all());
$datasource->order = false;
return $this->success(['pagination' => $datasource->paginate()]);
}
public function update(Title $title, int $pivotId)
{
$this->authorize('update', $title);
$data = $this->validate(request(), [
'character' => 'string|nullable',
'department' => 'string|nullable',
'job' => 'string|nullable',
]);
$model = $this->resolveCreditableModel($title, request()->all());
$model->updateCredit($pivotId, $data);
return $this->success();
}
public function store(Title $title)
{
$this->authorize('update', $title);
$data = $this->validate(request(), [
'person_id' => 'required|integer|exists:people,id',
'character' => 'required_if:department,cast|string',
'department' => 'required|string',
'job' => 'string|nullable',
]);
$model = $this->resolveCreditableModel($title, request()->all());
$model->createCredit($data);
return $this->success();
}
public function destroy(Title $title, int $pivotId)
{
$this->authorize('update', $title);
$model = $this->resolveCreditableModel($title, request()->all());
$model
->credits()
->wherePivot('id', $pivotId)
->detach();
return $this->success();
}
public function changeOrder()
{
$this->authorize('update', Title::class);
$data = $this->validate(request(), [
'ids' => 'array|min:1',
'ids.*' => 'integer',
]);
$queryPart = '';
foreach ($data['ids'] as $order => $id) {
$queryPart .= " when id=$id then $order";
}
DB::table('creditables')
->whereIn('id', $data['ids'])
->update(['order' => DB::raw("(case $queryPart end)")]);
return $this->success();
}
public function resolveCreditableModel(
Title $title,
array $params,
): Title|Season|Episode {
if (Arr::get($params, 'season') && Arr::get($params, 'episode')) {
return $title->findEpisode($params['season'], $params['episode']);
} elseif (Arr::get($params, 'season')) {
return $title->findSeason($params['season']);
}
return $title;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Common\Core\BaseController;
class TitleNewsController extends BaseController
{
public function __invoke(Title $title)
{
$this->authorize('show', $title);
$articles = $title->load([
'newsArticles' => fn($q) => $q->limit(4),
])->newsArticles;
return $this->success(['news_articles' => $articles]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Titles\Retrieve\PaginateTitleSeasons;
use App\Models\Title;
use Common\Core\BaseController;
class TitleSeasonsController extends BaseController
{
public function __invoke(Title $title)
{
$this->authorize('show', $title);
$pagination = app(PaginateTitleSeasons::class)->execute(
$title,
request()->all(),
);
return $this->success(['pagination' => $pagination]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Common\Core\BaseController;
use Illuminate\Support\Str;
class TitleTagsController extends BaseController
{
public function store(Title $title, string $type)
{
$this->authorize('update', $title);
$data = $this->validate(request(), [
'tag_name' => 'required|string',
]);
$relation = $this->getRelationName($type);
$tags = $title
->$relation()
->getModel()
->insertOrRetrieve([$data['tag_name']]);
$title->$relation()->syncWithoutDetaching($tags->pluck('id'));
return $this->success();
}
public function destroy(Title $title, string $type, int $tagId)
{
$this->authorize('update', $title);
$relation = $this->getRelationName($type);
$title->$relation()->detach([$tagId]);
return $this->success();
}
private function getRelationName($type)
{
return Str::plural(Str::camel($type));
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Lists\ListsLoader;
use App\Models\Episode;
use App\Models\Title;
use App\Models\User;
use Auth;
use Common\Auth\Events\UserAvatarChanged;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class UserProfileController extends BaseController
{
public function show(User $user)
{
$this->authorize('show', $user);
$user->load(['profile', 'links']);
$user->loadCount(['followers', 'followedUsers', 'lists']);
$user->is_pro = $user->subscribed();
return $this->success(['user' => $user]);
}
public function update()
{
$user = Auth::user();
$this->authorize('update', $user);
$data = $this->validate(request(), [
'user' => 'array',
'profile' => 'array',
'links' => 'array',
]);
User::unguard(true);
$oldAvatar = $user->avatar;
$user->fill($data['user'])->save();
if (
isset($data['user']['avatar']) &&
$oldAvatar !== $data['user']['avatar']
) {
event(new UserAvatarChanged($user));
}
$profile = $user
->profile()
->updateOrCreate(['user_id' => $user->id], $data['profile']);
$user->links()->delete();
$links = $user->links()->createMany($data['links']);
$user->setRelation('profile', $profile);
$user->setRelation('links', $links);
return $this->success(['user' => $user]);
}
public function lists(User $user)
{
$this->authorize('show', $user);
$pagination = (new ListsLoader())->forUser($user, request()->all());
return $this->success(['pagination' => $pagination]);
}
public function ratings(User $user)
{
$this->authorize('show', $user);
$datasource = new Datasource(
$user
->reviews()
->whereNull('body')
->with([
'reviewable' => function (MorphTo $morphTo) {
$morphTo
->morphWith([
Episode::class => ['title'],
])
->with('primaryVideo');
},
'user',
]),
request()->all(),
);
$pagination = $datasource->paginate();
return $this->success(['pagination' => $pagination]);
}
public function reviews(User $user)
{
$this->authorize('show', $user);
$datasource = new Datasource(
$user
->reviews()
->where('reviewable_type', Title::MODEL_TYPE)
->whereNotNull('body')
->with([
'reviewable' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Episode::class => ['title'],
]);
},
'user',
]),
request()->all(),
);
$pagination = $datasource->paginate();
return $this->success(['pagination' => $pagination]);
}
public function comments(User $user)
{
$this->authorize('show', $user);
$datasource = new Datasource(
$user
->comments()
->with([
'commentable' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Episode::class => ['title'],
]);
},
'user',
])
->where('deleted', false),
request()->all(),
);
$pagination = $datasource->paginate();
return $this->success(['pagination' => $pagination]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use Common\Core\BaseController;
use Illuminate\Support\Facades\Auth;
class UserRatingsController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function __invoke()
{
$ratings = Auth::user()
->reviews()
->select(['id', 'reviewable_id', 'reviewable_type', 'score'])
->limit(1000)
->get()
->map(
fn($review) => [
'id' => $review->id,
'score' => $review->score,
'reviewable_id' => $review->reviewable_id,
'type' => $review->reviewable_type,
],
)
->groupBy('type')
->map(
fn($group) => $group
->mapWithKeys(
fn($item) => [
$item['reviewable_id'] => [
'id' => $item['id'],
'score' => $item['score'],
],
],
)
->all(),
);
return $this->success(['ratings' => $ratings]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use Common\Core\BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class UserWatchlistController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function __invoke()
{
$list = Auth::user()
->watchlist()
->firstOrFail();
$items = DB::table('channelables')
->where('channel_id', $list->id)
->pluck('channelable_type', 'channelable_id')
->map(
fn($modelType, $itemId) => [
'id' => $itemId,
'type' => $modelType,
],
)
->groupBy('type')
->map(
fn($group) => $group->mapWithKeys(
fn($item) => [
$item['id'] => true,
],
),
);
return $this->success([
'watchlist' => [
'id' => $list->id,
'items' => $items,
],
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use Common\Core\BaseController;
class VideoApproveController extends BaseController
{
public function approve(Video $video)
{
$this->authorize('update', $video);
$video->update(['approved' => true]);
return $this->success(['video' => $video]);
}
public function disapprove(Video $video)
{
$this->authorize('update', $video);
$video->update(['approved' => false]);
return $this->success(['video' => $video]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Models\Title;
use Common\Core\BaseController;
use Illuminate\Support\Facades\DB;
class VideoOrderController extends BaseController
{
public function changeOrder(int $titleId)
{
$title = Title::findOrFail($titleId);
$this->authorize('update', $title);
request()->validate([
'ids' => 'array|min:1',
'ids.*' => 'integer',
]);
$queryPart = '';
foreach (request('ids') as $order => $id) {
$queryPart .= " when id=$id then $order";
}
DB::table('videos')
->whereIn('id', request('ids'))
->update(['order' => DB::raw("(case $queryPart end)")]);
return $this->success();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use Auth;
use Common\Core\BaseController;
use Illuminate\Database\Eloquent\Builder;
class VideoReportController extends BaseController
{
public function report(Video $video)
{
$userId = Auth::id();
$userIp = $this->request->ip();
// if we can't match current user, bail
if ( ! $userId && ! $userIp) return null;
$alreadyReported = $video->reports()
->where(function(Builder $query) use($userId, $userIp) {
$query->where('user_id', $userId)->orWhere('user_ip', $userIp);
})->first();
if ($alreadyReported) {
return $this->error(__('You have already reported this video.'));
} else {
$report = $video->reports()->create([
'user_id' => $userId,
'user_ip' => $userIp
]);
return $this->success(['report' => $report]);
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Plays\LogVideoPlay;
use App\Actions\Videos\CrupdateVideo;
use App\Models\Video;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Common\Database\Datasource\DatasourceFilters;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class VideosController extends BaseController
{
public function index()
{
$this->authorize('index', Video::class);
$builder = Video::with([
'captions',
'title' => fn(BelongsTo $query) => $query->with('seasons'),
])->withCount(['reports', 'plays']);
$filters = new DatasourceFilters(request('filters'));
if (
$titleFilter =
$filters->getAndRemove('title_id') ?? request('title_id')
) {
$titleId = $titleFilter['value'] ?? request('title_id');
$season = $titleFilter['season'] ?? request('season');
$episode = $titleFilter['episode'] ?? request('episode');
$builder->where('title_id', $titleId);
$builder->when($season, fn($q) => $q->where('season_num', $season));
$builder->when(
$episode,
fn($q) => $q->where('episode_num', $episode),
);
}
$datasource = new Datasource($builder, request()->all(), $filters);
$order = $datasource->getOrder();
if (Str::endsWith($order['col'], 'upvotes')) {
$datasource->order = false;
$builder->orderByMostUpvotes();
}
// order by percentage of likes, taking into account total amount of likes and dislikes
if (Str::endsWith($order['col'], 'score')) {
$datasource->order = false;
$builder->orderByWeightedScore();
}
// add a secondary order by episode number if ordering by season number
if (Str::endsWith($order['col'], 'season_num')) {
$datasource->order = false;
$builder
->orderBy('season_num', 'desc')
->orderBy('episode_num', 'desc');
}
return $this->success(['pagination' => $datasource->paginate()]);
}
public function show(Video $video)
{
$this->authorize('show', Video::class);
$video->load(['captions', 'title']);
return $this->success(['video' => $video]);
}
public function store()
{
$this->authorize('store', Video::class);
$this->validate(
request(),
[
'title_id' => 'required|integer',
'name' => ['required', 'string', 'min:3', 'max:250'],
'src' => 'required|max:1000',
'type' => 'required|string|min:3|max:250',
'category' => 'required|string|min:3|max:20',
'quality' => 'nullable|string|min:2|max:250',
'language' => 'required|nullable|string|max:10',
'season_num' => 'nullable|integer',
'episode_num' => 'requiredWith:season|integer|nullable',
'captions' => 'nullable|array',
'captions.*.name' => 'required|string|max:100',
'captions.*.url' => 'required|string|max:250',
'captions.*.language' => 'required|string|max:100',
],
[
'title_id.*' => __(
'Select a title this video should be attached to.',
),
],
);
$video = app(CrupdateVideo::class)->execute(request()->all());
return $this->success(['video' => $video]);
}
public function update($id)
{
$this->authorize('update', Video::class);
$this->validate(
request(),
[
'name' => 'string|min:3|max:250',
'src' => 'required|max:1000',
'type' => 'string|min:3|max:1000',
'quality' => 'nullable|string|min:2|max:250',
'language' => 'required|nullable|string|max:10',
'title_id' => 'integer',
'season_num' => 'nullable|integer',
'episode_num' => 'requiredWith:season|integer|nullable',
'captions' => 'nullable|array',
'captions.*.name' => 'required|string|max:100',
'captions.*.url' => 'required|string|max:250',
'captions.*.language' => 'required|string|max:100',
],
[
'title_id.*' => __(
'Select a title this video should be attached to.',
),
],
);
$video = app(CrupdateVideo::class)->execute(request()->all(), $id);
return $this->success(['video' => $video]);
}
public function destroy($ids)
{
$ids = explode(',', $ids);
$this->authorize('destroy', [Video::class, $ids]);
foreach ($ids as $id) {
$video = Video::find($id);
if (is_null($video)) {
continue;
}
$video->delete();
}
return $this->success();
}
public function logPlay(Video $video)
{
$this->authorize('show', Video::class);
if (request()->getContentType() === 'application/json') {
$data = request()->all();
} else {
$data = json_decode(request()->getContent(), true);
}
app(LogVideoPlay::class)->execute($video, $data);
return $this->success();
}
}

Some files were not shown because too many files have changed in this diff Show More