Supporting multiplatform track likes

This commit is contained in:
SrGooglo 2023-10-13 20:33:40 +00:00
parent fec281dece
commit 4add14652c
16 changed files with 374 additions and 85 deletions

View File

@ -1,7 +1,6 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import seekToTimeLabel from "utils/seekToTimeLabel"
import { ImageViewer } from "components" import { ImageViewer } from "components"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
@ -13,6 +12,17 @@ import { Context as PlaylistContext } from "contexts/WithPlaylistContext"
import "./index.less" import "./index.less"
const handlers = {
"like": async (ctx, track) => {
app.cores.player.toggleCurrentTrackLike(true, track)
ctx.closeMenu()
},
"unlike": async (ctx, track) => {
app.cores.player.toggleCurrentTrackLike(false, track)
ctx.closeMenu()
},
}
const Track = (props) => { const Track = (props) => {
const { const {
track_manifest, track_manifest,
@ -55,8 +65,19 @@ const Track = (props) => {
}) })
} }
const handleMoreMenuItemClick = () => { const handleMoreMenuItemClick = (e) => {
const { key } = e
if (typeof handlers[key] === "function") {
return handlers[key](
{
closeMenu: () => {
setMoreMenuOpened(false)
}
},
props.track
)
}
} }
const moreMenuItems = React.useMemo(() => { const moreMenuItems = React.useMemo(() => {
@ -70,19 +91,30 @@ const Track = (props) => {
key: "share", key: "share",
icon: <Icons.MdShare />, icon: <Icons.MdShare />,
label: "Share", label: "Share",
disabled: true,
}, },
{ {
key: "add_to_playlist", key: "add_to_playlist",
icon: <Icons.MdPlaylistAdd />, icon: <Icons.MdPlaylistAdd />,
label: "Add to playlist", label: "Add to playlist",
disabled: true,
}, },
{ {
key: "add_to_queue", key: "add_to_queue",
icon: <Icons.MdQueueMusic />, icon: <Icons.MdQueueMusic />,
label: "Add to queue", label: "Add to queue",
disabled: true,
} }
] ]
if (props.track.liked) {
items[0] = {
key: "unlike",
icon: <Icons.MdFavorite />,
label: "Unlike",
}
}
if (playlist_ctx) { if (playlist_ctx) {
if (playlist_ctx.owning_playlist) { if (playlist_ctx.owning_playlist) {
items.push({ items.push({
@ -98,7 +130,7 @@ const Track = (props) => {
} }
return items return items
}) }, [props.track])
return <div return <div
id={props.track._id} id={props.track._id}

View File

@ -18,8 +18,8 @@ const EventsHandlers = {
"playback": () => { "playback": () => {
return app.cores.player.playback.toggle() return app.cores.player.playback.toggle()
}, },
"like": () => { "like": async (ctx) => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
}, },
"previous": () => { "previous": () => {
return app.cores.player.playback.previous() return app.cores.player.playback.previous()

View File

@ -0,0 +1,37 @@
import React from "react"
import { Button } from "antd"
import { Icons } from "components/Icons"
import LikeButton from "components/LikeButton"
import { Context } from "contexts/WithPlayerContext"
const ExtraActions = (props) => {
const ctx = React.useContext(Context)
const handleClickLike = async () => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
}
return <div className="extra_actions">
{
app.isMobile && <Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!ctx.track_manifest.lyricsEnabled}
/>
}
{
!app.isMobile && <LikeButton
liked={ctx.track_manifest?.liked ?? false}
onClick={handleClickLike}
/>
}
<Button
type="ghost"
icon={<Icons.MdQueueMusic />}
/>
</div>
}
export default ExtraActions

View File

@ -13,7 +13,7 @@ import Controls from "components/Player/Controls"
import RGBStringToValues from "utils/rgbToValues" import RGBStringToValues from "utils/rgbToValues"
import LikeButton from "components/LikeButton" import ExtraActions from "../ExtraActions"
import "./index.less" import "./index.less"
@ -45,17 +45,6 @@ const ServiceIndicator = (props) => {
} }
} }
const ExtraActions = (props) => {
return <div className="extra_actions">
<LikeButton />
<antd.Button
type="ghost"
icon={<Icons.MdQueueMusic />}
/>
</div>
}
const Player = (props) => { const Player = (props) => {
const ctx = React.useContext(Context) const ctx = React.useContext(Context)

View File

@ -13,7 +13,7 @@ import AudioPlayerStorage from "./player.storage"
import defaultAudioProccessors from "./processors" import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession" import MediaSession from "./mediaSession"
import servicesToManifestResolver from "./servicesToManifestResolver" import ServicesHandlers from "./services"
export default class Player extends Core { export default class Player extends Core {
static dependencies = [ static dependencies = [
@ -94,6 +94,7 @@ export default class Player extends Core {
seek: this.seek.bind(this), seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this), minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this), collapse: this.toggleCollapse.bind(this),
toggleCurrentTrackLike: this.toggleCurrentTrackLike.bind(this),
state: new Proxy(this.state, { state: new Proxy(this.state, {
get: (target, prop) => { get: (target, prop) => {
return target[prop] return target[prop]
@ -127,6 +128,10 @@ export default class Player extends Core {
}, },
} }
wsEvents = {
}
async onInitialize() { async onInitialize() {
this.native_controls.initialize() this.native_controls.initialize()
@ -317,11 +322,16 @@ export default class Player extends Core {
// check if manifest has `manifest` property, if is and not inherit or missing source, resolve // check if manifest has `manifest` property, if is and not inherit or missing source, resolve
if (manifest.service) { if (manifest.service) {
if (!ServicesHandlers[manifest.service]) {
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
if (manifest.service !== "inherit" && !manifest.source) { if (manifest.service !== "inherit" && !manifest.source) {
const resolver = servicesToManifestResolver[manifest.service] const resolver = ServicesHandlers[manifest.service].resolve
if (!resolver) { if (!resolver) {
this.console.error(`Service ${manifest.service} is not supported`) this.console.error(`Resolving for service [${manifest.service}] is not supported`)
return false return false
} }
@ -536,6 +546,8 @@ export default class Player extends Core {
// play // play
await this.track_instance.media.play() await this.track_instance.media.play()
this.console.log(this.track_instance)
// update manifest // update manifest
this.state.track_manifest = instance.manifest this.state.track_manifest = instance.manifest
@ -960,4 +972,38 @@ export default class Player extends Core {
}) })
}) })
} }
async toggleCurrentTrackLike(to, manifest) {
let isCurrent = !!!manifest
if (typeof manifest === "undefined") {
manifest = this.track_instance.manifest
}
if (!manifest) {
this.console.error("Track instance or manifest not found")
return false
}
if (typeof to !== "boolean") {
this.console.warn("Like must be a boolean")
return false
}
const service = manifest.service ?? "default"
if (!ServicesHandlers[service].toggleLike) {
this.console.error(`Service [${service}] does not support like actions`)
return false
}
const result = await ServicesHandlers[service].toggleLike(manifest, to)
if (isCurrent) {
this.track_instance.manifest.liked = to
this.state.track_manifest.liked = to
}
return result
}
} }

View File

@ -0,0 +1,35 @@
import SyncModel from "comty.js/models/sync"
import MusicModel from "comty.js/models/music"
export default {
"default": {
resolve: () => { },
toggleLike: async (manifest, to) => {
return await MusicModel.toggleTrackLike(manifest, to)
}
},
"tidal": {
resolve: async (manifest) => {
const resolvedManifest = await SyncModel.tidalCore.getTrackManifest(manifest.id)
manifest.source = resolvedManifest.playback.url
if (!manifest.metadata) {
manifest.metadata = {}
}
manifest.metadata.title = resolvedManifest.metadata.title
manifest.metadata.artist = resolvedManifest.metadata.artists.map(artist => artist.name).join(", ")
manifest.metadata.album = resolvedManifest.metadata.album.title
const coverUID = resolvedManifest.metadata.album.cover.replace(/-/g, "/")
manifest.metadata.cover = `https://resources.tidal.com/images/${coverUID}/1280x1280.jpg`
return manifest
},
toggleLike: async (manifest, to) => {
return await MusicModel.toggleTrackLike(manifest, to)
}
}
}

View File

@ -1,23 +0,0 @@
import SyncModel from "comty.js/models/sync"
export default {
"tidal": async (manifest) => {
const resolvedManifest = await SyncModel.tidalCore.getTrackManifest(manifest.id)
manifest.source = resolvedManifest.playback.url
if (!manifest.metadata) {
manifest.metadata = {}
}
manifest.metadata.title = resolvedManifest.metadata.title
manifest.metadata.artist = resolvedManifest.metadata.artists.map(artist => artist.name).join(", ")
manifest.metadata.album = resolvedManifest.metadata.album.title
const coverUID = resolvedManifest.metadata.album.cover.replace(/-/g, "/")
manifest.metadata.cover = `https://resources.tidal.com/images/${coverUID}/1280x1280.jpg`
return manifest
}
}

View File

@ -8,6 +8,8 @@ import SeekBar from "components/Player/SeekBar"
import Controls from "components/Player/Controls" import Controls from "components/Player/Controls"
import { WithPlayerContext, Context } from "contexts/WithPlayerContext" import { WithPlayerContext, Context } from "contexts/WithPlayerContext"
import ExtraActions from "components/Player/ExtraActions"
import "./index.less" import "./index.less"
const ServiceIndicator = (props) => { const ServiceIndicator = (props) => {
@ -112,18 +114,7 @@ const AudioPlayerComponent = (props) => {
disabled={ctx.control_locked} disabled={ctx.control_locked}
/> />
<div className="extra_actions"> <ExtraActions />
<Button
type="ghost"
icon={<Icons.MdLyrics />}
disabled={!lyricsEnabled}
/>
<Button
type="ghost"
icon={<Icons.MdQueueMusic />}
/>
</div>
</div> </div>
</div> </div>
} }

View File

@ -506,21 +506,41 @@ export default class MusicModel {
/** /**
* Toggles the like status of a track. * Toggles the like status of a track.
* *
* @param {number} track_id - The ID of the track. * @param {Object} manifest - The manifest object containing track information.
* @throws {Error} If track_id is not provided. * @param {boolean} to - The like status to toggle (true for like, false for unlike).
* @return {Promise<Object>} The response data. * @throws {Error} Throws an error if the manifest is missing.
* @return {Object} The response data from the API.
*/ */
static async toggleTrackLike(track_id) { static async toggleTrackLike(manifest, to) {
if (!track_id) { if (!manifest) {
throw new Error("Track ID is required") throw new Error("Manifest is required")
} }
console.log(`Toggling track ${manifest._id} like status to ${to}`)
const track_id = manifest._id
switch (manifest.service) {
case "tidal": {
const response = await SyncModel.tidalCore.toggleTrackLike({
track_id,
to,
})
return response
}
default: {
const response = await request({ const response = await request({
instance: MusicModel.api_instance, instance: MusicModel.api_instance,
method: "POST", method: to ? "POST" : "DELETE",
url: `/tracks/${track_id}/toggle-like`, url: `/tracks/${track_id}/like`,
params: {
service: manifest.service
}
}) })
return response.data return response.data
} }
} }
}
}

View File

@ -156,4 +156,17 @@ export default class TidalService {
return data return data
} }
static async toggleTrackLike({
track_id,
to,
}) {
const { data } = await request({
instance: TidalService.api_instance,
method: to ? "POST" : "DELETE",
url: `/services/tidal/track/${track_id}/like`,
})
return data
}
} }

View File

@ -21,27 +21,16 @@ export default async (req, res) => {
user_id: req.session.user_id, user_id: req.session.user_id,
}) })
if (like) {
await like.delete() await like.delete()
like = null
} else {
like = new TrackLike({
track_id: track_id,
user_id: req.session.user_id,
created_at: new Date().getTime(),
})
await like.save()
}
global.ws.io.emit("music:self:track:toggle:like", { global.ws.io.emit("music:self:track:toggle:like", {
track_id: track_id, track_id: track_id,
user_id: req.session.user_id, user_id: req.session.user_id,
action: like ? "liked" : "unliked", action: "unliked",
}) })
return res.status(200).json({ return res.status(200).json({
message: "ok", message: "ok",
action: like ? "liked" : "unliked", action: "unliked",
}) })
} }

