[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 one or more lines are too long

File diff suppressed because one or more lines are too long

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>