mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2024-11-24 21:09:23 +08:00
feat: support Last.fm scrobble
This commit is contained in:
parent
cc50faeb09
commit
55585a921f
|
@ -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
|
||||
|
||||
|
|
|
@ -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
BIN
public/img/logos/lastfm.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
28
src/App.vue
28
src/App.vue
|
@ -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
79
src/api/lastfm.js
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -45,4 +45,7 @@ export default {
|
|||
updateDailyTracks(state, dailyTracks) {
|
||||
state.dailyTracks = dailyTracks;
|
||||
},
|
||||
updateLastfm(state, session) {
|
||||
state.lastfm = session;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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()) {
|
||||
|
|
98
src/views/lastfmCallback.vue
Normal file
98
src/views/lastfmCallback.vue
Normal 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>
|
|
@ -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");
|
||||
|
|
Loading…
Reference in New Issue
Block a user