feat: support Last.fm scrobble

This commit is contained in:
qier222 2021-03-23 23:43:29 +08:00
parent cc50faeb09
commit 55585a921f
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
12 changed files with 300 additions and 25 deletions

View File

@ -2,4 +2,7 @@ VUE_APP_NETEASE_API_URL=/api
VUE_APP_ELECTRON_API_URL=/api
VUE_APP_ELECTRON_API_URL_DEV=http://127.0.0.1:3000
VUE_APP_ENABLE_SENTRY=false
VUE_APP_LASTFM_API_KEY=09c55292403d961aa517ff7f5e8a3d9c
VUE_APP_LASTFM_API_SHARED_SECRET=307c9fda32b3904e53654baff215cb67
DEV_SERVER_PORT=20201

View File

@ -33,6 +33,7 @@
- 👆 支持 Touch Bar
- 🖥️ 支持 PWA可在 Chrome/Edge 里点击地址栏右边的 安装到电脑
- 🙉 支持显示歌曲和专辑的 Explicit 标志
- 🟥 支持 Last.fm Scrobble
- 🛠 更多特性开发中
## 📦️ 安装
@ -148,15 +149,9 @@ API 源代码来自 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryif
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[album-screenshot]: images/album.png
[artist-screenshot]: images/artist.png
[explore-screenshot]: images/explore.png
[home-screenshot]: images/home.png
[library-screenshot]: images/library.png
[playlist-screenshot]: images/playlist.png
[search-screenshot]: images/search.png

