From 9e447ab5231b6462b0ed8b969f2df6524505b934 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Tue, 7 Apr 2020 17:19:17 +0200
Subject: [PATCH] See #170: UI for albums / series

---
 api/funkwhale_api/music/serializers.py        |   1 +
 front/src/components/audio/AlbumEntries.vue   |  57 ++++
 front/src/components/audio/ArtistLabel.vue    |  26 ++
 front/src/components/audio/ChannelEntries.vue |   4 +-
 .../src/components/audio/ChannelEntryCard.vue |  58 +++-
 .../src/components/audio/ChannelSerieCard.vue |   8 +-
 front/src/components/audio/ChannelSeries.vue  |  13 +-
 front/src/components/audio/track/Row.vue      |   8 +-
 front/src/components/common/HumanDuration.vue |   9 +-
 front/src/components/library/AlbumBase.vue    | 301 ++++++++----------
 front/src/components/library/AlbumDetail.vue  |  40 +--
 .../src/components/library/AlbumDropdown.vue  | 134 ++++++++
 front/src/components/library/TrackDetail.vue  |   6 +-
 front/src/components/metadata/ArtistCard.vue  |  69 ----
 front/src/components/metadata/CardMixin.vue   |  47 ---
 front/src/components/metadata/ReleaseCard.vue |  71 -----
 front/src/components/metadata/Search.vue      | 158 ---------
 front/src/filters.js                          |   8 +
 front/src/style/_main.scss                    | 109 +++++--
 front/src/style/themes/_light.scss            |   3 +
 front/src/utils/time.js                       |   9 +-
 .../src/views/admin/library/UploadDetail.vue  |   2 +-
 front/src/views/channels/DetailBase.vue       |   6 +-
 front/src/views/channels/DetailOverview.vue   |  12 +-
 .../views/content/libraries/FilesTable.vue    |   2 +-
 front/tests/unit/specs/store/player.spec.js   |   4 +-
 26 files changed, 571 insertions(+), 594 deletions(-)
 create mode 100644 front/src/components/audio/AlbumEntries.vue
 create mode 100644 front/src/components/audio/ArtistLabel.vue
 create mode 100644 front/src/components/library/AlbumDropdown.vue
 delete mode 100644 front/src/components/metadata/ArtistCard.vue
 delete mode 100644 front/src/components/metadata/CardMixin.vue
 delete mode 100644 front/src/components/metadata/ReleaseCard.vue
 delete mode 100644 front/src/components/metadata/Search.vue

diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 9a26361b3..756d18bfc 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -210,6 +210,7 @@ def serialize_album_track(track):
 
 
 class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
+    # XXX: remove in 1.0, it's expensive and can work with a filter/api call
     tracks = serializers.SerializerMethodField()
     artist = serializers.SerializerMethodField()
     cover = cover_field
diff --git a/front/src/components/audio/AlbumEntries.vue b/front/src/components/audio/AlbumEntries.vue
new file mode 100644
index 000000000..c9218b33a
--- /dev/null
+++ b/front/src/components/audio/AlbumEntries.vue
@@ -0,0 +1,57 @@
+<template>
+  <div class="album-entries">
+    <div :class="[{active: currentTrack && isPlaying && track.id === currentTrack.id}, 'album-entry']" v-for="track in tracks" :key="track.id">
+      <div class="actions">
+        <play-button class="basic circular icon" :button-classes="['circular inverted orange icon button']" :discrete="true" :icon-only="true" :track="track"></play-button>
+      </div>
+      <div class="position">{{ prettyPosition(track.position) }}</div>
+      <div class="content ellipsis">
+        <router-link :to="{name: 'library.tracks.detail', params: {id: track.id}}" class="discrete link">
+          <strong>{{ track.title }}</strong><br>
+        </router-link>
+      </div>
+      <div class="meta">
+        <template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](track.id)">
+         <track-favorite-icon class="tiny" :track="track"></track-favorite-icon>
+        </template>
+        <human-duration v-if="track.uploads[0] && track.uploads[0].duration" :duration="track.uploads[0].duration"></human-duration>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from 'axios'
+import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
+import PlayButton from '@/components/audio/PlayButton'
+import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
+import { mapGetters } from "vuex"
+
+
+export default {
+  props: {
+    tracks: Array,
+  },
+  components: {
+    PlayButton,
+    TrackFavoriteIcon
+  },
+  computed: {
+    ...mapGetters({
+      currentTrack: "queue/currentTrack",
+    }),
+
+    isPlaying () {
+      return this.$store.state.player.playing
+    },
+  },
+  methods: {
+    prettyPosition (position, size) {
+      var s = String(position);
+      while (s.length < (size || 2)) {s = "0" + s;}
+      return s;
+    }
+  }
+}
+</script>
diff --git a/front/src/components/audio/ArtistLabel.vue b/front/src/components/audio/ArtistLabel.vue
new file mode 100644
index 000000000..6f2b6e211
--- /dev/null
+++ b/front/src/components/audio/ArtistLabel.vue
@@ -0,0 +1,26 @@
+<template>
+  <router-link class="artist-label ui image label" :to="route">
+    <img :class="[{circular: artist.content_category != 'podcast'}]" v-if="artist.cover && artist.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.small_square_crop)" />
+    <i :class="[artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" v-else />
+    {{ artist.name }}
+  </router-link>
+</template>
+
+<script>
+
+import {momentFormat} from '@/filters'
+
+export default {
+  props: {
+    artist: Object,
+  },
+  computed: {
+    route () {
+      if (this.artist.channel) {
+        return {name: 'channels.detail', params: {id: this.artist.channel.uuid}}
+      }
+      return {name: 'library.artists.detail', params: {id: this.artist.id}}
+    }
+  }
+}
+</script>
diff --git a/front/src/components/audio/ChannelEntries.vue b/front/src/components/audio/ChannelEntries.vue
index 99ff45942..af3f27943 100644
--- a/front/src/components/audio/ChannelEntries.vue
+++ b/front/src/components/audio/ChannelEntries.vue
@@ -15,7 +15,7 @@
     <template v-if="!isLoading && objects.length === 0">
       <div class="ui placeholder segment">
         <div class="ui icon header">
-          <i class="compact disc icon"></i>
+          <i class="music icon"></i>
           No results matching your query
         </div>
       </div>
