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

193
app/Models/Channel.php Executable file
View File

@@ -0,0 +1,193 @@
<?php
namespace App\Models;
use App\Actions\Channels\FetchContentFromLocalDatabase;
use App\Actions\Channels\FetchContentFromTmdb;
use App\Actions\People\PaginatePeople;
use App\Actions\Titles\Retrieve\PaginateTitles;
use Common\Channels\BaseChannel;
use Common\Database\Datasource\Datasource;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class Channel extends BaseChannel
{
protected $casts = [
'id' => 'integer',
'public' => 'boolean',
'internal' => 'boolean',
'user_id' => 'integer',
];
protected $hidden = ['pivot', 'internal'];
public function allTitles(array $params, $builder = null): AbstractPaginator
{
if (!$builder && $this->restriction) {
$builder = $this->restriction->titles();
}
return (new PaginateTitles())->execute($params, $builder);
}
public function allMovies(array $params, $builder = null)
{
if (!$builder && $this->restriction) {
$builder = $this->restriction->titles();
}
$params['type'] = Title::MOVIE_TYPE;
return (new PaginateTitles())->execute($params, $builder);
}
public function allSeries(array $params, $builder = null)
{
if (!$builder && $this->restriction) {
$builder = $this->restriction->titles();
}
$params['type'] = Title::SERIES_TYPE;
return (new PaginateTitles())->execute($params, $builder);
}
public function titles(): MorphToMany
{
return $this->channelableRelation(Title::class);
}
public function movies(): MorphToMany
{
return $this->channelableRelation(Title::class)->where(
'titles.is_series',
false,
);
}
public function series(): MorphToMany
{
return $this->channelableRelation(Title::class)->where(
'titles.is_series',
true,
);
}
public function people(): MorphToMany
{
return $this->channelableRelation(Person::class);
}
public function allPeople(
array $params,
mixed $builder = null,
Channel $parentChannel = null,
): AbstractPaginator {
$params['compact'] = !is_null($parentChannel);
return (new PaginatePeople())->execute($params, $builder);
}
public function newsArticles(): MorphToMany
{
return $this->channelableRelation(NewsArticle::class)->select([
'news_articles.id',
'news_articles.title',
'news_articles.created_at',
]);
}
public function allNewsArticles(
array $params,
mixed $builder = null,
): AbstractPaginator {
$datasource = new Datasource($builder ?? NewsArticle::query(), $params);
$paginator = $datasource->paginate();
$paginator->transform(function (NewsArticle $article) {
$article->body = Str::limit(strip_tags($article->body), 400);
return $article;
});
return $paginator;
}
protected function loadContentFromExternal(
string $autoUpdateMethod,
): Collection|array|null {
$provider = Arr::get($this->config, 'autoUpdateProvider', 'local');
$modelType = Arr::get($this->config, 'contentModel', 'movie');
$filters = [];
if (isset($this->config['restriction'])) {
$filters[$this->config['restriction']] =
$this->config['restrictionModelId'];
}
if ($provider === 'tmdb') {
$keywords = collect();
if (isset($filters['keyword'])) {
$keywords->push($filters['keyword']);
}
if (isset($this->config['tmdb_keywords'])) {
$keywords = $keywords
->merge($this->config['tmdb_keywords'])
->unique();
}
if (isset($this->config['tmdb_language'])) {
$filters['language'] = $this->config['tmdb_language'];
}
$filters['keyword'] = $keywords->implode(',');
return app(FetchContentFromTmdb::class)->execute(
$autoUpdateMethod,
$modelType,
$filters,
);
} else {
return app(FetchContentFromLocalDatabase::class)->execute(
$autoUpdateMethod,
$modelType,
$filters,
);
}
}
protected function channelableRelation(string $type): MorphToMany
{
return $this->morphedByMany(
$type,
'channelable',
null,
'channel_id',
)->withPivot(['id', 'channelable_id', 'order']);
}
public function resolveRouteBinding($value, $field = null)
{
$type = request('channelType');
if ($value === 'watchlist') {
if (!Auth::check()) {
abort(401);
}
$channel = Auth::user()
->watchlist()
->firstOrFail();
} elseif (ctype_digit($value)) {
$channel = app(Channel::class)
->when($type, fn($q) => $q->where('type', $type))
->findOrFail($value);
} else {
$channel = app(Channel::class)
->where('slug', $value)
->when($type, fn($q) => $q->where('type', $type))
->firstOrFail();
}
if ($channel->type === 'list') {
$channel->load('user');
}
return $channel;
}
}

