Initial commit

This commit is contained in:
Thomas Amland
2020-03-01 20:08:02 +01:00
commit b4623926a2
59 changed files with 11280 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
import axios, { AxiosRequestConfig, AxiosInstance } from "axios"
import { AuthService } from '@/auth/service';
export class API {
readonly http: AxiosInstance;
readonly get: (path: string, params?: any) => Promise<any>;
readonly post: (path: string, params?: any) => Promise<any>;
constructor(private auth: AuthService) {
this.http = axios.create({});
this.http.interceptors.request.use((config: AxiosRequestConfig) => {
config.params = config.params || {};
config.baseURL = this.auth.server
config.params.u = this.auth.username;
config.params.p = this.auth.password;
config.params.c = "app";
config.params.f = "json";
config.params.v = "1.15.0";
return config;
});
this.get = (path: string, params: any = {}) => {
return this.http.get(path, {params}).then(response => {
const subsonicResponse = response.data["subsonic-response"];
if (subsonicResponse.status !== "ok") {
const err = new Error(subsonicResponse.status);
return Promise.reject(err);
}
return Promise.resolve(subsonicResponse);
})
}
this.post = (path: string, params: any = {}) => {
return this.http.post(path, params).then(response => {
const subsonicResponse = response.data["subsonic-response"];
if (subsonicResponse.status !== "ok") {
const err = new Error(subsonicResponse.status);
return Promise.reject(err);
}
return Promise.resolve(subsonicResponse);
})
}
}
async getGenres() {
const response = await this.get("rest/getGenres", {});
return response.genres.genre.map((item: any) => ({
id: encodeURIComponent(item.value),
name: item.value,
...item,
}));
}
async getGenreDetails(id: string) {
const params = {
genre: decodeURIComponent(id),
count: 500,
offset: 0,
}
const response = await this.get("rest/getSongsByGenre", params);
return {
name: id,
tracks: this.normalizeTrackList(response.songsByGenre.song),
}
}
async getArtists(offset: any = 0, size: any = 400) {
const params = {
type: "random",
offset,
size,
};
const data = await this.get("rest/getArtists", params);
return data.artists.index.flatMap((index: any) => index.artist.map((artist: any) => ({
id: artist.id,
name: artist.name,
...artist
})));
}
async getAlbums(sort: string) {
const params = {
type: sort,
offset: "0",
size: "500",
};
const data = await this.get("rest/getAlbumList2", params);
return data.albumList2.album.map((item: any) => ({
...item,
image: item.coverArt ? this.getCoverArtUrl(item) : undefined,
}));
}
async getArtistDetails(id: string) {
const params = { id };
const [info1, info2] = await Promise.all([
this.get("rest/getArtist", params).then(r => r.artist),
this.get("rest/getArtistInfo2", params).then(r => r.artistInfo2),
])
return {
info1,
info2,
id: info1.id,
name: info1.name,
description: info2.biography,
image: info2.largeImageUrl || info2.mediumImageUrl || info2.smallImageUrl,
lastFmUrl: info2.lastFmUrl,
musicBrainzId: info2.musicBrainzId,
albums: info1.album.map((album: any) => this.normalizeAlbumResponse(album)),
similarArtist: (info2.similarArtist || []).map((artist: any) => ({
id: artist.id,
name: artist.name,
...artist
}))
};
}
async getAlbumDetails(id: string) {
const params = {id};
const data = await this.get("rest/getAlbum", params);
const item = data.album;
const image = this.getCoverArtUrl(item);
const trackList = item.song.map((s: any) => ({
...s,
image,
url: this.getStreamUrl(s.id),
}))
return {
...item,
image,
song: trackList,
};
}
async getPlaylists() {
const response = await this.get("rest/getPlaylists");
return response.playlists.playlist.map((playlist: any) => ({
...playlist,
name: playlist.name || "(Unnamed)",
}));
}
async getPlaylist(id: string) {
if (id === 'random') {
return {
id,
name: 'Random',
tracks: await this.getRandomSongs(),
};
}
const response = await this.get("rest/getPlaylist", { id });
return {
...response.playlist,
name: response.playlist.name || "(Unnamed)",
tracks: this.normalizeTrackList(response.playlist.entry || []),
};
}
async createPlaylist(name: string) {
await this.get("rest/createPlaylist", { name });
return this.getPlaylists();
}
async deletePlaylist(id: string) {
await this.get("rest/deletePlaylist", { id });
}
async addToPlaylist(playlistId: string, trackId: string) {
const params = {
playlistId,
songIdToAdd: trackId,
}
await this.get("rest/updatePlaylist", params);
}
async removeFromPlaylist(playlistId: string, index: string) {
const params = {
playlistId,
songIndexToRemove: index,
}
await this.get("rest/updatePlaylist", params);
}
async getRandomSongs() {
const params = {
size: 200,
};
const data = await this.get("rest/getRandomSongs", params);
return this.normalizeTrackList(data.randomSongs.song);
}
async getStarred() {
const [tracks, albums] = await Promise.all([
this.get("rest/getStarred2").then(r => r.starred2.song),
this.get("rest/getAlbumList2", { type: 'starred' }).then(r => r.albumList2),
this.get("rest/getAlbumList2", { type: 'starred' }).then(r => r.albumList2),
]);
return { tracks, albums }
}
async star(type: 'track' | 'album' | 'artist', id: string) {
const params = {
id: type === 'track' ? id : undefined,
albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined,
}
await this.get("rest/star", params);
}
async unstar(type: 'track' | 'album' | 'artist', id: string) {
const params = {
id: type === 'track' ? id : undefined,
albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined,
}
await this.get("rest/unstar", params);
}
async search(query: string) {
const params = {
query,
};
const data = await this.get("rest/search3", params);
return {
tracks: data.searchResult3.song || [],
albums: (data.searchResult3.album || []).map((x: any) => this.normalizeAlbumResponse(x)),
artists: (data.searchResult3.artist || []).map((x: any) => this.normalizeArtistResponse(x)),
};
}
private normalizeTrackList(items: any[]) {
return items.map((item => ({
...item,
url: this.getStreamUrl(item.id),
image: this.getCoverArtUrl(item),
})))
}
private normalizeAlbumResponse(item: any) {
return {
...item,
image: this.getCoverArtUrl(item)
}
}
private normalizeArtistResponse(item: any) {
return {
...item,
image: this.getCoverArtUrl(item)
}
}
private getCoverArtUrl(item: any) {
if (!item.coverArt) {
return undefined;
}
const { server, username, password } = this.auth;
return `${server}/rest/getCoverArt?id=${item.coverArt}&v=1.15.0&u=${username}&p=${password}&c=test&size=300`
}
private getStreamUrl(id: any) {
const { server, username, password } = this.auth;
return `${server}/rest/stream?id=${id}&format=raw&v=1.15.0&u=${username}&p=${password}&c=test&size=300`
}
}
+8
View File
@@ -0,0 +1,8 @@
<template>
<span :class="`mdi ${$slots.default[0].text}`"></span>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({});
</script>
+52
View File
@@ -0,0 +1,52 @@
<template functional>
<div class="fixed-img bg-secondary">
<div class="fixed-img-inner">
<img v-if="props.src" :src="props.src">
<div v-else class="text-muted">
<Icon>mdi-music</Icon>
</div>
</div>
</div>
</template>
<style lang="scss">
.fixed-img-sq {
padding-bottom: 100%;
}
.fixed-img-rect {
padding-bottom: 70%;
}
.fixed-img {
position: relative;
width: 100%;
.tile-img-inner {
position: absolute;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4.5rem;
}
}
}
</style>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
props: {
square: { type: Boolean, default: false },
}
});
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<b-dropdown variant="link" boundary="window" no-caret toggle-class="p-0">
<template #button-content>
<Icon>mdi-dots-vertical</Icon>
</template>
<slot/>
</b-dropdown>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({});
</script>
+12
View File
@@ -0,0 +1,12 @@
<template functional>
<div>
<slot v-if="props.data" :data="props.data"></slot>
<div v-else class="text-center">
<span class="spinner-grow"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({})
</script>
+79
View File
@@ -0,0 +1,79 @@
<template functional>
<div class="tiles" :class="props.square ? 'tiles-sq' : 'tiles-rect'">
<div v-for="(item, index) in props.items" :key="item.id">
<div class="card">
<div class="tile-img bg-secondary">
<div class="tile-img-inner">
<img v-if="item.image" :src="item.image">
<div v-else class="fallback-img text-muted">
<Icon>mdi-music</Icon>
</div>
</div>
</div>
<div class="card-body">
<slot :item="item" :index="index"></slot>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.tiles {
display: grid;
grid-gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
@media(max-width: 442px) { // 15px padding + 200px tile + 12px gap + 200px tile + 15px padding
.tiles {
grid-gap: 8px;
grid-template-columns: repeat(2, minmax(1px, 1fr))
}
}
.tiles-sq {
.tile-img {
padding-bottom: 100%;
}
}
.tiles-rect {
.tile-img {
padding-bottom: 70%;
}
}
.tile-img {
position: relative;
width: 100%;
.tile-img-inner {
position: absolute;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback-img {
*{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4.5rem;
}
}
}
}
</style>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
props: {
items: { type: Array, required: true },
square: { type: Boolean, default: false },
}
});
</script>
+19
View File
@@ -0,0 +1,19 @@
import Vue from 'vue';
import Icon from "./Icon.vue";
import OverflowMenu from "./OverflowMenu.vue";
import Spinner from "./Spinner.vue";
import Tiles from "./Tiles.vue";
const components = {
Icon,
OverflowMenu,
Spinner,
Tiles,
};
type Key = keyof typeof components;
Object.keys(components).forEach((_key) => {
const key = _key as keyof typeof components;
Vue.component(key, components[key]);
});
+12
View File
@@ -0,0 +1,12 @@
import Vue from 'vue';
Vue.filter("duration", (value: number) => {
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60);
return (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
})
Vue.filter("dateTime", (value: string) => {
return value;
})
+118
View File
@@ -0,0 +1,118 @@
import Vue from 'vue'
import Router from 'vue-router'
import Login from '@/auth/Login.vue'
import Queue from '@/player/Queue.vue'
import Home from '@/home/Home.vue'
import AlbumList from '@/library/album/AlbumList.vue'
import ArtistDetails from '@/library/artist/ArtistDetails.vue'
import ArtistList from '@/library/artist/ArtistList.vue'
import Album from '@/library/album/Album.vue'
import RandomSongs from '@/playlist/RandomSongs.vue'
import GenreList from '@/library/genre/GenreList.vue'
import GenreDetails from '@/library/genre/GenreDetails.vue'
import Starred from '@/library/starred/Starred.vue'
import Playlist from '@/playlist/Playlist.vue'
import PlaylistList from '@/playlist/PlaylistList.vue'
import SearchResult from '@/search/SearchResult.vue'
import { AuthService } from '@/auth/service';
export function setupRouter(auth: AuthService) {
const router = new Router({
mode: 'history',
linkExactActiveClass: 'active',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
name: 'login',
path: '/login',
component: Login,
props: (route) => ({
returnTo: route.query.returnTo,
})
},
{
name: 'queue',
path: '/queue',
component: Queue,
},
{
name: 'albums',
path: '/albums',
component: AlbumList
},
{
name: 'album',
path: '/album/:id',
component: Album,
props: true,
},
{
name: 'artists',
path: '/artists',
component: ArtistList
},
{
name: 'artist',
path: '/artist/:id',
component: ArtistDetails,
props: true,
},
{
name: 'genres',
path: '/genres',
component: GenreList,
},
{
name: 'genre',
path: '/genre/:id',
component: GenreDetails,
props: true,
},
{
name: 'starred',
path: '/starred',
component: Starred,
},
{
name: 'playlists',
path: '/playlists',
component: PlaylistList,
},
{
name: 'playlist',
path: '/playlist/:id',
component: Playlist,
props: true,
},
{
name: 'playlist-random',
path: '/random',
component: RandomSongs,
},
{
name: 'search',
path: '/search',
component: SearchResult,
props: (route) => ({
query: route.query.q,
})
},
]
});
router.beforeEach((to, from, next) => {
if (to.name !== 'login' && !auth.isAuthenticated()) {
next({name: 'login', query: { returnTo: to.fullPath }});
} else {
next();
}
});
return router;
}
+82
View File
@@ -0,0 +1,82 @@
import Vue from 'vue'
import Vuex, { Module } from 'vuex'
import { ActionContext } from "vuex"
import { playerModule } from "@/player/store"
import axios from 'axios';
import { AuthService } from '@/auth/service';
import { API } from './api';
interface State {
isLoggedIn: boolean;
username: null | string;
showMenu: boolean;
errors: any[];
playlists: any[];
}
const setupRootModule = (authService: AuthService, api: API): Module<State, any> => ({
state: {
isLoggedIn: false,
username: null,
showMenu: false,
errors: [],
playlists: [],
},
mutations: {
setLoginSuccess(state, { username }) {
state.isLoggedIn = true;
state.username = username;
},
toggleMenu(state) {
state.showMenu = !state.showMenu;
},
setPlaylists(state, playlists: any[]) {
state.playlists = playlists;
},
removePlaylist(state, id: string) {
state.playlists = state.playlists.filter(p => p.id !== id);
},
},
actions: {
loadPlaylists({ commit }) {
api.getPlaylists().then(result => {
commit("setPlaylists", result);
})
},
createPlaylist({ commit }, name) {
api.createPlaylist(name).then(result => {
commit("setPlaylists", result);
})
},
addTrackToPlaylist({ }, { playlistId, trackId }) {
api.addToPlaylist(playlistId, trackId);
},
deletePlaylist({ commit, state }, id) {
api.deletePlaylist(id).then(() => {
commit("removePlaylist", id)
})
}
},
});
export function setupStore(authService: AuthService, api: API) {
const store = new Vuex.Store({
strict: true,
...setupRootModule(authService, api),
modules: {
player: {
namespaced: true,
...playerModule
}
}
})
store.watch(
(state) => state.isLoggedIn,
() => {
store.dispatch("loadPlaylists")
}
);
return store;
}