From 3b1562269315d58b664e04adda5b6d33dd6f8528 Mon Sep 17 00:00:00 2001 From: Phan An Date: Tue, 8 Nov 2022 18:38:28 +0100 Subject: [PATCH] feat: add and use "last played" timestamp for songs (#1578) --- api-docs/api.yaml | 110 +++++++++--------- app/Models/Interaction.php | 4 +- app/Repositories/InteractionRepository.php | 2 +- app/Repositories/SongRepository.php | 3 +- app/Services/InteractionService.php | 2 + app/Services/SmartPlaylistService.php | 13 ++- app/Values/SmartPlaylistRule.php | 45 ++----- app/Values/SmartPlaylistSqlElements.php | 89 ++++++++++++++ ...last_played_at_into_interactions_table.php | 22 ++++ .../assets/js/components/ui/ScreenHeader.vue | 3 +- .../assets/js/config/smart-playlist/models.ts | 2 +- resources/assets/js/types.d.ts | 2 +- 12 files changed, 190 insertions(+), 107 deletions(-) create mode 100644 app/Values/SmartPlaylistSqlElements.php create mode 100644 database/migrations/2022_11_08_144634_add_last_played_at_into_interactions_table.php diff --git a/api-docs/api.yaml b/api-docs/api.yaml index 9832ade6..2f7233a1 100644 --- a/api-docs/api.yaml +++ b/api-docs/api.yaml @@ -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=`.' 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 diff --git a/app/Models/Interaction.php b/app/Models/Interaction.php index 95a4d465..b3039074 100644 --- a/app/Models/Interaction.php +++ b/app/Models/Interaction.php @@ -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 { diff --git a/app/Repositories/InteractionRepository.php b/app/Repositories/InteractionRepository.php index c8ee58b2..3d2e9bae 100644 --- a/app/Repositories/InteractionRepository.php +++ b/app/Repositories/InteractionRepository.php @@ -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); diff --git a/app/Repositories/SongRepository.php b/app/Repositories/SongRepository.php index 85683918..6b614275 100644 --- a/app/Repositories/SongRepository.php +++ b/app/Repositories/SongRepository.php @@ -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(); } diff --git a/app/Services/InteractionService.php b/app/Services/InteractionService.php index 2d528ecb..6dd4f0ba 100644 --- a/app/Services/InteractionService.php +++ b/app/Services/InteractionService.php @@ -27,6 +27,8 @@ class InteractionService $interaction->liked = false; } + $interaction->last_played_at = now(); + ++$interaction->play_count; $interaction->save(); }); diff --git a/app/Services/SmartPlaylistService.php b/app/Services/SmartPlaylistService.php index 3259b54a..83bb538f 100644 --- a/app/Services/SmartPlaylistService.php +++ b/app/Services/SmartPlaylistService.php @@ -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); }); }); }); diff --git a/app/Values/SmartPlaylistRule.php b/app/Values/SmartPlaylistRule.php index 3c3df7f8..a54a7742 100644 --- a/app/Values/SmartPlaylistRule.php +++ b/app/Values/SmartPlaylistRule.php @@ -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 */ - 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]; - } } diff --git a/app/Values/SmartPlaylistSqlElements.php b/app/Values/SmartPlaylistSqlElements.php new file mode 100644 index 00000000..f5bac3dc --- /dev/null +++ b/app/Values/SmartPlaylistSqlElements.php @@ -0,0 +1,89 @@ + '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); + } +} diff --git a/database/migrations/2022_11_08_144634_add_last_played_at_into_interactions_table.php b/database/migrations/2022_11_08_144634_add_last_played_at_into_interactions_table.php new file mode 100644 index 00000000..54a6032c --- /dev/null +++ b/database/migrations/2022_11_08_144634_add_last_played_at_into_interactions_table.php @@ -0,0 +1,22 @@ +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')" + ); + } +}; diff --git a/resources/assets/js/components/ui/ScreenHeader.vue b/resources/assets/js/components/ui/ScreenHeader.vue index d23e6d47..52448b4c 100644 --- a/resources/assets/js/components/ui/ScreenHeader.vue +++ b/resources/assets/js/components/ui/ScreenHeader.vue @@ -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); } diff --git a/resources/assets/js/config/smart-playlist/models.ts b/resources/assets/js/config/smart-playlist/models.ts index b24ec0b9..b16c8c00 100644 --- a/resources/assets/js/config/smart-playlist/models.ts +++ b/resources/assets/js/config/smart-playlist/models.ts @@ -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' }, { diff --git a/resources/assets/js/types.d.ts b/resources/assets/js/types.d.ts index 48bf67f1..9e2c7fc7 100644 --- a/resources/assets/js/types.d.ts +++ b/resources/assets/js/types.d.ts @@ -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'