mirror of
https://github.com/owntone/owntone-server.git
synced 2024-12-28 16:15:57 -05:00
Merge pull request #1335 from whatdoineed2do/web-composer-search
[web-src] add composer support
This commit is contained in:
commit
6f0278ebbc
@ -685,7 +685,7 @@ fetch_playlist(bool *notfound, uint32_t playlist_id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int
|
static int
|
||||||
fetch_genres(struct query_params *query_params, json_object *items, int *total)
|
fetch_browse_info(struct query_params *query_params, json_object *items, int *total)
|
||||||
{
|
{
|
||||||
struct db_browse_info dbbi;
|
struct db_browse_info dbbi;
|
||||||
json_object *item;
|
json_object *item;
|
||||||
@ -3960,11 +3960,9 @@ jsonapi_reply_library_genres(struct httpd_request *hreq)
|
|||||||
if (media_kind)
|
if (media_kind)
|
||||||
query_params.filter = db_mprintf("(f.media_kind = %d)", media_kind);
|
query_params.filter = db_mprintf("(f.media_kind = %d)", media_kind);
|
||||||
|
|
||||||
ret = fetch_genres(&query_params, items, NULL);
|
ret = fetch_browse_info(&query_params, items, &total);
|
||||||
if (ret < 0)
|
if (ret < 0)
|
||||||
goto error;
|
goto error;
|
||||||
else
|
|
||||||
total = json_object_array_length(items);
|
|
||||||
|
|
||||||
json_object_object_add(reply, "total", json_object_new_int(total));
|
json_object_object_add(reply, "total", json_object_new_int(total));
|
||||||
json_object_object_add(reply, "offset", json_object_new_int(query_params.offset));
|
json_object_object_add(reply, "offset", json_object_new_int(query_params.offset));
|
||||||
@ -3984,6 +3982,72 @@ jsonapi_reply_library_genres(struct httpd_request *hreq)
|
|||||||
return HTTP_OK;
|
return HTTP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
jsonapi_reply_library_composers(struct httpd_request *hreq)
|
||||||
|
{
|
||||||
|
struct query_params query_params;
|
||||||
|
const char *param;
|
||||||
|
enum media_kind media_kind;
|
||||||
|
json_object *reply;
|
||||||
|
json_object *items;
|
||||||
|
int total;
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
if (!is_modified(hreq->req, DB_ADMIN_DB_UPDATE))
|
||||||
|
return HTTP_NOTMODIFIED;
|
||||||
|
|
||||||
|
media_kind = 0;
|
||||||
|
param = evhttp_find_header(hreq->query, "media_kind");
|
||||||
|
if (param)
|
||||||
|
{
|
||||||
|
media_kind = db_media_kind_enum(param);
|
||||||
|
if (!media_kind)
|
||||||
|
{
|
||||||
|
DPRINTF(E_LOG, L_WEB, "Invalid media kind '%s'\n", param);
|
||||||
|
return HTTP_BADREQUEST;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply = json_object_new_object();
|
||||||
|
items = json_object_new_array();
|
||||||
|
json_object_object_add(reply, "items", items);
|
||||||
|
|
||||||
|
memset(&query_params, 0, sizeof(struct query_params));
|
||||||
|
|
||||||
|
ret = query_params_limit_set(&query_params, hreq);
|
||||||
|
if (ret < 0)
|
||||||
|
goto error;
|
||||||
|
|
||||||
|
query_params.type = Q_BROWSE_COMPOSERS;
|
||||||
|
query_params.sort = S_COMPOSER;
|
||||||
|
query_params.idx_type = I_NONE;
|
||||||
|
|
||||||
|
if (media_kind)
|
||||||
|
query_params.filter = db_mprintf("(f.media_kind = %d)", media_kind);
|
||||||
|
|
||||||
|
ret = fetch_browse_info(&query_params, items, &total);
|
||||||
|
if (ret < 0)
|
||||||
|
goto error;
|
||||||
|
|
||||||
|
json_object_object_add(reply, "total", json_object_new_int(total));
|
||||||
|
json_object_object_add(reply, "offset", json_object_new_int(query_params.offset));
|
||||||
|
json_object_object_add(reply, "limit", json_object_new_int(query_params.limit));
|
||||||
|
|
||||||
|
ret = evbuffer_add_printf(hreq->reply, "%s", json_object_to_json_string(reply));
|
||||||
|
if (ret < 0)
|
||||||
|
DPRINTF(E_LOG, L_WEB, "browse: Couldn't add composers to response buffer.\n");
|
||||||
|
|
||||||
|
error:
|
||||||
|
jparse_free(reply);
|
||||||
|
free_query_params(&query_params, 1);
|
||||||
|
|
||||||
|
if (ret < 0)
|
||||||
|
return HTTP_INTERNAL;
|
||||||
|
|
||||||
|
return HTTP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static int
|
static int
|
||||||
jsonapi_reply_library_count(struct httpd_request *hreq)
|
jsonapi_reply_library_count(struct httpd_request *hreq)
|
||||||
{
|
{
|
||||||
@ -4341,6 +4405,68 @@ search_albums(json_object *reply, struct httpd_request *hreq, const char *param_
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
search_composers(json_object *reply, struct httpd_request *hreq, const char *param_query, struct smartpl *smartpl_expression, enum media_kind media_kind)
|
||||||
|
{
|
||||||
|
json_object *type;
|
||||||
|
json_object *items;
|
||||||
|
struct query_params query_params;
|
||||||
|
int total;
|
||||||
|
int ret;
|
||||||
|
|
||||||
|
memset(&query_params, 0, sizeof(struct query_params));
|
||||||
|
|
||||||
|
ret = query_params_limit_set(&query_params, hreq);
|
||||||
|
if (ret < 0)
|
||||||
|
goto out;
|
||||||
|
|
||||||
|
type = json_object_new_object();
|
||||||
|
json_object_object_add(reply, "composers", type);
|
||||||
|
items = json_object_new_array();
|
||||||
|
json_object_object_add(type, "items", items);
|
||||||
|
|
||||||
|
query_params.type = Q_BROWSE_COMPOSERS;
|
||||||
|
query_params.sort = S_COMPOSER;
|
||||||
|
|
||||||
|
ret = query_params_limit_set(&query_params, hreq);
|
||||||
|
if (ret < 0)
|
||||||
|
goto out;
|
||||||
|
|
||||||
|
if (param_query)
|
||||||
|
{
|
||||||
|
if (media_kind)
|
||||||
|
query_params.filter = db_mprintf("(f.composer LIKE '%%%q%%' AND f.media_kind = %d)", param_query, media_kind);
|
||||||
|
else
|
||||||
|
query_params.filter = db_mprintf("(f.composer LIKE '%%%q%%')", param_query);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
query_params.filter = strdup(smartpl_expression->query_where);
|
||||||
|
query_params.having = safe_strdup(smartpl_expression->having);
|
||||||
|
query_params.order = safe_strdup(smartpl_expression->order);
|
||||||
|
|
||||||
|
if (smartpl_expression->limit > 0)
|
||||||
|
{
|
||||||
|
query_params.idx_type = I_SUB;
|
||||||
|
query_params.limit = smartpl_expression->limit;
|
||||||
|
query_params.offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = fetch_browse_info(&query_params, items, &total);
|
||||||
|
if (ret < 0)
|
||||||
|
goto out;
|
||||||
|
|
||||||
|
json_object_object_add(type, "total", json_object_new_int(total));
|
||||||
|
json_object_object_add(type, "offset", json_object_new_int(query_params.offset));
|
||||||
|
json_object_object_add(type, "limit", json_object_new_int(query_params.limit));
|
||||||
|
|
||||||
|
out:
|
||||||
|
free_query_params(&query_params, 1);
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
static int
|
static int
|
||||||
search_playlists(json_object *reply, struct httpd_request *hreq, const char *param_query)
|
search_playlists(json_object *reply, struct httpd_request *hreq, const char *param_query)
|
||||||
{
|
{
|
||||||
@ -4456,6 +4582,13 @@ jsonapi_reply_search(struct httpd_request *hreq)
|
|||||||
goto error;
|
goto error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (strstr(param_type, "composer"))
|
||||||
|
{
|
||||||
|
ret = search_composers(reply, hreq, param_query, &smartpl_expression, media_kind);
|
||||||
|
if (ret < 0)
|
||||||
|
goto error;
|
||||||
|
}
|
||||||
|
|
||||||
if (strstr(param_type, "playlist") && param_query)
|
if (strstr(param_type, "playlist") && param_query)
|
||||||
{
|
{
|
||||||
ret = search_playlists(reply, hreq, param_query);
|
ret = search_playlists(reply, hreq, param_query);
|
||||||
@ -4565,6 +4698,7 @@ static struct httpd_uri_map adm_handlers[] =
|
|||||||
{ EVHTTP_REQ_PUT, "^/api/library/tracks/[[:digit:]]+$", jsonapi_reply_library_tracks_put_byid },
|
{ EVHTTP_REQ_PUT, "^/api/library/tracks/[[:digit:]]+$", jsonapi_reply_library_tracks_put_byid },
|
||||||
{ EVHTTP_REQ_GET, "^/api/library/tracks/[[:digit:]]+/playlists$", jsonapi_reply_library_track_playlists },
|
{ EVHTTP_REQ_GET, "^/api/library/tracks/[[:digit:]]+/playlists$", jsonapi_reply_library_track_playlists },
|
||||||
{ EVHTTP_REQ_GET, "^/api/library/genres$", jsonapi_reply_library_genres},
|
{ EVHTTP_REQ_GET, "^/api/library/genres$", jsonapi_reply_library_genres},
|
||||||
|
{ EVHTTP_REQ_GET, "^/api/library/composers$", jsonapi_reply_library_composers },
|
||||||
{ EVHTTP_REQ_GET, "^/api/library/count$", jsonapi_reply_library_count },
|
{ EVHTTP_REQ_GET, "^/api/library/count$", jsonapi_reply_library_count },
|
||||||
{ EVHTTP_REQ_GET, "^/api/library/files$", jsonapi_reply_library_files },
|
{ EVHTTP_REQ_GET, "^/api/library/files$", jsonapi_reply_library_files },
|
||||||
{ EVHTTP_REQ_POST, "^/api/library/add$", jsonapi_reply_library_add },
|
{ EVHTTP_REQ_POST, "^/api/library/add$", jsonapi_reply_library_add },
|
||||||
|
84
web-src/src/components/ListComposers.vue
Normal file
84
web-src/src/components/ListComposers.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="is_grouped">
|
||||||
|
<div v-for="idx in composers.indexList" :key="idx" class="mb-6">
|
||||||
|
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span>
|
||||||
|
<list-item-composer v-for="composer in composers.grouped[idx]"
|
||||||
|
:key="composer.id"
|
||||||
|
:composer="composer"
|
||||||
|
@click="open_composer(composer)">
|
||||||
|
<template slot="actions">
|
||||||
|
<a @click="open_dialog(composer)">
|
||||||
|
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</list-item-composer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<list-item-composer v-for="composer in composers_list"
|
||||||
|
:key="composer.id"
|
||||||
|
:composer="composer"
|
||||||
|
@click="open_composer(composer)">
|
||||||
|
<template slot="actions">
|
||||||
|
<a @click="open_dialog(composer)">
|
||||||
|
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</list-item-composer>
|
||||||
|
</div>
|
||||||
|
<modal-dialog-composer :show="show_details_modal" :composer="selected_composer" :media_kind="media_kind" @close="show_details_modal = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ListItemComposer from '@/components/ListItemComposer'
|
||||||
|
import ModalDialogComposer from '@/components/ModalDialogComposer'
|
||||||
|
import Composers from '@/lib/Composers'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ListComposers',
|
||||||
|
components: { ListItemComposer, ModalDialogComposer },
|
||||||
|
|
||||||
|
props: ['composers', 'media_kind'],
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
show_details_modal: false,
|
||||||
|
selected_composer: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
media_kind_resolved: function () {
|
||||||
|
return this.media_kind ? this.media_kind : this.selected_composer.media_kind
|
||||||
|
},
|
||||||
|
|
||||||
|
composers_list: function () {
|
||||||
|
if (Array.isArray(this.composers)) {
|
||||||
|
return this.composers
|
||||||
|
}
|
||||||
|
return this.composers.sortedAndFiltered
|
||||||
|
},
|
||||||
|
|
||||||
|
is_grouped: function () {
|
||||||
|
return (this.composers instanceof Composers && this.composers.options.group)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open_composer: function (composer) {
|
||||||
|
this.selected_composer = composer
|
||||||
|
this.$router.push({ name: 'ComposerTracks', params: { composer: composer.name } })
|
||||||
|
},
|
||||||
|
|
||||||
|
open_dialog: function (composer) {
|
||||||
|
this.selected_composer = composer
|
||||||
|
this.show_details_modal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
20
web-src/src/components/ListItemComposer.vue
Normal file
20
web-src/src/components/ListItemComposer.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template functional>
|
||||||
|
<div class="media" :id="'index_' + props.composer.name.charAt(0).toUpperCase()">
|
||||||
|
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
|
||||||
|
<h1 class="title is-6">{{ props.composer.name }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="media-right">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ListItemComposer',
|
||||||
|
props: ['composer']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
77
web-src/src/components/ModalDialogComposer.vue
Normal file
77
web-src/src/components/ModalDialogComposer.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<transition name="fade">
|
||||||
|
<div class="modal is-active" v-if="show">
|
||||||
|
<div class="modal-background" @click="$emit('close')"></div>
|
||||||
|
<div class="modal-content fd-modal-card">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<p class="title is-4">
|
||||||
|
<a class="has-text-link" @click="open_albums">{{ composer.name }}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="heading">Albums</span>
|
||||||
|
<a class="has-text-link is-6" @click="open_albums">{{ composer.album_count }}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="heading">Tracks</span>
|
||||||
|
<a class="has-text-link is-6" @click="open_tracks">{{ composer.track_count }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer class="card-footer">
|
||||||
|
<a class="card-footer-item has-text-dark" @click="queue_add">
|
||||||
|
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span>
|
||||||
|
</a>
|
||||||
|
<a class="card-footer-item has-text-dark" @click="queue_add_next">
|
||||||
|
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span>
|
||||||
|
</a>
|
||||||
|
<a class="card-footer-item has-text-dark" @click="play">
|
||||||
|
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import webapi from '@/webapi'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ModalDialogComposer',
|
||||||
|
props: ['show', 'composer'],
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
play: function () {
|
||||||
|
this.$emit('close')
|
||||||
|
webapi.player_play_expression('composer is "' + this.composer.name + '" and media_kind is music', false)
|
||||||
|
},
|
||||||
|
|
||||||
|
queue_add: function () {
|
||||||
|
this.$emit('close')
|
||||||
|
webapi.queue_expression_add('composer is "' + this.composer.name + '" and media_kind is music')
|
||||||
|
},
|
||||||
|
|
||||||
|
queue_add_next: function () {
|
||||||
|
this.$emit('close')
|
||||||
|
webapi.queue_expression_add_next('composer is "' + this.composer.name + '" and media_kind is music')
|
||||||
|
},
|
||||||
|
|
||||||
|
open_albums: function () {
|
||||||
|
this.$emit('close')
|
||||||
|
this.$router.push({ name: 'ComposerAlbums', params: { composer: this.composer.name } })
|
||||||
|
},
|
||||||
|
|
||||||
|
open_tracks: function () {
|
||||||
|
this.show_details_modal = false
|
||||||
|
this.$router.push({ name: 'ComposerTracks', params: { composer: this.composer.name } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
@ -29,6 +29,12 @@
|
|||||||
<span class="">Genres</span>
|
<span class="">Genres</span>
|
||||||
</a>
|
</a>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link tag="li" to="/music/composers" active-class="is-active">
|
||||||
|
<a>
|
||||||
|
<span class="icon is-small"><i class="mdi mdi-book-open-page-variant"></i></span>
|
||||||
|
<span class="">Composers</span>
|
||||||
|
</a>
|
||||||
|
</router-link>
|
||||||
<router-link tag="li" to="/music/spotify" v-if="spotify_enabled" active-class="is-active">
|
<router-link tag="li" to="/music/spotify" v-if="spotify_enabled" active-class="is-active">
|
||||||
<a>
|
<a>
|
||||||
<span class="icon is-small"><i class="mdi mdi-spotify"></i></span>
|
<span class="icon is-small"><i class="mdi mdi-spotify"></i></span>
|
||||||
|
62
web-src/src/lib/Composers.js
Normal file
62
web-src/src/lib/Composers.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
export default class Composers {
|
||||||
|
constructor (items, options = { hideSingles: false, hideSpotify: false, sort: 'Name', group: false }) {
|
||||||
|
this.items = items
|
||||||
|
this.options = options
|
||||||
|
this.grouped = {}
|
||||||
|
this.sortedAndFiltered = []
|
||||||
|
this.indexList = []
|
||||||
|
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
init () {
|
||||||
|
this.createSortedAndFilteredList()
|
||||||
|
this.createGroupedList()
|
||||||
|
this.createIndexList()
|
||||||
|
}
|
||||||
|
|
||||||
|
getComposerIndex (composer) {
|
||||||
|
if (this.options.sort === 'Name') {
|
||||||
|
return composer.name_sort.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
return composer.time_added.substring(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
isComposerVisible (composer) {
|
||||||
|
if (this.options.hideSingles && composer.track_count <= (composer.album_count * 2)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this.options.hideSpotify && composer.data_kind === 'spotify') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
createIndexList () {
|
||||||
|
this.indexList = [...new Set(this.sortedAndFiltered
|
||||||
|
.map(composer => this.getComposerIndex(composer)))]
|
||||||
|
}
|
||||||
|
|
||||||
|
createSortedAndFilteredList () {
|
||||||
|
let composersSorted = this.items
|
||||||
|
if (this.options.hideSingles || this.options.hideSpotify || this.options.hideOther) {
|
||||||
|
composersSorted = composersSorted.filter(composer => this.isComposerVisible(composer))
|
||||||
|
}
|
||||||
|
if (this.options.sort === 'Recently added') {
|
||||||
|
composersSorted = [...composersSorted].sort((a, b) => b.time_added.localeCompare(a.time_added))
|
||||||
|
}
|
||||||
|
this.sortedAndFiltered = composersSorted
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroupedList () {
|
||||||
|
if (!this.options.group) {
|
||||||
|
this.grouped = {}
|
||||||
|
}
|
||||||
|
this.grouped = this.sortedAndFiltered.reduce((r, composer) => {
|
||||||
|
const idx = this.getComposerIndex(composer)
|
||||||
|
r[idx] = [...r[idx] || [], composer]
|
||||||
|
return r
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
}
|
98
web-src/src/pages/PageComposer.vue
Normal file
98
web-src/src/pages/PageComposer.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<content-with-heading>
|
||||||
|
<template slot="heading-left">
|
||||||
|
<p class="title is-4">{{ name }}</p>
|
||||||
|
</template>
|
||||||
|
<template slot="heading-right">
|
||||||
|
<div class="buttons is-centered">
|
||||||
|
<a class="button is-small is-light is-rounded" @click="show_composer_details_modal = true">
|
||||||
|
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
<a class="button is-small is-dark is-rounded" @click="play">
|
||||||
|
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template slot="content">
|
||||||
|
<p class="heading has-text-centered-mobile">{{ composer_albums.total }} albums | <a class="has-text-link" @click="open_tracks">tracks</a></p>
|
||||||
|
<list-item-albums v-for="album in composer_albums.items" :key="album.id" :album="album" @click="open_album(album)">
|
||||||
|
<template slot="actions">
|
||||||
|
<a @click="open_dialog(album)">
|
||||||
|
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</list-item-albums>
|
||||||
|
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
|
||||||
|
<modal-dialog-composer :show="show_composer_details_modal" :composer="{ 'name': name }" @close="show_composer_details_modal = false" />
|
||||||
|
</template>
|
||||||
|
</content-with-heading>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { LoadDataBeforeEnterMixin } from './mixin'
|
||||||
|
import ContentWithHeading from '@/templates/ContentWithHeading'
|
||||||
|
import ListItemAlbums from '@/components/ListItemAlbum'
|
||||||
|
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
|
||||||
|
import ModalDialogComposer from '@/components/ModalDialogComposer'
|
||||||
|
import webapi from '@/webapi'
|
||||||
|
|
||||||
|
const composerData = {
|
||||||
|
load: function (to) {
|
||||||
|
return webapi.library_composer(to.params.composer)
|
||||||
|
},
|
||||||
|
|
||||||
|
set: function (vm, response) {
|
||||||
|
vm.name = vm.$route.params.composer
|
||||||
|
vm.composer_albums = response.data.albums
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageComposer',
|
||||||
|
mixins: [LoadDataBeforeEnterMixin(composerData)],
|
||||||
|
components: { ContentWithHeading, ListItemAlbums, ModalDialogAlbum, ModalDialogComposer },
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
composer_albums: { items: [] },
|
||||||
|
show_details_modal: false,
|
||||||
|
selected_album: {},
|
||||||
|
|
||||||
|
show_composer_details_modal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
index_list () {
|
||||||
|
return [...new Set(this.composer_albums.items
|
||||||
|
.map(album => album.name_sort.charAt(0).toUpperCase()))]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open_tracks: function () {
|
||||||
|
this.show_details_modal = false
|
||||||
|
this.$router.push({ name: 'ComposerTracks', params: { composer: this.name } })
|
||||||
|
},
|
||||||
|
|
||||||
|
play: function () {
|
||||||
|
webapi.player_play_expression('composer is "' + this.name + '" and media_kind is music', true)
|
||||||
|
},
|
||||||
|
|
||||||
|
open_album: function (album) {
|
||||||
|
this.$router.push({ path: '/music/albums/' + album.id })
|
||||||
|
},
|
||||||
|
|
||||||
|
open_dialog: function (album) {
|
||||||
|
this.selected_album = album
|
||||||
|
this.show_details_modal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
112
web-src/src/pages/PageComposerTracks.vue
Normal file
112
web-src/src/pages/PageComposerTracks.vue
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<content-with-heading>
|
||||||
|
<template slot="heading-left">
|
||||||
|
<p class="title is-4">{{ composer }}</p>
|
||||||
|
</template>
|
||||||
|
<template slot="heading-right">
|
||||||
|
<div class="buttons is-centered">
|
||||||
|
<a class="button is-small is-light is-rounded" @click="show_composer_details_modal = true">
|
||||||
|
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
<a class="button is-small is-dark is-rounded" @click="play">
|
||||||
|
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template slot="content">
|
||||||
|
<p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_albums">albums</a> | {{ tracks.total }} tracks</p>
|
||||||
|
<list-item-track v-for="(track, index) in rated_tracks" :key="track.id" :track="track" @click="play_track(index)">
|
||||||
|
<template slot="actions">
|
||||||
|
<a @click="open_dialog(track)">
|
||||||
|
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</list-item-track>
|
||||||
|
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
|
||||||
|
<modal-dialog-composer :show="show_composer_details_modal" :composer="{ 'name': composer }" @close="show_composer_details_modal = false" />
|
||||||
|
</template>
|
||||||
|
</content-with-heading>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { LoadDataBeforeEnterMixin } from './mixin'
|
||||||
|
import ContentWithHeading from '@/templates/ContentWithHeading'
|
||||||
|
import ListItemTrack from '@/components/ListItemTrack'
|
||||||
|
import ModalDialogTrack from '@/components/ModalDialogTrack'
|
||||||
|
import ModalDialogComposer from '@/components/ModalDialogComposer'
|
||||||
|
import webapi from '@/webapi'
|
||||||
|
|
||||||
|
const tracksData = {
|
||||||
|
load: function (to) {
|
||||||
|
return webapi.library_composer_tracks(to.params.composer)
|
||||||
|
},
|
||||||
|
|
||||||
|
set: function (vm, response) {
|
||||||
|
vm.composer = vm.$route.params.composer
|
||||||
|
vm.tracks = response.data.tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageComposerTracks',
|
||||||
|
mixins: [LoadDataBeforeEnterMixin(tracksData)],
|
||||||
|
components: { ContentWithHeading, ListItemTrack, ModalDialogTrack, ModalDialogComposer },
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
tracks: { items: [] },
|
||||||
|
composer: '',
|
||||||
|
|
||||||
|
min_rating: 0,
|
||||||
|
|
||||||
|
show_details_modal: false,
|
||||||
|
selected_track: {},
|
||||||
|
|
||||||
|
show_composer_details_modal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
index_list () {
|
||||||
|
return [...new Set(this.tracks.items
|
||||||
|
.map(track => track.title_sort.charAt(0).toUpperCase()))]
|
||||||
|
},
|
||||||
|
|
||||||
|
rated_tracks () {
|
||||||
|
return this.tracks.items.filter(track => track.rating >= this.min_rating)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open_albums: function () {
|
||||||
|
this.show_details_modal = false
|
||||||
|
this.$router.push({ name: 'ComposerAlbums', params: { composer: this.composer } })
|
||||||
|
},
|
||||||
|
|
||||||
|
play: function () {
|
||||||
|
webapi.player_play_expression('composer is "' + this.composer + '" and media_kind is music', true)
|
||||||
|
},
|
||||||
|
|
||||||
|
play_track: function (position) {
|
||||||
|
webapi.player_play_expression('composer is "' + this.composer + '" and media_kind is music', false, position)
|
||||||
|
},
|
||||||
|
|
||||||
|
show_rating: function (rating) {
|
||||||
|
if (rating === 0.5) {
|
||||||
|
rating = 0
|
||||||
|
}
|
||||||
|
this.min_rating = Math.ceil(rating) * 20
|
||||||
|
},
|
||||||
|
|
||||||
|
open_dialog: function (track) {
|
||||||
|
this.selected_track = track
|
||||||
|
this.show_details_modal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
88
web-src/src/pages/PageComposers.vue
Normal file
88
web-src/src/pages/PageComposers.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tabs-music></tabs-music>
|
||||||
|
|
||||||
|
<content-with-heading>
|
||||||
|
<template slot="options">
|
||||||
|
<index-button-list :index="composers_list.indexList"></index-button-list>
|
||||||
|
</template>
|
||||||
|
<template slot="heading-left">
|
||||||
|
<p class="title is-4">{{ heading }}</p>
|
||||||
|
<p class="heading">{{ composers.total }} composers</p>
|
||||||
|
</template>
|
||||||
|
<template slot="content">
|
||||||
|
<list-composers :composers="composers_list"></list-composers>
|
||||||
|
</template>
|
||||||
|
</content-with-heading>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { LoadDataBeforeEnterMixin } from './mixin'
|
||||||
|
import ContentWithHeading from '@/templates/ContentWithHeading'
|
||||||
|
import TabsMusic from '@/components/TabsMusic'
|
||||||
|
import IndexButtonList from '@/components/IndexButtonList'
|
||||||
|
import ListComposers from '@/components/ListComposers'
|
||||||
|
import webapi from '@/webapi'
|
||||||
|
import Composers from '@/lib/Composers'
|
||||||
|
|
||||||
|
const composersData = {
|
||||||
|
load: function (to) {
|
||||||
|
return webapi.library_composers()
|
||||||
|
},
|
||||||
|
|
||||||
|
set: function (vm, response) {
|
||||||
|
if (response.data.composers) {
|
||||||
|
vm.composers = response.data.composers
|
||||||
|
vm.heading = vm.$route.params.genre
|
||||||
|
} else {
|
||||||
|
vm.composers = response.data
|
||||||
|
vm.heading = 'Composers'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageComposers',
|
||||||
|
mixins: [LoadDataBeforeEnterMixin(composersData)],
|
||||||
|
components: { ContentWithHeading, TabsMusic, IndexButtonList, ListComposers },
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
composers: { items: [] },
|
||||||
|
heading: '',
|
||||||
|
|
||||||
|
show_details_modal: false,
|
||||||
|
selected_composer: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
index_list () {
|
||||||
|
return [...new Set(this.composers.items
|
||||||
|
.map(composer => composer.name.charAt(0).toUpperCase()))]
|
||||||
|
},
|
||||||
|
|
||||||
|
composers_list () {
|
||||||
|
return new Composers(this.composers.items, {
|
||||||
|
sort: 'Name',
|
||||||
|
group: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open_composer: function (composer) {
|
||||||
|
this.$router.push({ name: 'ComposerAlbums', params: { composer: composer.name } })
|
||||||
|
},
|
||||||
|
|
||||||
|
open_dialog: function (composer) {
|
||||||
|
this.selected_composer = composer
|
||||||
|
this.show_details_modal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
@ -94,6 +94,28 @@
|
|||||||
</template>
|
</template>
|
||||||
</content-text>
|
</content-text>
|
||||||
|
|
||||||
|
<!-- Composers -->
|
||||||
|
<content-with-heading v-if="show_composers && composers.total">
|
||||||
|
<template slot="heading-left">
|
||||||
|
<p class="title is-4">Composers</p>
|
||||||
|
</template>
|
||||||
|
<template slot="content">
|
||||||
|
<list-composers :composers="composers.items"></list-composers>
|
||||||
|
</template>
|
||||||
|
<template slot="footer">
|
||||||
|
<nav v-if="show_all_composers_button" class="level">
|
||||||
|
<p class="level-item">
|
||||||
|
<a class="button is-light is-small is-rounded" v-on:click="open_search_composers">Show all {{ composers.total }} composers</a>
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
</content-with-heading>
|
||||||
|
<content-text v-if="show_composers && !composers.total">
|
||||||
|
<template slot="content">
|
||||||
|
<p><i>No composers found</i></p>
|
||||||
|
</template>
|
||||||
|
</content-text>
|
||||||
|
|
||||||
<!-- Playlists -->
|
<!-- Playlists -->
|
||||||
<content-with-heading v-if="show_playlists && playlists.total">
|
<content-with-heading v-if="show_playlists && playlists.total">
|
||||||
<template slot="heading-left">
|
<template slot="heading-left">
|
||||||
@ -169,13 +191,14 @@ import TabsSearch from '@/components/TabsSearch'
|
|||||||
import ListTracks from '@/components/ListTracks'
|
import ListTracks from '@/components/ListTracks'
|
||||||
import ListArtists from '@/components/ListArtists'
|
import ListArtists from '@/components/ListArtists'
|
||||||
import ListAlbums from '@/components/ListAlbums'
|
import ListAlbums from '@/components/ListAlbums'
|
||||||
|
import ListComposers from '@/components/ListComposers'
|
||||||
import ListPlaylists from '@/components/ListPlaylists'
|
import ListPlaylists from '@/components/ListPlaylists'
|
||||||
import webapi from '@/webapi'
|
import webapi from '@/webapi'
|
||||||
import * as types from '@/store/mutation_types'
|
import * as types from '@/store/mutation_types'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PageSearch',
|
name: 'PageSearch',
|
||||||
components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists },
|
components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists, ListComposers },
|
||||||
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
@ -184,6 +207,7 @@ export default {
|
|||||||
tracks: { items: [], total: 0 },
|
tracks: { items: [], total: 0 },
|
||||||
artists: { items: [], total: 0 },
|
artists: { items: [], total: 0 },
|
||||||
albums: { items: [], total: 0 },
|
albums: { items: [], total: 0 },
|
||||||
|
composers: { items: [], total: 0 },
|
||||||
playlists: { items: [], total: 0 },
|
playlists: { items: [], total: 0 },
|
||||||
audiobooks: { items: [], total: 0 },
|
audiobooks: { items: [], total: 0 },
|
||||||
podcasts: { items: [], total: 0 }
|
podcasts: { items: [], total: 0 }
|
||||||
@ -216,6 +240,13 @@ export default {
|
|||||||
return this.albums.total > this.albums.items.length
|
return this.albums.total > this.albums.items.length
|
||||||
},
|
},
|
||||||
|
|
||||||
|
show_composers () {
|
||||||
|
return this.$route.query.type && this.$route.query.type.includes('composer')
|
||||||
|
},
|
||||||
|
show_all_composers_button () {
|
||||||
|
return this.composers.total > this.composers.items.length
|
||||||
|
},
|
||||||
|
|
||||||
show_playlists () {
|
show_playlists () {
|
||||||
return this.$route.query.type && this.$route.query.type.includes('playlist')
|
return this.$route.query.type && this.$route.query.type.includes('playlist')
|
||||||
},
|
},
|
||||||
@ -282,6 +313,7 @@ export default {
|
|||||||
this.tracks = data.tracks ? data.tracks : { items: [], total: 0 }
|
this.tracks = data.tracks ? data.tracks : { items: [], total: 0 }
|
||||||
this.artists = data.artists ? data.artists : { items: [], total: 0 }
|
this.artists = data.artists ? data.artists : { items: [], total: 0 }
|
||||||
this.albums = data.albums ? data.albums : { items: [], total: 0 }
|
this.albums = data.albums ? data.albums : { items: [], total: 0 }
|
||||||
|
this.composers = data.composers ? data.composers : { items: [], total: 0 }
|
||||||
this.playlists = data.playlists ? data.playlists : { items: [], total: 0 }
|
this.playlists = data.playlists ? data.playlists : { items: [], total: 0 }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -346,7 +378,7 @@ export default {
|
|||||||
this.$router.push({
|
this.$router.push({
|
||||||
path: '/search/library',
|
path: '/search/library',
|
||||||
query: {
|
query: {
|
||||||
type: 'track,artist,album,playlist,audiobook,podcast',
|
type: 'track,artist,album,playlist,audiobook,podcast,composer',
|
||||||
query: this.search_query,
|
query: this.search_query,
|
||||||
limit: 3,
|
limit: 3,
|
||||||
offset: 0
|
offset: 0
|
||||||
@ -385,6 +417,16 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
open_search_composers: function () {
|
||||||
|
this.$router.push({
|
||||||
|
path: '/search/library',
|
||||||
|
query: {
|
||||||
|
type: 'tracks',
|
||||||
|
query: this.$route.query.query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
open_search_playlists: function () {
|
open_search_playlists: function () {
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
path: '/search/library',
|
path: '/search/library',
|
||||||
@ -415,9 +457,42 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
open_composer: function (composer) {
|
||||||
|
this.$router.push({ name: 'ComposerAlbums', params: { composer: composer.name } })
|
||||||
|
},
|
||||||
|
|
||||||
|
open_playlist: function (playlist) {
|
||||||
|
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
|
||||||
|
},
|
||||||
|
|
||||||
open_recent_search: function (query) {
|
open_recent_search: function (query) {
|
||||||
this.search_query = query
|
this.search_query = query
|
||||||
this.new_search()
|
this.new_search()
|
||||||
|
},
|
||||||
|
|
||||||
|
open_track_dialog: function (track) {
|
||||||
|
this.selected_track = track
|
||||||
|
this.show_track_details_modal = true
|
||||||
|
},
|
||||||
|
|
||||||
|
open_album_dialog: function (album) {
|
||||||
|
this.selected_album = album
|
||||||
|
this.show_album_details_modal = true
|
||||||
|
},
|
||||||
|
|
||||||
|
open_artist_dialog: function (artist) {
|
||||||
|
this.selected_artist = artist
|
||||||
|
this.show_artist_details_modal = true
|
||||||
|
},
|
||||||
|
|
||||||
|
open_composer_dialog: function (composer) {
|
||||||
|
this.selected_composer = composer
|
||||||
|
this.show_composer_details_modal = true
|
||||||
|
},
|
||||||
|
|
||||||
|
open_playlist_dialog: function (playlist) {
|
||||||
|
this.selected_playlist = playlist
|
||||||
|
this.show_playlist_details_modal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -15,6 +15,9 @@ import PageGenres from '@/pages/PageGenres'
|
|||||||
import PageGenre from '@/pages/PageGenre'
|
import PageGenre from '@/pages/PageGenre'
|
||||||
import PageGenreTracks from '@/pages/PageGenreTracks'
|
import PageGenreTracks from '@/pages/PageGenreTracks'
|
||||||
import PageArtistTracks from '@/pages/PageArtistTracks'
|
import PageArtistTracks from '@/pages/PageArtistTracks'
|
||||||
|
import PageComposers from '@/pages/PageComposers'
|
||||||
|
import PageComposer from '@/pages/PageComposer'
|
||||||
|
import PageComposerTracks from '@/pages/PageComposerTracks'
|
||||||
import PagePodcasts from '@/pages/PagePodcasts'
|
import PagePodcasts from '@/pages/PagePodcasts'
|
||||||
import PagePodcast from '@/pages/PagePodcast'
|
import PagePodcast from '@/pages/PagePodcast'
|
||||||
import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums'
|
import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums'
|
||||||
@ -128,6 +131,24 @@ export const router = new VueRouter({
|
|||||||
component: PageGenreTracks,
|
component: PageGenreTracks,
|
||||||
meta: { show_progress: true, has_index: true }
|
meta: { show_progress: true, has_index: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/music/composers',
|
||||||
|
name: 'Composers',
|
||||||
|
component: PageComposers,
|
||||||
|
meta: { show_progress: true, has_tabs: true, has_index: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/music/composers/:composer/albums',
|
||||||
|
name: 'ComposerAlbums',
|
||||||
|
component: PageComposer,
|
||||||
|
meta: { show_progress: true, has_tabs: true, has_index: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/music/composers/:composer/tracks',
|
||||||
|
name: 'ComposerTracks',
|
||||||
|
component: PageComposerTracks,
|
||||||
|
meta: { show_progress: true, has_tabs: true, has_index: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/podcasts',
|
path: '/podcasts',
|
||||||
name: 'Podcasts',
|
name: 'Podcasts',
|
||||||
|
@ -267,6 +267,32 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
library_composers () {
|
||||||
|
return axios.get('/api/library/composers')
|
||||||
|
},
|
||||||
|
|
||||||
|
library_composer (composer) {
|
||||||
|
const params = {
|
||||||
|
type: 'albums',
|
||||||
|
media_kind: 'music',
|
||||||
|
expression: 'composer is "' + composer + '"'
|
||||||
|
}
|
||||||
|
return axios.get('/api/search', {
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
library_composer_tracks (composer) {
|
||||||
|
const params = {
|
||||||
|
type: 'tracks',
|
||||||
|
media_kind: 'music',
|
||||||
|
expression: 'composer is "' + composer + '"'
|
||||||
|
}
|
||||||
|
return axios.get('/api/search', {
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
library_artist_tracks (artist) {
|
library_artist_tracks (artist) {
|
||||||
if (artist) {
|
if (artist) {
|
||||||
const artistParams = {
|
const artistParams = {
|
||||||
|
Loading…
Reference in New Issue
Block a user