mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: add and use "last played" timestamp for songs (#1578)
This commit is contained in:
parent
27bfe31391
commit
3b15622693
12 changed files with 190 additions and 107 deletions
|
@ -31,7 +31,7 @@ paths:
|
|||
$ref: '#/components/schemas/User'
|
||||
operationId: post-user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
description: Create a new user
|
||||
requestBody:
|
||||
content:
|
||||
|
@ -54,7 +54,7 @@ paths:
|
|||
- email
|
||||
- password
|
||||
- is_admin
|
||||
parameters: []
|
||||
parameters: [ ]
|
||||
'/api/user/{userId}':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -77,7 +77,7 @@ paths:
|
|||
operationId: patch-user-userId
|
||||
description: Update a user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -109,7 +109,7 @@ paths:
|
|||
operationId: delete-user-userId
|
||||
description: Delete a user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
/api/me:
|
||||
get:
|
||||
summary: Get profile
|
||||
|
@ -124,7 +124,7 @@ paths:
|
|||
$ref: '#/components/schemas/User'
|
||||
operationId: get-me
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
description: Get the current user's profile
|
||||
post:
|
||||
summary: Log in
|
||||
|
@ -177,7 +177,7 @@ paths:
|
|||
operationId: put-me
|
||||
description: Update the current user's profile
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -206,8 +206,8 @@ paths:
|
|||
operationId: delete-me
|
||||
description: Log the current user out
|
||||
security:
|
||||
- Bearer Token: []
|
||||
parameters: []
|
||||
- Bearer Token: [ ]
|
||||
parameters: [ ]
|
||||
/api/data:
|
||||
get:
|
||||
summary: Get application data
|
||||
|
@ -302,8 +302,8 @@ paths:
|
|||
operationId: get-data
|
||||
description: 'Retrieve a set of application data catered for the current authenticated user (songs, albums, artists, playlists, interactions, and if the user is an admin, settings as well). This call should typically be made right after the user is logged in, to populate the application''s interface with relevant information.'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
parameters: []
|
||||
- Bearer Token: [ ]
|
||||
parameters: [ ]
|
||||
/api/interaction/play:
|
||||
post:
|
||||
summary: Increase play count
|
||||
|
@ -317,7 +317,7 @@ paths:
|
|||
operationId: post-interaction-play
|
||||
description: 'Increase a song''s play count as the currently authenticated user. This request should be made whenever a song is played. '
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -331,7 +331,7 @@ paths:
|
|||
- song
|
||||
tags:
|
||||
- interaction
|
||||
parameters: []
|
||||
parameters: [ ]
|
||||
/api/interaction/like:
|
||||
post:
|
||||
summary: Toggle like/unlike a song
|
||||
|
@ -345,7 +345,7 @@ paths:
|
|||
operationId: post-interaction-like
|
||||
description: Toggle like ("favorite") or unlike ("unfavorite") a song. The "liked" status of the song will be reversed by this operation.
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -376,7 +376,7 @@ paths:
|
|||
operationId: post-interaction-batch-like
|
||||
description: 'Like several songs at once, useful for "batch" (e.g., drag-and-drop) actions'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -390,7 +390,7 @@ paths:
|
|||
type: string
|
||||
required:
|
||||
- songs
|
||||
parameters: []
|
||||
parameters: [ ]
|
||||
/api/interaction/batch/unlike:
|
||||
post:
|
||||
summary: Unlike multiple songs
|
||||
|
@ -402,7 +402,7 @@ paths:
|
|||
operationId: post-interaction-batch-unlike
|
||||
description: 'Unlike several songs at once, useful for "batch" (e.g., drag-and-drop) actions'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -440,7 +440,7 @@ paths:
|
|||
operationId: get-interaction-recently-played-count
|
||||
description: Get a list of songs recently played by the current authenticated user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
/api/playlist:
|
||||
get:
|
||||
summary: Get current user's playlists
|
||||
|
@ -458,7 +458,7 @@ paths:
|
|||
operationId: get-playlist
|
||||
description: Get all playlists owned by the current authenticated user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
post:
|
||||
summary: Create another playlist
|
||||
tags:
|
||||
|
@ -473,7 +473,7 @@ paths:
|
|||
operationId: post-playlist
|
||||
description: Create a new playlist that's owned by the current authenticated user
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -516,7 +516,7 @@ paths:
|
|||
operationId: patch-playlist-playlistId
|
||||
description: 'Update a playlist (name and, if a smart playlist, its rules)'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -537,7 +537,7 @@ paths:
|
|||
description: OK
|
||||
description: Delete a playlist
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
get:
|
||||
summary: Get playlist's songs
|
||||
operationId: get-playlist-playlistId
|
||||
|
@ -553,7 +553,7 @@ paths:
|
|||
type: string
|
||||
description: Get a playlist's songs
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
'/api/playlist/{playlistId}/sync':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -614,7 +614,7 @@ paths:
|
|||
operationId: put-songs
|
||||
description: Update the information of a song or multiple songs
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -661,7 +661,7 @@ paths:
|
|||
operationId: get-album-albumId-info
|
||||
description: 'Get extra information (image, description etc.) about an album. Currently this information is only available if Last.fm integration is enabled. If it''s not the case, `null` will be returned.'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
'/api/artist/{artistId}/info':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -684,7 +684,7 @@ paths:
|
|||
operationId: get-artist-artistId-info
|
||||
description: 'Get extra information (image, biography etc.) about an artist. Currently this information is only available if Last.fm integration is enabled. If it''s not the case, `null` will be returned.'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
'/api/song/{songId}/info':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -719,7 +719,7 @@ paths:
|
|||
operationId: get-song-songId-info
|
||||
description: 'Get a song''s extra information. The response of this request is a superset of both corresponding `album/{albumId}/info` and `artist/{artistId}/info` responses, combined with the song''s lyrics and related YouTube videos, if applicable. '
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
'/api/album/{albumId}/cover':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -748,7 +748,7 @@ paths:
|
|||
operationId: put-album-albumId-cover
|
||||
description: Upload an image as an album's cover
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -788,7 +788,7 @@ paths:
|
|||
operationId: put-artist-artistId-image
|
||||
description: Upload an artist's image
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -830,7 +830,7 @@ paths:
|
|||
operationId: get-album-album-thumbnail
|
||||
description: Get an album's thumbnail (a 48px-wide blurry version of the album's cover). Returns the full URL to the thumbnail or NULL if the album has no cover.
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
/api/settings:
|
||||
put:
|
||||
summary: Save the application settings
|
||||
|
@ -842,7 +842,7 @@ paths:
|
|||
operationId: post-settings
|
||||
description: Save the application settings. Right now there's only one setting to be saved (`media_path`). The current authenticated user must be an admin.
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -868,7 +868,7 @@ paths:
|
|||
$ref: '#/components/schemas/Song'
|
||||
operationId: post-os-s3-song
|
||||
description: Create a new song or update an existing one with data sent from AWS
|
||||
security: []
|
||||
security: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -923,7 +923,7 @@ paths:
|
|||
'204':
|
||||
description: No Content
|
||||
description: Remove a song whose information matches the data sent from AWS S3 (`bucket` and `key`)
|
||||
security: []
|
||||
security: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -940,7 +940,7 @@ paths:
|
|||
/api/upload:
|
||||
post:
|
||||
summary: Upload a song
|
||||
tags: []
|
||||
tags: [ ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
|
@ -951,7 +951,7 @@ paths:
|
|||
operationId: post-upload
|
||||
description: Upload a song. The current authenticated user must be an admin.
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
|
@ -981,7 +981,7 @@ paths:
|
|||
operationId: post-api-songId-scrobble
|
||||
description: 'Create a [Last.fm scrobble entry](https://www.last.fm/api/scrobbling) for a song. Only functional if Last.fm integration has been configured and the current authenticated user has connected to Last.fm.'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -1001,7 +1001,7 @@ paths:
|
|||
operationId: post-api-lastfm-session-key
|
||||
description: 'Set the Last.fm session key for the current authenticated user. This call should be made after the user is [connected to Last.fm](https://www.last.fm/api/authentication).'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -1018,11 +1018,11 @@ paths:
|
|||
summary: Connect to Last.fm
|
||||
tags:
|
||||
- last.fm
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-lastfm-connect
|
||||
description: '[Connect](https://www.last.fm/api/authentication) the current user to Last.fm. This is actually NOT an API request. The application should instead redirect the current user to this route, which will send them to Last.fm for authentication. After authentication is successful, the user will be redirected back to `/lastfm/callback?token=<Last.fm token>`.'
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
'/play/{songId}/{transcode}/{bitrate}':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -1045,21 +1045,21 @@ paths:
|
|||
summary: Play a song
|
||||
tags:
|
||||
- playback
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-play-songId-transcode-bitrate
|
||||
description: 'The GET request to play/stream a song. This is NOT an XmlHttpRequest By default Koel will serve the file as-is, unless it''s a FLAC file. If the value of `transcode` is truthy, Koel will attempt to transcode the file into `bitRate` kbps using ffmpeg.'
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
/download/songs:
|
||||
get:
|
||||
summary: Download songs
|
||||
tags:
|
||||
- download
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-download-songs
|
||||
description: Download a song or several songs. This is NOT an XmlHttpRequest. The response will be a download response of either the media file or a zip file containing multiple media files.
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
parameters:
|
||||
- schema:
|
||||
type: array
|
||||
|
@ -1078,11 +1078,11 @@ paths:
|
|||
summary: Download album
|
||||
tags:
|
||||
- download
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-download-album-albumId
|
||||
description: Download a whole album. This is NOT an XmlHttpRequest. The response will be a download response of either one media file or a zip file containing multiple media files.
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
'/download/artist/{artistId}':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -1095,11 +1095,11 @@ paths:
|
|||
summary: Download artist
|
||||
tags:
|
||||
- download
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-download-artist-artistId
|
||||
description: Download a whole artist's biography. This is NOT an XmlHttpRequest. The response will be a download response of either one media file or a zip file containing multiple media files.
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
'/download/playlist/{playlistId}':
|
||||
parameters:
|
||||
- schema:
|
||||
|
@ -1112,11 +1112,11 @@ paths:
|
|||
summary: Download playlist
|
||||
tags:
|
||||
- download
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-download-playlist-playlistId
|
||||
description: Download a whole playlist. This is NOT an XmlHttpRequest. The response will be a download response of either one media file or a zip file containing multiple media files.
|
||||
security:
|
||||
- api-token: []
|
||||
- api-token: [ ]
|
||||
/api/search:
|
||||
get:
|
||||
summary: 'Search for songs, albums, and artists'
|
||||
|
@ -1157,7 +1157,7 @@ paths:
|
|||
operationId: get-api-search
|
||||
description: 'Search for songs, albums, and artists, with a maximum of {count} results each.'
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
|
@ -1172,17 +1172,17 @@ paths:
|
|||
in: query
|
||||
name: count
|
||||
description: 'The maximum number of results for songs, artists, and albums'
|
||||
parameters: []
|
||||
parameters: [ ]
|
||||
/api/search/songs:
|
||||
get:
|
||||
summary: Search for songs
|
||||
tags:
|
||||
- search
|
||||
responses: {}
|
||||
responses: { }
|
||||
operationId: get-api-search-songs
|
||||
description: Get all songs that matches a search query.
|
||||
security:
|
||||
- Bearer Token: []
|
||||
- Bearer Token: [ ]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
@ -1192,7 +1192,7 @@ paths:
|
|||
songs:
|
||||
type: array
|
||||
description: An array of matching songs' IDs
|
||||
items: {}
|
||||
items: { }
|
||||
required:
|
||||
- songs
|
||||
components:
|
||||
|
@ -1376,7 +1376,7 @@ components:
|
|||
- album.name
|
||||
- artist.name
|
||||
- interactions.play_count
|
||||
- interactions.updated_at
|
||||
- interactions.last_played_at
|
||||
- length
|
||||
- created_at
|
||||
- updated_at
|
||||
|
@ -1693,7 +1693,7 @@ components:
|
|||
title: SongWithAlbumAndArtist
|
||||
type: object
|
||||
description: A Song model with loaded album and artist information
|
||||
x-examples: {}
|
||||
x-examples: { }
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property bool $liked
|
||||
|
@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property User $user
|
||||
* @property int $id
|
||||
* @property string $song_id
|
||||
* @property Carbon|string $last_played_at
|
||||
*/
|
||||
class Interaction extends Model
|
||||
{
|
||||
|
@ -24,7 +26,7 @@ class Interaction extends Model
|
|||
];
|
||||
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['id', 'user_id', 'created_at', 'updated_at'];
|
||||
protected $hidden = ['id', 'user_id', 'created_at', 'updated_at', 'last_played_at'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
|
|
|
@ -31,7 +31,7 @@ class InteractionRepository extends Repository
|
|||
->newQuery()
|
||||
->where('user_id', $user->id)
|
||||
->where('play_count', '>', 0)
|
||||
->latest('updated_at');
|
||||
->latest('last_played_at');
|
||||
|
||||
if ($count) {
|
||||
$query = $query->take($count);
|
||||
|
|
|
@ -63,8 +63,7 @@ class SongRepository extends Repository
|
|||
{
|
||||
return Song::query()
|
||||
->withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('interactions.play_count', '>', 0)
|
||||
->orderByDesc('interactions.updated_at')
|
||||
->orderByDesc('interactions.last_played_at')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ class InteractionService
|
|||
$interaction->liked = false;
|
||||
}
|
||||
|
||||
$interaction->last_played_at = now();
|
||||
|
||||
++$interaction->play_count;
|
||||
$interaction->save();
|
||||
});
|
||||
|
|
|
@ -6,8 +6,9 @@ use App\Exceptions\NonSmartPlaylistException;
|
|||
use App\Models\Playlist;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Values\SmartPlaylistRule;
|
||||
use App\Values\SmartPlaylistRuleGroup;
|
||||
use App\Values\SmartPlaylistRule as Rule;
|
||||
use App\Values\SmartPlaylistRuleGroup as RuleGroup;
|
||||
use App\Values\SmartPlaylistSqlElements as SqlElements;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
|
@ -20,13 +21,13 @@ class SmartPlaylistService
|
|||
|
||||
$query = Song::query()->withMeta($user ?? $playlist->user);
|
||||
|
||||
$playlist->rule_groups->each(static function (SmartPlaylistRuleGroup $group, int $index) use ($query): void {
|
||||
$playlist->rule_groups->each(static function (RuleGroup $group, int $index) use ($query): void {
|
||||
$clause = $index === 0 ? 'where' : 'orWhere';
|
||||
|
||||
$query->$clause(static function (Builder $subQuery) use ($group): void {
|
||||
$group->rules->each(static function (SmartPlaylistRule $rule) use ($subQuery): void {
|
||||
$subWhere = $rule->operator === SmartPlaylistRule::OPERATOR_IS_BETWEEN ? 'whereBetween' : 'where';
|
||||
$subQuery->$subWhere(...$rule->toCriteriaParameters());
|
||||
$group->rules->each(static function (Rule $rule) use ($subQuery): void {
|
||||
$tokens = SqlElements::fromRule($rule);
|
||||
$subQuery->{$tokens->clause}(...$tokens->parameters);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,6 +18,7 @@ final class SmartPlaylistRule implements Arrayable
|
|||
public const OPERATOR_ENDS_WITH = 'endsWith';
|
||||
public const OPERATOR_IN_LAST = 'inLast';
|
||||
public const OPERATOR_NOT_IN_LAST = 'notInLast';
|
||||
public const OPERATOR_IS_NOT_BETWEEN = 'isNotBetween';
|
||||
|
||||
public const VALID_OPERATORS = [
|
||||
self::OPERATOR_BEGINS_WITH,
|
||||
|
@ -26,6 +27,7 @@ final class SmartPlaylistRule implements Arrayable
|
|||
self::OPERATOR_IN_LAST,
|
||||
self::OPERATOR_IS,
|
||||
self::OPERATOR_IS_BETWEEN,
|
||||
self::OPERATOR_IS_NOT_BETWEEN,
|
||||
self::OPERATOR_IS_GREATER_THAN,
|
||||
self::OPERATOR_IS_LESS_THAN,
|
||||
self::OPERATOR_IS_NOT,
|
||||
|
@ -34,14 +36,14 @@ final class SmartPlaylistRule implements Arrayable
|
|||
];
|
||||
|
||||
private const MODEL_TITLE = 'title';
|
||||
private const MODEL_ALBUM_NAME = 'album.name';
|
||||
private const MODEL_ARTIST_NAME = 'artist.name';
|
||||
public const MODEL_ALBUM_NAME = 'album.name';
|
||||
public const MODEL_ARTIST_NAME = 'artist.name';
|
||||
private const MODEL_PLAY_COUNT = 'interactions.play_count';
|
||||
private const MODEL_LAST_PLAYED = 'interactions.updated_at';
|
||||
public const MODEL_LAST_PLAYED = 'interactions.last_played_at';
|
||||
private const MODEL_USER_ID = 'interactions.user_id';
|
||||
private const MODEL_LENGTH = 'length';
|
||||
private const MODEL_DATE_ADDED = 'created_at';
|
||||
private const MODEL_DATE_MODIFIED = 'updated_at';
|
||||
public const MODEL_DATE_ADDED = 'created_at';
|
||||
public const MODEL_DATE_MODIFIED = 'updated_at';
|
||||
private const MODEL_GENRE = 'genre';
|
||||
private const MODEL_YEAR = 'year';
|
||||
|
||||
|
@ -58,13 +60,6 @@ final class SmartPlaylistRule implements Arrayable
|
|||
self::MODEL_YEAR,
|
||||
];
|
||||
|
||||
private const MODEL_COLUMN_MAP = [
|
||||
self::MODEL_ALBUM_NAME => 'albums.name',
|
||||
self::MODEL_ARTIST_NAME => 'artists.name',
|
||||
self::MODEL_DATE_ADDED => 'songs.created_at',
|
||||
self::MODEL_DATE_MODIFIED => 'songs.updated_at',
|
||||
];
|
||||
|
||||
public ?int $id;
|
||||
public string $operator;
|
||||
public array $value;
|
||||
|
@ -117,30 +112,4 @@ final class SmartPlaylistRule implements Arrayable
|
|||
&& !array_diff($this->value, $rule->value)
|
||||
&& $this->model === $rule->model;
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toCriteriaParameters(): array
|
||||
{
|
||||
$column = array_key_exists($this->model, self::MODEL_COLUMN_MAP)
|
||||
? self::MODEL_COLUMN_MAP[$this->model]
|
||||
: $this->model;
|
||||
|
||||
$resolvers = [
|
||||
self::OPERATOR_BEGINS_WITH => [$column, 'LIKE', "{$this->value[0]}%"],
|
||||
self::OPERATOR_ENDS_WITH => [$column, 'LIKE', "%{$this->value[0]}"],
|
||||
self::OPERATOR_IS => [$column, '=', $this->value[0]],
|
||||
self::OPERATOR_IS_NOT => [$column, '<>', $this->value[0]],
|
||||
self::OPERATOR_CONTAINS => [$column, 'LIKE', "%{$this->value[0]}%"],
|
||||
self::OPERATOR_NOT_CONTAIN => [$column, 'NOT LIKE', "%{$this->value[0]}%"],
|
||||
self::OPERATOR_IS_LESS_THAN => [$column, '<', $this->value[0]],
|
||||
self::OPERATOR_IS_GREATER_THAN => [$column, '>', $this->value[0]],
|
||||
self::OPERATOR_IS_BETWEEN => [$column, $this->value],
|
||||
self::OPERATOR_NOT_IN_LAST => fn (): array => [$column, '<', now()->subDays($this->value[0])],
|
||||
self::OPERATOR_IN_LAST => fn (): array => [$column, '>=', now()->subDays($this->value[0])],
|
||||
];
|
||||
|
||||
Assert::keyExists($resolvers, $this->operator);
|
||||
|
||||
return is_callable($resolvers[$this->operator]) ? $resolvers[$this->operator]() : $resolvers[$this->operator];
|
||||
}
|
||||
}
|
||||
|
|
89
app/Values/SmartPlaylistSqlElements.php
Normal file
89
app/Values/SmartPlaylistSqlElements.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use App\Values\SmartPlaylistRule as Rule;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
final class SmartPlaylistSqlElements
|
||||
{
|
||||
private const DATE_MODELS = [
|
||||
Rule::MODEL_LAST_PLAYED,
|
||||
Rule::MODEL_DATE_ADDED,
|
||||
Rule::MODEL_DATE_MODIFIED,
|
||||
];
|
||||
|
||||
private const MODEL_COLUMN_REMAP = [
|
||||
Rule::MODEL_ALBUM_NAME => 'albums.name',
|
||||
Rule::MODEL_ARTIST_NAME => 'artists.name',
|
||||
Rule::MODEL_DATE_ADDED => 'songs.created_at',
|
||||
Rule::MODEL_DATE_MODIFIED => 'songs.updated_at',
|
||||
];
|
||||
|
||||
private const CLAUSE_WHERE = 'where';
|
||||
private const CLAUSE_WHERE_BETWEEN = 'whereBetween';
|
||||
private const CLAUSE_WHERE_NOT_BETWEEN = 'whereNotBetween';
|
||||
|
||||
public string $clause;
|
||||
public array $parameters;
|
||||
|
||||
private function __construct(string $clause, ...$parameters)
|
||||
{
|
||||
$this->clause = $clause;
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
public static function fromRule(Rule $rule): self
|
||||
{
|
||||
$operator = $rule->operator;
|
||||
$value = $rule->value;
|
||||
|
||||
// If the rule is a date rule and the operator is "is" or "is not", we need to
|
||||
// convert the date to a range of dates and use the "between" or "not between" operator instead,
|
||||
// as we store dates as timestamps in the database.
|
||||
if (in_array($rule->model, self::DATE_MODELS, true)) {
|
||||
$nextDay = Carbon::createFromFormat('Y-m-d', $value[0])->addDay()->format('Y-m-d');
|
||||
|
||||
if ($operator === Rule::OPERATOR_IS) {
|
||||
$operator = Rule::OPERATOR_IS_BETWEEN;
|
||||
$value = [$value[0], $nextDay];
|
||||
} elseif ($operator === Rule::OPERATOR_IS_NOT) {
|
||||
$operator = Rule::OPERATOR_IS_NOT_BETWEEN;
|
||||
$value = [$value[0], $nextDay];
|
||||
}
|
||||
}
|
||||
|
||||
$column = array_key_exists($rule->model, self::MODEL_COLUMN_REMAP)
|
||||
? self::MODEL_COLUMN_REMAP[$rule->model]
|
||||
: $rule->model;
|
||||
|
||||
$resolvers = [
|
||||
Rule::OPERATOR_BEGINS_WITH => [$column, 'LIKE', "$value[0]%"],
|
||||
Rule::OPERATOR_ENDS_WITH => [$column, 'LIKE', "%$value[0]"],
|
||||
Rule::OPERATOR_IS => [$column, '=', $value[0]],
|
||||
Rule::OPERATOR_IS_NOT => [$column, '<>', $value[0]],
|
||||
Rule::OPERATOR_CONTAINS => [$column, 'LIKE', "%$value[0]%"],
|
||||
Rule::OPERATOR_NOT_CONTAIN => [$column, 'NOT LIKE', "%$value[0]%"],
|
||||
Rule::OPERATOR_IS_LESS_THAN => [$column, '<', $value[0]],
|
||||
Rule::OPERATOR_IS_GREATER_THAN => [$column, '>', $value[0]],
|
||||
Rule::OPERATOR_IS_BETWEEN => [$column, $value],
|
||||
Rule::OPERATOR_IS_NOT_BETWEEN => [$column, $value],
|
||||
Rule::OPERATOR_NOT_IN_LAST => static fn () => [$column, '<', now()->subDays($value[0])],
|
||||
Rule::OPERATOR_IN_LAST => static fn () => [$column, '>=', now()->subDays($value[0])],
|
||||
];
|
||||
|
||||
Assert::keyExists($resolvers, $operator);
|
||||
|
||||
$clause = match ($operator) {
|
||||
Rule::OPERATOR_IS_BETWEEN => self::CLAUSE_WHERE_BETWEEN,
|
||||
Rule::OPERATOR_IS_NOT_BETWEEN => self::CLAUSE_WHERE_NOT_BETWEEN,
|
||||
default => self::CLAUSE_WHERE,
|
||||
};
|
||||
|
||||
$parameters = $resolvers[$operator] instanceof Closure ? $resolvers[$operator]() : $resolvers[$operator];
|
||||
|
||||
return new self($clause, ...$parameters);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interactions', static function (Blueprint $table): void {
|
||||
$table->timestamp('last_played_at')->nullable();
|
||||
});
|
||||
|
||||
DB::statement('UPDATE interactions SET last_played_at = updated_at');
|
||||
|
||||
DB::statement(
|
||||
"UPDATE playlists SET rules = REPLACE(rules, 'interactions.updated_at', 'interactions.last_played_at')"
|
||||
);
|
||||
}
|
||||
};
|
|
@ -87,7 +87,7 @@ header.screen-header {
|
|||
}
|
||||
|
||||
h1.name {
|
||||
font-size: 4rem;
|
||||
font-size: clamp(1.8rem, 3vw, 4rem);
|
||||
font-weight: var(--font-weight-bold);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
@ -136,7 +136,6 @@ header.screen-header {
|
|||
}
|
||||
|
||||
h1.name {
|
||||
font-size: 1.8rem;
|
||||
font-weight: var(--font-weight-thin);
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ const models: SmartPlaylistModel[] = [
|
|||
type: 'number',
|
||||
label: 'Plays'
|
||||
}, {
|
||||
name: 'interactions.updated_at',
|
||||
name: 'interactions.last_played_at',
|
||||
type: 'date',
|
||||
label: 'Last Played'
|
||||
}, {
|
||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -162,7 +162,7 @@ interface SmartPlaylistRuleGroup {
|
|||
}
|
||||
|
||||
interface SmartPlaylistModel {
|
||||
name: 'title' | 'length' | 'created_at' | 'updated_at' | 'album.name' | 'artist.name' | 'interactions.play_count' | 'interactions.updated_at' | 'genre' | 'year'
|
||||
name: 'title' | 'length' | 'created_at' | 'updated_at' | 'album.name' | 'artist.name' | 'interactions.play_count' | 'interactions.last_played_at' | 'genre' | 'year'
|
||||
type: 'text' | 'number' | 'date'
|
||||
label: string
|
||||
unit?: 'seconds' | 'days'
|
||||
|
|
Loading…
Reference in a new issue