166
app/Models/Episode.php Executable file
View File

@@ -0,0 +1,166 @@
<?php
namespace App\Models;
use App\Actions\Titles\HasCreditableRelation;
use App\Actions\Titles\HasVideoRelation;
use Common\Comments\Comment;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Arr;
class Episode extends Model
{
use HasCreditableRelation, HasVideoRelation;
public const MODEL_TYPE = 'episode';
protected $guarded = ['id'];
protected $appends = [
'model_type',
'rating',
'vote_count',
'status',
'year',
];
protected $casts = [
'id' => 'integer',
'episode_number' => 'integer',
'season_number' => 'integer',
'year' => 'integer',
'title_id' => 'integer',
'season_id' => 'integer',
'allow_update' => 'boolean',
'tmdb_vote_count' => 'integer',
'tmdb_vote_average' => 'float',
'popularity' => 'integer',
'runtime' => 'integer',
'rating' => 'float',
'vote_count' => 'integer',
'release_date' => 'date',
];
public $hidden = [
'imdb_rating',
'imdb_votes_num',
'tmdb_vote_average',
'local_vote_average',
'local_vote_count',
'tmdb_vote_count',
'mc_user_score',
'mc_critic_score',
];
protected function year(): Attribute
{
return Attribute::make(
get: function () {
return $this->release_date?->year;
},
);
}
protected function status(): Attribute
{
return Attribute::make(
get: function () {
return $this->release_date?->isFuture()
? 'upcoming'
: 'released';
},
);
}
protected function rating(): Attribute
{
return Attribute::make(
get: function () {
return (float) Arr::get(
$this->attributes,
config('common.site.rating_column'),
);
},
);
}
public function getRatingAttribute()
{
return Arr::get($this->attributes, config('common.site.rating_column'));
}
protected function voteCount(): Attribute
{
return Attribute::make(
get: function () {
$column = str_replace(
'_average',
'_count',
config('common.site.rating_column'),
);
return Arr::get($this->attributes, $column) ?: 0;
},
);
}
public function title(): BelongsTo
{
return $this->belongsTo(Title::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable')->orderBy(
'created_at',
'desc',
);
}
public function plays(): HasManyThrough
{
return $this->hasManyThrough(VideoPlay::class, Video::class);
}
public function season(): BelongsTo
{
return $this->belongsTo(Season::class);
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->relationLoaded('title')
? $this->title->name
: null,
'image' => $this->poster,
'model_type' => self::MODEL_TYPE,
];
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'release_date' => $this->release_date,
'popularity' => $this->popularity,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at', 'release_date', 'popularity'];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

36
app/Models/Genre.php Executable file
View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Common\Tags\Tag;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
class Genre extends Tag
{
const MODEL_TYPE = 'genre';
protected $hidden = ['pivot', 'created_at', 'updated_at'];
public function titles(): BelongsToMany
{
return $this->belongsToMany(Title::class);
}
public function insertOrRetrieve(
Collection|array $tags,
?string $type = 'custom',
?int $userId = null,
): Collection {
// genre table will not have type or user_id columns
return parent::insertOrRetrieve($tags, null, null);
}
public function getByNames(
Collection $names,
?string $type = null,
?int $userId = null,
): Collection {
return parent::getByNames($names, null, null);
}
}

15
app/Models/Image.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $source
* @property string $url
*/
class Image extends Model
{
protected $guarded = ['id'];
protected $hidden = ['model_id', 'model_type'];
}

48
app/Models/Keyword.php Executable file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Common\Tags\Tag;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
class Keyword extends Tag
{
use Searchable;
const MODEL_TYPE = 'keyword';
public function titles(): BelongsToMany
{
return $this->belongsToMany(Title::class);
}
public function insertOrRetrieve(
Collection|array $tags,
?string $type = 'custom',
?int $userId = null,
): Collection {
// keywords table will not have type or user_id columns
return parent::insertOrRetrieve($tags, null, null);
}
public function getByNames(
Collection $names,
string $type = null,
int $userId = null,
): Collection {
return parent::getByNames($names, null, null);
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'display_name' => $this->display_name,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
}

17
app/Models/Listable.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Listable extends Model
{
protected $casts = [
'id' => 'integer',
'order' => 'integer',
'list_id' => 'integer',
'listable_id' => 'integer',
];
public const UPDATED_AT = null;
}

19
app/Models/Movie.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
class Movie extends Title
{
protected $table = 'titles';
protected static function boot()
{
parent::boot();
static::addGlobalScope('titleType', function (Builder $builder) {
$builder->where('is_series', false);
});
}
}

69
app/Models/NewsArticle.php Executable file
View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Common\Pages\CustomPage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Str;
class NewsArticle extends CustomPage
{
const MODEL_TYPE = 'newsArticle';
protected $guarded = ['id'];
protected $appends = ['model_type'];
protected function slug(): Attribute
{
return Attribute::make(set: fn(string $value) => slugify($value));
}
public function scopeCompact(Builder $query)
{
return $query->select([
'id',
'image',
'title',
'slug',
'byline',
'source',
'created_at',
]);
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'slug' => $this->slug,
'source' => $this->source,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->title,
'image' => $this->image,
'description' => Str::limit($this->body, 100),
'model_type' => static::MODEL_TYPE,
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public static function getModelTypeAttribute(): string
{
return static::MODEL_TYPE;
}
}

186
app/Models/Person.php Executable file
View File

@@ -0,0 +1,186 @@
<?php
namespace App\Models;
use App\Actions\People\StorePersonData;
use App\Actions\Titles\HandlesEncodedTmdbId;
use App\Actions\Titles\InsertsTmdbTitleOrPerson;
use App\Services\Data\Tmdb\TmdbApi;
use Carbon\Carbon;
use Common\Core\BaseModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
use Laravel\Scout\Searchable;
class Person extends BaseModel
{
use Searchable, HandlesEncodedTmdbId, InsertsTmdbTitleOrPerson;
public const MODEL_TYPE = 'person';
protected $guarded = ['id', 'relation_data', 'model_type'];
protected $appends = ['model_type'];
protected $casts = [
'id' => 'integer',
'tmdb_id' => 'integer',
'allow_update' => 'boolean',
'fully_synced' => 'boolean',
'adult' => 'boolean',
'birth_date' => 'date',
'death_date' => 'date',
];
protected static function booted()
{
static::addGlobalScope('adult', function (Builder $builder) {
if (!config('tmdb.includeAdult')) {
$builder->where('adult', false);
}
});
}
public function scopeOrderByBirthDate(Builder $query, string $direction)
{
$query->orderByRaw(
"CASE WHEN birth_date IS NULL THEN 1 ELSE 0 END, birth_date $direction",
);
}
public static function firstOrCreateFromEncodedTmdbId(
string $encodedId,
): static {
[$tmdbId] = static::decodeTmdbIdOrFail($encodedId);
return static::withoutGlobalScope('adult')->firstOrCreate([
'tmdb_id' => $tmdbId,
]);
}
public function maybeUpdateFromExternal(array $options = []): static|null
{
$tmdbImportingIsEnabled =
settings('content.people_provider') === 'tmdb' ||
Arr::get($options, 'forceAutomation');
if (
$tmdbImportingIsEnabled &&
$this->needsUpdating($options['ignoreLastUpdate'] ?? false)
) {
$data = app(TmdbApi::class)->getPerson($this);
if (!$data) {
return null;
}
app(StorePersonData::class)->execute($this, $data);
}
return $this;
}
public function needsUpdating($force = false): bool
{
if (!$this->exists || !$this->tmdb_id) {
return false;
}
if ($force) {
return true;
}
// sync every week
return $this->allow_update &&
(!$this->updated_at ||
$this->updated_at->lessThan(Carbon::now()->subWeek()));
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function scopeCompact(Builder $query): Builder
{
return $query->select(['titles.id', 'titles.name', 'titles.poster']);
}
public function credits(): BelongsToMany
{
return $this->morphedByMany(Title::class, 'creditable')
->select(
'titles.id',
'is_series',
'poster',
'backdrop',
'popularity',
'name',
'release_date',
'tmdb_vote_average',
'local_vote_average',
)
->withPivot(['id', 'job', 'department', 'order', 'character'])
->orderBy('titles.release_date', 'desc');
}
public function episodeCredits(int $tileId = null): BelongsToMany
{
$query = $this->morphedByMany(Episode::class, 'creditable');
if ($tileId) {
$query->where('episodes.title_id', $tileId);
}
$query
->select(
'episodes.id',
'episodes.title_id',
'name',
'release_date',
'season_number',
'episode_number',
)
->withPivot(['job', 'department', 'order', 'character'])
->orderBy('episodes.season_number', 'desc')
->orderBy('episodes.episode_number', 'desc');
return $query;
}
/**
* @param int|null $tileId
* @return BelongsToMany
*/
public function seasonCredits($tileId = null)
{
$query = $this->morphedByMany(Season::class, 'creditable');
if ($tileId) {
$query->where('seasons.title_id', $tileId);
}
$query
->select('seasons.id', 'seasons.title_id')
->withPivot(['job', 'department', 'order', 'character'])
->orderBy('seasons.number', 'desc');
return $query;
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'image' => $this->poster,
'model_type' => self::MODEL_TYPE,
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Common\Tags\Tag;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
class ProductionCountry extends Tag
{
const MODEL_TYPE = 'production_country';
public function titles(): BelongsToMany
{
return $this->belongsToMany(Title::class, 'country_title');
}
public function insertOrRetrieve(
Collection|array $tags,
?string $type = 'custom',
?int $userId = null,
): Collection {
// countries table will not have type or user_id columns
return parent::insertOrRetrieve($tags, null, null);
}
public function getByNames(
Collection $names,
string $type = null,
int $userId = null,
): Collection {
return parent::getByNames($names, null, null);
}
}

19
app/Models/ProfileLink.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProfileLink extends Model
{
protected $guarded = ['id'];
protected $visible = [
'url', 'title'
];
public function getUrlAttribute($value)
{
return parse_url($value, PHP_URL_SCHEME) === null ? "https://$value" : $value;
}
}

98
app/Models/Review.php Executable file
View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Common\Core\BaseModel;
use Common\Votes\OrdersByWeightedScore;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Review extends BaseModel
{
use OrdersByWeightedScore;
public const MODEL_TYPE = 'review';
protected $guarded = ['id'];
protected $appends = ['model_type'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'reviewable_id' => 'integer',
'score' => 'integer',
'has_text' => 'boolean',
'helpful_count' => 'integer',
'not_helpful_count' => 'integer',
];
public function feedback(): HasMany
{
return $this->hasMany(ReviewFeedback::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->select(
'id',
'first_name',
'last_name',
'email',
'avatar',
);
}
public function reviewable(): MorphTo
{
return $this->morphTo();
}
public function reports(): HasMany
{
return $this->hasMany(ReviewReport::class);
}
public function scopeOrderByMostHelpful(Builder $query): Builder
{
return $query->orderByWeightedScore(
'desc',
'helpful_count',
'not_helpful_count',
);
}
public function scopeWithTextOnly(Builder $query): Builder
{
return $query->where('has_text', true);
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'body' => $this->body,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'model_type' => self::MODEL_TYPE,
];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

30
app/Models/ReviewFeedback.php Executable file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReviewFeedback extends Model
{
protected $table = 'review_feedback';
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'review_id' => 'integer',
'is_helpful' => 'boolean',
];
public function review(): BelongsTo
{
return $this->belongsTo(Review::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

15
app/Models/ReviewReport.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewReport extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'review_id' => 'integer',
];
}

108
app/Models/Season.php Executable file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Models;
use App\Actions\Titles\HasCreditableRelation;
use App\Actions\Titles\Store\StoreSeasonData;
use App\Services\Data\Tmdb\TmdbApi;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Arr;
class Season extends Model
{
use HasCreditableRelation;
public const MODEL_TYPE = 'season';
protected $guarded = ['id'];
protected $appends = ['model_type'];
protected $casts = [
'id' => 'integer',
'fully_synced' => 'boolean',
'episodes_count' => 'integer',
'number' => 'integer',
'release_date' => 'date',
];
public function episodes(): HasMany
{
return $this->hasMany(Episode::class);
}
public function title(): BelongsTo
{
return $this->belongsTo(Title::class);
}
public function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function findEpisode(int $number): Episode|null
{
return $this->episodes()
->where('episode_number', $number)
->firstOrFail();
}
public function maybeUpdateFromExternal(
Title $title,
array $options = [],
): self {
if ($this->needsUpdating($title, $options)) {
$data = app(TmdbApi::class)->getSeason($title, $this->number);
if ($data) {
app(StoreSeasonData::class)->execute($title, $data);
$this->refresh();
}
}
return $this;
}
protected function needsUpdating(Title $title, array $options = []): bool
{
$isFullySynced = $options['forceAutomation'] ?? $this->fully_synced;
$tmdbImportingIsEnabled =
settings('content.title_provider') === 'tmdb' ||
Arr::get($options, 'forceAutomation');
if (!$this->exists || !$title->tmdb_id) {
return false;
}
// series ended and this season is already fully updated from external site
if ($title->series_ended && $isFullySynced) {
return false;
}
// season is fully synced, and it's not the latest season
if ($isFullySynced && $title->season_count > $this->number) {
return false;
}
if (
!$tmdbImportingIsEnabled &&
// might need to fetch title seasons, even if automation is disabled because they can't be
// fetched when importing multiple titles without hitting tmdb api rate limits
!settings('content.force_season_update')
) {
return false;
}
if (!$isFullySynced) {
return true;
}
if (Arr::get($options, 'ignoreLastUpdate')) {
return true;
}
return !$this->updated_at ||
$this->updated_at->lessThan(Carbon::now()->subWeek());
}
}

19
app/Models/Series.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
class Series extends Title
{
protected $table = 'titles';
protected static function boot()
{
parent::boot();
static::addGlobalScope('titleType', function (Builder $builder) {
$builder->where('is_series', true);
});
}
}

361
app/Models/Title.php Executable file
View File

@@ -0,0 +1,361 @@
<?php
namespace App\Models;
use App\Actions\Titles\HandlesEncodedTmdbId;
use App\Actions\Titles\HasCreditableRelation;
use App\Actions\Titles\HasVideoRelation;
use App\Actions\Titles\InsertsTmdbTitleOrPerson;
use App\Actions\Titles\Store\StoreTitleData;
use App\Services\Data\Tmdb\TmdbApi;
use Carbon\Carbon;
use Common\Comments\Comment;
use Common\Core\BaseModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Arr;
use Laravel\Scout\Searchable;
class Title extends BaseModel
{
use HasCreditableRelation,
HasVideoRelation,
Searchable,
HandlesEncodedTmdbId,
InsertsTmdbTitleOrPerson;
public const MOVIE_TYPE = 'movie';
public const SERIES_TYPE = 'series';
public const MODEL_TYPE = 'title';
protected $guarded = ['id', 'type'];
protected $appends = [
'rating',
'model_type',
'vote_count',
'status',
'year',
];
public $hidden = [
'imdb_rating',
'imdb_votes_num',
'tmdb_vote_average',
'local_vote_average',
'local_vote_count',
'tmdb_vote_count',
'mc_user_score',
'mc_critic_score',
];
protected $casts = [
'id' => 'integer',
'allow_update' => 'boolean',
'series_ended' => 'boolean',
'is_series' => 'boolean',
'tmdb_vote_count' => 'integer',
'runtime' => 'integer',
'views' => 'integer',
'popularity' => 'integer',
'tmdb_vote_average' => 'float',
'local_vote_average' => 'float',
'fully_synced' => 'boolean',
'adult' => 'boolean',
'rating' => 'float',
'vote_count' => 'integer',
'seasons_count' => 'integer',
'release_date' => 'date',
];
protected static function booted()
{
static::addGlobalScope('adult', function (Builder $builder) {
if (!config('tmdb.includeAdult')) {
$builder->where('adult', false);
}
});
}
public function findSeason(int $number): Season
{
return $this->seasons()
->where('number', $number)
->firstOrFail();
}
public function findEpisode(int $season, int $episode): Episode
{
return $this->episodes()
->where('season_number', $season)
->where('episode_number', $episode)
->firstOrFail();
}
public function maybeUpdateFromExternal(array $options = []): static|null
{
$tmdbImportingIsEnabled =
settings('content.title_provider') === 'tmdb' ||
Arr::get($options, 'forceAutomation');
$needsUpdating = $this->needsUpdating(
$options['ignoreLastUpdate'] ?? false,
);
// first update title itself, if needed
if ($tmdbImportingIsEnabled && $needsUpdating) {
$data = app(TmdbApi::class)->getTitle($this);
if (!$data) {
return null;
}
app(StoreTitleData::class)->execute($this, $data);
}
// then update 3 last seasons
if (
$needsUpdating &&
$this->is_series &&
Arr::get($options, 'updateLast3Seasons') &&
(settings('content.force_season_update') || $tmdbImportingIsEnabled)
) {
$this->seasons()
->orderBy('number', 'desc')
->take(3)
->get()
->each(
fn(Season $season) => $season->maybeUpdateFromExternal(
$this,
$options,
),
);
}
return $this;
}
protected function needsUpdating($force = false): bool
{
if (!$this->tmdb_id || !$this->exists) {
return false;
}
if ($force) {
return true;
}
// only partial data was fetched
if (
is_null($this->release_date) ||
(is_null($this->runtime) &&
is_null($this->revenue) &&
is_null($this->country) &&
is_null($this->budget) &&
is_null($this->imdb_id))
) {
return true;
}
// sync every week
return $this->allow_update &&
$this->updated_at->lessThan(Carbon::now()->subWeek());
}
public static function firstOrCreateFromEncodedTmdbId(
string $encodedId,
): static {
[$tmdbId, $type] = static::decodeTmdbIdOrFail($encodedId);
if (!$tmdbId || !$type) {
throw new ModelNotFoundException();
}
return static::withoutGlobalScope('adult')->firstOrCreate([
'tmdb_id' => $tmdbId,
'is_series' => $type === Title::SERIES_TYPE,
]);
}
protected function rating(): Attribute
{
return Attribute::make(
get: function () {
return (float) Arr::get(
$this->attributes,
config('common.site.rating_column'),
);
},
);
}
protected function status(): Attribute
{
return Attribute::make(
get: function () {
if ($this->release_date?->isFuture()) {
return 'upcoming';
} elseif ($this->is_series) {
return $this->series_ended ? 'ended' : 'ongoing';
} else {
return 'released';
}
},
);
}
protected function voteCount(): Attribute
{
return Attribute::make(
get: function () {
$column = str_replace(
'_average',
'_count',
config('common.site.rating_column'),
);
return Arr::get($this->attributes, $column) ?: 0;
},
);
}
protected function year(): Attribute
{
return Attribute::make(
get: function () {
return $this->release_date?->year;
},
);
}
public function plays(): HasManyThrough
{
return $this->hasManyThrough(VideoPlay::class, Video::class);
}
public function genres(): BelongsToMany
{
return $this->belongsToMany(Genre::class);
}
public function keywords(): BelongsToMany
{
return $this->belongsToMany(Keyword::class);
}
public function productionCountries(): BelongsToMany
{
return $this->belongsToMany(ProductionCountry::class, 'country_title');
}
public function scopeCompact(Builder $query): Builder
{
return $query->select([
'titles.id',
'titles.name',
'titles.poster',
'titles.backdrop',
]);
}
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'model')
->select(['id', 'model_id', 'model_type', 'url', 'type', 'source'])
->orderBy('order', 'asc');
}
public function newsArticles(): MorphToMany
{
return $this->morphToMany(
NewsArticle::class,
'model',
'news_article_models',
'model_id',
'article_id',
)->orderBy('created_at', 'desc');
}
public function reviews(): MorphMany
{
return $this->morphMany(Review::class, 'reviewable');
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable')
->orderBy('created_at', 'desc')
->orderByWeightedScore();
}
public function seasons(): HasMany
{
return $this->hasMany(Season::class);
}
public function season(): HasOne
{
return $this->hasOne(Season::class);
}
public function episodes(): HasMany
{
return $this->hasMany(Episode::class);
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'original_title' => $this->original_title,
'release_date' => $this->release_date,
'popularity' => $this->popularity,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at', 'release_date', 'popularity'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->release_date?->format('Y'),
'image' => $this->poster
? preg_replace('/original|w1280/', 'w92', $this->poster)
: null,
'model_type' => self::MODEL_TYPE,
];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
public function resolveRouteBinding($value, $field = null): static
{
if (is_numeric($value) || ctype_digit($value)) {
return $this->findOrFail($value);
}
[$tmdbId, $type] = static::decodeTmdbIdOrFail($value);
if (!$tmdbId || !$type) {
throw new ModelNotFoundException();
}
return static::where('tmdb_id', $tmdbId)
->where('is_series', $type === Title::SERIES_TYPE)
->firstOrFail();
}
}

47
app/Models/User.php Executable file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Common\Auth\BaseUser;
use Common\Comments\Comment;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Laravel\Sanctum\HasApiTokens;
class User extends BaseUser
{
use HasApiTokens;
public function watchlist(): HasOne
{
return $this->hasOne(Channel::class)
->where('type', 'list')
->where('name', 'watchlist');
}
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
public function lists(): HasMany
{
return $this->hasMany(Channel::class)->where('type', 'list');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
}
public function links(): MorphMany
{
return $this->morphMany(ProfileLink::class, 'linkeable');
}
}

17
app/Models/UserProfile.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserProfile extends Model
{
protected $guarded = ['id'];
protected $hidden = [
'id',
'user_id',
'created_at',
'updated_at',
];
}

162
app/Models/Video.php Executable file
View File

@@ -0,0 +1,162 @@
<?php
namespace App\Models;
use Common\Comments\Comment;
use Common\Core\BaseModel;
use Common\Votes\OrdersByWeightedScore;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\DB;
class Video extends BaseModel
{
use OrdersByWeightedScore;
public const VIDEO_TYPE_EMBED = 'embed';
public const VIDEO_TYPE_DIRECT = 'direct';
public const VIDEO_TYPE_EXTERNAL = 'external';
public const MODEL_TYPE = 'video';
protected $guarded = ['id'];
protected $appends = ['score', 'model_type'];
protected $casts = [
'negative_votes' => 'integer',
'positive_votes' => 'integer',
'order' => 'integer',
'approved' => 'boolean',
'title_id' => 'integer',
'id' => 'integer',
'user_id' => 'integer',
];
public function title(): BelongsTo
{
return $this->belongsTo(Title::class)->select([
'id',
'name',
'poster',
'backdrop',
'is_series',
]);
}
public function votes(): HasMany
{
return $this->hasMany(VideoVote::class);
}
public function reports(): HasMany
{
return $this->hasMany(VideoReport::class);
}
public function captions(): HasMany
{
return $this->hasMany(VideoCaption::class)->orderBy('order', 'asc');
}
public function plays(): HasMany
{
return $this->hasMany(VideoPlay::class);
}
public function latestPlay(): HasOne
{
return $this->hasOne(VideoPlay::class)->orderBy('created_at', 'desc');
}
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class);
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable')
->orderBy('created_at', 'desc')
->orderByWeightedScore();
}
public function scopeOrderByMostUpvotes(Builder $query): Builder
{
return $query->orderByWeightedScore('desc');
}
public function getScoreAttribute()
{
$total = $this->positive_votes + $this->negative_votes;
if (!$total) {
return null;
}
return round(($this->positive_votes / $total) * 100);
}
public function scopeApplySelectedSort(Builder $query): Builder
{
[$col, $dir] = explode(
':',
settings('streaming.default_sort', 'order:asc'),
);
if ($col === 'score') {
$query->orderByWeightedScore();
} elseif ($col === 'order') {
$query->orderByRaw('`category` = "trailer" desc, `order` asc');
} else {
$query
->orderBy(DB::raw('`category` = "trailer"'), 'desc')
->orderBy($col, $dir);
}
return $query;
}
public function scopeFromConfiguredCategory(Builder $builder): Builder
{
$contentType = settings('streaming.video_panel_content');
if ($contentType === 'full') {
$builder->where('category', 'full');
} elseif ($contentType === 'short') {
$builder->where('category', '!=', 'full');
} elseif ($contentType !== 'all') {
$builder->where('category', $contentType);
}
return $builder;
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'image' => $this->thumbnail,
'model_type' => self::MODEL_TYPE,
];
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

22
app/Models/VideoCaption.php Executable file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VideoCaption extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'video_id' => 'integer',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
}

23
app/Models/VideoPlay.php Executable file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class VideoPlay extends Model
{
public const UPDATED_AT = null;
protected $guarded = ['id'];
protected $casts = ['user_id' => 'integer', 'video_id' => 'integer'];
public function scopeForCurrentUser(Builder $builder): Builder
{
if (Auth::check()) {
return $builder->where('user_id', Auth::id());
} else {
return $builder->where('ip', getIp());
}
}
}

15
app/Models/VideoReport.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoReport extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'comment_id' => 'integer',
];
}

9
app/Models/VideoVote.php Executable file
View File

@@ -0,0 +1,9 @@
<?php
namespace App\Models;
use Common\Votes\Vote;
class VideoVote extends Vote
{
}