@@ -31,7 +31,7 @@ import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
 export default {
   props: {
     filters: {type: Object, required: true},
-    limit: {type: Number, default: 5},
+    limit: {type: Number, default: 10},
   },
   components: {
     ChannelEntryCard
diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue
index 3ceb89bfb..ade7f892c 100644
--- a/front/src/components/audio/ChannelEntryCard.vue
+++ b/front/src/components/audio/ChannelEntryCard.vue
@@ -1,33 +1,67 @@
 <template>
-  <div class="channel-entry-card">
-    <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
-    <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
+  <div :class="[{active: currentTrack && isPlaying && entry.id === currentTrack.id}, 'channel-entry-card']">
+    <div class="controls">
+      <play-button class="basic circular icon" :discrete="true" :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'inverted orange', 'icon', 'button']" :track="entry"></play-button>
+    </div>
+    <img
+      @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
+      class="channel-image image"
+      v-if="cover && cover.original"
+      v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+    <span
+      @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
+      class="channel-image image"
+      v-else-if="entry.artist.content_category === 'podcast'">#{{ entry.position }}</span>
+    <img
+      @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
+      class="channel-image image"
+      v-else-if="entry.album && entry.album.cover && entry.album.cover.original"
+      v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.square_crop)">
+    <img
+      @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
+      class="channel-image image"
+      v-else
+      src="../../assets/audio/default-cover.png">
     <div class="ellipsis content">
       <strong>
         <router-link class="discrete link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
           {{ entry.title }}
         </router-link>
       </strong>
-      <div class="description">
-        <human-date :date="entry.creation_date"></human-date><template v-if="duration"> ·
-        <human-duration :duration="duration"></human-duration></template>
-      </div>
+      <br>
+      <human-date class="really discrete" :date="entry.creation_date"></human-date>
     </div>
-    <div class="controls">
-      <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button>
+    <div class="meta">
+        <template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
+          <track-favorite-icon class="tiny" :track="entry"></track-favorite-icon>
+        </template>
+        <human-duration v-if="duration" :duration="duration"></human-duration>
+
     </div>
   </div>
 </template>
 
 <script>
 import PlayButton from '@/components/audio/PlayButton'
