Skip to content

Commit

Permalink
Add favourites feature
Browse files Browse the repository at this point in the history
  • Loading branch information
espidev committed Jul 26, 2023
1 parent 37503b5 commit 4c38834
Show file tree
Hide file tree
Showing 9 changed files with 399 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { checkAuthenticated } from "@/util/api";
import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants";
import { getDB } from "@/util/db";
import { NextResponse } from "next/server";

// POST /collection/[accountUuid]/favourites/add-track
// Add a track to the favourites
// Request body:
// { trackId }

export async function POST(request: Request, { params }: { params: { accountUuid: string } }) {
const { trackId } = await request.json();

// check authorization
const tokenUuid = await checkAuthenticated();
if (tokenUuid === null || tokenUuid !== params.accountUuid) {
return NextResponse.json({ error: "not authorized" }, { status: 401 });
}

const conn = await getDB();

try {

let playlistId = 0;

// check if playlist exists
const playlistRes = await conn.query(`
SELECT id FROM playlist WHERE name = $1 AND account_uuid = $2
`, [FAVOURITES_PLAYLIST_NAME, tokenUuid]);

if (playlistRes.rowCount === 0) {

// add favourites playlist if it doesn't exist
const insertQuery = await conn.query(`
INSERT INTO playlist (name, account_uuid) VALUES ($1, $2) RETURNING *
`, [FAVOURITES_PLAYLIST_NAME, tokenUuid]);

playlistId = insertQuery.rows[0].id;

} else {
playlistId = playlistRes.rows[0].id;
}

// check if track exists
const trackRes = await conn.query(`
SELECT id FROM track WHERE id = $1 AND account_uuid = $2
`, [trackId, tokenUuid]);

if (trackRes.rowCount === 0) {
await conn.end();
return NextResponse.json({ error: 'track not found' }, { status: 404 });
}

// check how many tracks there are
const trackCountRes = await conn.query(`
SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = $1
`, [playlistId]);

const trackCount = trackCountRes.rows[0].count;

// insert the track association
await conn.query(`
INSERT INTO playlist_tracks(account_uuid, playlist_id, track_id, position, added_on)
VALUES ($1, $2, $3, $4, $5)
`,
[tokenUuid, playlistId, trackId, trackCount, new Date().toISOString()]
);

await conn.end();

return NextResponse.json({ status: 'success' }, { status: 200 });
} catch (error) {
await conn.end();
console.error("Error updating playlist:", error);
return NextResponse.json({ error: 'an error occurred while updating the playlist' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { checkAuthenticated } from "@/util/api";
import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants";
import { getDB } from "@/util/db";
import { DBPlaylistTrack } from "@/util/models/playlist";
import { NextResponse } from "next/server";

// POST /collection/[accountUuid]/favourites/remove-track
// Removes a track from the favourites
// Request body:
// { trackId }

export async function POST(request: Request, { params }: { params: { accountUuid: string } }) {
let { trackId } = await request.json();

// check authorization
const tokenUuid = await checkAuthenticated();
if (tokenUuid === null || tokenUuid !== params.accountUuid) {
return NextResponse.json({ error: "not authorized" }, { status: 401 });
}

const conn = await getDB();

try {
// check if playlist exists
const playlistRes = await conn.query(`
SELECT id FROM playlist WHERE name = $1 AND account_uuid = $2
`, [FAVOURITES_PLAYLIST_NAME, tokenUuid]);

if (playlistRes.rowCount === 0) {
await conn.end();
return NextResponse.json({ error: "favourites not found or not authorized to update" }, { status: 404 });
}

const playlistId = playlistRes.rows[0].id;

// check how many tracks there are
const tracksRes = await conn.query(`
SELECT * FROM playlist_tracks WHERE playlist_id = $1
`, [playlistId]);

await conn.query(`BEGIN`);

// delete favourites entry
const deleteRes = await conn.query(`
DELETE FROM playlist_tracks
WHERE playlist_id = $1 AND track_id = $2
RETURNING *
`, [playlistId, trackId]);

// if the track wasn't found
if (deleteRes.rowCount === 0) {
await conn.query(`COMMIT`);
await conn.end();
return NextResponse.json({ error: 'track not found in playlist' }, { status: 404 });
}

// add the position it was deleted at
const position = deleteRes.rows[0].position;

const playlistTracks = tracksRes.rows as DBPlaylistTrack[];

// update other entries (all position spots after)
for (const playlistTrack of playlistTracks) {
if (playlistTrack.position > position) {

await conn.query(`
UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3 AND track_id = $4
`, [playlistTrack.position - 1, playlistId, playlistTrack.position, playlistTrack.track_id]);

}
}

await conn.query(`COMMIT`);
await conn.end();

return NextResponse.json({ status: 'success' }, { status: 200 });
} catch (error) {

await conn.query(`ROLLBACK`);

await conn.end();
console.error("Error updating playlist:", error);
return NextResponse.json({ error: 'an error occurred while updating the playlist' }, { status: 500 });
}
}
78 changes: 78 additions & 0 deletions website/app/api/collection/[accountUuid]/favourites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { checkAuthenticated } from "@/util/api";
import { FAVOURITES_PLAYLIST_NAME } from "@/util/constants";
import { getDB } from "@/util/db";
import { getAPIAlbum } from "@/util/models/album";
import { getAPIArtist } from "@/util/models/artist";
import { getAPIGenre } from "@/util/models/genre";
import { DBPlaylist, getAPIPlaylist } from "@/util/models/playlist";
import { getAPITrack } from "@/util/models/track";
import { NextResponse } from "next/server";

// GET /api/collection/favourites
// get a user's favourites track list

export async function GET(request: Request, { params }: { params: { accountUuid: string } }) {
const accountUuid = params.accountUuid;

// check authorization
const tokenUuid = await checkAuthenticated();
if (tokenUuid === null || tokenUuid !== accountUuid) {
return NextResponse.json({ error: "not authorized" }, { status: 401 });
}

const conn = await getDB();

const playlistRes = await conn.query(`
SELECT * FROM playlist WHERE account_uuid = $1 AND name = $2 LIMIT 1
`, [accountUuid, FAVOURITES_PLAYLIST_NAME]);

if (playlistRes.rowCount < 1) {
await conn.end();
return NextResponse.json([], { status: 200 });
}

const dbPlaylist = playlistRes.rows[0] as DBPlaylist;

if (tokenUuid !== dbPlaylist.account_uuid) {
await conn.end();
return NextResponse.json({ error: "not authorized" }, { status: 401 });
}

const trackRes = await conn.query(`
SELECT
t.*,
playlist_tracks.position as playlist_position,
JSON_AGG(artist.*) as artists,
JSON_AGG(album.*) as albums,
JSON_AGG(genre.*) as genres,
JSON_AGG(playlist.*) as playlists
FROM playlist_tracks
INNER JOIN track as t ON t.id = playlist_tracks.track_id
LEFT OUTER JOIN track_to_artist ON t.id = track_to_artist.track_id
LEFT OUTER JOIN artist ON track_to_artist.artist_id = artist.id
LEFT OUTER JOIN track_to_album ON t.id = track_to_album.album_id
LEFT OUTER JOIN album ON track_to_album.album_id = album.id
LEFT OUTER JOIN track_to_genre ON t.id = track_to_genre.track_id
LEFT OUTER JOIN genre ON track_to_genre.genre_id = genre.id
FULL OUTER JOIN playlist_tracks AS pt2 ON t.id = pt2.track_id
FULL OUTER JOIN playlist ON pt2.playlist_id = playlist.id
WHERE playlist_tracks.playlist_id = $1
GROUP BY t.id, playlist_tracks.position
ORDER BY playlist_tracks.position ASC
`, [dbPlaylist.id]);

await conn.end();

const tracks = trackRes.rows.map(track => {
const apiTrack: any = getAPITrack(track);
apiTrack.albums = track.albums.filter((album: any) => album).map((album: any) => getAPIAlbum(album));
apiTrack.artists = track.artists.filter((artist: any) => artist).map((artist: any) => getAPIArtist(artist));
apiTrack.genres = track.genres.filter((genre: any) => genre).map((genre: any) => getAPIGenre(genre));
apiTrack.playlists = track.playlists.filter((playlist: any) => playlist).map((playlist: any) => getAPIPlaylist(playlist));
apiTrack.playlist_position = track.playlist_position;

return apiTrack;
});

return NextResponse.json(tracks, { status: 200 });
}
11 changes: 2 additions & 9 deletions website/app/api/playlist/[playlistId]/add-track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,9 @@ export async function POST(request: Request, { params }: { params: { playlistId:
const trackCount = trackCountRes.rows[0].count;

// insert the track association
await conn.query(
`
await conn.query(`
INSERT INTO playlist_tracks(account_uuid, playlist_id, track_id, position, added_on)
VALUES (
$1,
$2,
$3,
$4,
$5
)
VALUES ($1, $2, $3, $4, $5)
`,
[tokenUuid, playlistId, trackId, trackCount, new Date().toISOString()]
);
Expand Down
90 changes: 90 additions & 0 deletions website/app/collection/favourites/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client'

import { useEffect, useState } from "react";
import { apiGetCollectionFavourites, apiGetCollectionTracks, apiGetCollectionTracksSearch } from "@/components/apiclient";
import { useAppStateContext } from "@/components/appstateprovider";
import { APITrack } from "@/util/models/track";
import {Grid, TextField, Typography, InputAdornment, CssBaseline} from "@mui/material";
import { Search } from "@mui/icons-material";
import { useRouter } from "next/navigation";
import { useLoginStateContext } from "@/components/loginstateprovider";
import AlertComponent, { AlertEntry } from "@/components/alerts";
import TrackTable from "@/components/trackTable";
import { ThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme } from '@/components/themes';
import {styled} from '@mui/system';

const StyledGrid = styled(Grid)(({ theme }) => ({
height: 1,
backgroundColor: theme.palette.mode === 'dark' ? 'rgb(5, 10, 25)' : 'rgb(255, 245, 245)',
}));

export default function CollectionPage() {
const appState = useAppStateContext();
const theme = appState.theme;
const loginState = useLoginStateContext();
const router = useRouter();

const color = theme === 'dark' ? 'white' : 'rgb(50, 50, 50)';

const [tracks, setTracks] = useState([] as APITrack[]);
const [alerts, setAlerts] = useState([] as AlertEntry[]);

const loadTracks = () => {
apiGetCollectionFavourites(loginState.loggedInUserUuid)
.then((res) => {
setTracks(res.data as APITrack[]);
})
.catch(err => {
setAlerts([...alerts, { severity: "error", message: "Error fetching tracks, see console for details." }]);
console.error(err);
});
};

useEffect(() => {
// wait for credentials to be loaded
if (!loginState.loggedInStateValid) {
return;
}

// if not logged in, go to login page
if (!loginState.isLoggedIn) {
router.push('/login');
return;
}

// load tracks
loadTracks();
}, [loginState]);

if (!loginState.loggedInStateValid) {
return <></>;
}

const handleTrackClick = (track: APITrack) => {
appState.changeQueue(tracks, tracks.indexOf(track));
appState.playCurrentTrack();
};

return (
<ThemeProvider theme={theme === 'dark' ? darkTheme : lightTheme}>
<CssBaseline />
<StyledGrid>
<AlertComponent alerts={alerts} setAlerts={setAlerts} />

<Grid sx={{ padding: 2, color: color }} container direction="row" justifyContent="space-between">
<Typography variant="h6">Favourites</Typography>
</Grid>

{/* Weird. paddingBottom works but not marginBottom. */}
<Grid sx={{ paddingBottom: '5em' }}>
<TrackTable
tracks={tracks}
handleTrackClick={handleTrackClick}
handleTrackUpdate={loadTracks}
/>
</Grid>
</StyledGrid>
</ThemeProvider>
);
}
23 changes: 23 additions & 0 deletions website/components/apiclient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,26 @@ export function apiGetGenreTracks(genreId: string) {
url: `/api/genre/${genreId}/tracks`
});
}

export function apiGetCollectionFavourites(accountUuid: string) {
return axios({
method: 'get',
url: `/api/collection/${accountUuid}/favourites`
});
}

export function apiPostCollectionAddFavouritesTrack(accountUuid: string, trackId: number) {
return axios({
method: 'post',
url: `/api/collection/${accountUuid}/favourites/add-track`,
data: { trackId }
});
}

export function apiPostCollectionRemoveFavouritesTrack(accountUuid: string, trackId: number) {
return axios({
method: 'post',
url: `/api/collection/${accountUuid}/favourites/remove-track`,
data: { trackId }
});
}
Loading

0 comments on commit 4c38834

Please sign in to comment.