Skip to content

Commit

Permalink
Add "Smart Playlist" backend logics (#849)
Browse files Browse the repository at this point in the history
This commit prepares the backend for the "Smart Playlist" feature.
  • Loading branch information
phanan committed Nov 3, 2018
1 parent 106b382 commit d088561
Show file tree
Hide file tree
Showing 9 changed files with 682 additions and 3 deletions.
41 changes: 41 additions & 0 deletions app/Factories/SmartPlaylistRuleParameterFactory.php
@@ -0,0 +1,41 @@
<?php

namespace App\Factories;

use App\Models\Rule;
use Carbon\Carbon;
use InvalidArgumentException;

class SmartPlaylistRuleParameterFactory
{
public function createParameters(string $model, string $operator, array $value): array
{
switch ($operator) {
case Rule::OPERATOR_BEGINS_WITH:
return [$model, 'LIKE', "{$value[0]}%"];
case Rule::OPERATOR_ENDS_WITH:
return [$model, 'LIKE', "%{$value[0]}"];
case Rule::OPERATOR_IS:
return [$model, '=', $value[0]];
case Rule::OPERATOR_NOT_IN_LAST:
return [$model, '<', (new Carbon())->subDay($value[0])];
case Rule::OPERATOR_NOT_CONTAIN:
return [$model, 'NOT LIKE', "%{$value[0]}%"];
case Rule::OPERATOR_IS_NOT:
return [$model, '<>', $value[0]];
case Rule::OPERATOR_IS_LESS_THAN:
return [$model, '<', $value[0]];
case Rule::OPERATOR_IS_GREATER_THAN:
return [$model, '>', $value[0]];
case Rule::OPERATOR_IS_BETWEEN:
return [$model, $value];
case Rule::OPERATOR_IN_LAST:
return [$model, '>=', (new Carbon())->subDay($value[0])];
case Rule::OPERATOR_CONTAINS:
return [$model, 'LIKE', "%{$value[0]}%"];
default:
// should never reach here actually
throw new InvalidArgumentException('Invalid operator.');
}
}
}
13 changes: 11 additions & 2 deletions app/Http/Controllers/API/PlaylistController.php
Expand Up @@ -6,6 +6,7 @@
use App\Http\Requests\API\PlaylistSyncRequest;
use App\Models\Playlist;
use App\Repositories\PlaylistRepository;
use App\Services\SmartPlaylistService;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
Expand All @@ -14,10 +15,12 @@
class PlaylistController extends Controller
{
private $playlistRepository;
private $smartPlaylistService;

public function __construct(PlaylistRepository $playlistRepository)
public function __construct(PlaylistRepository $playlistRepository, SmartPlaylistService $smartPlaylistService)
{
$this->playlistRepository = $playlistRepository;
$this->smartPlaylistService = $smartPlaylistService;
}

/**
Expand Down Expand Up @@ -73,6 +76,8 @@ public function sync(PlaylistSyncRequest $request, Playlist $playlist)
{
$this->authorize('owner', $playlist);

abort_if($playlist->is_smart, 403, 'A smart playlist\'s content cannot be updated manually.');

$playlist->songs()->sync((array) $request->songs);

return response()->json();
Expand All @@ -89,7 +94,11 @@ public function getSongs(Playlist $playlist)
{
$this->authorize('owner', $playlist);

return response()->json($playlist->songs->pluck('id'));
return response()->json(
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist)->pluck('id')
: $playlist->songs->pluck('id')
);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions app/Models/Playlist.php
Expand Up @@ -12,6 +12,10 @@
* @property int $user_id
* @property Collection $songs
* @property int $id
* @property array $rules
* @property bool $is_smart
* @property string $name
* @property user $user
*/
class Playlist extends Model
{
Expand All @@ -21,6 +25,7 @@ class Playlist extends Model
protected $guarded = ['id'];
protected $casts = [
'user_id' => 'int',
'rules' => 'array',
];

public function songs(): BelongsToMany
Expand All @@ -32,4 +37,9 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

public function getIsSmartAttribute(): bool
{
return (bool) $this->rules;
}
}
125 changes: 125 additions & 0 deletions app/Models/Rule.php
@@ -0,0 +1,125 @@
<?php

namespace App\Models;

use App\Factories\SmartPlaylistRuleParameterFactory;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;

class Rule
{
private const LOGIC_OR = 'or';
private const LOGIC_AND = 'and';

private const VALID_LOGICS = [
self::LOGIC_AND,
self::LOGIC_OR,
];

public const OPERATOR_IS = 'is';
public const OPERATOR_IS_NOT = 'isNot';
public const OPERATOR_CONTAINS = 'contains';
public const OPERATOR_NOT_CONTAIN = 'notContain';
public const OPERATOR_IS_BETWEEN = 'isBetween';
public const OPERATOR_IS_GREATER_THAN = 'isGreaterThan';
public const OPERATOR_IS_LESS_THAN = 'isLessThan';
public const OPERATOR_BEGINS_WITH = 'beginsWith';
public const OPERATOR_ENDS_WITH = 'endsWith';
public const OPERATOR_IN_LAST = 'inLast';
public const OPERATOR_NOT_IN_LAST = 'notInLast';

public const VALID_OPERATORS = [
self::OPERATOR_BEGINS_WITH,
self::OPERATOR_CONTAINS,
self::OPERATOR_ENDS_WITH,
self::OPERATOR_IN_LAST,
self::OPERATOR_IS,
self::OPERATOR_IS_BETWEEN,
self::OPERATOR_IS_GREATER_THAN,
self::OPERATOR_IS_LESS_THAN,
self::OPERATOR_IS_NOT,
self::OPERATOR_NOT_CONTAIN,
self::OPERATOR_NOT_IN_LAST,
];

private $operator;
private $logic;
private $value;
private $model;
private $parameterFactory;

private function __construct(array $config)
{
$this->validateLogic($config['logic']);
$this->validateOperator($config['operator']);

$this->logic = $config['logic'];
$this->value = $config['value'];
$this->model = $config['model'];
$this->operator = $config['operator'];

$this->parameterFactory = new SmartPlaylistRuleParameterFactory();
}

public static function create(array $config): self
{
return new static($config);
}

public function build(Builder $query, ?string $model = null): Builder
{
if (!$model) {
$model = $this->model;
}

$fragments = explode('.', $model, 2);

if (count($fragments) === 1) {
return $query->{$this->resolveLogic()}(
...$this->parameterFactory->createParameters($model, $this->operator, $this->value)
);
}

// If the model is something like 'artist.name' or 'interactions.play_count', we have a subquery to deal with.
// We handle such a case with a recursive call which, in theory, should work with an unlimited level of nesting,
// though in practice we only have one level max.
$subQueryLogic = self::LOGIC_AND ? 'whereHas' : 'orWhereHas';

return $query->$subQueryLogic($fragments[0], function (Builder $subQuery) use ($fragments): Builder {
return $this->build($subQuery, $fragments[1]);
});
}

private function resolveLogic(): string
{
if ($this->operator === self::OPERATOR_IS_BETWEEN) {
return $this->logic === self::LOGIC_AND ? 'whereBetween' : 'orWhereBetween';
}

return $this->logic === self::LOGIC_AND ? 'where' : 'orWhere';
}

private function validateLogic(string $logic): void
{
if (!in_array($logic, self::VALID_LOGICS, true)) {
throw new InvalidArgumentException(
sprintf(
'%s is not a valid value for logic. Valid values are: %s', $logic, implode(', ', self::VALID_LOGICS)
)
);
}
}

private function validateOperator(string $operator): void
{
if (!in_array($operator, self::VALID_OPERATORS, true)) {
throw new InvalidArgumentException(
sprintf(
'%s is not a valid value for operators. Valid values are: %s',
$operator,
implode(', ', self::VALID_OPERATORS)
)
);
}
}
}
6 changes: 6 additions & 0 deletions app/Models/Song.php
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;

/**
Expand Down Expand Up @@ -72,6 +73,11 @@ public function playlists(): BelongsToMany
return $this->belongsToMany(Playlist::class);
}

public function interactions(): HasMany
{
return $this->hasMany(Interaction::class);
}

/**
* Update song info.
*
Expand Down
79 changes: 79 additions & 0 deletions app/Services/SmartPlaylistService.php
@@ -0,0 +1,79 @@
<?php

namespace App\Services;

use App\Models\Playlist;
use App\Models\Rule;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use RuntimeException;

class SmartPlaylistService
{
private const RULE_REQUIRES_USER_PREFIXES = ['interactions.'];

private $songRepository;

public function __construct(SongRepository $songRepository)
{
$this->songRepository = $songRepository;
}

public function getSongs(Playlist $playlist): Collection
{
if (!$playlist->is_smart) {
throw new RuntimeException($playlist->name.' is not a smart playlist.');
}

$rules = $this->addRequiresUserRules($playlist->rules, $playlist->user);

return $this->buildQueryForRules($rules)->get();
}

public function buildQueryForRules(array $rules): Builder
{
return tap(Song::query(), static function (Builder $query) use ($rules): Builder {
foreach ($rules as $config) {
$query = Rule::create($config)->build($query);
}

return $query;
});
}

/**
* Some rules need to be driven by an additional "user" factor, for example play count, liked, or last played
* (basically everything related to interactions).
* For those, we create an additional "user_id" rule.
*
* @param string[] $rules
*/
private function addRequiresUserRules(array $rules, User $user): array
{
$additionalRules = [];

foreach ($rules as $rule) {
foreach (self::RULE_REQUIRES_USER_PREFIXES as $modelPrefix) {
if (starts_with($rule['model'], $modelPrefix)) {
$additionalRules[] = $this->createRequireUserRule($user, $modelPrefix);
}
}
}

// make sure all those additional rules are unique.
return array_merge($rules, collect($additionalRules)->unique('model')->all());
}

private function createRequireUserRule(User $user, string $modelPrefix): array
{
return [
'logic' => 'and',
'model' => $modelPrefix.'user_id',
'operator' => 'is',
'value' => [$user->id],
];
}
}
6 changes: 5 additions & 1 deletion database/factories/ModelFactory.php
Expand Up @@ -11,7 +11,7 @@
];
});

$factory->defineAs(App\Models\User::class, 'admin', function ($faker) use ($factory) {
$factory->defineAs(App\Models\User::class, 'admin', function () use ($factory) {
$user = $factory->raw(App\Models\User::class);

return array_merge($user, ['is_admin' => true]);
Expand Down Expand Up @@ -49,7 +49,11 @@

$factory->define(App\Models\Playlist::class, function ($faker) {
return [
'user_id' => static function (): int {
throw new InvalidArgumentException('A user_id must be supplied');
},
'name' => $faker->name,
'rules' => null,
];
});

Expand Down
32 changes: 32 additions & 0 deletions database/migrations/2018_11_03_182520_add_rules_into_playlists.php
@@ -0,0 +1,32 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddRulesIntoPlaylists extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('playlists', static function (Blueprint $table): void {
$table->text('rules')->after('name')->nullable();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::table('playlists', static function (Blueprint $table): void {
$table->dropColumn('rules');
});
}
}

0 comments on commit d088561

Please sign in to comment.