+import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
+import { mapGetters } from "vuex"
+
 
 export default {
   props: ['entry'],
   components: {
     PlayButton,
+    TrackFavoriteIcon,
   },
   computed: {
+
+    ...mapGetters({
+      currentTrack: "queue/currentTrack",
+    }),
+
+    isPlaying () {
+      return this.$store.state.player.playing
+    },
     imageUrl () {
       let url = '../../assets/audio/default-cover.png'
       let cover = this.cover
@@ -42,9 +76,6 @@ export default {
       if (this.entry.cover) {
         return this.entry.cover
       }
-      if (this.entry.album && this.entry.album.cover) {
-        return this.entry.album.cover
-      }
     },
     duration () {
       let uploads = this.entry.uploads.filter((e) => {
@@ -60,7 +91,4 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped>
-.default-cover {
-  background-image: url("../../assets/audio/default-cover.png") !important;
-}
 </style>
diff --git a/front/src/components/audio/ChannelSerieCard.vue b/front/src/components/audio/ChannelSerieCard.vue
index 7d4e246e5..aa27a8b0f 100644
--- a/front/src/components/audio/ChannelSerieCard.vue
+++ b/front/src/components/audio/ChannelSerieCard.vue
@@ -1,10 +1,10 @@
 <template>
   <div class="channel-serie-card">
     <div class="two-images">
-      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
-      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
-      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
-      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
+      <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+      <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
+      <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+      <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
     </div>
     <div class="content">
       <strong>
diff --git a/front/src/components/audio/ChannelSeries.vue b/front/src/components/audio/ChannelSeries.vue
index 0de82fbd3..5f9fa99d9 100644
--- a/front/src/components/audio/ChannelSeries.vue
+++ b/front/src/components/audio/ChannelSeries.vue
@@ -5,7 +5,12 @@
     <div v-if="isLoading" class="ui inverted active dimmer">
       <div class="ui loader"></div>
     </div>
-    <channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
+    <template v-if="isPodcast">
+      <channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
+    </template>
+    <div v-else class="ui app-cards cards">
+      <album-card v-for="album in objects" :album="album" :key="album.id" />
+    </div>
     <template v-if="nextPage">
       <div class="ui hidden divider"></div>
       <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
@@ -27,14 +32,18 @@
 import _ from '@/lodash'
 import axios from 'axios'
 import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
+import AlbumCard from '@/components/audio/album/Card'
+
 
 export default {
   props: {
     filters: {type: Object, required: true},
+    isPodcast: {type: Boolean, default: true},
     limit: {type: Number, default: 5},
   },
   components: {
-    ChannelSerieCard
+    ChannelSerieCard,
+    AlbumCard,
   },
   data () {
     return {
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
index 9afd67dd3..d655a2126 100644
--- a/front/src/components/audio/track/Row.vue
+++ b/front/src/components/audio/track/Row.vue
@@ -26,7 +26,7 @@
       </router-link>
     </td>
     <td colspan="4" v-if="track.uploads && track.uploads.length > 0">
-      {{ time.parse(track.uploads[0].duration) }}
+      <human-duration :duration="track.uploads[0].duration"></human-duration>
     </td>
     <td colspan="4" v-else>
       <translate translate-context="*/*/*">N/A</translate>
@@ -49,7 +49,6 @@
 
 <script>
 import { mapGetters } from "vuex"
-import time from '@/utils/time'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
 import PlayButton from '@/components/audio/PlayButton'
@@ -67,11 +66,6 @@ export default {
     TrackPlaylistIcon,
     PlayButton
   },
-  data () {
-    return {
-      time
-    }
-  },
   computed: {
     ...mapGetters({
       currentTrack: "queue/currentTrack",
diff --git a/front/src/components/common/HumanDuration.vue b/front/src/components/common/HumanDuration.vue
index 3fc1ffdab..07b4fdd03 100644
--- a/front/src/components/common/HumanDuration.vue
+++ b/front/src/components/common/HumanDuration.vue
@@ -1,20 +1,13 @@
 <template>
   <time :datetime="`${duration}s`">
-    <template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}:</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }}
+    {{ duration | duration}}
   </time>
 
 </template>
 <script>
-import {secondsToObject} from '@/filters'
-
 export default {
   props: {
     duration: {required: true},
   },
-  computed: {
-    durationObj () {
-      return secondsToObject(this.duration)
-    }
-  }
 }
 </script>
diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue
index 9e43be1c9..614f9be44 100644
--- a/front/src/components/library/AlbumBase.vue
+++ b/front/src/components/library/AlbumBase.vue
@@ -4,131 +4,127 @@
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
     <template v-if="object">
-      <section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title">
-        <div class="segment-content">
-          <h2 class="ui center aligned icon header">
-            <i class="circular inverted sound yellow icon"></i>
-            <div class="content">
-              {{ object.title }}
-              <div v-html="subtitle"></div>
-            </div>
-          </h2>
-          <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
-          <div class="ui hidden divider"></div>
-          <div class="header-buttons">
-
-            <div class="ui buttons">
-              <play-button class="orange" :tracks="object.tracks">
-                <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
-              </play-button>
-            </div>
-
-            <modal v-if="publicLibraries.length > 0" :show.sync="showEmbedModal">
-              <div class="header">
-                <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate>
-              </div>
-              <div class="content">
-                <div class="description">
-                  <embed-wizard type="album" :id="object.id" />
-
+      <section class="ui vertical stripe segment channel-serie">
+        <div class="ui stackable grid container">
+          <div class="ui seven wide column">
+            <div v-if="isSerie" class="padded basic segment">
+              <div class="ui two column grid" v-if="isSerie">
+                <div class="column">
+                  <div class="large two-images">
+                    <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
+                    <img class="channel-image" v-else src="../../assets/audio/default-cover.png">
+                    <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
+                    <img class="channel-image" v-else src="../../assets/audio/default-cover.png">
+                  </div>
                 </div>
-              </div>
-              <div class="actions">
-                <div class="ui basic deny button">
-                  <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+                <div class="ui column right aligned">
+                  <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
+                  <div class="ui small hidden divider"></div>
+                  <human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
+                  <template v-if="totalTracks > 0">
+                    <div class="ui hidden very small divider"></div>
+                    <translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
+                      translate-plural="%{ count } episodes"
+                      :translate-n="totalTracks"
+                      :translate-params="{count: totalTracks}">
+                      %{ count } episode
+                    </translate>
+                    <translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate>
+                  </template>
+                  <div class="ui small hidden divider"></div>
+                  <play-button class="orange" :tracks="object.tracks"></play-button>
+                  <div class="ui hidden horizontal divider"></div>
+                  <album-dropdown
+                    :object="object"
+                    :public-libraries="publicLibraries"
+                    :is-loading="isLoading"
+                    :is-album="isAlbum"
+                    :is-serie="isSerie"
+                    :is-channel="isChannel"
+                    :artist="artist"></album-dropdown>
                 </div>
               </div>
-            </modal>
-            <div class="ui buttons">
-              <button class="ui button" @click="$refs.dropdown.click()">
-                <translate translate-context="*/*/Button.Label/Noun">More…</translate>
-              </button>
-              <div class="ui floating dropdown icon button" ref="dropdown" v-dropdown>
-                <i class="dropdown icon"></i>
-                <div class="menu">
-                  <div
-                    role="button"
-                    v-if="publicLibraries.length > 0"
-                    @click="showEmbedModal = !showEmbedModal"
-                    class="basic item">
-                    <i class="code icon"></i>
-                    <translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
-                  </div>
-                  <a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item">
-                    <i class="wikipedia w icon"></i>
-                    <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate>
-                  </a>
-                  <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item">
-                    <i class="external icon"></i>
-                    <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
-                  </a>
-		  <a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
-		    <i class="external icon"></i>
-		    <translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
-                  </a>
-		  <router-link
-                    v-if="object.is_local"
-                    :to="{name: 'library.albums.edit', params: {id: object.id }}"
-                    class="basic item">
-                    <i class="edit icon"></i>
-                    <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
-                  </router-link>
-                  <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'item']"
-                    v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
-                    @confirm="remove()">
-                    <i class="ui trash icon"></i>
-                    <translate translate-context="*/*/*/Verb">Delete…</translate>
-                    <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
-                    <div slot="modal-content">
-                      <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
-                    </div>
-                    <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
-                  </dangerous-button>
-                  <div class="divider"></div>
-                  <div
-                    role="button"
-                    class="basic item"
-                    v-for="obj in getReportableObjs({album: object})"
-                    :key="obj.target.type + obj.target.id"
-                    @click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
-                    <i class="share icon" /> {{ obj.label }}
-                  </div>
-                  <div class="divider"></div>
-                  <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
-                    <i class="wrench icon"></i>
-                    <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
-                  </router-link>
-                  <a
-                    v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
-                    class="basic item"
-                    :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
-                    target="_blank" rel="noopener noreferrer">
-                    <i class="wrench icon"></i>
-                    <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
-                  </a>
-                </div>
+              <div class="ui small hidden divider"></div>
+              <header>
+                <h2 class="ui header" :title="object.title">
+                  {{ object.title }}
+                </h2>
+                <artist-label :artist="artist"></artist-label>
+              </header>
+            </div>
+            <div v-else class="ui center aligned text padded basic segment">
+              <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
+              <img class="channel-image" v-else src="../../assets/audio/default-cover.png">
+              <div class="ui hidden divider"></div>
+              <header>
+                <h2 class="ui header" :title="object.title">
+                  {{ object.title }}
+                </h2>
+                <artist-label class="rounded" :artist="artist"></artist-label>
+              </header>
+              <div class="ui small hidden divider"></div>
+              <template v-if="totalTracks > 0">
+                <div class="ui hidden very small divider"></div>
+                <translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
+                  translate-plural="%{ count } episodes"
+                  :translate-n="totalTracks"
+                  :translate-params="{count: totalTracks}">
+                  %{ count } episode
+                </translate>
+                <translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate> ·
+              </template>
+              <human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
+              <div class="ui small hidden divider"></div>
+              <play-button class="orange" :tracks="object.tracks"></play-button>
+              <div class="ui horizontal hidden divider"></div>
+              <album-dropdown
+                :object="object"
+                :public-libraries="publicLibraries"
+                :is-loading="isLoading"
+                :is-album="isAlbum"
+                :is-serie="isSerie"
+                :is-channel="isChannel"
+                :artist="artist"></album-dropdown>
+              <div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
+                <div class="ui small hidden divider"></div>
+                <div class="ui divider"></div>
+                <div class="ui small hidden divider"></div>
+                <template v-if="object.tags && object.tags.length > 0" >
+                  <tags-list :tags="object.tags"></tags-list>
+                  <div class="ui small hidden divider"></div>
+                </template>
+                <rendered-description
+                  v-if="object.description"
+                  :content="object.description"
+                  :can-update="false"></rendered-description>
+                <router-link v-else-if="$store.state.auth.authenticated && object.is_local" :to="{name: 'library.albums.edit', params: {id: object.id }}">
+                  <i class="pencil icon"></i>
+                  <translate translate-context="Content/*/Button.Label/Verb">Add a description…</translate>
+                </router-link>
               </div>
             </div>
           </div>
+          <rendered-description
+            v-if="isSerie"
+            :content="object.description"
+            :can-update="false"></rendered-description>
+          <div class="nine wide column">
+            <router-view v-if="object" :is-serie="isSerie" :artist="artist" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
+          </div>
         </div>
       </section>
-      <router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
     </template>
   </main>
 </template>
 
 <script>
 import axios from "axios"
-import logger from "@/logging"
+import lodash from "@/lodash"
 import backend from "@/audio/backend"
 import PlayButton from "@/components/audio/PlayButton"
-import EmbedWizard from "@/components/audio/EmbedWizard"
-import Modal from '@/components/semantic/Modal'
 import TagsList from "@/components/tags/List"
-import ReportMixin from '@/components/mixins/Report'
-
-const FETCH_URL = "albums/"
+import ArtistLabel from '@/components/audio/ArtistLabel'
+import AlbumDropdown from './AlbumDropdown'
 
 
 function groupByDisc(acc, track) {
@@ -143,13 +139,12 @@ function groupByDisc(acc, track) {
 }
 
 export default {
-  mixins: [ReportMixin],
   props: ["id"],
   components: {
     PlayButton,
-    EmbedWizard,
-    Modal,
     TagsList,
+    ArtistLabel,
+    AlbumDropdown,
   },
   data() {
     return {
@@ -158,26 +153,24 @@ export default {
       artist: null,
       discs: [],
       libraries: [],
-      showEmbedModal: false
     }
   },
-  created() {
-    this.fetchData()
+  async created() {
+    await this.fetchData()
   },
   methods: {
-    fetchData() {
-      var self = this
+    async fetchData() {
       this.isLoading = true
-      let url = FETCH_URL + this.id + "/"
-      logger.default.debug('Fetching album "' + this.id + '"')
-      axios.get(url, {params: {refresh: 'true'}}).then(response => {
-        self.object = backend.Album.clean(response.data)
-        self.discs = self.object.tracks.reduce(groupByDisc, [])
-        axios.get(`artists/${response.data.artist.id}/`).then(response => {
-          self.artist = response.data
-        })
-        self.isLoading = false
-      })
+      let albumResponse = await axios.get(`albums/${this.id}/`, {params: {refresh: 'true'}})
+      let artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
+      this.artist = artistResponse.data
+      if (this.artist.channel) {
+        this.artist.channel.artist = this.artist
+      }
+      this.object = backend.Album.clean(albumResponse.data)
+      this.discs = this.object.tracks.reduce(groupByDisc, [])
+      this.isLoading = false
+
     },
     remove () {
       let self = this
@@ -193,9 +186,30 @@ export default {
     }
   },
   computed: {
+    totalTracks () {
+      return this.object.tracks.length
+    },
+    isChannel () {
+      return this.object.artist.channel
+    },
+    isSerie () {
+      return this.object.artist.content_category === 'podcast'
+    },
+    isAlbum () {
+      return this.object.artist.content_category === 'music'
+    },
+    totalDuration () {
+      let durations = [0]
+      this.object.tracks.forEach((t) => {
+        if (t.uploads[0] && t.uploads[0].duration) {
+          durations.push(t.uploads[0].duration)
+        }
+      })
+      return lodash.sum(durations)
+    },
     labels() {
       return {
-        title: this.$pgettext('*/*/*', 'Album')
+        title: this.$pgettext('*/*/*', 'Album'),
       }
     },
     publicLibraries () {
@@ -203,39 +217,6 @@ export default {
         return l.privacy_level === 'everyone'
       })
     },
-    wikipediaUrl() {
-      return (
-        "https://en.wikipedia.org/w/index.php?search=" +
-        encodeURI(this.object.title + " " + this.object.artist.name)
-      )
-    },
-    musicbrainzUrl() {
-      if (this.object.mbid) {
-        return "https://musicbrainz.org/release/" + this.object.mbid
-      }
-    },
-    discogsUrl() {
-      return (
-        "https://discogs.com/search/?type=release&title=" +
-	encodeURI(this.object.title) + "&artist=" +
-	encodeURI(this.object.artist.name)
-	)
-    },
-    headerStyle() {
-      if (!this.object.cover.original) {
-        return ""
-      }
-      return (
-        "background-image: url(" +
-        this.$store.getters["instance/absoluteUrl"](this.object.cover.original) +
-        ")"
-      )
-    },
-    subtitle () {
-      let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.object.artist.id }})
-      let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.object.tracks.length)
-      return this.$gettextInterpolate(msg, {count: this.object.tracks.length, artist: this.object.artist.name, artistUrl: route.href})
-    }
   },
   watch: {
     id() {
diff --git a/front/src/components/library/AlbumDetail.vue b/front/src/components/library/AlbumDetail.vue
index a3b924d94..f4a8fa6aa 100644
--- a/front/src/components/library/AlbumDetail.vue
+++ b/front/src/components/library/AlbumDetail.vue
@@ -1,35 +1,33 @@
 <template>
   <div v-if="object">
-    <template v-if="discs && discs.length > 1">
-      <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment">
+    <h2 class="ui header">
+      <translate key="1" v-if="isSerie" translate-context="Content/Channels/*">Episodes</translate>
+      <translate key="2" v-else translate-context="*/*/*">Tracks</translate>
+    </h2>
+    <channel-entries v-if="artist.channel && isSerie" :limit="50" :filters="{channel: artist.channel.uuid, ordering: '-creation_date'}">
+    </channel-entries>
+    <template v-else-if="discs && discs.length > 1">
+      <div v-for="(tracks, discNumber) in discs" :key="discNumber">
+        <div class="ui hidden divider"></div>
         <translate
-          tag="h2"
-          class="left floated"
-          :translate-params="{number: disc_number + 1}"
+          tag="h3"
+          :translate-params="{number: discNumber + 1}"
           translate-context="Content/Album/"
         >Volume %{ number }</translate>
-        <play-button class="right floated orange" :tracks="tracks">
-          <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
-        </play-button>
-        <track-table :artist="object.artist" :display-position="true" :tracks="tracks"></track-table>
-      </section>
+        <album-entries :tracks="tracks"></album-entries>
+      </div>
     </template>
     <template v-else>
-      <section class="ui vertical stripe segment">
-        <h2>
-          <translate translate-context="*/*/*">Tracks</translate>
-        </h2>
-        <track-table v-if="object" :artist="object.artist" :display-position="true" :tracks="object.tracks"></track-table>
-      </section>
+      <album-entries :tracks="object.tracks"></album-entries>
     </template>
-    <section class="ui vertical stripe segment">
+    <template v-if="!artist.channel && !isSerie">
       <h2>
         <translate translate-context="Content/*/Title/Noun">User libraries</translate>
       </h2>
       <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'albums/' + object.id + '/libraries/'">
         <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate>
       </library-widget>
-    </section>
+    </template>
   </div>
 </template>
 
@@ -41,11 +39,15 @@ import url from "@/utils/url"
 import logger from "@/logging"
 import LibraryWidget from "@/components/federation/LibraryWidget"
 import TrackTable from "@/components/audio/track/Table"
+import ChannelEntries from '@/components/audio/ChannelEntries'
+import AlbumEntries from '@/components/audio/AlbumEntries'
 
 export default {
-  props: ["object", "libraries", "discs"],
+  props: ["object", "libraries", "discs", "isSerie", "artist"],
   components: {
     LibraryWidget,
+    AlbumEntries,
+    ChannelEntries,
     TrackTable
   },
   data() {
diff --git a/front/src/components/library/AlbumDropdown.vue b/front/src/components/library/AlbumDropdown.vue
new file mode 100644
index 000000000..8a065833e
--- /dev/null
+++ b/front/src/components/library/AlbumDropdown.vue
@@ -0,0 +1,134 @@
+<template>
+  <span>
+
+    <modal v-if="isEmbedable" :show.sync="showEmbedModal">
+      <div class="header">
+        <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate>
+      </div>
+      <div class="content">
+        <div class="description">
+          <embed-wizard type="album" :id="object.id" />
+
+        </div>
+      </div>
+      <div class="actions">
+        <div class="ui basic deny button">
+          <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+        </div>
+      </div>
+    </modal>
+    <div role="button" class="ui floating dropdown circular icon basic button" :title="labels.more" v-dropdown="{direction: 'downward'}">
+      <i class="ellipsis vertical icon"></i>
+      <div class="menu">
+        <div
+          role="button"
+          v-if="isEmbedable"
+          @click="showEmbedModal = !showEmbedModal"
+          class="basic item">
+          <i class="code icon"></i>
+          <translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
+        </div>
+        <a v-if="isAlbum && musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item">
+          <i class="external icon"></i>
+          <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
+        </a>
+        <a v-if="!isChannel && isAlbum" :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
+          <i class="external icon"></i>
+          <translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
+                    </a>
+        <router-link
+          v-if="object.is_local"
+          :to="{name: 'library.albums.edit', params: {id: object.id }}"
+          class="basic item">
+          <i class="edit icon"></i>
+          <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
+        </router-link>
+        <dangerous-button
+          :class="['ui', {loading: isLoading}, 'item']"
+          v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
+          @confirm="remove()">
+          <i class="ui trash icon"></i>
+          <translate translate-context="*/*/*/Verb">Delete…</translate>
+          <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
+          <div slot="modal-content">
+            <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
+          </div>
+          <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
+        </dangerous-button>
+        <div class="divider"></div>
+        <div
+          role="button"
+          class="basic item"
+          v-for="obj in getReportableObjs({album: object, channel: artist.channel})"
+          :key="obj.target.type + obj.target.id"
+          @click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
+          <i class="share icon" /> {{ obj.label }}
+        </div>
+        <div class="divider"></div>
+        <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
+          <i class="wrench icon"></i>
+          <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
+        </router-link>
+        <a
+          v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
+          class="basic item"
+          :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
+          target="_blank" rel="noopener noreferrer">
+          <i class="wrench icon"></i>
+          <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
+        </a>
+      </div>
+    </div>
+  </span>
+</template>
+<script>
+import EmbedWizard from "@/components/audio/EmbedWizard"
+import Modal from '@/components/semantic/Modal'
+import ReportMixin from '@/components/mixins/Report'
+
+
+export default {
+  mixins: [ReportMixin],
+  props: {
+    isLoading: Boolean,
+    artist: Object,
+    object: Object,
+    publicLibraries: Array,
+    isAlbum: Boolean,
+    isChannel: Boolean,
+    isSerie: Boolean,
+  },
+  components: {
+    EmbedWizard,
+    Modal,
+  },
+  data () {
+    return {
+      showEmbedModal: false,
+    }
+  },
+  computed: {
+    labels() {
+      return {
+        more: this.$pgettext('*/*/Button.Label/Noun', "More…"),
+      }
+    },
+    isEmbedable () {
+      return (this.isChannel && this.artist.channel.actor) || this.publicLibraries.length > 0
+    },
+
+    musicbrainzUrl() {
+      if (this.object.mbid) {
+        return "https://musicbrainz.org/release/" + this.object.mbid
+      }
+    },
+    discogsUrl() {
+      return (
+        "https://discogs.com/search/?type=release&title=" +
+        encodeURI(this.object.title) + "&artist=" +
+        encodeURI(this.object.artist.name)
+      )
+    },
+  }
+}
+</script>
diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue
index d271ed88c..ffc4be818 100644
--- a/front/src/components/library/TrackDetail.vue
+++ b/front/src/components/library/TrackDetail.vue
@@ -16,7 +16,7 @@
                     <translate translate-context="Content/*/*">Duration</translate>
                   </td>
                   <td class="right aligned">
-                    <template v-if="upload.duration">{{ time.parse(upload.duration) }}</template>
+                    <template v-if="upload.duration">{{ upload.duration | duration }}</template>
                     <translate v-else translate-context="*/*/*">N/A</translate>
                   </td>
                 </tr>
@@ -60,7 +60,7 @@
 
           <rendered-description
             :content="track.description"
-            can-update="false"></rendered-description>
+            :can-update="false"></rendered-description>
           <h2 class="ui header">
             <translate translate-context="Content/*/*">Release Details</translate>
           </h2>
@@ -154,7 +154,6 @@
 </template>
 
 <script>
-import time from "@/utils/time"
 import axios from "axios"
 import url from "@/utils/url"
 import logger from "@/logging"
@@ -173,7 +172,6 @@ export default {
   },
   data() {
     return {
-      time,
       id: this.track.id,
       licenseData: null
     }
diff --git a/front/src/components/metadata/ArtistCard.vue b/front/src/components/metadata/ArtistCard.vue
deleted file mode 100644
index 531c2645c..000000000
--- a/front/src/components/metadata/ArtistCard.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<template>
-  <div class="ui card">
-    <div class="content">
-      <div v-if="isLoading" class="ui vertical segment">
-        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      </div>
-      <template v-if="data.id">
-        <header class="header">
-          <a :href="getMusicbrainzUrl('artist', data.id)" target="_blank" :title="labels.musicbrainz">{{ data.name }}</a>
-        </header>
-        <div class="description">
-          <table class="ui very basic fixed single line compact table">
-            <tbody>
-              <tr v-for="group in releasesGroups">
-                <td>
-                  {{ group['first-release-date'] }}
-                </td>
-                <td colspan="3">
-                  <a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" :title="labels.musicbrainz">
-                    {{ group.title }}
-                  </a>
-                </td>
-                  <td>
-                  </td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-      </template>
-    </div>
-  </div>
-</template>
-
-<script>
-import Vue from "vue"
-import CardMixin from "./CardMixin"
-import time from "@/utils/time"
-
-export default Vue.extend({
-  mixins: [CardMixin],
-  data() {
-    return {
-      time
-    }
-  },
-  computed: {
-    labels() {
-      return {
-        musicbrainz: this.$pgettext('Content/*/*/Clickable, Verb', "View on MusicBrainz")
-      }
-    },
-    type() {
-      return "artist"
-    },
-    releasesGroups() {
-      return this.data["release-group-list"].filter(r => {
-        return r.type === "Album"
-      })
-    }
-  }
-})
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.ui.card {
-  width: 100% !important;
-}
-</style>
diff --git a/front/src/components/metadata/CardMixin.vue b/front/src/components/metadata/CardMixin.vue
deleted file mode 100644
index a7cd476f6..000000000
--- a/front/src/components/metadata/CardMixin.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<template>
-
-</template>
-
-<script>
-import axios from 'axios'
-import logger from '@/logging'
-
-export default {
-  props: {
-    mbId: {type: String, required: true}
-  },
-  created: function () {
-    this.fetchData()
-  },
-  data: function () {
-    return {
-      isLoading: false,
-      data: {}
-    }
-  },
-  methods: {
-    fetchData () {
-      let self = this
-      this.isLoading = true
-      let url = 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
-      axios.get(url).then((response) => {
-        logger.default.info('successfully fetched', self.type, self.mbId)
-        self.data = response.data[self.type]
-        this.$emit('metadata-changed', self.data)
-        self.isLoading = false
-      }, (response) => {
-        logger.default.error('error while fetching', self.type, self.mbId)
-        self.isLoading = false
-      })
-    },
-    getMusicbrainzUrl (type, id) {
-      return 'https://musicbrainz.org/' + type + '/' + id
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-
-</style>
diff --git a/front/src/components/metadata/ReleaseCard.vue b/front/src/components/metadata/ReleaseCard.vue
deleted file mode 100644
index 08a0fe4a5..000000000
--- a/front/src/components/metadata/ReleaseCard.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<template>
-  <div class="ui card">
-    <div class="content">
-      <div v-if="isLoading" class="ui vertical segment">
-        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      </div>
-      <template v-if="data.id">
-        <div class="header">
-          <a :href="getMusicbrainzUrl('release', data.id)" target="_blank" :title="labels.musicbrainz">{{ data.title }}</a>
-        </div>
-        <div class="meta">
-          <a :href="getMusicbrainzUrl('artist', data['artist-credit'][0]['artist']['id'])" target="_blank" :title="labels.musicbrainz">{{ data['artist-credit-phrase'] }}</a>
-        </div>
-        <div class="description">
-          <table class="ui very basic fixed single line compact table">
-            <tbody>
-              <tr v-for="track in tracks">
-                <td>
-                  {{ track.position }}
-                </td>
-                <td colspan="3">
-                  <a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" :title="labels.musicbrainz">
-                    {{ track.recording.title }}
-                  </a>
-                </td>
-                  <td>
-                    {{ time.parse(parseInt(track.length) / 1000) }}
-                  </td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-      </template>
-    </div>
-  </div>
-</template>
-
-<script>
-import Vue from 'vue'
-import CardMixin from './CardMixin'
-import time from '@/utils/time'
-
-export default Vue.extend({
-  mixins: [CardMixin],
-  data () {
-    return {
-      time
-    }
-  },
-  computed: {
-    labels () {
-      return {
-        musicbrainz: this.$pgettext('Content/*/*/Clickable, Verb', 'View on MusicBrainz')
-      }
-    },
-    type () {
-      return 'release'
-    },
-    tracks () {
-      return this.data['medium-list'][0]['track-list']
-    }
-  }
-})
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.ui.card {
-    width: 100% !important;
-}
-</style>
diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue
deleted file mode 100644
index f7feb511f..000000000
--- a/front/src/components/metadata/Search.vue
+++ /dev/null
@@ -1,158 +0,0 @@
-<template>
-  <div>
-    <div class="ui form">
-      <div class="inline fields">
-        <div v-for="type in types"  class="field">
-          <div class="ui radio checkbox">
-            <input type="radio" :value="type.value" v-model="currentType">
-            <label >{{ type.label }}</label>
-          </div>
-        </div>
-      </div>
-    </div>
-    <div class="ui fluid search">
-      <div class="ui icon input">
-        <input class="prompt" :placeholder="labels.placeholder" type="text">
-        <i class="search icon"></i>
-      </div>
-      <div class="results"></div>
-    </div>
-  </div>
-</template>
-
-<script>
-import jQuery from 'jquery'
-
-export default {
-  props: {
-    mbType: {type: String, required: false},
-    mbId: {type: String, required: false}
-  },
-  data: function () {
-    return {
-      currentType: this.mbType || 'artist',
-      currentId: this.mbId || ''
-    }
-  },
-
-  mounted: function () {
-    jQuery(this.$el).find('.ui.checkbox').checkbox()
-    this.setUpSearch()
-  },
-  methods: {
-
-    setUpSearch () {
-      var self = this
-      jQuery(this.$el).search({
-        minCharacters: 3,
-        onSelect (result, response) {
-          self.currentId = result.id
-        },
-        apiSettings: {
-          beforeXHR: function (xhrObject, s) {
-            xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
-            return xhrObject
-          },
-          onResponse: function (initialResponse) {
-            let category = self.currentTypeObject.value
-            let results = initialResponse[category + '-list'].map(r => {
-              let description = []
-              if (category === 'artist') {
-                if (r.type) {
-                  description.push(r.type)
-                }
-                if (r.area) {
-                  description.push(r.area.name)
-                } else if (r['begin-area']) {
-                  description.push(r['begin-area'].name)
-                }
-                return {
-                  title: r.name,
-                  id: r.id,
-                  description: description.join(' - ')
-                }
-              }
-              if (category === 'release') {
-                if (r['medium-track-count']) {
-                  description.push(
-                    r['medium-track-count'] + ' tracks'
-                  )
-                }
-                if (r['artist-credit-phrase']) {
-                  description.push(r['artist-credit-phrase'])
-                }
-                if (r['date']) {
-                  description.push(r['date'])
-                }
-                return {
-                  title: r.title,
-                  id: r.id,
-                  description: description.join(' - ')
-                }
-              }
-              if (category === 'recording') {
-                if (r['artist-credit-phrase']) {
-                  description.push(r['artist-credit-phrase'])
-                }
-                return {
-                  title: r.title,
-                  id: r.id,
-                  description: description.join(' - ')
-                }
-              }
-            })
-            return {results: results}
-          },
-          url: this.searchUrl
-        }
-      })
-    }
-  },
-  computed: {
-    labels () {
-      return {
-        placeholder: this.$pgettext('Content/Library/Input.Placeholder/Verb', 'Enter your search query…')
-      }
-    },
-    currentTypeObject: function () {
-      let self = this
-      return this.types.filter(t => {
-        return t.value === self.currentType
-      })[0]
-    },
-    searchUrl: function () {
-      return this.$store.getters['instance/absoluteUrl']('api/v1/providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}')
-    },
-    types: function () {
-      return [
-        {
-          value: 'artist',
-          label: this.$pgettext('*/*/*/Noun', 'Artist')
-        },
-        {
-          value: 'release',
-          label: this.$pgettext('*/*/*', 'Album')
-        },
-        {
-          value: 'recording',
-          label: this.$pgettext('*/*/*/Noun', 'Track')
-        }
-      ]
-    }
-  },
-  watch: {
-    currentType (newValue) {
-      this.setUpSearch()
-      this.$emit('type-changed', newValue)
-    },
-    currentId (newValue) {
-      this.$emit('id-changed', newValue)
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-
-</style>
diff --git a/front/src/filters.js b/front/src/filters.js
index b88df2f0e..030e50e83 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -1,5 +1,7 @@
 import Vue from 'vue'
 
+import time from '@/utils/time'
+
 import moment from 'moment'
 
 export function truncate (str, max, ellipsis, middle) {
@@ -89,6 +91,12 @@ export function padDuration (duration) {
 
 Vue.filter('padDuration', padDuration)
 
+export function duration (seconds) {
+  return time.parse(seconds)
+}
+
+Vue.filter('duration', duration)
+
 export function momentFormat (date, format) {
   format = format || 'lll'
   return moment(date).format(format)
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 3af8480d3..ce47d9736 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -420,6 +420,10 @@ input + .help {
 .ui.very.small.divider {
   margin: 0.25rem 0;
 }
+.ui.horizontal.divider {
+  display: inline-block;
+  margin: 0 0.5em;
+}
 
 .queue.segment.player-focused #queue-grid #player {
   @include media("<desktop") {
@@ -562,39 +566,106 @@ input + .help {
 }
 
 //  channels stuff
-
+@mixin two-images {
+  margin-right: 1em;
+  position: relative;
+  width: 3.5em;
+  height: 3.5em;
+  &.large {
+    width: 15em;
+    height: 15em;
+    img {
+      width: 11em;
+    }
+  }
+  img {
+    width: 2.5em;
+    position: absolute;
+    &:last-child {
+      bottom: 0;
+      left: 0;
+    }
+    &:first-child {
+      top: 0;
+      right: 0;
+    }
+  }
+}
+.two-images {
+  @include two-images;
+}
 .channel-entry-card, .channel-serie-card {
   display: flex;
   width: 100%;
   align-items: center;
-  margin: 0 auto 1em;
   justify-content: space-between;
+  .controls {
+    margin-right: 1em;
+  }
   .image {
-    width: 3.5em;
+    width: 3em;
+    height: 3em;
     margin-right: 1em;
+    line-height: 3em;
+    text-align: center;
+    font-weight: bold;
   }
   .two-images {
-    width: 3.5em;
-    height: 3.5em;
-    margin-right: 1em;
-    position: relative;
-    img {
-      width: 2.5em;
-      position: absolute;
-      &:last-child {
-        bottom: 0;
-        left: 0;
-      }
-      &:first-child {
-        top: 0;
-        right: 0;
-      }
-    }
+    @include two-images;
+  }
+  .content {
+    flex-grow: 1;
+  }
+}
+.album-entries {
+  > div {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
   }
   .content {
     flex-grow: 1;
   }
 }
+.ui.artist-label {
+  .icon {
+    width: 2em;
+  }
+  &.rounded {
+    border-radius: 5em;
+    padding: 0.2em 0.75em 0.2em 0.2em;
+    line-height: 2em;
+    img {
+      border-radius: 50%;
+      vertical-align: middle;
+    }
+  }
+}
+.album-entry, .channel-entry-card {
+  border-radius: 5px;
+  padding: 0.5em;
+  .meta {
+    text-align: right;
+    min-width: 7em;
+  }
+  > div {
+    padding: 0.25em;
+    &:not(:last-child) {
+      margin-right: 0.25em;
+    }
+  }
+  &.active {
+    background: rgba(155, 155, 155, 0.2);
+  }
+  &:hover {
+    background: rgba(155, 155, 155, 0.1);
+  }
+  .favorite-icon.tiny.button {
+    border: none !important;
+    padding: 0 !important;
+    margin: 0 0.5em;
+  }
+}
 .channel-image {
   border: 1px solid rgba(0, 0, 0, 0.15);
   background-color: white;
diff --git a/front/src/style/themes/_light.scss b/front/src/style/themes/_light.scss
index 2ab581eb8..acfeee911 100644
--- a/front/src/style/themes/_light.scss
+++ b/front/src/style/themes/_light.scss
@@ -18,6 +18,9 @@
   .discrete {
     color: rgba(0, 0, 0, 0.87);
   }
+  .really.discrete {
+    color: rgba(0, 0, 0, 0.57);
+  }
   .playlist.card {
     .attached.button {
       background-color: rgb(243, 244, 245);
diff --git a/front/src/utils/time.js b/front/src/utils/time.js
index ca3edbdea..6c5770c12 100644
--- a/front/src/utils/time.js
+++ b/front/src/utils/time.js
@@ -9,9 +9,16 @@ function pad (val) {
 export default {
   parse: function (sec) {
     let min = 0
+    let hours = Math.floor(sec/3600)
+    if (hours >= 1) {
+      sec = sec % 3600
+    }
     min = Math.floor(sec / 60)
     sec = sec - min * 60
-    return pad(min) + ':' + pad(sec)
+    if (hours >= 1) {
+      return hours + ':' + pad(min) + ':' + pad(sec)
+    }
+    return min + ':' + pad(sec)
   },
   durationFormatted (v) {
     let duration = parseInt(v)
diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue
index 3cd32ed41..eaef1cc4a 100644
--- a/front/src/views/admin/library/UploadDetail.vue
+++ b/front/src/views/admin/library/UploadDetail.vue
@@ -240,7 +240,7 @@
                     </td>
                     <td>
                       <template v-if="object.duration">
-                        {{ time.parse(object.duration) }}
+                        {{ object.duration | duration }}
                       </template>
                       <translate v-else translate-context="*/*/*">N/A</translate>
                     </td>
diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue
index 2445f1091..de07b4e22 100644
--- a/front/src/views/channels/DetailBase.vue
+++ b/front/src/views/channels/DetailBase.vue
@@ -206,7 +206,8 @@
                 <translate translate-context="Content/Channels/Link">Overview</translate>
               </router-link>
               <router-link class="item" :exact="true" :to="{name: 'channels.detail.episodes', params: {id: id}}">
-                <translate translate-context="Content/Channels/*">Episodes</translate>
+                <translate key="1" v-if="isPodcast" translate-context="Content/Channels/*">Episodes</translate>
+                <translate key="2" v-else translate-context="*/*/*">Tracks</translate>
               </router-link>
             </div>
             <div class="ui hidden divider"></div>
@@ -313,6 +314,9 @@ export default {
     isOwner () {
       return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
     },
+    isPodcast () {
+      return this.object.artist.content_category === 'podcast'
+    },
     labels () {
       return {
         title: this.$pgettext('*/*/*', 'Channel')
diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue
index 8a739028a..7d1212753 100644
--- a/front/src/views/channels/DetailOverview.vue
+++ b/front/src/views/channels/DetailOverview.vue
@@ -51,13 +51,16 @@
     </div>
     <channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date'}">
       <h2 class="ui header">
-        <translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
+        <translate key="1" v-if="isPodcast" translate-context="Content/Channel/Paragraph">Latest episodes</translate>
+        <translate key="2" v-else translate-context="Content/Channel/Paragraph">Latest tracks</translate>
       </h2>
     </channel-entries>
     <div class="ui hidden divider"></div>
-    <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters">
+    <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters" :is-podcast="isPodcast">
       <h2 class="ui with-actions header">
-        <translate translate-context="Content/Channel/Paragraph">Series</translate>
+
+        <translate key="1" v-if="isPodcast" translate-context="Content/Channel/Paragraph">Series</translate>
+        <translate key="2" v-else translate-context="*/*/*">Albums</translate>
         <div class="actions" v-if="isOwner">
           <a @click.stop.prevent="$refs.albumModal.show = true">
             <i class="plus icon"></i>
@@ -114,6 +117,9 @@ export default {
     });
   },
   computed: {
+    isPodcast () {
+      return this.object.artist.content_category === 'podcast'
+    },
     isOwner () {
       return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
     },
diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue
index 79c498a3e..d60b21ede 100644
--- a/front/src/views/content/libraries/FilesTable.vue
+++ b/front/src/views/content/libraries/FilesTable.vue
@@ -165,7 +165,7 @@
               <i class="question circle outline icon"></i>
             </button>
           </td>
-          <td v-if="scope.obj.duration">{{ time.parse(scope.obj.duration) }}</td>
+          <td v-if="scope.obj.duration">{{ scope.obj.duration | duration }}</td>
           <td v-else>
             <translate translate-context="*/*/*">N/A</translate>
           </td>
diff --git a/front/tests/unit/specs/store/player.spec.js b/front/tests/unit/specs/store/player.spec.js
index c3f1c4583..ac995ab1a 100644
--- a/front/tests/unit/specs/store/player.spec.js
+++ b/front/tests/unit/specs/store/player.spec.js
@@ -90,11 +90,11 @@ describe('store/player', () => {
   describe('getters', () => {
     it('durationFormatted', () => {
       const state = { duration: 12.51 }
-      expect(store.getters['durationFormatted'](state)).to.equal('00:13')
+      expect(store.getters['durationFormatted'](state)).to.equal('0:13')
     })
     it('currentTimeFormatted', () => {
       const state = { currentTime: 12.51 }
-      expect(store.getters['currentTimeFormatted'](state)).to.equal('00:13')
+      expect(store.getters['currentTimeFormatted'](state)).to.equal('0:13')
     })
     it('progress', () => {
       const state = { currentTime: 4, duration: 10 }
-- 
GitLab