diff --git a/src/httpd_jsonapi.c b/src/httpd_jsonapi.c index ea9dab78..7cd9e738 100644 --- a/src/httpd_jsonapi.c +++ b/src/httpd_jsonapi.c @@ -685,7 +685,7 @@ fetch_playlist(bool *notfound, uint32_t playlist_id) } 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; json_object *item; @@ -3960,11 +3960,9 @@ jsonapi_reply_library_genres(struct httpd_request *hreq) if (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) 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, "offset", json_object_new_int(query_params.offset)); @@ -3984,6 +3982,72 @@ jsonapi_reply_library_genres(struct httpd_request *hreq) 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 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; } +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 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; } + 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) { 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_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/composers$", jsonapi_reply_library_composers }, { EVHTTP_REQ_GET, "^/api/library/count$", jsonapi_reply_library_count }, { EVHTTP_REQ_GET, "^/api/library/files$", jsonapi_reply_library_files }, { EVHTTP_REQ_POST, "^/api/library/add$", jsonapi_reply_library_add }, diff --git a/web-src/src/components/ListComposers.vue b/web-src/src/components/ListComposers.vue new file mode 100644 index 00000000..e87407d0 --- /dev/null +++ b/web-src/src/components/ListComposers.vue @@ -0,0 +1,84 @@ + + + + + {{ idx }} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-src/src/components/ListItemComposer.vue b/web-src/src/components/ListItemComposer.vue new file mode 100644 index 00000000..586a2376 --- /dev/null +++ b/web-src/src/components/ListItemComposer.vue @@ -0,0 +1,20 @@ + + + + {{ props.composer.name }} + + + + + + + + + + diff --git a/web-src/src/components/ModalDialogComposer.vue b/web-src/src/components/ModalDialogComposer.vue new file mode 100644 index 00000000..7051be42 --- /dev/null +++ b/web-src/src/components/ModalDialogComposer.vue @@ -0,0 +1,77 @@ + + + + + + + + + + {{ composer.name }} + + + Albums + {{ composer.album_count }} + + + Tracks + {{ composer.track_count }} + + + + + + + + + + + + + + diff --git a/web-src/src/components/TabsMusic.vue b/web-src/src/components/TabsMusic.vue index fe47b9ab..b5353025 100644 --- a/web-src/src/components/TabsMusic.vue +++ b/web-src/src/components/TabsMusic.vue @@ -29,6 +29,12 @@ Genres + + + + Composers + + diff --git a/web-src/src/lib/Composers.js b/web-src/src/lib/Composers.js new file mode 100644 index 00000000..67f2c8c7 --- /dev/null +++ b/web-src/src/lib/Composers.js @@ -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 + }, {}) + } +} diff --git a/web-src/src/pages/PageComposer.vue b/web-src/src/pages/PageComposer.vue new file mode 100644 index 00000000..609fec61 --- /dev/null +++ b/web-src/src/pages/PageComposer.vue @@ -0,0 +1,98 @@ + + + + + {{ name }} + + + + + + + + Shuffle + + + + + {{ composer_albums.total }} albums | tracks + + + + + + + + + + + + + + + + + diff --git a/web-src/src/pages/PageComposerTracks.vue b/web-src/src/pages/PageComposerTracks.vue new file mode 100644 index 00000000..2eb022a9 --- /dev/null +++ b/web-src/src/pages/PageComposerTracks.vue @@ -0,0 +1,112 @@ + + + + + {{ composer }} + + + + + + + + Shuffle + + + + + albums | {{ tracks.total }} tracks + + + + + + + + + + + + + + + + + diff --git a/web-src/src/pages/PageComposers.vue b/web-src/src/pages/PageComposers.vue new file mode 100644 index 00000000..854d36e4 --- /dev/null +++ b/web-src/src/pages/PageComposers.vue @@ -0,0 +1,88 @@ + + + + + + + + + + {{ heading }} + {{ composers.total }} composers + + + + + + + + + + + diff --git a/web-src/src/pages/PageSearch.vue b/web-src/src/pages/PageSearch.vue index a4ae6848..e695ed09 100644 --- a/web-src/src/pages/PageSearch.vue +++ b/web-src/src/pages/PageSearch.vue @@ -94,6 +94,28 @@ + + + + Composers + + + + + + + + Show all {{ composers.total }} composers + + + + + + + No composers found + + + @@ -169,13 +191,14 @@ import TabsSearch from '@/components/TabsSearch' import ListTracks from '@/components/ListTracks' import ListArtists from '@/components/ListArtists' import ListAlbums from '@/components/ListAlbums' +import ListComposers from '@/components/ListComposers' import ListPlaylists from '@/components/ListPlaylists' import webapi from '@/webapi' import * as types from '@/store/mutation_types' export default { name: 'PageSearch', - components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists }, + components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists, ListComposers }, data () { return { @@ -184,6 +207,7 @@ export default { tracks: { items: [], total: 0 }, artists: { items: [], total: 0 }, albums: { items: [], total: 0 }, + composers: { items: [], total: 0 }, playlists: { items: [], total: 0 }, audiobooks: { items: [], total: 0 }, podcasts: { items: [], total: 0 } @@ -216,6 +240,13 @@ export default { 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 () { 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.artists = data.artists ? data.artists : { 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 } }) }, @@ -346,7 +378,7 @@ export default { this.$router.push({ path: '/search/library', query: { - type: 'track,artist,album,playlist,audiobook,podcast', + type: 'track,artist,album,playlist,audiobook,podcast,composer', query: this.search_query, limit: 3, 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 () { this.$router.push({ 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) { this.search_query = query 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 } }, diff --git a/web-src/src/router/index.js b/web-src/src/router/index.js index 80e19d54..df2fe184 100644 --- a/web-src/src/router/index.js +++ b/web-src/src/router/index.js @@ -15,6 +15,9 @@ import PageGenres from '@/pages/PageGenres' import PageGenre from '@/pages/PageGenre' import PageGenreTracks from '@/pages/PageGenreTracks' 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 PagePodcast from '@/pages/PagePodcast' import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums' @@ -128,6 +131,24 @@ export const router = new VueRouter({ component: PageGenreTracks, 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', name: 'Podcasts', diff --git a/web-src/src/webapi/index.js b/web-src/src/webapi/index.js index bc14b78e..f3c9eebb 100644 --- a/web-src/src/webapi/index.js +++ b/web-src/src/webapi/index.js @@ -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) { if (artist) { const artistParams = {
+ {{ composer.name }} +
+ Albums + {{ composer.album_count }} +
+ Tracks + {{ composer.track_count }} +
{{ name }}
{{ composer_albums.total }} albums | tracks
{{ composer }}
albums | {{ tracks.total }} tracks
{{ heading }}
{{ composers.total }} composers
Composers
+ Show all {{ composers.total }} composers +
No composers found