View File

@ -0,0 +1,42 @@
import { TrackLike, Track } from "@shared-classes/DbModels"
import { AuthorizationError, NotFoundError } from "@shared-classes/Errors"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
const { track_id } = req.params
const track = await Track.findById(track_id).catch((err) => {
return null
})
if (!track) {
return new NotFoundError(req, res, "Track not found")
}
let like = await TrackLike.findOne({
track_id: track_id,
user_id: req.session.user_id,
})
like = new TrackLike({
track_id: track_id,
user_id: req.session.user_id,
created_at: new Date().getTime(),
})
await like.save()
global.ws.io.emit("music:self:track:toggle:like", {
track_id: track_id,
user_id: req.session.user_id,
action: "liked" ,
})
return res.status(200).json({
message: "ok",
action: "liked",
})
}

View File

@ -49,6 +49,7 @@
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"p-map": "^6.0.0", "p-map": "^6.0.0",
"p-queue": "^7.3.4", "p-queue": "^7.3.4",
"qs": "^6.11.2",
"redis": "^4.6.6", "redis": "^4.6.6",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"split-chunk-merge": "^1.0.0", "split-chunk-merge": "^1.0.0",

View File

@ -0,0 +1,34 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError } from "@shared-classes/Errors"
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
try {
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
if (!access_token) {
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
}
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
user_data = JSON.parse(user_data)
let response = await TidalAPI.toggleTrackLike({
trackId: req.params.track_id,
to: false,
user_id: user_data.id,
access_token,
country: user_data.country,
})
return res.json(response)
} catch (error) {
return new InternalServerError(req, res, error)
}
}

