[web] Add Spotify audiobooks #1900

This commit is contained in:
Alain Nussbaumer
2025-08-16 10:40:23 +10:00
parent 0017c9cace
commit fd5a34c6b6
36 changed files with 1129 additions and 1004 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,16 +14,16 @@
"dependencies": {
"@aacassandra/vue3-progressbar": "^1.0.3",
"@mdi/js": "^7.4.47",
"@spotify/web-api-ts-sdk": "^1.2.0",
"@ts-pro/vue-eternal-loading": "^1.3.1",
"axios": "^1.10.0",
"axios": "^1.11.0",
"bulma": "^1.0.4",
"luxon": "^3.6.1",
"luxon": "^3.7.1",
"mdi-vue": "^3.0.13",
"pinia": "^3.0.3",
"reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.5.2",
"vue": "^3.5.17",
"vue-i18n": "^11.1.6",
"vue": "^3.5.18",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1",
"vue3-click-away": "^1.2.4",
"vue3-lazyload": "^0.3.8",
@@ -31,12 +31,12 @@
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-vue": "^10.2.0",
"prettier": "^3.5.3",
"sass": "^1.89.2",
"vite": "^6.3.5"
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.4.0",
"prettier": "^3.6.2",
"sass": "^1.90.0",
"vite": "^7.1.2"
}
}

View File

@@ -1,3 +1,4 @@
import { SpotifyApi } from '@spotify/web-api-ts-sdk'
import api from '@/api'
export default {
@@ -14,6 +15,12 @@ export default {
return api.get('./api/spotify-logout')
},
spotify() {
return api.get('./api/spotify')
return api.get('./api/spotify').then((configuration) => {
const sdk = SpotifyApi.withAccessToken(configuration.webapi_client_id, {
access_token: configuration.webapi_token,
token_type: 'Bearer'
})
return { api: sdk, configuration }
})
}
}

View File