BIN
public/img/logos/lastfm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -1,6 +1,6 @@
<template>
<div id="app">
<Navbar ref="navbar" />
<Navbar ref="navbar" v-show="showNavbar" />
<main v-show="!this.$store.state.showLyrics">
<keep-alive>
<router-view v-if="$route.meta.keepAlive"></router-view>
@ -8,15 +8,12 @@
<router-view v-if="!$route.meta.keepAlive"></router-view>
</main>
<transition name="slide-up">
<Player
v-if="this.$store.state.player.enabled"
ref="player"
v-show="showPlayer"
<Player v-if="enablePlayer" ref="player" v-show="showPlayer"
/></transition>
<Toast />
<ModalAddTrackToPlaylist v-if="isAccountLoggedIn" />
<ModalNewPlaylist v-if="isAccountLoggedIn" />
<transition name="slide-up" v-if="this.$store.state.player.enabled">
<transition name="slide-up" v-if="enablePlayer">
<Lyrics v-show="this.$store.state.showLyrics" />
</transition>
</div>
@ -53,11 +50,24 @@ export default {
},
showPlayer() {
return (
["mv", "loginUsername", "login", "loginAccount"].includes(
this.$route.name
) === false
[
"mv",
"loginUsername",
"login",
"loginAccount",
"lastfmCallback",
].includes(this.$route.name) === false
);
},
enablePlayer() {
return (
this.$store.state.player.enabled &&
this.$route.name !== "lastfmCallback"
);
},
showNavbar() {
return this.$route.name !== "lastfmCallback";
},
},
created() {
if (this.isElectron) {

79
src/api/lastfm.js Normal file
View File

@ -0,0 +1,79 @@
// Last.fm API documents 👉 https://www.last.fm/api
import axios from "axios";
import md5 from "crypto-js/md5";
const apiKey = process.env.VUE_APP_LASTFM_API_KEY;
const apiSharedSecret = process.env.VUE_APP_LASTFM_API_SHARED_SECRET;
const baseUrl = window.location.origin;
const url = "https://ws.audioscrobbler.com/2.0/";
const sign = (params) => {
const sortParamsKeys = Object.keys(params).sort();
const sortedParams = sortParamsKeys.reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
let signature = "";
for (const [key, value] of Object.entries(sortedParams)) {
signature += `${key}${value}`;
}
return md5(signature + apiSharedSecret).toString();
};
export function auth() {
window.open(
`https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${baseUrl}/#/lastfm/callback`
);
}
export function authGetSession(token) {
const signature = md5(
`api_key${apiKey}methodauth.getSessiontoken${token}${apiSharedSecret}`
).toString();
return axios({
url,
method: "GET",
params: {
method: "auth.getSession",
format: "json",
api_key: apiKey,
api_sig: signature,
token,
},
});
}
export function trackUpdateNowPlaying(params) {
params.api_key = apiKey;
params.method = "track.updateNowPlaying";
params.sk = JSON.parse(localStorage.getItem("lastfm"))["key"];
const signature = sign(params);
return axios({
url,
method: "POST",
params: {
...params,
api_sig: signature,
format: "json",
},
});
}
export function trackScrobble(params) {
params.api_key = apiKey;
params.method = "track.scrobble";
params.sk = JSON.parse(localStorage.getItem("lastfm"))["key"];
const signature = sign(params);
return axios({
url,
method: "POST",
params: {
...params,
api_sig: signature,
format: "json",
},
});
}

View File

@ -103,6 +103,7 @@ class Background {
minHeight: 720,
titleBarStyle: "hiddenInset",
frame: process.platform !== "win32",
title: "YesPlayMusic",
webPreferences: {
webSecurity: false,
nodeIntegration: true,
@ -186,6 +187,25 @@ class Background {
this.window.webContents.on("new-window", function (e, url) {
e.preventDefault();
console.log("open url");
const excludeHosts = ["www.last.fm"];
const exclude = excludeHosts.find((host) => url.includes(host));
if (exclude) {
const newWindow = new BrowserWindow({
width: 800,
height: 600,
titleBarStyle: "default",
title: "YesPlayMusic",
webPreferences: {
webSecurity: false,
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false,
},
});
newWindow.loadURL(url);
return;
}
shell.openExternal(url);
});
}

View File

@ -123,6 +123,11 @@ const routes = [
name: "dailySongs",
component: () => import("@/views/dailyTracks.vue"),
},
{
path: "/lastfm/callback",
name: "lastfmCallback",
component: () => import("@/views/lastfmCallback.vue"),
},
];
const router = new VueRouter({
routes,
@ -160,7 +165,7 @@ router.beforeEach((to, from, next) => {
router.afterEach((to) => {
if (
to.matched.some((record) => !record.meta.keepAlive) &&
!["settings", "dailySongs"].includes(to.name)
!["settings", "dailySongs", "lastfmCallback"].includes(to.name)
) {
NProgress.start();
}

View File

@ -45,4 +45,7 @@ export default {
updateDailyTracks(state, dailyTracks) {
state.dailyTracks = dailyTracks;
},
updateLastfm(state, session) {
state.lastfm = session;
},
};

View File

@ -35,6 +35,7 @@ export default {
},
},
dailyTracks: [],
lastfm: JSON.parse(localStorage.getItem("lastfm")) || {},
player: JSON.parse(localStorage.getItem("player")),
settings: JSON.parse(localStorage.getItem("settings")),
data: JSON.parse(localStorage.getItem("data")),

View File

@ -9,6 +9,7 @@ import { getArtist } from "@/api/artist";
import { personalFM, fmTrash } from "@/api/others";
import store from "@/store";
import { isAccountLoggedIn } from "@/utils/auth";
import { trackUpdateNowPlaying, trackScrobble } from "@/api/lastfm";
const electron =
process.env.IS_ELECTRON === true ? window.require("electron") : null;
@ -160,16 +161,29 @@ export default class {
this._shuffledList = shuffle(list);
if (firstTrackID !== "first") this._shuffledList.unshift(firstTrackID);
}
async _scrobble(complete = false) {
let time = this._howler.seek();
if (complete) {
time = ~~(this._currentTrack.dt / 100);
}
async _scrobble(track, time, complete = false) {
console.log("scrobble");
const trackDuration = ~~(track.dt / 1000);
scrobble({
id: this._currentTrack.id,
id: track.id,
sourceid: this.playlistSource.id,
time,
time: complete ? trackDuration : time,
});
if (
store.state.lastfm.key !== undefined &&
(time >= trackDuration / 2 || time >= 240)
) {
console.log({ currentTrack: track });
const timestamp = ~~(new Date().getTime() / 1000) - time;
trackScrobble({
artist: track.ar[0].name,
track: track.name,
timestamp,
album: track.al.name,
trackNumber: track.no,
duration: trackDuration,
});
}
}
_playAudioSource(source, autoplay = true) {
Howler.unload();
@ -235,6 +249,7 @@ export default class {
autoplay = true,
ifUnplayableThen = "playNextTrack"
) {
if (autoplay) this._scrobble(this.currentTrack, this._howler.seek(), true);
return getTrackDetail(id).then((data) => {
let track = data.songs[0];
this._currentTrack = track;
@ -321,7 +336,6 @@ export default class {
}
}
_nextTrackCallback() {
this._scrobble(true);
if (this.repeatMode === "one") {
this._replaceCurrentTrack(this._currentTrack.id);
} else {
@ -410,6 +424,16 @@ export default class {
this._playing = true;
document.title = `${this._currentTrack.name} · ${this._currentTrack.ar[0].name} - YesPlayMusic`;
this._playDiscordPresence(this._currentTrack, this.seek());
if (store.state.lastfm.key !== undefined) {
console.log({ currentTrack: this.currentTrack });
trackUpdateNowPlaying({
artist: this.currentTrack.ar[0].name,
track: this.currentTrack.name,
album: this.currentTrack.al.name,
trackNumber: this.currentTrack.no,
duration: ~~(this.currentTrack.dt / 1000),
});
}
}
playOrPause() {
if (this._howler.playing()) {

View File

@ -0,0 +1,98 @@
<template>
<div class="lastfm-callback">
<div class="section-1">
<img src="/img/logos/yesplaymusic.png" />
<svg-icon icon-class="x"></svg-icon>
<img src="/img/logos/lastfm.png" />
</div>
<div class="message">{{ message }}</div>
<button @click="close" v-show="done"> 完成 </button>
</div>
</template>
<script>
import { authGetSession } from "@/api/lastfm";
export default {
name: "lastfmCallback",
data() {
return { message: "请稍等...", done: false };
},
created() {
const token = new URLSearchParams(window.location.search).get("token");
if (!token) {
this.message = "连接失败请重试或联系开发者无Token";
this.done = true;
return;
}
console.log(token);
authGetSession(token).then((result) => {
console.log(result);
if (!result.data.session) {
this.message = "连接失败请重试或联系开发者无Session";
this.done = true;
return;
}
localStorage.setItem("lastfm", JSON.stringify(result.data.session));
this.$store.commit("updateLastfm", result.data.session);
this.message = "已成功连接到 Last.fm";
this.done = true;
});
},
methods: {
close() {
window.close();
},
},
};
</script>
<style lang="scss" scoped>
.lastfm-callback {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: calc(100vh - 192px);
}
.section-1 {
margin-bottom: 16px;
display: flex;
align-items: center;
img {
height: 64px;
margin: 20px;
}
.svg-icon {
height: 24px;
width: 24px;
color: rgba(82, 82, 82, 0.28);
}
}
.message {
font-size: 1.4rem;
font-weight: 500;
color: var(--color-text);
}
button {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
background-color: var(--color-primary-bg);
color: var(--color-primary);
border-radius: 8px;
margin-top: 24px;
transition: 0.2s;
padding: 8px 16px;
&:hover {
transform: scale(1.06);
}
&:active {
transform: scale(0.94);
}
}
</style>

View File

@ -197,6 +197,25 @@
</div>
</div>
</div>
<div class="item">
<div class="left">
<div class="title">
{{
isLastfmConnected
? `已连接到 Last.fm (${lastfm.name})`
: "连接 Last.fm "
}}</div
>
</div>
<div class="right">
<button @click="lastfmDisconnect()" v-if="isLastfmConnected"
>断开连接
</button>
<button @click="lastfmConnect()" v-else> 授权连接 </button>
</div>
</div>
<div class="item">
<div class="left">
<div class="title">
@ -282,6 +301,7 @@
<script>
import { mapState } from "vuex";
import { doLogout } from "@/utils/auth";
import { auth as lastfmAuth } from "@/api/lastfm";
import { changeAppearance, bytesToSize } from "@/utils/common";
import { countDBSize, clearDB } from "@/utils/db";
import pkg from "../../package.json";
@ -304,7 +324,7 @@ export default {
};
},
computed: {
...mapState(["player", "settings", "data"]),
...mapState(["player", "settings", "data", "lastfm"]),
isElectron() {
return process.env.IS_ELECTRON;
},
@ -470,6 +490,9 @@ export default {
});
},
},
isLastfmConnected() {
return this.lastfm.key !== undefined;
},
},
methods: {
getAllOutputDevices() {
@ -515,6 +538,20 @@ export default {
this.countDBSize("tracks");
});
},
lastfmConnect() {
lastfmAuth();
let lastfmChecker = setInterval(() => {
const session = localStorage.getItem("lastfm");
if (session) {
this.$store.commit("updateLastfm", JSON.parse(session));
clearInterval(lastfmChecker);
}
}, 1000);
},
lastfmDisconnect() {
localStorage.removeItem("lastfm");
this.$store.commit("updateLastfm", {});
},
},
created() {
this.countDBSize("tracks");