Merge branch 'podcast-search-capabilities' into 'develop'

Podcast search capabilities

See merge request funkwhale/funkwhale!1252
This commit is contained in:
Ciarán Ainsworth 2020-12-04 09:33:17 +01:00
commit 7213d9327c
11 changed files with 538 additions and 233 deletions

View File

@ -103,6 +103,7 @@ class ArtistFilter(
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
has_albums = filters.BooleanFilter(field_name="_", method="filter_has_albums")
tag = TAG_FILTER
content_category = filters.CharFilter("content_category")
scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor",
distinct=True,

View File

@ -0,0 +1 @@
Added new search functions to allow users to more easily search for podcasts in the UI.

View File

@ -118,10 +118,9 @@ Scope:
- "actor:alice@example.com"
- "domain:example.com"
ContentType:
name: "content_type"
ContentCategory:
name: "content_category"
in: "query"
default: "all"
description: |
Limits the results to those whose artist content type matches the query.

View File

@ -407,6 +407,7 @@ paths:
- $ref: "./api/parameters.yml#/PageSize"
- $ref: "./api/parameters.yml#/Related"
- $ref: "./api/parameters.yml#/Scope"
- $ref: "./api/parameters.yml#/ContentCategory"
responses:
200:
content:
@ -505,7 +506,7 @@ paths:
- $ref: "./api/parameters.yml#/PageSize"
- $ref: "./api/parameters.yml#/Related"
- $ref: "./api/parameters.yml#/Scope"
- $ref: "./api/parameters.yml#/ContentType"
- $ref: "./api/parameters.yml#/ContentCategory"
responses:
200:

View File

@ -114,20 +114,22 @@
<div class="ui small hidden divider"></div>
<section :class="['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']" :aria-label="labels.mainMenu">
<nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu">
<div :class="[{collapsed: !exploreExpanded}, 'collaspable item']">
<div :class="[{collapsed: !exploreExpanded}, 'collapsible item']">
<h2 class="header" role="button" @click="exploreExpanded = true" tabindex="0" @focus="exploreExpanded = true">
<translate translate-context="*/*/*/Verb">Explore</translate>
<i class="angle right icon" v-if="!exploreExpanded"></i>
</h2>
<div class="menu">
<router-link class="item" :to="{name: 'search'}"><i class="search icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Search</translate></router-link>
<router-link class="item" :exact="true" :to="{name: 'library.index'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link>
<router-link class="item" :to="{name: 'library.podcasts.browse'}"><i class="podcast icon"></i><translate translate-context="*/*/*">Podcasts</translate></router-link>
<router-link class="item" :to="{name: 'library.albums.browse'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link>
<router-link class="item" :to="{name: 'library.artists.browse'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link>
<router-link class="item" :to="{name: 'library.playlists.browse'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link>
<router-link class="item" :to="{name: 'library.radios.browse'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link>
</div>
</div>
<div :class="[{collapsed: !myLibraryExpanded}, 'collaspable item']" v-if="$store.state.auth.authenticated">
<div :class="[{collapsed: !myLibraryExpanded}, 'collapsible item']" v-if="$store.state.auth.authenticated">
<h3 class="header" role="button" @click="myLibraryExpanded = true" tabindex="0" @focus="myLibraryExpanded = true">
<translate translate-context="*/*/*/Noun">My Library</translate>
<i class="angle right icon" v-if="!myLibraryExpanded"></i>
@ -225,7 +227,9 @@ export default {
},
focusedMenu () {
let mapping = {
"search": 'exploreExpanded',
"library.index": 'exploreExpanded',
"library.podcasts.browse": 'exploreExpanded',
"library.albums.browse": 'exploreExpanded',
"library.albums.detail": 'exploreExpanded',
"library.artists.browse": 'exploreExpanded',

View File

@ -157,7 +157,9 @@ export default {
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
ordering: this.getOrderingAsString(),
content_category: 'music',
include_channels: true,
}).toString()
)
},
@ -175,6 +177,7 @@ export default {
playable: "true",
tag: this.tags,
include_channels: "true",
content_category: 'music',
}
logger.default.debug("Fetching artists")
axios.get(

View File

@ -0,0 +1,247 @@
<template>
<main v-title="labels.title">
<section class="ui vertical stripe segment">
<h2 class="ui header">
<translate translate-context="Content/Podcasts/Title">Browsing Podcasts</translate>
</h2>
<form :class="['ui', {'loading': isLoading}, 'form']" @submit.prevent="updatePage();updateQueryString();fetchData()">
<div class="fields">
<div class="field">
<label for="artist-search">
<translate translate-context="Content/Search/Input.Label/Noun">Podcast Title</translate>
</label>
<div class="ui action input">
<input id="artist-search" type="text" name="search" v-model="query" :placeholder="labels.searchPlaceholder"/>
<button class="ui icon button" type="submit" :aria-label="$pgettext('Content/Search/Input.Label/Noun', 'Search')">
<i class="search icon"></i>
</button>
</div>
</div>
<div class="field">
<label for="tags-search"><translate translate-context="*/*/*/Noun">Tags</translate></label>
<tags-selector v-model="tags"></tags-selector>
</div>
<div class="field">
<label for="artist-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select id="artist-ordering" class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label for="artist-ordering-direction"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label>
<select id="artist-ordering-direction" class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
</select>
</div>
<div class="field">
<label for="artist-results"><translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate></label>
<select id="artist-results" class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(30)">30</option>
<option :value="parseInt(50)">50</option>
</select>
</div>
</div>
</form>
<div class="ui hidden divider"></div>
<div v-if="result && result.results.length > 0" class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<artist-card :artist="artist" v-for="artist in result.results" :key="artist.id"></artist-card>
</div>
<div v-else-if="!isLoading" class="ui placeholder segment sixteen wide column" style="text-align: center; display: flex; align-items: center">
<div class="ui icon header">
<i class="podcast icon"></i>
<translate translate-context="Content/Artists/Placeholder">
No results matching your query
</translate>
</div>
<router-link
v-if="$store.state.auth.authenticated"
:to="{name: 'content.index'}"
class="ui success button labeled icon">
<i class="upload icon"></i>
<translate translate-context="Content/*/Verb">
Create a Channel
</translate>
</router-link>
<h1 v-if ="$store.state.auth.authenticated" class="ui with-actions header">
<div class="actions">
<a @click.stop.prevent="showSubscribeModal = true">
<i class="plus icon"></i>
<translate translate-context="Content/Profile/Button">Subscribe to feed</translate>
</a>
</div>
</h1>
</div>
<div class="ui center aligned basic segment">
<pagination
v-if="result && result.count > paginateBy"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
</div>
</section>
<modal class="tiny" :show.sync="showSubscribeModal" :fullscreen="false">
<h2 class="header">
<translate translate-context="*/*/*/Noun">Subscription</translate>
</h2>
<div class="scrolling content" ref="modalContent">
<remote-search-form
type="rss"
:show-submit="false"
:standalone="false"
@subscribed="showSubscribeModal = false; fetchData()"
:redirect="false"></remote-search-form>
</div>
<div class="actions">
<button class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</button>
<button form="remote-search" type="submit" class="ui primary button">
<i class="bookmark icon"></i>
<translate translate-context="*/*/*/Verb">Subscribe</translate>
</button>
</div>
</modal>
</main>
</template>
<script>
import qs from 'qs'
import axios from "axios"
import _ from "@/lodash"
import $ from "jquery"
import logger from "@/logging"
import OrderingMixin from "@/components/mixins/Ordering"
import PaginationMixin from "@/components/mixins/Pagination"
import TranslationsMixin from "@/components/mixins/Translations"
import ArtistCard from "@/components/audio/artist/Card"
import Pagination from "@/components/Pagination"
import TagsSelector from '@/components/library/TagsSelector'
import Modal from '@/components/semantic/Modal'
import RemoteSearchForm from "@/components/RemoteSearchForm"
const FETCH_URL = "artists/"
export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: {
defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } },
scope: { type: String, required: false, default: "all" },
},
components: {
ArtistCard,
Pagination,
TagsSelector,
RemoteSearchForm,
Modal,
},
data() {
return {
isLoading: true,
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }),
orderingOptions: [["creation_date", "creation_date"], ["name", "name"]],
showSubscribeModal: false,
}
},
created() {
this.fetchData()
},
mounted() {
$(".ui.dropdown").dropdown()
},
computed: {
labels() {
let searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', "Search…")
let title = this.$pgettext('*/*/*/Noun', "Podcasts")
return {
searchPlaceholder,
title
}
}
},
methods: {
updateQueryString: function() {
history.pushState(
{},
null,
this.$route.path + '?' + new URLSearchParams(
{
query: this.query,
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString(),
include_channels: true,
content_category: 'podcast',
}).toString()
)
},
fetchData: function() {
var self = this
this.isLoading = true
let url = FETCH_URL
let params = {
scope: this.scope,
page: this.page,
page_size: this.paginateBy,
has_albums: this.excludeCompilation,
q: this.query,
ordering: this.getOrderingAsString(),
playable: "true",
tag: this.tags,
include_channels: "true",
content_category: 'podcast',
}
logger.default.debug("Fetching artists")
axios.get(
url,
{
params: params,
paramsSerializer: function(params) {
return qs.stringify(params, { indices: false })
}
}
).then(response => {
self.result = response.data
self.isLoading = false
}, error => {
self.result = null
self.isLoading = false
})
},
selectPage: function(page) {
this.page = page
},
updatePage() {
this.page = this.defaultPage
},
},
watch: {
page() {
this.updateQueryString()
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
},
excludeCompilation() {
this.fetchData()
}
}
}
</script>

