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 * as antd from "antd"
import classnames from "classnames"
import seekToTimeLabel from "utils/seekToTimeLabel"
import { ImageViewer } from "components"
import { Icons } from "components/Icons"
@ -13,6 +12,17 @@ import { Context as PlaylistContext } from "contexts/WithPlaylistContext"
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_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(() => {
@ -70,19 +91,30 @@ const Track = (props) => {
key: "share",
icon: <Icons.MdShare />,
label: "Share",
disabled: true,
},
{
key: "add_to_playlist",
icon: <Icons.MdPlaylistAdd />,
label: "Add to playlist",
disabled: true,
},
{
key: "add_to_queue",
icon: <Icons.MdQueueMusic />,
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.owning_playlist) {
items.push({
@ -98,7 +130,7 @@ const Track = (props) => {
}
return items
})
}, [props.track])
return <div
id={props.track._id}

View File

@ -18,8 +18,8 @@ const EventsHandlers = {
"playback": () => {
return app.cores.player.playback.toggle()
},
"like": () => {
"like": async (ctx) => {
await app.cores.player.toggleCurrentTrackLike(!ctx.track_manifest?.liked)
},
"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 LikeButton from "components/LikeButton"
import ExtraActions from "../ExtraActions"
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 ctx = React.useContext(Context)

View File

@ -13,7 +13,7 @@ import AudioPlayerStorage from "./player.storage"
import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession"
import servicesToManifestResolver from "./servicesToManifestResolver"
import ServicesHandlers from "./services"
export default class Player extends Core {
static dependencies = [
@ -94,6 +94,7 @@ export default class Player extends Core {
seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this),
toggleCurrentTrackLike: this.toggleCurrentTrackLike.bind(this),
state: new Proxy(this.state, {
get: (target, prop) => {
return target[prop]
@ -127,6 +128,10 @@ export default class Player extends Core {
},
}
wsEvents = {
}
async onInitialize() {
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
if (manifest.service) {
if (!ServicesHandlers[manifest.service]) {
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
if (manifest.service !== "inherit" && !manifest.source) {
const resolver = servicesToManifestResolver[manifest.service]
const resolver = ServicesHandlers[manifest.service].resolve
if (!resolver) {
this.console.error(`Service ${manifest.service} is not supported`)
this.console.error(`Resolving for service [${manifest.service}] is not supported`)
return false
}
@ -536,6 +546,8 @@ export default class Player extends Core {
// play
await this.track_instance.media.play()
this.console.log(this.track_instance)
// update 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 { WithPlayerContext, Context } from "contexts/WithPlayerContext"
import ExtraActions from "components/Player/ExtraActions"
import "./index.less"
const ServiceIndicator = (props) => {
@ -112,18 +114,7 @@ const AudioPlayerComponent = (props) => {
disabled={ctx.control_locked}
/>
<div className="extra_actions">
<Button
type="ghost"
icon={<Icons.MdLyrics />}
disabled={!lyricsEnabled}
/>
<Button
type="ghost"
icon={<Icons.MdQueueMusic />}
/>
</div>
<ExtraActions />
</div>
</div>
}

View File

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

View File

@ -156,4 +156,17 @@ export default class TidalService {
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,
})
if (like) {
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()
}
await like.delete()
global.ws.io.emit("music:self:track:toggle:like", {
track_id: track_id,
user_id: req.session.user_id,
action: like ? "liked" : "unliked",
action: "unliked",
})
return res.status(200).json({
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",
"p-map": "^6.0.0",
"p-queue": "^7.3.4",
"qs": "^6.11.2",
"redis": "^4.6.6",
"sharp": "^0.31.3",
"split-chunk-merge": "^1.0.0",
@ -61,4 +62,4 @@
"mocha": "^10.2.0",
"nodemon": "^2.0.15"
}
}
}

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 FormData from "form-data"
import qs from "qs"
const TIDAL_CLIENT_ID = process.env.TIDAL_CLIENT_ID
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.
*
@ -423,11 +424,58 @@ export default class TidalAPI {
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) {
}
}