diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83009fe..eabcfaf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,9 +5,10 @@ import { LoginSuccess } from "./components/LoginSuccess"; import { NavBar } from "./components/NavBar"; import { RecentListens } from "./components/RecentListens"; import { ReportListens } from "./components/ReportListens"; +import { ReportTopAlbums } from "./components/ReportTopAlbums"; +import { ReportTopArtists } from "./components/ReportTopArtists"; import { useAuth } from "./hooks/use-auth"; import "./tailwind/generated.css"; -import { ReportTopArtists } from "./components/ReportTopArtists"; export function App() { const { isLoaded } = useAuth(); @@ -28,6 +29,7 @@ export function App() { + ); diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index ec02fc4..2b95506 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -5,6 +5,8 @@ import { ListenReportItem } from "./entities/listen-report-item"; import { ListenReportOptions } from "./entities/listen-report-options"; import { Pagination } from "./entities/pagination"; import { PaginationOptions } from "./entities/pagination-options"; +import { TopAlbumsItem } from "./entities/top-albums-item"; +import { TopAlbumsOptions } from "./entities/top-albums-options"; import { TopArtistsItem } from "./entities/top-artists-item"; import { TopArtistsOptions } from "./entities/top-artists-options"; @@ -110,3 +112,40 @@ export const getTopArtists = async ( } = res; return items; }; + +export const getTopAlbums = async ( + options: TopAlbumsOptions, + client: AxiosInstance +): Promise => { + const { + time: { timePreset, customTimeStart, customTimeEnd }, + } = options; + + const res = await client.get<{ items: TopAlbumsItem[] }>( + `/api/v1/reports/top-albums`, + { + params: { + timePreset, + customTimeStart: formatISO(customTimeStart), + customTimeEnd: formatISO(customTimeEnd), + }, + } + ); + + switch (res.status) { + case 200: { + break; + } + case 401: { + throw new UnauthenticatedError(`No token or token expired`); + } + default: { + throw new Error(`Unable to getTopAlbums: ${res.status}`); + } + } + + const { + data: { items }, + } = res; + return items; +}; diff --git a/frontend/src/api/entities/album.ts b/frontend/src/api/entities/album.ts index 2fb1059..b8ad33f 100644 --- a/frontend/src/api/entities/album.ts +++ b/frontend/src/api/entities/album.ts @@ -1,7 +1,9 @@ +import { Artist } from "./artist"; import { SpotifyInfo } from "./spotify-info"; export interface Album { id: string; name: string; spotify?: SpotifyInfo; + artists?: Artist[]; } diff --git a/frontend/src/api/entities/top-albums-item.ts b/frontend/src/api/entities/top-albums-item.ts new file mode 100644 index 0000000..9dc5114 --- /dev/null +++ b/frontend/src/api/entities/top-albums-item.ts @@ -0,0 +1,6 @@ +import { Album } from "./album"; + +export interface TopAlbumsItem { + album: Album; + count: number; +} diff --git a/frontend/src/api/entities/top-albums-options.ts b/frontend/src/api/entities/top-albums-options.ts new file mode 100644 index 0000000..c4aaf6e --- /dev/null +++ b/frontend/src/api/entities/top-albums-options.ts @@ -0,0 +1,5 @@ +import { TimeOptions } from "./time-options"; + +export interface TopAlbumsOptions { + time: TimeOptions; +} diff --git a/frontend/src/api/entities/top-artists-options.ts b/frontend/src/api/entities/top-artists-options.ts index 7853978..19ab792 100644 --- a/frontend/src/api/entities/top-artists-options.ts +++ b/frontend/src/api/entities/top-artists-options.ts @@ -1,4 +1,3 @@ -import { TimePreset } from "./time-preset.enum"; import { TimeOptions } from "./time-options"; export interface TopArtistsOptions { diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 4d85308..253c581 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -28,6 +28,9 @@ export const NavBar: React.FC = () => { Top Artists + + Top Albums + > )} diff --git a/frontend/src/components/ReportTimeOptions.tsx b/frontend/src/components/ReportTimeOptions.tsx index 5763786..588efdb 100644 --- a/frontend/src/components/ReportTimeOptions.tsx +++ b/frontend/src/components/ReportTimeOptions.tsx @@ -23,7 +23,7 @@ export const ReportTimeOptions: React.FC = ({ setTimeOptions, }) => { return ( - + Timeframe { + const { user } = useAuth(); + + const [timeOptions, setTimeOptions] = useState({ + timePreset: TimePreset.LAST_90_DAYS, + customTimeStart: new Date(0), + customTimeEnd: new Date(), + }); + + const options = useMemo( + () => ({ + time: timeOptions, + }), + [timeOptions] + ); + + const { topAlbums, isLoading } = useTopAlbums(options); + + const reportHasItems = !isLoading && topAlbums.length !== 0; + + if (!user) { + return ; + } + + return ( + + + + Top Albums + + + + {isLoading && ( + + + + )} + {!reportHasItems && ( + + Report is emtpy! :( + + )} + {reportHasItems && + topAlbums.map(({ album, count }) => ( + + ))} + + + + ); +}; + +const ReportItem: React.FC<{ + album: Album; + count: number; +}> = ({ album, count }) => { + const artists = album.artists?.map((artist) => artist.name).join(", ") || ""; + + return ( + + ); +}; diff --git a/frontend/src/hooks/use-api.tsx b/frontend/src/hooks/use-api.tsx index 91c7089..dfeb7b0 100644 --- a/frontend/src/hooks/use-api.tsx +++ b/frontend/src/hooks/use-api.tsx @@ -1,7 +1,13 @@ -import { useMemo, useState } from "react"; -import { getListensReport, getRecentListens, getTopArtists } from "../api/api"; +import { useMemo } from "react"; +import { + getListensReport, + getRecentListens, + getTopAlbums, + getTopArtists, +} from "../api/api"; import { ListenReportOptions } from "../api/entities/listen-report-options"; import { PaginationOptions } from "../api/entities/pagination-options"; +import { TopAlbumsOptions } from "../api/entities/top-albums-options"; import { TopArtistsOptions } from "../api/entities/top-artists-options"; import { useApiClient } from "./use-api-client"; import { useAsync } from "./use-async"; @@ -59,3 +65,19 @@ export const useTopArtists = (options: TopArtistsOptions) => { return { topArtists, isLoading, error }; }; + +export const useTopAlbums = (options: TopAlbumsOptions) => { + const { client } = useApiClient(); + + const fetchData = useMemo(() => () => getTopAlbums(options, client), [ + options, + client, + ]); + + const { value: topAlbums, pending: isLoading, error } = useAsync( + fetchData, + INITIAL_EMPTY_ARRAY + ); + + return { topAlbums, isLoading, error }; +}; diff --git a/src/music-library/album.entity.ts b/src/music-library/album.entity.ts index 7a68377..32e5d4a 100644 --- a/src/music-library/album.entity.ts +++ b/src/music-library/album.entity.ts @@ -20,10 +20,10 @@ export class Album { @ManyToMany((type) => Artist, (artist) => artist.albums) @JoinTable({ name: "album_artists" }) - artists: Artist[]; + artists?: Artist[]; @OneToMany((type) => Track, (track) => track.album) - tracks: Track[]; + tracks?: Track[]; @Column((type) => SpotifyLibraryDetails) spotify: SpotifyLibraryDetails; diff --git a/src/music-library/track.entity.ts b/src/music-library/track.entity.ts index 758ceb4..22917da 100644 --- a/src/music-library/track.entity.ts +++ b/src/music-library/track.entity.ts @@ -19,11 +19,11 @@ export class Track { name: string; @ManyToOne((type) => Album, (album) => album.tracks) - album: Album; + album?: Album; @ManyToMany((type) => Artist) @JoinTable({ name: "track_artists" }) - artists: Artist[]; + artists?: Artist[]; @Column((type) => SpotifyLibraryDetails) spotify?: SpotifyLibraryDetails; diff --git a/src/reports/dto/get-top-albums-report.dto.ts b/src/reports/dto/get-top-albums-report.dto.ts new file mode 100644 index 0000000..3759339 --- /dev/null +++ b/src/reports/dto/get-top-albums-report.dto.ts @@ -0,0 +1,10 @@ +import { ValidateNested } from "class-validator"; +import { User } from "../../users/user.entity"; +import { ReportTimeDto } from "./report-time.dto"; + +export class GetTopAlbumsReportDto { + user: User; + + @ValidateNested() + time: ReportTimeDto; +} diff --git a/src/reports/dto/top-albums-report.dto.ts b/src/reports/dto/top-albums-report.dto.ts new file mode 100644 index 0000000..279df5c --- /dev/null +++ b/src/reports/dto/top-albums-report.dto.ts @@ -0,0 +1,8 @@ +import { Album } from "../../music-library/album.entity"; + +export class TopAlbumsReportDto { + items: { + album: Album; + count: number; + }[]; +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 14e60b3..368b763 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -4,6 +4,7 @@ import { ReqUser } from "../auth/decorators/req-user.decorator"; import { User } from "../users/user.entity"; import { ListenReportDto } from "./dto/listen-report.dto"; import { ReportTimeDto } from "./dto/report-time.dto"; +import { TopAlbumsReportDto } from "./dto/top-albums-report.dto"; import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; import { ReportsService } from "./reports.service"; import { Timeframe } from "./timeframe.enum"; @@ -30,4 +31,13 @@ export class ReportsController { ): Promise { return this.reportsService.getTopArtists({ user, time }); } + + @Get("top-albums") + @AuthAccessToken() + async getTopAlbums( + @Query() time: ReportTimeDto, + @ReqUser() user: User + ): Promise { + return this.reportsService.getTopAlbums({ user, time }); + } } diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index f864601..eebea76 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -16,8 +16,10 @@ import { } from "date-fns"; import { ListensService } from "../listens/listens.service"; import { GetListenReportDto } from "./dto/get-listen-report.dto"; +import { GetTopAlbumsReportDto } from "./dto/get-top-albums-report.dto"; import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto"; import { ListenReportDto } from "./dto/listen-report.dto"; +import { TopAlbumsReportDto } from "./dto/top-albums-report.dto"; import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; import { Interval } from "./interval"; import { Timeframe } from "./timeframe.enum"; @@ -124,6 +126,55 @@ export class ReportsService { }; } + async getTopAlbums( + options: GetTopAlbumsReportDto + ): Promise { + const { user, time: timePreset } = options; + + const interval = this.getIntervalFromPreset(timePreset); + + const getListensQB = () => + this.listensService + .getScopedQueryBuilder() + .byUser(user) + .duringInterval(interval); + + const [rawAlbumsWithCount, rawAlbumDetails] = await Promise.all([ + getListensQB() + .leftJoin("listen.track", "track") + .leftJoinAndSelect("track.album", "album") + .groupBy("album.id") + .select("album.id") + .addSelect("count(*) as listens") + .orderBy("listens", "DESC") + .getRawMany(), + + // Because of the GROUP BY required to calculate the count we can + // not properly join the album relations in one query + getListensQB() + .leftJoinAndSelect("listen.track", "track") + .leftJoinAndSelect("track.album", "album") + .leftJoinAndSelect("album.artists", "artists") + .distinctOn(["album.id"]) + .getMany(), + ]); + + const albumDetails = rawAlbumDetails + .map((listen) => listen.track.album) + .filter((album) => album && album.artists); // Make sure entities are set + + const items: TopAlbumsReportDto["items"] = rawAlbumsWithCount.map( + (data) => ({ + count: Number.parseInt(data.listens, 10), + album: albumDetails.find((album) => album.id === data.album_id), + }) + ); + + return { + items, + }; + } + private getIntervalFromPreset(options: { timePreset: TimePreset; customTimeStart?: string;
Top Albums
Report is emtpy! :(