View File

@ -637,6 +637,23 @@ export default new Router({
defaultPage: route.query.page
})
},
{
path: "podcasts/",
name: "library.podcasts.browse",
component: () =>
import(
/* webpackChunkName: "podcasts" */ "@/components/library/Podcasts"
),
props: route => ({
defaultOrdering: route.query.ordering,
defaultQuery: route.query.query,
defaultTags: Array.isArray(route.query.tag || [])
? route.query.tag
: [route.query.tag],
defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page
})
},
{
path: "me/albums",
name: "library.albums.me",

View File

@ -45,6 +45,11 @@ export default {
orderingDirection: "-",
ordering: "creation_date",
},
"library.podcasts.browse": {
paginateBy: 30,
orderingDirection: "-",
ordering: "creation_date",
},
"library.radios.browse": {
paginateBy: 12,
orderingDirection: "-",

View File

@ -1,244 +1,237 @@
.ui.wide.left.sidebar {
@include media(">desktop") {
width: $desktop-sidebar-width;
}
@include media(">widedesktop") {
width: $widedesktop-sidebar-width;
}
@include media(">desktop") {
width: $desktop-sidebar-width;
}
@include media(">widedesktop") {
width: $widedesktop-sidebar-width;
}
}
.sidebar {
.logo {
&.bordered.icon {
padding: .5em .41em !important;
.logo {
&.bordered.icon {
padding: .5em .41em !important;
}
path {
fill: white;
}
}
path {
fill: white;
.tab {
flex-direction: column;
}
}
.tab {
flex-direction: column;
}
}
.component-sidebar {
.ui.search .input {
flex: 1;
.prompt {
border-radius: 0;
.ui.search .input {
flex: 1;
.prompt {
border-radius: 0;
}
}
}
.ui.search .results {
vertical-align: middle;
}
.ui.search .name {
vertical-align: middle;
}
&.sidebar {
overflow-y: visible !important;
background: var(--sidebar-background);
z-index: 1;
@include media(">desktop") {
display: flex;
flex-direction: column;
justify-content: space-between;
padding-bottom: 4em;
.ui.search .results {
vertical-align: middle;
}
> nav {
flex-grow: 1;
overflow-y: auto;
.ui.search .name {
vertical-align: middle;
}
@include media(">desktop") {
.menu .item.collapse-button-wrapper {
&.sidebar {
overflow-y: visible !important;
background: var(--sidebar-background);
z-index: 1;
@include media(">desktop") {
display: flex;
flex-direction: column;
justify-content: space-between;
padding-bottom: 4em;
}
>nav {
flex-grow: 1;
overflow-y: auto;
}
@include media(">desktop") {
.menu .item.collapse-button-wrapper {
padding: 0;
}
.collapse.button {
display: none !important;
}
}
@include media("<=desktop") {
position: static !important;
width: 100% !important;
&.collapsed {
.player-wrapper,
.search,
.signup.segment,
nav.secondary {
display: none;
}
}
}
>div {
margin: 0;
background-color: var(--sidebar-background);
}
.menu.vertical {
background: transparent;
}
}
.ui.vertical.menu {
.item .item {
font-size: 1em;
>i.icon {
float: none;
margin: 0 0.5em 0 0;
}
}
.item.active {
border-right: 5px solid var(--vibrant-color);
border-radius: 0 !important;
background: var(--sidebar-active-item-background) !important;
}
.item.collapsed {
&:not(:focus)>.menu {
display: none;
}
.header {
margin-bottom: 0;
}
}
.collapsible.item .header {
cursor: pointer;
}
}
.ui.secondary.menu {
margin-left: 0;
margin-right: 0;
}
.tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
justify-content: space-between;
@include media("<=desktop") {
max-height: 500px;
}
}
.ui.tab.active {
display: flex;
}
.tab[data-tab="queue"] {
flex-direction: column;
tr {
cursor: pointer;
}
td:nth-child(2) {
width: 55px;
}
}
.item .header .angle.icon {
float: right;
margin: 0;
}
.tab[data-tab="library"] {
flex-direction: column;
flex: 1 1 auto;
>.menu {
flex: 1;
flex-grow: 1;
}
>.player-wrapper {
width: 100%;
}
}
.sidebar .segment {
margin: 0;
border-radius: 0;
}
.ui.menu .item.inline.admin-dropdown.dropdown>.menu {
left: 0;
right: auto;
}
.ui.segment.header-wrapper {
background: var(--sidebar-header-background);
color: var(--sidebar-header-color);
box-shadow: var(--sidebar-header-box-shadow);
padding: 0;
}
.collapse.button {
display: none !important;
}
}
@include media("<=desktop") {
position: static !important;
width: 100% !important;
&.collapsed {
.player-wrapper,
.search,
.signup.segment,
nav.secondary {
display: none;
}
}
}
> div {
margin: 0;
background-color: var(--sidebar-background);
}
.menu.vertical {
background: transparent;
}
}
.ui.vertical.menu {
.item .item {
font-size: 1em;
> i.icon {
float: none;
margin: 0 0.5em 0 0;
}
}
.item.active {
border-right: 5px solid var(--vibrant-color);
border-radius: 0 !important;
background: var(--sidebar-active-item-background) !important;
}
.item.collapsed {
&:not(:focus) > .menu {
display: none;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
height: 4em;
margin-bottom: 0;
}
}
.collaspable.item .header {
cursor: pointer;
}
}
.ui.secondary.menu {
margin-left: 0;
margin-right: 0;
}
.tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
justify-content: space-between;
@include media("<=desktop") {
max-height: 500px;
}
}
.ui.tab.active {
display: flex;
}
.tab[data-tab="queue"] {
flex-direction: column;
tr {
cursor: pointer;
}
td:nth-child(2) {
width: 55px;
}
}
.item .header .angle.icon {
float: right;
margin: 0;
}
.tab[data-tab="library"] {
flex-direction: column;
flex: 1 1 auto;
> .menu {
flex: 1;
flex-grow: 1;
}
> .player-wrapper {
width: 100%;
}
}
.sidebar .segment {
margin: 0;
border-radius: 0;
}
.ui.menu .item.inline.admin-dropdown.dropdown > .menu {
left: 0;
right: auto;
}
.ui.segment.header-wrapper {
background: var(--sidebar-header-background);
color: var(--sidebar-header-color);
box-shadow: var(--sidebar-header-box-shadow);
padding: 0;
display: flex;
justify-content: space-between;
align-items: center;
height: 4em;
margin-bottom: 0;
nav {
> .item, > .menu > .item > .item {
&:hover {
background-color: transparent;
nav {
>.item,
>.menu>.item>.item {
&:hover {
background-color: transparent;
}
}
}
}
}
}
nav.top.title-menu {
flex-grow: 1;
.item {
font-size: 1.5em;
nav.top.title-menu {
flex-grow: 1;
.item {
font-size: 1.5em;
}
}
}
.logo {
cursor: pointer;
display: inline-block;
margin: 0px;
}
&.collapsed .search-wrapper {
@include media("<desktop") {
padding: 0;
.logo {
cursor: pointer;
display: inline-block;
margin: 0px;
}
}
.ui.search {
display: flex;
}
.ui.message.black {
background: var(--sidebar-background);
}
.ui.mini.image {
width: 100%;
}
nav.top {
align-items: self-end;
padding: 0.5em 0;
> .item, > .right.menu > .item {
// color: rgba(255, 255, 255, 0.9) !important;
font-size: 1.2em;
&:hover, > .dropdown > .icon {
// color: rgba(255, 255, 255, 0.9) !important;
}
> .label, > .dropdown > .label {
font-size: 0.5em;
right: 1.7em;
bottom: -0.5em;
z-index: 0 !important;
}
&.collapsed .search-wrapper {
@include media("<desktop") {
padding: 0;
}
}
}
.ui.user-dropdown > .text > .label {
margin-right: 0;
}
.logo-wrapper {
display: inline-block;
margin: 0 auto;
@include media("<desktop") {
margin: 0;
.ui.search {
display: flex;
}
img {
height: 1em;
display: inline-block;
margin: 0 auto;
.ui.message.black {
background: var(--sidebar-background);
}
@include media(">tablet") {
img {
height: 1.5em;
}
.ui.mini.image {
width: 100%;
}
}
}
nav.top {
align-items: self-end;
padding: 0.5em 0;
>.item,
>.right.menu>.item {
// color: rgba(255, 255, 255, 0.9) !important;
font-size: 1.2em;
&:hover,
>.dropdown>.icon {
// color: rgba(255, 255, 255, 0.9) !important;
}
>.label,
>.dropdown>.label {
font-size: 0.5em;
right: 1.7em;
bottom: -0.5em;
z-index: 0 !important;
}
}
}
.ui.user-dropdown>.text>.label {
margin-right: 0;
}
.logo-wrapper {
display: inline-block;
margin: 0 auto;
@include media("<desktop") {
margin: 0;
}
img {
height: 1em;
display: inline-block;
margin: 0 auto;
}
@include media(">tablet") {
img {
height: 1.5em;
}
}
}
}

View File

@ -43,11 +43,11 @@
<empty-state v-else-if="!currentResults || currentResults.count === 0" @refresh="search" :refresh="true"></empty-state>
<div v-else-if="type === 'artists'" class="ui five app-cards cards">
<div v-else-if="type === 'artists' || type === 'podcasts'" class="ui five app-cards cards">
<artist-card :artist="artist" v-for="artist in currentResults.results" :key="artist.id"></artist-card>
</div>
<div v-else-if="type === 'albums'" class="ui five app-cards cards">
<div v-else-if="type === 'albums' || type === 'series'" class="ui five app-cards cards">
<album-card
v-for="album in currentResults.results"
:key="album.id"
@ -124,6 +124,8 @@ export default {
playlists: null,
radios: null,
tags: null,
podcasts: null,
series: null,
},
isLoading: false,
paginateBy: 25,
@ -147,15 +149,28 @@ export default {
submitSearch
}
},
axiosParams() {
const params = new URLSearchParams();
params.append('q', this.query);
params.append('page', this.page);
params.append('page_size', this.paginateBy);
if(this.currentType.contentCategory != undefined) {params.append('content_category', this.currentType.contentCategory)};
if(this.currentType.includeChannels != undefined) {params.append('include_channels', this.currentType.includeChannels)};
return params;
},
types () {
return [
{
id: 'artists',
label: this.$pgettext("*/*/*/Noun", "Artists"),
includeChannels: true,
contentCategory: 'music',
},
{
id: 'albums',
label: this.$pgettext("*/*/*", "Albums"),
includeChannels: true,
contentCategory: 'music',
},
{
id: 'tracks',
@ -174,6 +189,20 @@ export default {
id: 'tags',
label: this.$pgettext("*/*/*", "Tags"),
},
{
id: 'podcasts',
label: this.$pgettext("*/*/*", "Podcasts"),
endpoint: '/artists',
contentCategory: 'podcast',
includeChannels: true,
},
{
id: 'series',
label: this.$pgettext("*/*/*", "Series"),
endpoint: '/albums',
includeChannels: true,
contentCategory: 'podcast',
},
]
},
currentType () {
@ -197,13 +226,18 @@ export default {
this.isLoading = true
let response = await axios.get(
this.currentType.endpoint || this.currentType.id,
{params: {q: this.query, page: this.page, page_size: this.paginateBy}}
{params: this.axiosParams}
)
this.results[this.currentType.id] = response.data
this.isLoading = false
this.types.forEach(t => {
if (t.id != this.currentType.id) {
axios.get(t.endpoint || t.id, {params: {q: this.query, page_size: 1}}).then(response => {
axios.get(t.endpoint || t.id, {params: {
q: this.query,
page_size: 1,
content_category: t.contentCategory,
include_channels: t.includeChannels,
}}).then(response => {
this.results[t.id] = response.data
})
}