@@ -41,10 +41,7 @@ export default {
return { settingsStore: useSettingsStore() }
},
data() {
return {
selectedItem: {},
showDetailsModal: false
}
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
image(item) {

View File

@@ -7,7 +7,7 @@
:index="item.index"
:lines="[
item.name,
item.artists[0]?.name,
item.artists.map((item) => item.name).join(', '),
$formatters.toDate(item.release_date)
]"
@open="open(item)"
@@ -15,6 +15,7 @@
/>
<loader-list-item :load="load" />
<modal-dialog-album-spotify
v-if="showDetailsModal"
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"

View File

@@ -3,6 +3,7 @@
v-for="item in items"
:key="item.id"
:is-item="true"
:image="image(item)"
:index="item.index"
:lines="[item.name]"
@open="open(item)"
@@ -32,6 +33,9 @@ export default {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
image(item) {
return { caption: item.name, url: item.images?.[0]?.url ?? '' }
},
open(item) {
this.$router.push({
name: 'music-spotify-artist',

View File

@@ -0,0 +1,62 @@
<template>
<list-item
v-for="item in items"
:key="item.id"
:is-item="item.isItem"
:image="image(item)"
:index="item.index"
:lines="[
item.name,
item.authors.map((item) => item.name).join(', '),
$formatters.toDate(item.release_date)
]"
@open="open(item)"
@open-details="openDetails(item)"
/>
<loader-list-item :load="load" />
<modal-dialog-album-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import LoaderListItem from '@/components/LoaderListItem.vue'
import ModalDialogAlbumSpotify from '@/components/ModalDialogAlbumSpotify.vue'
import { useSettingsStore } from '@/stores/settings'
export default {
name: 'ListAlbumsSpotify',
components: { ListItem, LoaderListItem, ModalDialogAlbumSpotify },
props: {
items: { required: true, type: Object },
load: { default: null, type: Function }
},
setup() {
return { settingsStore: useSettingsStore() }
},
data() {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
image(item) {
if (this.settingsStore.showCoverArtworkInAlbumLists) {
return { caption: item.name, url: item.images?.[0]?.url ?? '' }
}
return null
},
open(item) {
this.$router.push({
name: 'music-spotify-audiobook',
params: { id: item.id }
})
},
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}
}
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<list-item
v-for="item in items"
:key="item.id"
:is-playable="item.is_playable"
:lines="[
item.name,
item.album.authors.map((item) => item.name).join(', '),
item.album.name
]"
@open="open(item)"
@open-details="openDetails(item)"
>
<template v-if="!item.is_playable" #reason>
(<span v-text="$t('list.spotify.not-playable-track')" />
<span
v-if="item.restrictions?.reason"
v-text="
$t('list.spotify.restriction-reason', {
reason: item.restrictions.reason
})
"
/>)
</template>
</list-item>
<loader-list-item :load="load" />
<modal-dialog-track-spotify
v-if="selectedItem"
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import LoaderListItem from '@/components/LoaderListItem.vue'
import ModalDialogTrackSpotify from '@/components/ModalDialogTrackSpotify.vue'
import queue from '@/api/queue'
export default {
name: 'ListChaptersSpotify',
components: { ListItem, LoaderListItem, ModalDialogTrackSpotify },
props: {
contextUri: { default: '', type: String },
items: { required: true, type: Object },
load: { default: null, type: Function }
},
data() {
return { selectedItem: null, showDetailsModal: false }
},
methods: {
open(item) {
if (item.is_playable) {
queue.playUri(this.contextUri || item.uri, false, item.position || 0)
}
},
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}
}
}
</script>

View File

@@ -3,6 +3,7 @@
v-for="item in items"
:key="item.id"
:is-item="item.isItem"
:image="image(item)"
:index="item.index"
:lines="[item.name, item.owner.display_name]"
@open="open(item)"
@@ -20,6 +21,7 @@
import ListItem from '@/components/ListItem.vue'
import LoaderListItem from '@/components/LoaderListItem.vue'
import ModalDialogPlaylistSpotify from '@/components/ModalDialogPlaylistSpotify.vue'
import { useSettingsStore } from '@/stores/settings'
export default {
name: 'ListPlaylistsSpotify',
@@ -28,12 +30,19 @@ export default {
items: { required: true, type: Object },
load: { default: null, type: Function }
},
setup() {
return { settingsStore: useSettingsStore() }
},
data() {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
image(item) {
if (this.settingsStore.showCoverArtworkInAlbumLists) {
return { caption: item.name, url: item.images?.[0]?.url ?? '' }
}
return null
},
open(item) {
this.$router.push({ name: 'playlist-spotify', params: { id: item.id } })
},

View File

@@ -3,7 +3,11 @@
v-for="item in items"
:key="item.id"
:is-playable="item.is_playable"
:lines="[item.name, item.artists[0].name, item.album.name]"
:lines="[
item.name,
item.artists.map((item) => item.name).join(', '),
item.album.name
]"
@open="open(item)"
@open-details="openDetails(item)"
>
@@ -21,6 +25,7 @@
</list-item>
<loader-list-item :load="load" />
<modal-dialog-track-spotify
v-if="showDetailsModal"
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"

View File

@@ -17,19 +17,30 @@ export default {
computed: {
playable() {
return {
image: this.item?.images?.[0]?.url || '',
name: this.item.name || '',
image: this.item.images?.[0]?.url || '',
name: this.item.name,
properties: [
{
handler: this.openArtist,
key: 'property.artist',
value: this.item?.artists?.[0]?.name
value: this.item.artists?.map((item) => item.name).join(', ')
},
{
key: 'property.author',
value: this.item.authors?.map((item) => item.name).join(', ')
},
{ key: 'property.chapters', value: this.item.total_chapters },
{ key: 'property.edition', value: this.item.edition },
{
key: 'property.narrator',
value: this.item.narrators?.map((item) => item.name).join(', ')
},
{
key: 'property.release-date',
value: this.$formatters.toDate(this.item.release_date)
},
{ key: 'property.type', value: this.item.album_type }
{ key: 'property.type', value: this.item.album_type },
{ key: 'property.path', value: this.item.uri }
],
uri: this.item.uri
}

View File

@@ -39,13 +39,24 @@ export default {
computed: {
actions() {
return [
{ handler: this.addToQueue, icon: 'playlist-plus', key: 'actions.add' },
{
disabled: !this.item.playable,
handler: this.addToQueue,
icon: 'playlist-plus',
key: 'actions.add'
},
{
disabled: !this.item.playable,
handler: this.addNextToQueue,
icon: 'playlist-play',
key: 'actions.add-next'
},
{ handler: this.play, icon: 'play', key: 'actions.play' }
{
disabled: !this.item.playable,
handler: this.play,
icon: 'play',
key: 'actions.play'
}
]
}
},

View File

@@ -14,19 +14,15 @@
<script>
import ListProperties from '@/components/ListProperties.vue'
import ModalDialog from '@/components/ModalDialog.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import player from '@/api/player'
import queue from '@/api/queue'
import { useServicesStore } from '@/stores/services'
import services from '@/api/services'
export default {
name: 'ModalDialogQueueItem',
components: { ListProperties, ModalDialog },
props: { item: { required: true, type: Object }, show: Boolean },
emits: ['close'],
setup() {
return { servicesStore: useServicesStore() }
},
data() {
return {
spotifyTrack: {}
@@ -50,7 +46,7 @@ export default {
},
{
handler: this.openArtist,
key: 'property.album-artist',
key: 'property.artist',
value: this.item.album_artist
},
{ key: 'property.composer', value: this.item.composer },
@@ -90,16 +86,17 @@ export default {
watch: {
item() {
if (this.item?.data_kind === 'spotify') {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.servicesStore.spotify.webapi_token)
spotifyApi
.getTrack(this.item.path.slice(this.item.path.lastIndexOf(':') + 1))
.then((response) => {
return services.spotify().then(({ api }) => {
const trackId = this.item.path.slice(
this.item.path.lastIndexOf(':') + 1
)
return api.tracks.get(trackId).then((response) => {
this.spotifyTrack = response
})
} else {
this.spotifyTrack = {}
})
}
this.spotifyTrack = {}
return {}
}
},
methods: {

View File

@@ -28,6 +28,7 @@ export default {
playable() {
return {
name: this.item.title,
playable: true,
properties: [
{
handler: this.openAlbum,
@@ -36,7 +37,7 @@ export default {
},
{
handler: this.openArtist,
key: 'property.album-artist',
key: 'property.artist',
value: this.item.album_artist
},
{ key: 'property.composer', value: this.item.composer },

View File

@@ -16,11 +16,9 @@ export default {
emits: ['close'],
computed: {
playable() {
if (!this.item.artists) {
return {}
}
return {
name: this.item.name,
playable: this.item.is_playable,
properties: [
{
handler: this.openAlbum,
@@ -29,13 +27,22 @@ export default {
},
{
handler: this.openArtist,
key: 'property.album-artist',
value: this.item.artists[0]?.name
key: 'property.artist',
value: this.item.artists?.map((item) => item.name).join(', ')
},
{
handler: this.openArtist,
key: 'property.author',
value: this.item.album.authors?.map((item) => item.name).join(', ')
},
{
key: 'property.release-date',
value: this.$formatters.toDate(this.item.album.release_date)
},
{
key: 'property.release-date',
value: this.$formatters.toDate(this.item.release_date)
},
{
key: 'property.position',
value: [this.item.disc_number, this.item.track_number].join(' / ')

View File

@@ -298,17 +298,20 @@
"property": {
"added-on": "Hinzugefügt am",
"album": "Album",
"album-artist": "Album-Künstler",
"albums": "Alben",
"artist": "Künstler",
"artists": "Künstler",
"author": "Autor",
"chapters": "Kapitel",
"comment": "Kommentar",
"composer": "Komponist",
"duration": "Dauer",
"edition": "Ausgabe",
"folders": "Ordner",
"genre": "Genre",
"genres": "Genres",
"name": "Name",
"narrator": "Sprecher",
"owner": "Besitzer",
"path": "Pfad",
"playlists": "Playlisten",

View File

@@ -298,17 +298,20 @@
"property": {
"added-on": "Added On",
"album": "Album",
"album-artist": "Album Artist",
"albums": "Albums",
"artist": "Artist",
"artists": "Artists",
"author": "Author",
"chapters": "Chapters",
"comment": "Comment",
"composer": "Composer",
"duration": "Duration",
"edition": "Edition",
"folders": "Folders",
"genre": "Genre",
"genres": "Genres",
"name": "Name",
"narrator": "Narrator",
"owner": "Owner",
"path": "Path",
"playlists": "Playlists",

View File

@@ -298,17 +298,20 @@
"property": {
"added-on": "Ajouté le",
"album": "Album",
"album-artist": "Artiste de lalbum",
"albums": "Albums",
"artist": "Artiste",
"artists": "Artistes",
"author": "Auteur",
"chapters": "Chapitres",
"comment": "Commentaire",
"composer": "Compositeur",
"duration": "Durée",
"edition": "Édition",
"folders": "Dossiers",
"genre": "Genre",
"genres": "Genres",
"name": "Nom",
"narrator": "Narrateur",
"owner": "Propriétaire",
"path": "Emplacement",
"playlists": "Listes de lecture",

View File

@@ -298,17 +298,20 @@
"property": {
"added-on": "添加时间",
"album": "专辑",
"album-artist": "专辑艺人",
"albums": "张专辑",
"artist": "专辑艺人",
"artists": "艺人",
"authors": "作者",
"chapters": "章节",
"comment": "评论",
"composer": "作曲家",
"duration": "时长",
"edition": "版本",
"folders": "文件夹",
"genre": "流派",
"genres": "流派",
"name": "名称",
"narrator": "叙述者",
"owner": "所有者",
"path": "路径",
"playlists": "播放列表",

View File

@@ -298,17 +298,20 @@
"property": {
"added-on": "新增時間",
"album": "專輯",
"album-artist": "專輯藝人",
"albums": "張專輯",
"artist": "專輯藝人",
"artists": "藝人",
"author": "作者",
"chapters": "章節",
"comment": "評論",
"composer": "作曲家",
"duration": "時長",
"edition": "版本",
"folders": "檔案夾",
"genre": "音樂類型",
"genres": "音樂類型",
"name": "名稱",
"narrator": "敘述者",
"owner": "所有者",
"path": "路徑",
"playlists": "播放列表",

View File

@@ -28,10 +28,8 @@ import ControlImage from '@/components/ControlImage.vue'
import ListTracksSpotify from '@/components/ListTracksSpotify.vue'
import ModalDialogAlbumSpotify from '@/components/ModalDialogAlbumSpotify.vue'
import PaneHero from '@/components/PaneHero.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import queue from '@/api/queue'
import services from '@/api/services'
import { useServicesStore } from '@/stores/services'
export default {
name: 'PageAlbumSpotify',
@@ -43,13 +41,9 @@ export default {
PaneHero
},
beforeRouteEnter(to, from, next) {
const spotifyApi = new SpotifyWebApi()
services.spotify().then((data) => {
spotifyApi.setAccessToken(data.webapi_token)
spotifyApi
.getAlbum(to.params.id, {
market: useServicesStore().spotify.webapi_country
})
services.spotify().then(({ api, configuration }) => {
api.albums
.get(to.params.id, configuration.webapi_country)
.then((album) => {
next((vm) => {
vm.album = album
@@ -57,9 +51,6 @@ export default {
})
})
},
setup() {
return { servicesStore: useServicesStore() }
},
data() {
return {
album: { artists: [{}], tracks: {} },
@@ -75,7 +66,7 @@ export default {
],
count: this.$t('data.tracks', { count: this.album.tracks.total }),
handler: this.openArtist,
subtitle: this.album.artists[0].name,
subtitle: this.album.artists.map((item) => item.name).join(', '),
title: this.album.name
}
},
@@ -98,7 +89,6 @@ export default {
this.showDetailsModal = true
},
play() {
this.showDetailsModal = false
queue.playUri(this.album.uri, true)
}
}

View File

@@ -28,10 +28,8 @@ import ControlButton from '@/components/ControlButton.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import ModalDialogArtistSpotify from '@/components/ModalDialogArtistSpotify.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import queue from '@/api/queue'
import services from '@/api/services'
import { useServicesStore } from '@/stores/services'
const PAGE_SIZE = 50
@@ -45,17 +43,16 @@ export default {
PaneTitle
},
beforeRouteEnter(to, from, next) {
services.spotify().then((data) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
services.spotify().then(({ api, configuration }) => {
Promise.all([
spotifyApi.getArtist(to.params.id),
spotifyApi.getArtistAlbums(to.params.id, {
include_groups: 'album,single',
limit: PAGE_SIZE,
market: useServicesStore().spotify.webapi_country,
offset: 0
})
api.artists.get(to.params.id),
api.artists.albums(
to.params.id,
'album,single',
configuration.webapi_country,
PAGE_SIZE,
0
)
]).then(([artist, albums]) => {
next((vm) => {
vm.artist = artist
@@ -66,9 +63,6 @@ export default {
})
})
},
setup() {
return { servicesStore: useServicesStore() }
},
data() {
return {
albums: [],
@@ -93,15 +87,15 @@ export default {
this.offset += data.limit
},
load({ loaded }) {
services.spotify().then((data) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
spotifyApi
.getArtistAlbums(this.artist.id, {
include_groups: 'album,single',
limit: PAGE_SIZE,
offset: this.offset
})
services.spotify().then(({ api, configuration }) => {
api.artists
.albums(
this.artist.id,
'album,single',
configuration.webapi_country,
PAGE_SIZE,
this.offset
)
.then((albums) => {
this.appendAlbums(albums)
loaded(albums.items.length, PAGE_SIZE)
@@ -112,7 +106,6 @@ export default {
this.showDetailsModal = true
},
play() {
this.showDetailsModal = false
queue.playUri(this.artist.uri, true)
}
}

View File

@@ -0,0 +1,89 @@
<template>
<content-with-hero>
<template #heading>
<pane-hero :content="heading" />
</template>
<template #image>
<control-image
:url="album.images?.[0]?.url ?? ''"
:caption="album.name"
class="is-medium"
@click="openDetails"
/>
</template>
<template #content>
<list-chapters-spotify :items="tracks" :context-uri="album.uri" />
</template>
</content-with-hero>
<modal-dialog-album-spotify
:item="album"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ContentWithHero from '@/templates/ContentWithHero.vue'
import ControlImage from '@/components/ControlImage.vue'
import ListChaptersSpotify from '@/components/ListChaptersSpotify.vue'
import ModalDialogAlbumSpotify from '@/components/ModalDialogAlbumSpotify.vue'
import PaneHero from '@/components/PaneHero.vue'
import queue from '@/api/queue'
import services from '@/api/services'
export default {
name: 'PageAudiobookSpotify',
components: {
ContentWithHero,
ControlImage,
ListChaptersSpotify,
ModalDialogAlbumSpotify,
PaneHero
},
beforeRouteEnter(to, from, next) {
services.spotify().then(({ api, configuration }) => {
api.audiobooks
.get(to.params.id, configuration.webapi_country)
.then((album) => {
next((vm) => {
vm.album = album
})
})
})
},
data() {
return {
album: { authors: [{}], chapters: {} },
showDetailsModal: false
}
},
computed: {
heading() {
return {
actions: [
{ handler: this.play, icon: 'shuffle', key: 'actions.shuffle' },
{ handler: this.openDetails, icon: 'dots-horizontal' }
],
count: this.$t('data.tracks', { count: this.album.chapters.total }),
subtitle: this.album.authors.map((item) => item.name).join(', '),
title: this.album.name
}
},
tracks() {
const { album } = this
if (album.chapters.total) {
return album.chapters.items.map((track) => ({ ...track, album }))
}
return []
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
play() {
queue.playUri(this.album.uri, true)
}
}
}
</script>

View File

@@ -1,84 +1,102 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title :content="{ title: $t('page.music.recently-added.title') }" />
</template>
<template #content>
<list-albums :items="albums" />
</template>
<template #footer>
<router-link
class="button is-small is-rounded"
:to="{ name: 'music-recently-added' }"
>
{{ $t('actions.show-more') }}
</router-link>
</template>
</content-with-heading>
<content-with-heading>
<template #heading>
<pane-title
:content="{ title: $t('page.music.recently-played.title') }"
/>
</template>
<template #content>
<list-tracks :items="tracks" />
</template>
<template #footer>
<router-link
class="button is-small is-rounded"
:to="{ name: 'music-recently-played' }"
>
{{ $t('actions.show-more') }}
</router-link>
</template>
</content-with-heading>
<content-with-lists :results="results" :types="types" />
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ContentWithLists from '@/templates/ContentWithLists.vue'
import { GroupedList } from '@/lib/GroupedList'
import ListAlbums from '@/components/ListAlbums.vue'
import ListTracks from '@/components/ListTracks.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import TabsMusic from '@/components/TabsMusic.vue'
import library from '@/api/library'
import { useSettingsStore } from '@/stores/settings'
const PAGE_SIZE = 3
const PAGE_SIZE_EXPANDED = 50
export default {
name: 'PageMusic',
components: {
ContentWithHeading,
ListAlbums,
ListTracks,
PaneTitle,
TabsMusic
},
components: { ContentWithLists },
beforeRouteEnter(to, from, next) {
Promise.all([
library.search({
expression:
'time_added after 8 weeks ago and media_kind is music having track_count > 3 order by time_added desc',
limit: 3,
type: 'album'
}),
library.search({
expression:
'time_played after 8 weeks ago and media_kind is music order by time_played desc',
limit: 3,
type: 'track'
})
]).then(([{ albums }, { tracks }]) => {
next((vm) => {
vm.albums = new GroupedList(albums)
vm.tracks = new GroupedList(tracks)
next((vm) => {
Object.values(vm.types).forEach(({ handler }) => {
handler(PAGE_SIZE)
})
})
},
data() {
return {
albums: [],
tracks: null
results: new Map()
}
},
computed: {
types() {
return {
album: {
title: 'page.music.recently-added.title',
component: ListAlbums,
handler: this.recentlyAddedAlbums
},
track: {
title: 'page.music.recently-played.title',
component: ListTracks,
handler: this.recentlyPlayedTracks
}
}
}
},
methods: {
recentlyAddedAlbums(limit, expanded, loaded) {
library
.search({
expression:
'media_kind is music having track_count > 3 order by time_added desc',
limit: limit || useSettingsStore().recentlyAddedLimit,
type: 'album'
})
.then(({ albums }) => {
this.storeResults({
type: 'album',
items: new GroupedList(albums, {
criteria: [{ field: 'time_added', order: -1, type: Date }],
index: { field: 'time_added', type: Date }
}),
expanded,
loaded
})
})
},
recentlyPlayedTracks(limit, expanded, loaded) {
library
.search({
expression: 'media_kind is music order by time_played desc',
limit,
type: 'track'
})
.then(({ tracks }) => {
this.storeResults({
type: 'track',
items: new GroupedList(tracks, {
criteria: [{ field: 'time_played', order: -1, type: Date }],
index: { field: 'time_played', type: Date }
}),
expanded,
loaded
})
})
},
storeResults({ type, items, expanded, loaded }) {
if (loaded) {
const current = this.results.get(type) || []
const updated = [...current, ...items.items]
this.results.clear()
this.results.set(type, new GroupedList(updated))
loaded(items.total - updated.length, PAGE_SIZE_EXPANDED)
} else {
if (expanded) {
this.results.clear()
}
this.results.set(type, items)
}
}
}
}

View File

@@ -1,54 +0,0 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title :content="{ title: $t('page.music.recently-added.title') }" />
</template>
<template #content>
<list-albums :items="albums" />
</template>
</content-with-heading>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import { GroupedList } from '@/lib/GroupedList'
import ListAlbums from '@/components/ListAlbums.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import TabsMusic from '@/components/TabsMusic.vue'
import library from '@/api/library'
import { useSettingsStore } from '@/stores/settings'
export default {
name: 'PageMusicRecentlyAdded',
components: { ContentWithHeading, ListAlbums, PaneTitle, TabsMusic },
beforeRouteEnter(to, from, next) {
const limit = useSettingsStore().recentlyAddedLimit
library
.search({
expression:
'media_kind is music having track_count > 3 order by time_added desc',
limit,
type: 'album'
})
.then((data) => {
next((vm) => {
vm.albums = new GroupedList(data.albums, {
criteria: [{ field: 'time_added', order: -1, type: Date }],
index: { field: 'time_added', type: Date }
})
})
})
},
setup() {
return {
settingsStore: useSettingsStore()
}
},
data() {
return {
albums: new GroupedList()
}
}
}
</script>

View File

@@ -1,46 +0,0 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title
:content="{ title: $t('page.music.recently-played.title') }"
/>
</template>
<template #content>
<list-tracks :items="tracks" />
</template>
</content-with-heading>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import { GroupedList } from '@/lib/GroupedList'
import ListTracks from '@/components/ListTracks.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import TabsMusic from '@/components/TabsMusic.vue'
import library from '@/api/library'
export default {
name: 'PageMusicRecentlyPlayed',
components: { ContentWithHeading, ListTracks, PaneTitle, TabsMusic },
beforeRouteEnter(to, from, next) {
library
.search({
expression:
'time_played after 8 weeks ago and media_kind is music order by time_played desc',
limit: 50,
type: 'track'
})
.then((data) => {
next((vm) => {
vm.tracks = new GroupedList(data.tracks)
})
})
},
data() {
return {
tracks: new GroupedList()
}
}
}
</script>

View File

@@ -1,84 +1,89 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title :content="{ title: $t('page.spotify.music.new-releases') }" />
</template>
<template #content>
<list-albums-spotify :items="albums" />
</template>
<template #footer>
<router-link
:to="{ name: 'music-spotify-new-releases' }"
class="button is-small is-rounded"
>
{{ $t('actions.show-more') }}
</router-link>
</template>
</content-with-heading>
<content-with-heading>
<template #heading>
<pane-title
:content="{ title: $t('page.spotify.music.featured-playlists') }"
/>
</template>
<template #content>
<list-playlists-spotify :items="playlists" />
</template>
<template #footer>
<router-link
:to="{ name: 'music-spotify-featured-playlists' }"
class="button is-small is-rounded"
>
{{ $t('actions.show-more') }}
</router-link>
</template>
</content-with-heading>
<content-with-lists :results="results" :types="types" />
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ContentWithLists from '@/templates/ContentWithLists.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import ListPlaylistsSpotify from '@/components/ListPlaylistsSpotify.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import services from '@/api/services'
const PAGE_SIZE = 3
const PAGE_SIZE_EXPANDED = 50
export default {
name: 'PageMusicSpotify',
components: {
ContentWithHeading,
ListAlbumsSpotify,
ListPlaylistsSpotify,
PaneTitle,
TabsMusic
},
components: { ContentWithLists },
beforeRouteEnter(to, from, next) {
services.spotify().then((data) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
Promise.all([
spotifyApi.getNewReleases({
country: data.webapi_country,
limit: 3
}),
spotifyApi.getFeaturedPlaylists({
country: data.webapi_country,
limit: 3
})
]).then((response) => {
next((vm) => {
vm.albums = response[0].albums.items
vm.playlists = response[1].playlists.items
})
next((vm) => {
Object.values(vm.types).forEach(({ handler }) => {
handler(PAGE_SIZE)
})
})
},
data() {
return {
albums: [],
playlists: []
results: new Map()
}
},
computed: {
types() {
return {
album: {
title: 'page.spotify.music.new-releases',
component: ListAlbumsSpotify,
handler: this.newReleases
},
playlist: {
title: 'page.spotify.music.featured-playlists',
component: ListPlaylistsSpotify,
handler: this.featuredPlaylists
}
}
}
},
methods: {
featuredPlaylists(limit, expanded, loaded) {
services.spotify().then(({ api, configuration }) => {
api.browse
.getFeaturedPlaylists(configuration.webapi_country, null, null, limit)
.then(({ playlists }) => {
this.storeResults({
type: 'playlist',
items: playlists,
expanded,
loaded
})
})
})
},
newReleases(limit, expanded, loaded) {
services.spotify().then(({ api, configuration }) => {
api.browse
.getNewReleases(configuration.webapi_country, limit)
.then(({ albums }) => {
this.storeResults({
type: 'album',
items: albums,
expanded,
loaded
})
})
})
},
storeResults({ type, items, expanded = false, loaded = null }) {
if (loaded) {
const current = this.results.get(type) || []
const updated = [...current, ...items.items]
this.results.clear()
this.results.set(type, updated)
loaded(items.total - updated.length, PAGE_SIZE_EXPANDED)
} else {
if (expanded) {
this.results.clear()
}
this.results.set(type, items.items)
}
}
}
}

View File

@@ -1,56 +0,0 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title :content="heading" />
</template>
<template #content>
<list-playlists-spotify :items="playlists" />
</template>
</content-with-heading>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListPlaylistsSpotify from '@/components/ListPlaylistsSpotify.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import services from '@/api/services'
export default {
name: 'PageMusicSpotifyFeaturedPlaylists',
components: {
ContentWithHeading,
ListPlaylistsSpotify,
PaneTitle,
TabsMusic
},
beforeRouteEnter(to, from, next) {
services.spotify().then((data) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
spotifyApi
.getFeaturedPlaylists({
country: data.webapi_country,
limit: 50
})
.then((response) => {
next((vm) => {
vm.playlists = response.playlists.items
})
})
})
},
data() {
return {
playlists: []
}
},
computed: {
heading() {
return { title: this.$t('page.spotify.music.featured-playlists') }
}
}
}
</script>

View File

@@ -1,56 +0,0 @@
<template>
<tabs-music />
<content-with-heading>
<template #heading>
<pane-title :content="heading" />
</template>
<template #content>
<list-albums-spotify :items="albums" />
</template>
</content-with-heading>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import services from '@/api/services'
export default {
name: 'PageMusicSpotifyNewReleases',
components: {
ContentWithHeading,
ListAlbumsSpotify,
PaneTitle,
TabsMusic
},
beforeRouteEnter(to, from, next) {
services.spotify().then((data) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
spotifyApi
.getNewReleases({
country: data.webapi_country,
limit: 50
})
.then((response) => {
next((vm) => {
vm.albums = response.albums.items
})
})
})
},
data() {
return {
albums: []
}
},
computed: {
heading() {
return { title: this.$t('page.spotify.music.new-releases') }
}
}
}
</script>

View File

@@ -37,9 +37,8 @@ import ControlButton from '@/components/ControlButton.vue'
import ListTracksSpotify from '@/components/ListTracksSpotify.vue'
import ModalDialogPlaylistSpotify from '@/components/ModalDialogPlaylistSpotify.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import queue from '@/api/queue'
import { useServicesStore } from '@/stores/services'
import services from '@/api/services'
const PAGE_SIZE = 50
@@ -53,28 +52,27 @@ export default {
PaneTitle
},
beforeRouteEnter(to, from, next) {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(useServicesStore().spotify.webapi_token)
Promise.all([
spotifyApi.getPlaylist(to.params.id),
spotifyApi.getPlaylistTracks(to.params.id, {
limit: PAGE_SIZE,
market: useServicesStore().$state.spotify.webapi_country,
offset: 0
})
]).then(([playlist, tracks]) => {
next((vm) => {
vm.playlist = playlist
vm.tracks = []
vm.total = 0
vm.offset = 0
vm.appendTracks(tracks)
services.spotify().then(({ api, configuration }) => {
Promise.all([
api.playlists.getPlaylist(to.params.id),
api.playlists.getPlaylistItems(
to.params.id,
configuration.webapi_country,
null,
PAGE_SIZE,
0
)
]).then(([playlist, tracks]) => {
next((vm) => {
vm.playlist = playlist
vm.tracks = []
vm.total = 0
vm.offset = 0
vm.appendTracks(tracks)
})
})
})
},
setup() {
return { servicesStore: useServicesStore() }
},
data() {
return {
offset: 0,
@@ -88,9 +86,7 @@ export default {
heading() {
if (this.playlist.name) {
return {
subtitle: [
{ count: this.playlist.tracks.total, key: 'data.playlists' }
],
subtitle: [{ count: this.playlist.tracks.total, key: 'data.tracks' }],
title: this.playlist.name
}
}
@@ -118,21 +114,22 @@ export default {
this.offset += data.limit
},
load({ loaded }) {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(this.servicesStore.spotify.webapi_token)
spotifyApi
.getPlaylistTracks(this.playlist.id, {
limit: PAGE_SIZE,
market: this.servicesStore.spotify.webapi_country,
offset: this.offset
})
.then((data) => {
this.appendTracks(data)
loaded(data.items.length, PAGE_SIZE)
})
services.spotify().then(({ api, configuration }) => {
api.playlists
.getPlaylistItems(
this.playlist.id,
configuration.webapi_country,
null,
PAGE_SIZE,
this.offset
)
.then((data) => {
this.appendTracks(data)
loaded(data.items.length, PAGE_SIZE)
})
})
},
play() {
this.showDetailsModal = false
queue.playUri(this.playlist.uri, true)
},
openDetails() {

View File

@@ -4,7 +4,7 @@
:expanded="expanded"
:get-items="getItems"
:history="history"
:load="(expanded && searchNext) || null"
:load="(expanded && load) || null"
:results="results"
@search="search"
@search-library="searchLibrary"
@@ -18,15 +18,15 @@
import ContentWithSearch from '@/templates/ContentWithSearch.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import ListArtistsSpotify from '@/components/ListArtistsSpotify.vue'
import ListAudiobooksSpotify from '@/components/ListAudiobooksSpotify.vue'
import ListPlaylistsSpotify from '@/components/ListPlaylistsSpotify.vue'
import ListTracksSpotify from '@/components/ListTracksSpotify.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import services from '@/api/services'
import { useSearchStore } from '@/stores/search'
const PAGE_SIZE = 3
const PAGE_SIZE_EXPANDED = 50
const SEARCH_TYPES = ['track', 'artist', 'album', 'playlist']
const SEARCH_TYPES = ['track', 'artist', 'album', 'audiobook', 'playlist']
export default {
name: 'PageSearchSpotify',
@@ -35,6 +35,7 @@ export default {
return {
components: {
album: ListAlbumsSpotify,
audiobook: ListAudiobooksSpotify,
artist: ListArtistsSpotify,
playlist: ListPlaylistsSpotify,
track: ListTracksSpotify
@@ -95,21 +96,22 @@ export default {
}
},
searchItems() {
return services.spotify().then((data) => {
this.parameters.market = data.webapi_country
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.search(
this.searchStore.query,
this.types,
this.parameters
return services
.spotify()
.then(({ api, configuration }) =>
api.search(
this.searchStore.query,
this.types,
configuration.webapi_country,
this.parameters.limit,
this.parameters.offset
)
)
})
},
searchLibrary() {
this.$router.push({ name: 'search-library' })
},
searchNext({ loaded }) {
load({ loaded }) {
const items = this.results.get(this.types[0])
this.parameters.limit = PAGE_SIZE_EXPANDED
this.searchItems().then((data) => {

View File

@@ -12,6 +12,7 @@ import PageAudiobookAlbums from '@/pages/PageAudiobookAlbums.vue'
import PageAudiobookArtist from '@/pages/PageAudiobookArtist.vue'
import PageAudiobookArtists from '@/pages/PageAudiobookArtists.vue'
import PageAudiobookGenres from '@/pages/PageAudiobookGenres.vue'
import PageAudiobookSpotify from '@/pages/PageAudiobookSpotify.vue'
import PageComposerAlbums from '@/pages/PageComposerAlbums.vue'
import PageComposerTracks from '@/pages/PageComposerTracks.vue'
import PageComposers from '@/pages/PageComposers.vue'
@@ -20,11 +21,7 @@ import PageGenreAlbums from '@/pages/PageGenreAlbums.vue'
import PageGenreTracks from '@/pages/PageGenreTracks.vue'
import PageGenres from '@/pages/PageGenres.vue'
import PageMusic from '@/pages/PageMusic.vue'
import PageMusicRecentlyAdded from '@/pages/PageMusicRecentlyAdded.vue'
import PageMusicRecentlyPlayed from '@/pages/PageMusicRecentlyPlayed.vue'
import PageMusicSpotify from '@/pages/PageMusicSpotify.vue'
import PageMusicSpotifyFeaturedPlaylists from '@/pages/PageMusicSpotifyFeaturedPlaylists.vue'
import PageMusicSpotifyNewReleases from '@/pages/PageMusicSpotifyNewReleases.vue'
import PagePlayer from '@/pages/PagePlayer.vue'
import PagePlaylistFolder from '@/pages/PagePlaylistFolder.vue'
import PagePlaylistTracks from '@/pages/PagePlaylistTracks.vue'
@@ -103,6 +100,11 @@ export const router = createRouter({
name: 'audiobook-genres',
path: '/audiobook/genres'
},
{
component: PageAudiobookSpotify,
name: 'music-spotify-audiobook',
path: '/music/spotify/audiobooks/:id'
},
{
name: 'audiobooks',
path: '/audiobooks',
@@ -118,31 +120,11 @@ export const router = createRouter({
name: 'music-history',
path: '/music/history'
},
{
component: PageMusicRecentlyAdded,
name: 'music-recently-added',
path: '/music/recently-added'
},
{
component: PageMusicRecentlyPlayed,
name: 'music-recently-played',
path: '/music/recently-played'
},
{
component: PageMusicSpotify,
name: 'music-spotify',
path: '/music/spotify'
},
{
component: PageMusicSpotifyFeaturedPlaylists,
name: 'music-spotify-featured-playlists',
path: '/music/spotify/featured-playlists'
},
{
component: PageMusicSpotifyNewReleases,
name: 'music-spotify-new-releases',
path: '/music/spotify/new-releases'
},
{
component: PageComposerAlbums,
name: 'music-composer-albums',

View File

@@ -7,16 +7,19 @@ export const useServicesStore = defineStore('ServicesStore', {
this.lastfm = await services.lastfm()
},
initialiseSpotify() {
services.spotify().then((data) => {
this.spotify = data
services.spotify().then(({ configuration }) => {
this.spotify = configuration
if (this.spotifyTimerId > 0) {
clearTimeout(this.spotifyTimerId)
this.spotifyTimerId = 0
}
if (data.webapi_token_expires_in > 0 && data.webapi_token) {
if (
configuration.webapi_token_expires_in > 0 &&
configuration.webapi_token
) {
this.spotifyTimerId = setTimeout(
() => this.initialiseSpotify(),
1000 * data.webapi_token_expires_in
1000 * configuration.webapi_token_expires_in
)
}
})

View File

@@ -0,0 +1,52 @@
<template>
<tabs-music />
<content-with-heading v-for="[type, items] in results" :key="type">
<template #heading>
<pane-title :content="{ title: $t(types[type].title) }" />
</template>
<template #content>
<component
:is="types[type].component"
:items="items"
:load="(expanded && ((args) => load(type, args))) || null"
/>
</template>
<template v-if="!expanded" #footer>
<control-button
:button="{
handler: () => load(type),
title: $t('actions.show-more')
}"
/>
</template>
</content-with-heading>
</template>
<script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ControlButton from '@/components/ControlButton.vue'
import PaneTitle from '@/components/PaneTitle.vue'
import TabsMusic from '@/components/TabsMusic.vue'
const PAGE_SIZE_EXPANDED = 50
export default {
name: 'ContentWithLists',
components: { ContentWithHeading, ControlButton, PaneTitle, TabsMusic },
props: {
results: { required: true, type: Object },
types: { required: true, type: Object }
},
data() {
return {
expanded: false
}
},
methods: {
load(type, { loaded } = {}) {
this.expanded = true
this.types[type].handler(PAGE_SIZE_EXPANDED, this.expanded, loaded)
}
}
}
</script>