View File

@ -0,0 +1,35 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError } from "@shared-classes/Errors"
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
try {
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
if (!access_token) {
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
}
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
user_data = JSON.parse(user_data)
let response = await TidalAPI.toggleTrackLike({
trackId: req.params.track_id,
to: true,
user_id: user_data.id,
access_token,
country: user_data.countryCode,
})
return res.json(response)
} catch (error) {
console.error(error)
return new InternalServerError(req, res, error)
}
}

View File

@ -1,4 +1,6 @@
import axios from "axios" import axios from "axios"
import FormData from "form-data"
import qs from "qs"
const TIDAL_CLIENT_ID = process.env.TIDAL_CLIENT_ID const TIDAL_CLIENT_ID = process.env.TIDAL_CLIENT_ID
const TIDAL_CLIENT_SECRET = process.env.TIDAL_CLIENT_SECRET const TIDAL_CLIENT_SECRET = process.env.TIDAL_CLIENT_SECRET
@ -256,7 +258,6 @@ export default class TidalAPI {
} }
} }
/** /**
* Retrieves self favorite playlists based on specified parameters. * Retrieves self favorite playlists based on specified parameters.
* *
@ -423,8 +424,55 @@ export default class TidalAPI {
return response.data return response.data
} }
static async toggleTrackLike(track_id) { /**
* Toggles the like status of a track.
*
* @param {Object} params - The parameters for toggling the track like.
* @param {string} params.trackId - The ID of the track to toggle the like status.
* @param {boolean} params.to - The new like status. True to like the track, false to unlike it.
* @param {string} params.user_id - The ID of the user performing the action.
* @param {string} params.access_token - The access token for authentication.
* @param {string} params.country - The country code.
* @return {Object} - The response data from the API.
*/
static async toggleTrackLike({
trackId,
to,
user_id,
access_token,
country,
}) {
let url = `${TidalAPI.API_V1}/users/${user_id}/favorites/tracks`
let payload = null
let headers = {
Origin: "http://listen.tidal.com",
Authorization: `Bearer ${access_token}`,
}
if (!to) {
url = `${url}/${trackId}`
} else {
payload = qs.stringify({
trackIds: trackId,
onArtifactNotFound: "FAIL"
})
headers["Content-Type"] = "application/x-www-form-urlencoded"
}
let response = await axios({
url: url,
method: to ? "POST" : "DELETE",
headers: headers,
params: {
countryCode: country,
deviceType: "BROWSER"
},
data: payload
})
return response.data
} }
static async togglePlaylistLike(playlist_id) { static async togglePlaylistLike(playlist_id) {