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

234
common/Channels/BaseChannel.php Executable file
View File

@@ -0,0 +1,234 @@
<?php
namespace Common\Channels;
use App\Models\User;
use Carbon\Carbon;
use Common\Core\BaseModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
abstract class BaseChannel extends BaseModel
{
const MODEL_TYPE = 'channel';
protected $guarded = ['id'];
protected $appends = ['model_type'];
protected $hidden = ['pivot', 'internal'];
protected $casts = [
'id' => 'integer',
'public' => 'boolean',
'internal' => 'boolean',
'user_id' => 'integer',
];
protected static function booted(): void
{
// touch parent channels
static::updated(function (self $channel) {
$parentIds = DB::table('channelables')
->where('channelable_type', static::MODEL_TYPE)
->where('channelable_id', $channel->id)
->pluck('channel_id');
static::withoutEvents(function () use ($parentIds) {
static::whereIn('id', $parentIds)->update([
'updated_at' => Carbon::now(),
]);
});
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function users(): MorphToMany
{
return $this->morphedByMany(User::class, 'channelable')->withPivot([
'id',
'channelable_id',
'order',
]);
}
public function channels(): MorphToMany
{
return $this->morphedByMany(static::class, 'channelable')->withPivot([
'id',
'channelable_id',
'order',
]);
}
public function getConfigAttribute(): ?array
{
return isset($this->attributes['config'])
? json_decode($this->attributes['config'], true)
: [];
}
public function setConfigAttribute($value)
{
$this->attributes['config'] = is_array($value)
? json_encode($value)
: $value;
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'image' => $this->image,
'description' =>
$this->description ||
Arr::get($this->attributes, 'config.seoDescription'),
'model_type' => static::MODEL_TYPE,
];
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
public static function filterableFields(): array
{
return ['id', 'created_at', 'updated_at'];
}
public static function getModelTypeAttribute(): string
{
return static::MODEL_TYPE;
}
public function loadRestrictionModel(string $urlParam = null)
{
$restriction = null;
$modelName = $this->config['restriction'];
$modelId = $this->config['restrictionModelId'] ?? null;
$model = app(modelTypeToNamespace($modelName))->select([
'id',
'name',
'display_name',
]);
if ($modelId === 'urlParam' && $urlParam) {
$restriction = $model->where('name', $urlParam)->first();
} elseif (isset($modelId) && $modelId !== 'urlParam') {
$restriction = $model->find($modelId);
}
if ($restriction && !$restriction->display_name) {
$restriction->display_name = ucwords($restriction->name);
}
return $restriction;
}
public function loadContent(array $params = [], self $parent = null): static
{
$channelContent = (new LoadChannelContent())->execute(
$this,
$params,
$parent,
);
if (Arr::get($params, 'normalizeContent') && $channelContent) {
$channelContent->transform(function ($item) {
$normalized = $item->toNormalizedArray();
// needed in order to preserve "created_at" date when updating items
if (isset($item->pivot)) {
$normalized['created_at'] = $item->pivot->created_at;
}
return $normalized;
});
}
$this->setRelation('content', $channelContent);
return $this;
}
public function updateContentFromExternal(
string $autoUpdateMethod = null,
): void {
$method =
$autoUpdateMethod ?? Arr::get($this->config, 'autoUpdateMethod');
if (
!$method ||
Arr::get($this->config, 'contentType') !== 'autoUpdate'
) {
return;
}
$content = $this->loadContentFromExternal($method);
// bail if we could not fetch any content
if (!$content || $content->isEmpty()) {
return;
}
// detach all channel items from the channel
DB::table('channelables')
->where(['channel_id' => $this->id])
->delete();
// group content by model type (track, album, playlist etc)
// and attach each group via its own separate relation
$groupedContent = $content->groupBy('model_type');
$groupedContent->each(function (Collection $contentGroup, $modelType) {
$pivots = $contentGroup->mapWithKeys(
fn($item, $index) => [$item['id'] => ['order' => $index]],
);
// track => tracks
$relation = Str::plural($modelType);
$this->$relation()->syncWithoutDetaching($pivots->toArray());
});
// clear channel cache, it's based on updated_at timestamp
$this->touch();
}
public function shouldRestrictContent()
{
// when channel is set to auto update, content will be filtered when auto updating
return Arr::get($this->config, 'contentType') !== 'autoUpdate' &&
Arr::get($this->config, 'restriction');
}
abstract protected function loadContentFromExternal(
string $autoUpdateMethod,
): Collection|array|null;
public function resolveRouteBinding($value, $field = null)
{
$type = request('channelType');
if (ctype_digit($value)) {
$channel = $this->when(
$type,
fn($q) => $q->where('type', $type),
)->findOrFail($value);
} else {
$channel = $this->where('slug', $value)
->when($type, fn($q) => $q->where('type', $type))
->firstOrFail();
}
return $channel;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Common\Core\BaseController;
use Illuminate\Support\Facades\DB;
class ChannelContentOrderController extends BaseController
{
public function changeOrder(int $channelId)
{
$channel = Channel::findOrFail($channelId);
$this->authorize('update', $channel);
$data = request()->validate([
'ids' => 'array|min:1',
'ids.*' => 'int',
'modelType' => 'required|string',
]);
$queryPart = '';
foreach ($data['ids'] as $order => $id) {
$queryPart .= " when channelable_id=$id then $order";
}
DB::table('channelables')
->where('channel_id', $channel->id)
->whereIn('channelable_id', $data['ids'])
->where('channelable_type', $data['modelType'])
->update(['order' => DB::raw("(case $queryPart end)")]);
return $this->success();
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace Common\Channels;
use App\Http\Resources\ChannelResource;
use App\Models\Channel;
use App\Services\ChannelPresets;
use Common\Core\BaseController;
use Common\Core\Prerender\Actions\ReplacePlaceholders;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ChannelController extends BaseController
{
public function index(): Response
{
$this->authorize('index', [Channel::class, 'channel']);
$pagination = (new PaginateChannels())->execute(request()->all());
return $this->success([
'pagination' => $pagination,
'presets' => (new ChannelPresets())->getAll(),
]);
}
public function show(Channel $channel)
{
$this->authorize('show', $channel);
$loader = request('loader', 'channelPage');
$params = request()->all();
if ($loader === 'editUserListPage') {
$params['normalizeContent'] = true;
} elseif ($loader === 'editChannelPage') {
$params['normalizeContent'] = true;
$params['perPage'] = 100;
}
$channel->loadContent($params);
if (
$loader === 'channelPage' &&
$channel->shouldRestrictContent() &&
!$channel->restriction
) {
abort(404);
}
$channel =
$loader === 'channelPage' && class_exists(ChannelResource::class)
? new ChannelResource($channel)
: $channel;
// return only content for pagination
if (request()->get('returnContentOnly')) {
return [
'pagination' => $channel->toArray(request())['content'],
];
}
$data = [
'channel' => $channel,
'loader' => $loader,
];
if ($loader === 'channelPage') {
// used as default value during SSR in layout selector button
$channel->config = array_merge($channel->config, [
'selectedLayout' => Arr::get(
$_COOKIE,
"channel-layout-{$channel->config['contentModel']}",
false,
),
'seoTitle' => isset($channel->config['seoTitle'])
? app(ReplacePlaceholders::class)->execute(
$channel->config['seoTitle'],
$data,
)
: $channel->name,
'seoDescription' => isset($channel->config['seoDescription'])
? app(ReplacePlaceholders::class)->execute(
$channel->config['seoDescription'],
$data,
)
: $channel->description ?? $channel->name,
]);
}
return $this->renderClientOrApi([
'pageName' => $loader === 'channelPage' ? 'channel-page' : null,
'data' => [
'channel' => $channel,
'loader' => $loader,
],
]);
}
public function store(CrupdateChannelRequest $request): Response
{
$this->authorize('store', [Channel::class, request('type', 'channel')]);
$channel = app(CrupdateChannel::class)->execute(
$request->validationData(),
);
return $this->success(['channel' => $channel]);
}
public function update(
Channel $channel,
CrupdateChannelRequest $request,
): Response {
$this->authorize('update', $channel);
$channel = app(CrupdateChannel::class)->execute(
$request->validationData(),
$channel,
);
return $this->success(['channel' => $channel]);
}
public function destroy(string $ids): Response
{
$ids = explode(',', $ids);
$channels = Channel::whereIn('id', $ids)->get();
$this->authorize('destroy', [Channel::class, $channels]);
app(DeleteChannels::class)->execute($channels);
return $this->success();
}
public function updateContent(Channel $channel): Response
{
$this->authorize('update', $channel);
if ($newConfig = request('channelConfig')) {
$config = $channel->config;
foreach ($newConfig as $key => $value) {
$config[$key] = $value;
}
$channel->fill(['config' => $config])->save();
}
$channel->updateContentFromExternal();
$channel->loadContent(request()->all());
return $this->success([
'channel' => $channel,
]);
}
public function searchForAddableContent(): Response
{
$namespace = modelTypeToNamespace(request('modelType'));
$this->authorize('index', $namespace);
$builder = app($namespace);
if (request('query')) {
$builder = $builder->mysqlSearch(request('query'));
}
$results = $builder
->take(20)
->get()
->filter(function ($result) {
if (request('modelType') === 'channel') {
// exclude user lists
return $result->type === 'channel';
}
return true;
})
->map(fn($result) => $result->toNormalizedArray())
->slice(0, request('limit', 10))
->values();
return $this->success(['results' => $results]);
}
public function applyPreset()
{
$this->authorize('destroy', Channel::class);
$data = request()->validate([
'preset' => 'required|string',
]);
$ids = Channel::where('type', 'channel')->pluck('id');
DB::table('channelables')
->whereIn('channel_id', $ids)
->delete();
Channel::whereIn('id', $ids)->delete();
(new ChannelPresets())->apply($data['preset']);
if (settings('homepage.type') === 'channels') {
$homepage = Channel::where('name', 'homepage')->first();
if ($homepage) {
settings()->save(['homepage.value' => $homepage->id]);
} else {
settings()->save(['homepage.value' => null]);
}
}
Cache::flush();
return $this->success();
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class CrupdateChannel
{
public function execute($params, Channel $initialChannel = null): Channel
{
// can either specify channel model or namespace from which to instantiate
if (!$initialChannel) {
$channel = app(Channel::class)->newInstance([
'user_id' => Auth::id(),
]);
} else {
$channel = $initialChannel;
}
$attributes = [
'name' => $params['name'],
'public' => $params['public'] ?? true,
'internal' => $params['internal'] ?? false,
'type' => $params['type'] ?? $channel->type ?? 'channel',
'description' => $params['description'] ?? null,
// merge old config so config that is not in crupdate channel form is not lost
'config' => array_merge(
$initialChannel['config'] ?? [],
$params['config'],
),
];
if ($attributes['type'] !== 'list') {
$attributes['slug'] = $params['slug'] ?? slugify($params['name']);
}
$channel
->fill(
array_merge($attributes, [
// make sure updated_at is always changed, event if model is
// not dirty otherwise channel cache will not be cleared
'updated_at' => now(),
]),
)
->save();
if (
$channel->config['contentType'] === 'manual' &&
($channelContent = Arr::get($params, 'content.data'))
) {
// detach old channelables
DB::table('channelables')
->where('channel_id', $channel->id)
->delete();
$pivots = collect($channelContent)
->map(function ($item, $i) use ($channel) {
return [
'channel_id' => $channel->id,
'channelable_id' => $item['id'],
'channelable_type' => $item['model_type'],
'created_at' => $item['created_at'] ?? now(),
'order' => $i,
];
})
->filter(function ($item) use ($channel) {
// channels should not be attached to themselves
return $item['channelable_type'] !== Channel::MODEL_TYPE ||
$item['channelable_id'] !== $channel->id;
});
DB::table('channelables')->insert($pivots->toArray());
}
return $channel;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Common\Channels;
use Common\Core\BaseFormRequest;
use Illuminate\Validation\Rule;
class CrupdateChannelRequest extends BaseFormRequest
{
public function rules(): array
{
$required = $this->getMethod() === 'POST' ? 'required' : '';
$ignore = $this->getMethod() === 'PUT' ? $this->route('channel')->id : '';
return [
'name' => [
$required, 'string', 'min:3', 'max:100',
Rule::unique('channels')->ignore($ignore)
],
'description' => 'nullable|string|max:500',
'public' => 'boolean',
'content.data' => 'array',
'config' => 'array',
'type' => 'string',
'config.autoUpdateMethod' => 'required_if:config.contentType,autoUpdate'
];
}
public function messages()
{
return [
'config.autoUpdateMethod' => __('Auto update method is required.'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DeleteChannels
{
public function execute(Collection $channels): int
{
if (
$channels->some(
fn(Channel $channel) => $channel->internal ||
Arr::get($channel->config, 'preventDeletion'),
)
) {
abort(422, __("Internal channels can't be deleted"));
}
if (
settings('homepage.type') === 'channels' &&
$channels->contains('id', (int) settings('homepage.value'))
) {
abort(422, __('You can not delete the homepage channel'));
}
$channelIds = $channels->pluck('id')->toArray();
// touch all channels that have channels we're deleting
// nested so cache for them is cleared properly
$parentChannelIds = DB::table('channelables')
->where('channelable_type', Channel::MODEL_TYPE)
->whereIn('channelable_id', $channelIds)
->pluck('channel_id');
Channel::whereIn('id', $parentChannelIds)->update([
'updated_at' => Carbon::now(),
]);
DB::table('channelables')
->whereIn('channel_id', $channelIds)
->delete();
Channel::whereIn('id', $channelIds)->delete();
return count($channelIds);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Illuminate\Support\Arr;
class GenerateChannelsFromConfig
{
public function execute(array $configPaths): Channel|null
{
foreach ($configPaths as $configPath) {
$configs = json_decode(file_get_contents($configPath), true);
$createdChannels = [];
foreach ($configs as $config) {
$nestedChannelSlugs = Arr::pull($config, 'nestedChannels');
$presetDescription = Arr::pull($config, 'presetDescription');
$config['config']['adminDescription'] = $presetDescription;
$channel = Channel::create(
array_merge($config, [
'type' => 'channel',
'public' => true,
'internal' => $config['internal'] ?? false,
]),
);
$createdChannels[] = [
'parent' => $channel,
'nestedChannelSlugs' => $nestedChannelSlugs,
];
}
foreach ($createdChannels as $createdChannel) {
if (isset($createdChannel['nestedChannelSlugs'])) {
foreach ($createdChannel['nestedChannelSlugs'] as $slug) {
$nestedChannel = Channel::where('slug', $slug)->first();
$createdChannel['parent']
->channels()
->attach($nestedChannel->id);
}
}
}
$homeChannel = Arr::first(
$createdChannels,
fn($c) => $c['parent']->slug === 'homepage',
);
if (isset($homeChannel)) {
return $homeChannel['parent'];
}
}
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use BadMethodCallException;
use Common\Core\Prerender\Actions\ReplacePlaceholders;
use Common\Database\Datasource\Datasource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class LoadChannelContent
{
public function execute(
Channel $channel,
array $params = [],
Channel $parent = null,
): ?AbstractPaginator {
$params['perPage'] = $params['perPage'] ?? 50;
$params['paginate'] = $this->resolvePaginateType($channel, $params);
if (!isset($params['orderBy']) && !isset($params['order'])) {
$params['order'] = Arr::get($channel->config, 'contentOrder');
}
if (
$channel->shouldRestrictContent() &&
Arr::get($params, 'loader') !== 'editChannelPage'
) {
$this->applyRestriction($channel, $params, $parent);
// If restriction could not be loaded bail. This is used to cancel content loading and return 404,
// if, for example, loading genre channel, but specified genre does not exist.
if (!$channel->restriction) {
return new Paginator([], 15);
}
}
$contentType = Arr::get($channel->config, 'contentType');
if ($contentType === 'listAll') {
return $this->paginateAllContentFromDatabase(
$channel,
$params,
$parent,
);
} else {
// only cache channels that have other nested channels as content
$shouldCache =
Arr::get($channel->config, 'contentModel') ===
Channel::MODEL_TYPE;
if (!$shouldCache) {
return $this->loadCuratedContent($channel, $params);
} else {
$paramsKey = json_encode($params);
return Cache::remember(
// use "updated at" so channel changes from admin area will automatically
// cause new cache item, without having to clear cache manually
"channels.$channel->id.$channel->updated_at.$paramsKey",
now()->addHours(24),
fn() => $this->loadCuratedContent($channel, $params),
);
}
}
}
private function paginateAllContentFromDatabase(
Channel $channel,
array $params,
Channel $parent = null,
): AbstractPaginator {
$contentModel = Arr::get($channel->config, 'contentModel');
$methodName = sprintf('all%s', ucfirst(Str::plural($contentModel)));
// if channel specifies a method to load this model, use that
if (method_exists($channel, $methodName)) {
return $channel->{$methodName}($params, null, $parent);
// otherwise do a basic pagination for the model
} else {
$namespace = modelTypeToNamespace($contentModel);
$datasource = new Datasource(app($namespace)::query(), $params);
return $datasource->paginate();
}
}
private function loadCuratedContent(
Channel $channel,
array $params,
): AbstractPaginator {
$contentModel = Arr::get($channel, 'config.contentModel');
$methodName = sprintf('all%s', ucfirst(Str::plural($contentModel)));
$builder = $channel->{Str::plural($contentModel)}();
// if channel specifies a method to load this model, use that
if (method_exists($channel, $methodName)) {
$pagination = $channel->{$methodName}($params, $builder);
} else {
$datasource = new Datasource($builder, $params);
$order = $datasource->getOrder();
// get only column name, in case it's prefixed with table name
if (last(explode('.', $order['col'])) === 'popularity') {
$datasource->order = false;
try {
$builder->orderByPopularity($order['dir']);
} catch (BadMethodCallException $e) {
//
}
}
$pagination = $datasource->paginate();
}
$pagination
->filter(fn($model) => $model->pivot)
->transform(function (Model $model) use ($channel, $params) {
$model['channelable_id'] = $model->pivot->id;
$model['channelable_order'] = $model->pivot->order;
if ($model instanceof Channel) {
$model->loadContent(
array_merge($params, [
// clear parent channel pagination params and only load 12 items per nested channel
'perPage' => 12,
'page' => 1,
'paginate' => 'simple',
// clear this so nested channel always uses sorting order set in that channel's config
'order' => null,
]),
$channel,
);
}
return $model;
});
return $pagination;
}
private function applyRestriction(
Channel $channel,
array $params = [],
Channel $parent = null,
): void {
$urlParam = Arr::get($params, 'restriction');
$restriction =
$parent->restriction ?? $channel->loadRestrictionModel($urlParam);
if ($restriction) {
$channel->setAttribute('restriction', $restriction);
$channel->name =
app(ReplacePlaceholders::class)->execute($channel->name, [
'channel' => $channel,
]) ?:
$channel->name;
}
}
protected function resolvePaginateType(
Channel $channel,
array $params,
): string {
if (isset($params['paginate'])) {
return $params['paginate'];
}
if (isset($channel->config['paginationType'])) {
return match ($channel->config['paginationType']) {
'lengthAware' => 'lengthAware',
// simple and infinite scroll
default => 'simple',
};
}
return 'simple';
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Illuminate\Support\Collection;
class LoadChannelMenuItems
{
public function execute(): Collection
{
return Channel::limit(40)
->where('type', 'channel')
->get()
->map(
fn(Channel $channel) => [
'label' => $channel->name,
'action' => '/' . $channel->slug,
'type' => 'route',
'model_id' => $channel->id,
'id' => $channel->id,
],
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Common\Database\Datasource\Datasource;
use Illuminate\Pagination\AbstractPaginator;
class PaginateChannels
{
public function execute(array $params): AbstractPaginator
{
// $builder = Channel::where('type', 'channel')->whereNotExists(function (
// $query,
// ) {
// $query
// ->select('id')
// ->from('channelables')
// ->whereColumn('channelable_id', 'channels.id');
// });
$builder = Channel::where('type', 'channel');
$paginator = new Datasource($builder, $params);
if (!isset($params['orderBy'])) {
$builder->orderByRaw('`internal` = "1" desc, `updated_at` desc');
$paginator->order = false;
}
$pagination = $paginator->paginate();
$pagination->transform(function (Channel $channel) {
$channel->makeVisible(['internal']);
return $channel;
});
return $pagination;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Common\Channels;
use App\Models\Channel;
use Illuminate\Console\Command;
class UpdateAllChannelsContent extends Command
{
protected $signature = 'channels:update';
public function handle(): void
{
$this->info('Updating channels content...');
$channels = app(Channel::class)
->where('type', 'channel')
->limit(20)
->get();
$this->withProgressBar($channels, function (Channel $channel) {
$channel->updateContentFromExternal();
});
}
}