This commit is contained in:
McMistrzYT 2024-01-28 22:02:29 +01:00
parent b97048d3fb
commit 017b619766
9 changed files with 124 additions and 47 deletions

View File

@ -7,8 +7,9 @@ import { RequireAuthentication, ValidateBody } from "../Modules/Middleware";
import { writeFileSync } from "fs"; import { writeFileSync } from "fs";
import { ForcedCategory } from "../Schemas/ForcedCategory"; import { ForcedCategory } from "../Schemas/ForcedCategory";
import { fromBuffer } from "file-type"; import { fromBuffer } from "file-type";
import { Debug } from "../Modules/Logger";
import { magenta } from "colorette";
import ffmpeg from "fluent-ffmpeg"; import ffmpeg from "fluent-ffmpeg";
import exif from "exif-reader";
import j from "joi"; import j from "joi";
const App = Router(); const App = Router();
@ -75,26 +76,37 @@ async (req, res) => {
}); });
App.post("/upload/audio", App.post("/upload/audio",
ValidateBody(j.object({ RequireAuthentication(),
Data: j.string().hex().required(), ValidateBody(j.object({
TargetSong: j.string().uuid().required() Data: j.string().hex().required(),
})), TargetSong: j.string().uuid().required()
async (req, res) => { })),
const Decoded = Buffer.from(req.body.Data, "hex"); async (req, res) => {
const ext = (await fromBuffer(Decoded))!.ext; const Decoded = Buffer.from(req.body.Data, "hex");
const ext = (await fromBuffer(Decoded))!.ext;
if (!["mp3", "m4a", "ogg", "wav"].includes(ext)) if (!["mp3", "m4a", "ogg", "wav"].includes(ext))
return res.status(404).send("Invalid audio file. (supported: mp3, m4a, ogg, wav)"); return res.status(404).send("Invalid audio file. (supported: mp3, m4a, ogg, wav)");
if (!await Song.exists({ where: { ID: req.body.TargetSong } })) if (!await Song.exists({ where: { ID: req.body.TargetSong } }))
return res.status(404).send("The song you're trying to upload audio for does not exist."); return res.status(404).send("The song you're trying to upload audio for does not exist.");
// TODO: implement checks for this await writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded);
writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded); ffmpeg()
.input(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`)
.outputOptions([
"-map 0",
"-use_timeline 1",
"-f dash"
])
.output(`./Saved/Songs/${req.body.TargetSong}/Chunks/Manifest.mpd`)
.on("start", cl => Debug(`ffmpeg running with ${magenta(cl)}`))
.on("end", () => Debug("Ffmpeg finished running"))
.on("error", (e, stdout, stderr) => { console.error(e); console.log(stdout); console.error(stderr); })
.run();
ffmpeg() res.send("ffmpeg now running on song.");
.input("") });
})
App.post("/upload/cover", App.post("/upload/cover",
ValidateBody(j.object({ ValidateBody(j.object({
@ -106,13 +118,13 @@ async (req, res) => {
const ext = (await fromBuffer(Decoded))!.ext; const ext = (await fromBuffer(Decoded))!.ext;
if (ext !== "png") if (ext !== "png")
return res.status(404).send("Invalid image file. (supported: png)"); return res.status(400).send("Invalid image file. (supported: png)");
if (!await Song.exists({ where: { ID: req.body.TargetSong } })) if (!await Song.exists({ where: { ID: req.body.TargetSong } }))
return res.status(404).send("The song you're trying to upload a cover for does not exist."); return res.status(404).send("The song you're trying to upload a cover for does not exist.");
try { try {
const ImageMetadata = exif(Decoded); /*const ImageMetadata = exif(Decoded);
if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength) if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength)
throw new Error("Invalid image file."); throw new Error("Invalid image file.");
@ -120,7 +132,7 @@ async (req, res) => {
return res.status(400).send("Image must have a 1:1 ratio."); return res.status(400).send("Image must have a 1:1 ratio.");
if (ImageMetadata.Image.ImageWidth < 512 || ImageMetadata.Image.ImageWidth > 2048) if (ImageMetadata.Image.ImageWidth < 512 || ImageMetadata.Image.ImageWidth > 2048)
return res.status(400).send("Image cannot be smaller than 512 pixels and larger than 2048 pixels."); return res.status(400).send("Image cannot be smaller than 512 pixels and larger than 2048 pixels.");*/
} catch (err) { } catch (err) {
console.error(err) console.error(err)
return res.status(400).send("Invalid image file."); return res.status(400).send("Invalid image file.");

View File

@ -9,7 +9,7 @@ App.get("/", async (req, res) => {
const New = { const New = {
ID: "new", ID: "new",
Header: "Recently added", Header: "Recently added",
Songs: (await Song.find({ take: 10, order: { CreationDate: "DESC" } })).map(x => x.Package()), Songs: (await Song.find({ where: { IsDraft: false }, take: 10, order: { CreationDate: "DESC" } })).map(x => x.Package()),
Priority: 100, Priority: 100,
Custom: false Custom: false
} }

View File

@ -3,15 +3,23 @@ import { existsSync, readFileSync } from "fs";
import { FULL_SERVER_ROOT } from "../Modules/Constants"; import { FULL_SERVER_ROOT } from "../Modules/Constants";
import { CreateBlurl } from "../Modules/BLURL"; import { CreateBlurl } from "../Modules/BLURL";
import { Song } from "../Schemas/Song"; import { Song } from "../Schemas/Song";
import { RequireAuthentication } from "../Modules/Middleware";
const App = Router(); const App = Router();
App.get("/song/download/:InternalID/:File", async (req, res) => { App.get("/song/download/:InternalID/:File",
RequireAuthentication(),
async (req, res) => {
//const Song = AvailableFestivalSongs.find(x => x.UUID === req.params.SongUUID); //const Song = AvailableFestivalSongs.find(x => x.UUID === req.params.SongUUID);
const SongData = await Song.findOne({ where: { ID: req.params.InternalID } }); const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Author: true } });
if (!SongData) if (!SongData)
return res.status(404).json({ errorMessage: "Song not found." }); return res.status(404).json({ errorMessage: "Song not found." });
console.log(SongData);
if (SongData.IsDraft && SongData.Author.ID !== req.user!.ID)
return res.status(403).json({ errorMessage: "You cannot use this track, because it's a draft." });
const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`; const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`;
switch (req.params.File.toLowerCase()) { switch (req.params.File.toLowerCase()) {
case "master.blurl": case "master.blurl":
@ -64,10 +72,13 @@ App.get("/song/download/:InternalID/:File", async (req, res) => {
}); });
App.get("/:InternalID", async (req, res, next) => { App.get("/:InternalID", async (req, res, next) => {
const SongData = await Song.findOne({ where: { ID: req.params.InternalID } }); const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Author: true } });
if (!SongData) if (!SongData)
return next(); // trust me bro return next(); // trust me bro
if (SongData.IsDraft && SongData.Author.ID !== req.user!.ID)
return res.status(403).json({ errorMessage: "You cannot use this track, because it's a draft." });
const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`; const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`;
res.set("content-type", "application/json"); res.set("content-type", "application/json");
res.json({ res.json({

View File

@ -7,8 +7,9 @@ import { Song } from "../Schemas/Song";
import { Debug } from "../Modules/Logger"; import { Debug } from "../Modules/Logger";
import { magenta } from "colorette"; import { magenta } from "colorette";
import { fromBuffer } from "file-type"; import { fromBuffer } from "file-type";
import { writeFileSync } from "fs"; import { rmSync, writeFileSync } from "fs";
import { FULL_SERVER_ROOT } from "../Modules/Constants"; import { FULL_SERVER_ROOT } from "../Modules/Constants";
import { UserPermissions } from "../Schemas/User";
const App = Router(); const App = Router();
@ -32,7 +33,8 @@ App.post("/create",
async (req, res) => { async (req, res) => {
const SongData = await Song.create({ const SongData = await Song.create({
...req.body, ...req.body,
IsDraft: true IsDraft: true,
Author: req.user!
}).save(); }).save();
Debug(`New draft created by ${magenta(req.user!.ID!)} as ${magenta(`${SongData.ArtistName} - ${SongData.Name}`)}`) Debug(`New draft created by ${magenta(req.user!.ID!)} as ${magenta(`${SongData.ArtistName} - ${SongData.Name}`)}`)
@ -71,11 +73,15 @@ App.post("/upload/cover",
if (ext !== "png") if (ext !== "png")
return res.status(404).send("Invalid image file. (supported: png)"); return res.status(404).send("Invalid image file. (supported: png)");
if (!await Song.exists({ where: { ID: req.body.TargetSong } })) const SongData = await Song.findOne({ where: { ID: req.body.TargetSong }, relations: { Author: true } })
if (!SongData)
return res.status(404).send("The song you're trying to upload a cover for does not exist."); return res.status(404).send("The song you're trying to upload a cover for does not exist.");
if (req.user!.PermissionLevel! < UserPermissions.Administrator && SongData.Author.ID !== req.user!.ID)
return res.status(403).send("You don't have permission to upload to this song.");
try { try {
const ImageMetadata = exif(Decoded); /*const ImageMetadata = exif(Decoded);
if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength) if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength)
throw new Error("Invalid image file."); throw new Error("Invalid image file.");
@ -83,7 +89,7 @@ App.post("/upload/cover",
return res.status(400).send("Image must have a 1:1 ratio."); return res.status(400).send("Image must have a 1:1 ratio.");
if (ImageMetadata.Image.ImageWidth < 512 || ImageMetadata.Image.ImageWidth > 2048) if (ImageMetadata.Image.ImageWidth < 512 || ImageMetadata.Image.ImageWidth > 2048)
return res.status(400).send("Image cannot be smaller than 512 pixels and larger than 2048 pixels."); return res.status(400).send("Image cannot be smaller than 512 pixels and larger than 2048 pixels.");*/
} catch (err) { } catch (err) {
console.error(err) console.error(err)
return res.status(400).send("Invalid image file."); return res.status(400).send("Invalid image file.");
@ -112,19 +118,23 @@ App.post("/upload/audio",
await writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded); await writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded);
ffmpeg() ffmpeg()
.input(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`) .input(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`)
.inputOptions(["-re"])
.outputOptions([ .outputOptions([
"-map 0", "-map 0",
"-c:a aac",
"-ar:a:0 48000",
"-use_timeline 1", "-use_timeline 1",
"-adaptation_sets \"id=0,streams=a\"",
"-f dash" "-f dash"
]) ])
.output(`./Saved/Songs/${req.body.TargetSong}/Chunks/Manifest.mpd`) .output(`./Saved/Songs/${req.body.TargetSong}/Chunks/Manifest.mpd`)
.on("start", cl => Debug(`ffmpeg running with ${magenta(cl)}`)) .on("start", cl => Debug(`ffmpeg running with ${magenta(cl)}`))
.on("end", () => Debug("Ffmpeg finished running")) .on("end", () => {
.on("error", (e, stdout, stderr) => { console.error(e); console.log(stdout); console.error(stderr); }) Debug("Ffmpeg finished running");
rmSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`);
})
.on("error", (e, stdout, stderr) => {
console.error(e);
console.log(stdout);
console.error(stderr);
rmSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`);
})
.run(); .run();
res.send("ffmpeg now running on song."); res.send("ffmpeg now running on song.");

View File

@ -6,9 +6,10 @@ import j from "joi";
const App = Router(); const App = Router();
App.get("/me", RequireAuthentication({ BookmarkedSongs: true }), (req, res) => { App.get("/me", RequireAuthentication({ BookmarkedSongs: true, CreatedTracks: true }), (req, res) => {
res.json({ res.json({
Bookmarks: req.user?.BookmarkedSongs.map(x => x.Package()), Bookmarks: req.user?.BookmarkedSongs.map(x => x.Package()),
Created: req.user?.CreatedTracks.map(x => x.Package()),
Library: req.user?.Library Library: req.user?.Library
}) })
}) })
@ -60,10 +61,13 @@ async (req, res) => {
if (req.user?.BookmarkedSongs.findIndex(x => x.ID.toLowerCase() === req.body.SongID.toLowerCase()) !== -1) if (req.user?.BookmarkedSongs.findIndex(x => x.ID.toLowerCase() === req.body.SongID.toLowerCase()) !== -1)
return res.status(400).json({ errorMessage: "You're already subscribed to this song." }); return res.status(400).json({ errorMessage: "You're already subscribed to this song." });
const SongData = await Song.findOne({ where: { ID: req.body.SongID } }); const SongData = await Song.findOne({ where: { ID: req.body.SongID }, relations: { Author: true } });
if (!SongData) if (!SongData)
return res.status(404).json({ errorMessage: "Provided song doesn't exist." }); return res.status(404).json({ errorMessage: "Provided song doesn't exist." });
if (SongData.IsDraft && SongData.Author.ID !== req.user.ID)
return res.status(403).json({ errorMessage: "You cannot subscribe to this track, because it's a draft." });
req.user?.BookmarkedSongs.push(SongData); req.user?.BookmarkedSongs.push(SongData);
req.user?.save(); req.user?.save();
@ -86,10 +90,15 @@ async (req, res) => {
res.json(req.user?.BookmarkedSongs.map(x => x.Package())); res.json(req.user?.BookmarkedSongs.map(x => x.Package()));
}) })
App.get("/song/data/:InternalID", async (req, res) => { App.get("/song/data/:InternalID",
const SongData = await Song.findOne({ where: { ID: req.params.InternalID } }); RequireAuthentication(),
async (req, res) => {
const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Author: true } });
if (!SongData) if (!SongData)
return res.status(404).json({ errorMessage: "Song not found." }); return res.status(404).json({ errorMessage: "Provided song doesn't exist." });
if (SongData.IsDraft && SongData.Author.ID !== req.user!.ID)
return res.status(403).json({ errorMessage: "You cannot use this track, because it's a draft." });
res.json(SongData.Package()); res.json(SongData.Package());
}) })

View File

@ -65,6 +65,9 @@ export class Song extends BaseEntity {
@Column() @Column()
IsDraft: boolean; IsDraft: boolean;
@Column({ default: false })
DraftAwaitingReview: boolean;
@Column() @Column()
CreationDate: Date; CreationDate: Date;

View File

@ -8,12 +8,18 @@
"build:prod": "vite build", "build:prod": "vite build",
"build:stage": "vite build --mode staging", "build:stage": "vite build --mode staging",
"create:prod": "mkdir \"./Out\" && npm run build:prod && move \"./dist\" \"./Out/dist\" && cd \"Server\" && tsc && cd .. && copy \"./Server/.env.prod\" \"./Out/.env\" && copy \"./Server/package.json\" \"./Out/package.json\" && copy \"./Server/package-lock.json\" \"./Out/package-lock.json\"", "win:create:prod": "mkdir \"./Out\" && npm run build:prod && move \"./dist\" \"./Out/dist\" && cd \"Server\" && tsc && cd .. && copy \"./Server/.env.prod\" \"./Out/.env\" && copy \"./Server/package.json\" \"./Out/package.json\" && copy \"./Server/package-lock.json\" \"./Out/package-lock.json\"",
"publish:prod": "npm run create:prod && ssh partypack \"cd /home/PartypackProd; rm -rf ./Out\" && scp -r \"./Out\" partypack:/home/PartypackProd && ssh partypack \"cd /home/PartypackProd/Out && npm i && pm2 restart PartypackProd --update-env\" && rmdir \"./Out\"", "win:publish:prod": "npm run create:prod && ssh partypack \"cd /home/PartypackProd; rm -rf ./Out\" && scp -r \"./Out\" partypack:/home/PartypackProd && ssh partypack \"cd /home/PartypackProd/Out && npm i && pm2 restart PartypackProd --update-env\" && rmdir \"./Out\"",
"create:stage": "mkdir \"./Out\" && npm run build:stage && move \"./dist\" \"./Out/dist\" && cd \"Server\" && tsc && cd .. && copy \"./Server/.env.staging\" \"./Out/.env\" && copy \"./Server/package.json\" \"./Out/package.json\" && copy \"./Server/package-lock.json\" \"./Out/package-lock.json\"", "win:create:stage": "mkdir \"./Out\" && npm run build:stage && move \"./dist\" \"./Out/dist\" && cd \"Server\" && tsc && cd .. && copy \"./Server/.env.staging\" \"./Out/.env\" && copy \"./Server/package.json\" \"./Out/package.json\" && copy \"./Server/package-lock.json\" \"./Out/package-lock.json\"",
"publish:stage": "npm run create:stage && ssh partypack \"cd /home/PartypackStage; rm -rf ./Out\" && scp -r ./Out partypack:/home/PartypackStage && ssh partypack \"cd /home/PartypackStage/Out && npm i && pm2 restart PartypackStage --update-env\" && rmdir \"./Out\"", "win:publish:stage": "npm run create:stage && ssh partypack \"cd /home/PartypackStage; rm -rf ./Out\" && scp -r ./Out partypack:/home/PartypackStage && ssh partypack \"cd /home/PartypackStage/Out && npm i && pm2 restart PartypackStage --update-env\" && rmdir \"./Out\"",
"create:prod": "mkdir ./Out && npm run build:prod && mv ./dist ./Out/dist && cd Server && tsc && cd .. && cp ./Server/.env.prod ./Out/.env && cp ./Server/package.json ./Out && cp ./Server/package-lock.json ./Out",
"publish:prod": "npm run create:prod && ssh partypack \"cd /home/PartypackProd; rm -rf ./Out\" && scp -r ./Out partypack:/home/PartypackProd && ssh partypack \"cd /home/PartypackProd/Out && npm i && pm2 restart PartypackProd --update-env\" && rm -rf ./Out",
"create:stage": "mkdir ./Out && npm run build:stage && mv ./dist ./Out/dist && cd Server && tsc && cd .. && cp ./Server/.env.staging ./Out/.env && cp ./Server/package.json ./Out && cp ./Server/package-lock.json ./Out",
"publish:stage": "npm run create:stage && ssh partypack \"cd /home/PartypackStage; rm -rf ./Out\" && scp -r ./Out partypack:/home/PartypackStage && ssh partypack \"cd /home/PartypackStage/Out && npm i && pm2 restart PartypackStage --update-env\" && rm -rf ./Out",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"dev:all": "start cmd.exe /k \"cd ./Server && npm run dev:watch\" && vite" "dev:all": "start cmd.exe /k \"cd ./Server && npm run dev:watch\" && vite"

View File

@ -1,4 +1,4 @@
import { Box, Text } from "@primer/react"; import { Box, Label, Text } from "@primer/react";
import { Divider } from "@primer/react/lib-esm/ActionList/Divider"; import { Divider } from "@primer/react/lib-esm/ActionList/Divider";
export function Song({ data, children }: { data: any, children?: JSX.Element[] | JSX.Element | string }) { export function Song({ data, children }: { data: any, children?: JSX.Element[] | JSX.Element | string }) {
@ -8,6 +8,9 @@ export function Song({ data, children }: { data: any, children?: JSX.Element[] |
<center> <center>
<Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}>{data.ArtistName}</Text> <Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}>{data.ArtistName}</Text>
<Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}><b>{data.Name}</b></Text> <Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}><b>{data.Name}</b></Text>
{
data.IsDraft ? <Label variant="danger">Draft - not published</Label> : <></>
}
{ {
children ? <Divider /> : <></> children ? <Divider /> : <></>
} }

View File

@ -15,6 +15,7 @@ export function Profile() {
const [isActivateDialogOpen, setIsActivateDialogOpen] = useState<boolean>(false); const [isActivateDialogOpen, setIsActivateDialogOpen] = useState<boolean>(false);
const [librarySongs, setLibrarySongs] = useState<unknown[]>([]); const [librarySongs, setLibrarySongs] = useState<unknown[]>([]);
const [bookmarkedSongs, setBookmarkedSongs] = useState<unknown[]>([]); const [bookmarkedSongs, setBookmarkedSongs] = useState<unknown[]>([]);
const [draftsSongs, setDraftsSongs] = useState<unknown[]>([]);
const [availableOverrides, setAvailableOverrides] = useState<{ Name: string, Template: string }[]>([]); const [availableOverrides, setAvailableOverrides] = useState<{ Name: string, Template: string }[]>([]);
const [overriding, setOverriding] = useState<unknown>({}); const [overriding, setOverriding] = useState<unknown>({});
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,9 +29,9 @@ export function Profile() {
return toast("An error has occured while getting your library!", { type: "error" }); return toast("An error has occured while getting your library!", { type: "error" });
const LibSongs = (await Promise.all(Data.data.Library.map((x: { SongID: string; }) => axios.get(`/api/library/song/data/${x.SongID}`)))).map(x => { return { ...x.data, Override: Data.data.Library.find((y: { SongID: string; }) => y.SongID === x.data.ID).Overriding } }); const LibSongs = (await Promise.all(Data.data.Library.map((x: { SongID: string; }) => axios.get(`/api/library/song/data/${x.SongID}`)))).map(x => { return { ...x.data, Override: Data.data.Library.find((y: { SongID: string; }) => y.SongID === x.data.ID).Overriding } });
const BookSongs = (await Promise.all(Data.data.Bookmarks.map((x: { ID: string; }) => axios.get(`/api/library/song/data/${x.ID}`)))).map(x => x.data);
setLibrarySongs(LibSongs); setLibrarySongs(LibSongs);
setBookmarkedSongs(BookSongs); setBookmarkedSongs(Data.data.Bookmarks);
setDraftsSongs(Data.data.Created);
setAvailableOverrides(Overrides.data); setAvailableOverrides(Overrides.data);
})(); })();
}, []); }, []);
@ -126,6 +127,28 @@ export function Profile() {
: <Text>You have no bookmarked songs.</Text> : <Text>You have no bookmarked songs.</Text>
} }
</Box> </Box>
<Heading sx={{ marginTop: 2, marginBottom: 2 }}>My Drafts & Published Songs</Heading>
<Box className="songCategory">
{
draftsSongs.length >= 1 ?
draftsSongs.map(x => {
return <Song data={x}>
<Button sx={{ width: "100%", marginBottom: 1 }} variant="primary" onClick={() => { setIsActivateDialogOpen(true); setOverriding(x) }} disabled={librarySongs.findIndex(y => y.ID === x.ID) !== -1}>Add to Active</Button>
<Button sx={{ width: "100%", marginBottom: 1 }}>Publish</Button>
<Button sx={{ width: "100%" }} variant="danger" onClick={async () => {
const Res = await axios.post("/api/drafts/delete", { SongID: x.ID });
if (Res.status === 200) {
draftsSongs.splice(draftsSongs.findIndex(y => y.ID === x.ID), 1);
setDraftsSongs([...draftsSongs]);
}
else
toast(Res.data.errorMessage, { type: "error" })
}}>Unsubscribe</Button>
</Song>;
})
: <Text>You have no bookmarked songs.</Text>
}
</Box>
</Box> : </Box> :
<> <>
<Text>You are not logged in.<br />Log in using the button in the top right.</Text> <Text>You are not logged in.<br />Log in using the button in the top right.</Text>