feat: add and use "last played" timestamp for songs (#1578)

This commit is contained in:
Phan An 2022-11-08 18:38:28 +01:00 committed by GitHub
parent 27bfe31391
commit 3b15622693
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 190 additions and 107 deletions

View file

@ -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

View file

@ -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
{

View file

@ -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);

View file

@ -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();
}

View file

@ -27,6 +27,8 @@ class InteractionService
$interaction->liked = false;
}
$interaction->last_played_at = now();
++$interaction->play_count;
$interaction->save();
});

View file

@ -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);
});
});
});

View file

@ -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];
}
}

View 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);
}
}

View file

@ -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')"
);
}
};

View file

@ -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);
}

View file

@ -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'
}, {

View file

@ -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'