mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat: add top-artists report
This commit is contained in:
parent
6a6ba493f6
commit
6fc10c40ca
18 changed files with 345 additions and 30 deletions
|
|
@ -7,6 +7,7 @@ import { RecentListens } from "./components/RecentListens";
|
||||||
import { ReportListens } from "./components/ReportListens";
|
import { ReportListens } from "./components/ReportListens";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import "./tailwind/generated.css";
|
import "./tailwind/generated.css";
|
||||||
|
import { ReportTopArtists } from "./components/ReportTopArtists";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { isLoaded } = useAuth();
|
const { isLoaded } = useAuth();
|
||||||
|
|
@ -26,6 +27,7 @@ export function App() {
|
||||||
<Route path="/login/failure" exact component={LoginFailure} />
|
<Route path="/login/failure" exact component={LoginFailure} />
|
||||||
<Route path="/listens" exact component={RecentListens} />
|
<Route path="/listens" exact component={RecentListens} />
|
||||||
<Route path="/reports/listens" exact component={ReportListens} />
|
<Route path="/reports/listens" exact component={ReportListens} />
|
||||||
|
<Route path="/reports/top-artists" exact component={ReportTopArtists} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { formatISO, parseISO } from "date-fns";
|
import { formatISO, parseISO } from "date-fns";
|
||||||
|
import { qs } from "../util/queryString";
|
||||||
import { Listen } from "./entities/listen";
|
import { Listen } from "./entities/listen";
|
||||||
import { ListenReportItem } from "./entities/listen-report-item";
|
import { ListenReportItem } from "./entities/listen-report-item";
|
||||||
import { ListenReportOptions } from "./entities/listen-report-options";
|
import { ListenReportOptions } from "./entities/listen-report-options";
|
||||||
import { Pagination } from "./entities/pagination";
|
import { Pagination } from "./entities/pagination";
|
||||||
import { PaginationOptions } from "./entities/pagination-options";
|
import { PaginationOptions } from "./entities/pagination-options";
|
||||||
|
import { TopArtistsItem } from "./entities/top-artists-item";
|
||||||
|
import { TopArtistsOptions } from "./entities/top-artists-options";
|
||||||
import { User } from "./entities/user";
|
import { User } from "./entities/user";
|
||||||
import { qs } from "../util/queryString";
|
|
||||||
|
|
||||||
export class UnauthenticatedError extends Error {}
|
export class UnauthenticatedError extends Error {}
|
||||||
|
|
||||||
|
|
@ -99,10 +101,42 @@ export const getListensReport = async (
|
||||||
throw new UnauthenticatedError(`No token or token expired`);
|
throw new UnauthenticatedError(`No token or token expired`);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unable to getRecentListens: ${res.status}`);
|
throw new Error(`Unable to getListensReport: ${res.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawItems: { count: number; date: string }[] = (await res.json()).items;
|
const rawItems: { count: number; date: string }[] = (await res.json()).items;
|
||||||
return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) }));
|
return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTopArtists = async (
|
||||||
|
options: TopArtistsOptions
|
||||||
|
): Promise<TopArtistsItem[]> => {
|
||||||
|
const { timePreset, customTimeStart, customTimeEnd } = options;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/reports/top-artists?${qs({
|
||||||
|
timePreset,
|
||||||
|
customTimeStart: formatISO(customTimeStart),
|
||||||
|
customTimeEnd: formatISO(customTimeEnd),
|
||||||
|
})}`,
|
||||||
|
{
|
||||||
|
headers: getDefaultHeaders(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (res.status) {
|
||||||
|
case 200: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 401: {
|
||||||
|
throw new UnauthenticatedError(`No token or token expired`);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unable to getTopArtists: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TopArtistsItem[] = (await res.json()).items;
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
|
||||||
7
frontend/src/api/entities/album.ts
Normal file
7
frontend/src/api/entities/album.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { SpotifyInfo } from "./spotify-info";
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
spotify?: SpotifyInfo;
|
||||||
|
}
|
||||||
7
frontend/src/api/entities/artist.ts
Normal file
7
frontend/src/api/entities/artist.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { SpotifyInfo } from "./spotify-info";
|
||||||
|
|
||||||
|
export interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
spotify?: SpotifyInfo;
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,7 @@
|
||||||
|
import { Track } from "./track";
|
||||||
|
|
||||||
export interface Listen {
|
export interface Listen {
|
||||||
id: string;
|
id: string;
|
||||||
playedAt: string;
|
playedAt: string;
|
||||||
track: Track;
|
track: Track;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Track {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
album: Album;
|
|
||||||
artists: Artist[];
|
|
||||||
spotify?: SpotifyInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Album {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spotify?: SpotifyInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Artist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spotify?: SpotifyInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpotifyInfo {
|
|
||||||
id: string;
|
|
||||||
uri: string;
|
|
||||||
type: string;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
6
frontend/src/api/entities/spotify-info.ts
Normal file
6
frontend/src/api/entities/spotify-info.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface SpotifyInfo {
|
||||||
|
id: string;
|
||||||
|
uri: string;
|
||||||
|
type: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
9
frontend/src/api/entities/time-preset.enum.ts
Normal file
9
frontend/src/api/entities/time-preset.enum.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export enum TimePreset {
|
||||||
|
LAST_7_DAYS = "last_7_days",
|
||||||
|
LAST_30_DAYS = "last_30_days",
|
||||||
|
LAST_90_DAYS = "last_90_days",
|
||||||
|
LAST_180_DAYS = "last_180_days",
|
||||||
|
LAST_365_DAYS = "last_365_days",
|
||||||
|
ALL_TIME = "all_time",
|
||||||
|
CUSTOM = "custom",
|
||||||
|
}
|
||||||
6
frontend/src/api/entities/top-artists-item.ts
Normal file
6
frontend/src/api/entities/top-artists-item.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Artist } from "./artist";
|
||||||
|
|
||||||
|
export interface TopArtistsItem {
|
||||||
|
artist: Artist;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
7
frontend/src/api/entities/top-artists-options.ts
Normal file
7
frontend/src/api/entities/top-artists-options.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { TimePreset } from "./time-preset.enum";
|
||||||
|
|
||||||
|
export interface TopArtistsOptions {
|
||||||
|
timePreset: TimePreset;
|
||||||
|
customTimeStart: Date;
|
||||||
|
customTimeEnd: Date;
|
||||||
|
}
|
||||||
11
frontend/src/api/entities/track.ts
Normal file
11
frontend/src/api/entities/track.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Album } from "./album";
|
||||||
|
import { Artist } from "./artist";
|
||||||
|
import { SpotifyInfo } from "./spotify-info";
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
album: Album;
|
||||||
|
artists: Artist[];
|
||||||
|
spotify?: SpotifyInfo;
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ export const NavBar: React.FC = () => {
|
||||||
<Link to="/reports/listens">
|
<Link to="/reports/listens">
|
||||||
<NavItem>Listens Report</NavItem>
|
<NavItem>Listens Report</NavItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/reports/top-artists">
|
||||||
|
<NavItem>Top Artists</NavItem>
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
65
frontend/src/components/ReportOptions.tsx
Normal file
65
frontend/src/components/ReportOptions.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import React from "react";
|
||||||
|
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||||
|
import { TopArtistsOptions } from "../api/entities/top-artists-options";
|
||||||
|
import { DateSelect } from "./inputs/DateSelect";
|
||||||
|
|
||||||
|
interface ReportOptionsProps {
|
||||||
|
reportOptions: TopArtistsOptions;
|
||||||
|
setReportOptions: (options: TopArtistsOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timePresetOptions = [
|
||||||
|
{ value: TimePreset.LAST_7_DAYS, description: "Last 7 days" },
|
||||||
|
{ value: TimePreset.LAST_30_DAYS, description: "Last 30 days" },
|
||||||
|
{ value: TimePreset.LAST_90_DAYS, description: "Last 90 days" },
|
||||||
|
{ value: TimePreset.LAST_180_DAYS, description: "Last 180 days" },
|
||||||
|
{ value: TimePreset.LAST_365_DAYS, description: "Last 365 days" },
|
||||||
|
{ value: TimePreset.ALL_TIME, description: "All time" },
|
||||||
|
{ value: TimePreset.CUSTOM, description: "Custom" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ReportOptions: React.FC<ReportOptionsProps> = ({
|
||||||
|
reportOptions,
|
||||||
|
setReportOptions,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="md:flex">
|
||||||
|
<div className="text-gray-700">
|
||||||
|
<label className="text-sm">Timeframe</label>
|
||||||
|
<select
|
||||||
|
className="block appearance-none min-w-full md:w-1/4 bg-white border border-gray-400 hover:border-gray-500 p-2 rounded shadow leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
onChange={(e) =>
|
||||||
|
setReportOptions({
|
||||||
|
...reportOptions,
|
||||||
|
timePreset: e.target.value as TimePreset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timePresetOptions.map(({ value, description }) => (
|
||||||
|
<option value={value} key={value}>
|
||||||
|
{description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{reportOptions.timePreset === TimePreset.CUSTOM && (
|
||||||
|
<div className="md:flex text-gray-700">
|
||||||
|
<DateSelect
|
||||||
|
label="Start"
|
||||||
|
value={reportOptions.customTimeStart}
|
||||||
|
onChange={(newDate) =>
|
||||||
|
setReportOptions({ ...reportOptions, customTimeStart: newDate })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DateSelect
|
||||||
|
label="End"
|
||||||
|
value={reportOptions.customTimeEnd}
|
||||||
|
onChange={(newDate) =>
|
||||||
|
setReportOptions({ ...reportOptions, customTimeEnd: newDate })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
frontend/src/components/ReportTopArtists.tsx
Normal file
62
frontend/src/components/ReportTopArtists.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Redirect } from "react-router-dom";
|
||||||
|
import { getTopArtists } from "../api/api";
|
||||||
|
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||||
|
import { TopArtistsOptions } from "../api/entities/top-artists-options";
|
||||||
|
import { useAsync } from "../hooks/use-async";
|
||||||
|
import { useAuth } from "../hooks/use-auth";
|
||||||
|
import { ReportOptions } from "./ReportOptions";
|
||||||
|
|
||||||
|
export const ReportTopArtists: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [reportOptions, setReportOptions] = useState<TopArtistsOptions>({
|
||||||
|
timePreset: TimePreset.LAST_90_DAYS,
|
||||||
|
customTimeStart: new Date(0),
|
||||||
|
customTimeEnd: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = useMemo(() => () => getTopArtists(reportOptions), [
|
||||||
|
reportOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { value: report, pending: isLoading } = useAsync(fetchData, []);
|
||||||
|
|
||||||
|
const reportHasItems = !isLoading && report.length !== 0;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Redirect to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:flex md:justify-center p-4">
|
||||||
|
<div className="md:flex-shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-2xl font-normal text-gray-700">Top Artists</p>
|
||||||
|
</div>
|
||||||
|
<div className="shadow-xl bg-gray-100 rounded p-5 m-2">
|
||||||
|
<ReportOptions
|
||||||
|
reportOptions={reportOptions}
|
||||||
|
setReportOptions={setReportOptions}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<div>
|
||||||
|
<div className="loader rounded-full border-8 h-64 w-64"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!reportHasItems && (
|
||||||
|
<div>
|
||||||
|
<p>Report is emtpy! :(</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reportHasItems &&
|
||||||
|
report.map(({ artist, count }) => (
|
||||||
|
<div>
|
||||||
|
{count} - {artist.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
frontend/src/components/inputs/DateSelect.tsx
Normal file
31
frontend/src/components/inputs/DateSelect.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { format, parse } from "date-fns";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const parseDateFromDateInput = (input: string) =>
|
||||||
|
parse(input, "yyyy-MM-dd", new Date());
|
||||||
|
|
||||||
|
const formatDateForDateInput = (date: Date) => format(date, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
interface DateSelectProps {
|
||||||
|
label: string;
|
||||||
|
value: Date;
|
||||||
|
onChange: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateSelect: React.FC<DateSelectProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm">{label}</label>
|
||||||
|
<input
|
||||||
|
className="block appearance-none min-w-full md:win-w-0 md:w-1/4 bg-white border border-gray-400 hover:border-gray-500 p-2 rounded shadow leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
type="date"
|
||||||
|
value={formatDateForDateInput(value)}
|
||||||
|
onChange={(e) => onChange(parseDateFromDateInput(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/reports/dto/get-top-artists-report.dto.ts
Normal file
18
src/reports/dto/get-top-artists-report.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { IsEnum, IsISO8601, ValidateIf } from "class-validator";
|
||||||
|
import { User } from "../../users/user.entity";
|
||||||
|
import { TimePreset } from "../timePreset.enum";
|
||||||
|
|
||||||
|
export class GetTopArtistsReportDto {
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@IsEnum(TimePreset)
|
||||||
|
timePreset: TimePreset;
|
||||||
|
|
||||||
|
@ValidateIf((o) => o.timePreset === TimePreset.CUSTOM)
|
||||||
|
@IsISO8601()
|
||||||
|
customTimeStart: string;
|
||||||
|
|
||||||
|
@ValidateIf((o) => o.timePreset === TimePreset.CUSTOM)
|
||||||
|
@IsISO8601()
|
||||||
|
customTimeEnd: string;
|
||||||
|
}
|
||||||
8
src/reports/dto/top-artists-report.dto.ts
Normal file
8
src/reports/dto/top-artists-report.dto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Artist } from "../../music-library/artist.entity";
|
||||||
|
|
||||||
|
export class TopArtistsReportDto {
|
||||||
|
items: {
|
||||||
|
artist: Artist;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,9 @@ import { Auth } from "src/auth/decorators/auth.decorator";
|
||||||
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { GetListenReportDto } from "./dto/get-listen-report.dto";
|
import { GetListenReportDto } from "./dto/get-listen-report.dto";
|
||||||
|
import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto";
|
||||||
import { ListenReportDto } from "./dto/listen-report.dto";
|
import { ListenReportDto } from "./dto/listen-report.dto";
|
||||||
|
import { TopArtistsReportDto } from "./dto/top-artists-report.dto";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
@Controller("api/v1/reports")
|
@Controller("api/v1/reports")
|
||||||
|
|
@ -18,4 +20,13 @@ export class ReportsController {
|
||||||
): Promise<ListenReportDto> {
|
): Promise<ListenReportDto> {
|
||||||
return this.reportsService.getListens({ ...options, user });
|
return this.reportsService.getListens({ ...options, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("top-artists")
|
||||||
|
@Auth()
|
||||||
|
async getTopArtists(
|
||||||
|
@Query() options: Omit<GetTopArtistsReportDto, "user">,
|
||||||
|
@ReqUser() user: User
|
||||||
|
): Promise<TopArtistsReportDto> {
|
||||||
|
return this.reportsService.getTopArtists({ ...options, user });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ import {
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { ListensService } from "../listens/listens.service";
|
import { ListensService } from "../listens/listens.service";
|
||||||
import { GetListenReportDto } from "./dto/get-listen-report.dto";
|
import { GetListenReportDto } from "./dto/get-listen-report.dto";
|
||||||
|
import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto";
|
||||||
import { ListenReportDto } from "./dto/listen-report.dto";
|
import { ListenReportDto } from "./dto/listen-report.dto";
|
||||||
|
import { TopArtistsReportDto } from "./dto/top-artists-report.dto";
|
||||||
|
import { Interval } from "./interval";
|
||||||
import { Timeframe } from "./timeframe.enum";
|
import { Timeframe } from "./timeframe.enum";
|
||||||
import { TimePreset } from "./timePreset.enum";
|
import { TimePreset } from "./timePreset.enum";
|
||||||
|
|
||||||
|
|
@ -55,6 +58,8 @@ const timePresetToDays: { [x in TimePreset]: number } = {
|
||||||
[TimePreset.CUSTOM]: 0, // Not used for this
|
[TimePreset.CUSTOM]: 0, // Not used for this
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGINATION_LIMIT_UNLIMITED = 10000000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
constructor(private readonly listensService: ListensService) {}
|
constructor(private readonly listensService: ListensService) {}
|
||||||
|
|
@ -73,7 +78,7 @@ export class ReportsService {
|
||||||
user,
|
user,
|
||||||
filter: { time: interval },
|
filter: { time: interval },
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10000000,
|
limit: PAGINATION_LIMIT_UNLIMITED,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reportInterval: Interval = {
|
const reportInterval: Interval = {
|
||||||
|
|
@ -91,6 +96,54 @@ export class ReportsService {
|
||||||
|
|
||||||
return { items: reportItems, timeStart, timeEnd, timeFrame };
|
return { items: reportItems, timeStart, timeEnd, timeFrame };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTopArtists(
|
||||||
|
options: GetTopArtistsReportDto
|
||||||
|
): Promise<TopArtistsReportDto> {
|
||||||
|
const { user, timePreset, customTimeStart, customTimeEnd } = options;
|
||||||
|
|
||||||
|
const interval = this.getIntervalFromPreset({
|
||||||
|
timePreset,
|
||||||
|
customTimeStart,
|
||||||
|
customTimeEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items: listens } = await this.listensService.getListens({
|
||||||
|
user,
|
||||||
|
filter: { time: interval },
|
||||||
|
page: 1,
|
||||||
|
limit: PAGINATION_LIMIT_UNLIMITED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Declare types for metrics calculation
|
||||||
|
type Item = TopArtistsReportDto["items"][0];
|
||||||
|
type Accumulator = {
|
||||||
|
[x: string]: Item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: TopArtistsReportDto["items"] = Object.values<Item>(
|
||||||
|
listens
|
||||||
|
.flatMap((listen) => listen.track.artists)
|
||||||
|
.reduce<Accumulator>((counters, artist) => {
|
||||||
|
if (!counters[artist.id]) {
|
||||||
|
counters[artist.id] = {
|
||||||
|
artist,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
counters[artist.id].count += 1;
|
||||||
|
|
||||||
|
return counters;
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.count - b.count)
|
||||||
|
.reverse() // sort descending
|
||||||
|
.slice(0, 20); // TODO: Make configurable
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
private getIntervalFromPreset(options: {
|
private getIntervalFromPreset(options: {
|
||||||
timePreset: TimePreset;
|
timePreset: TimePreset;
|
||||||
customTimeStart?: string;
|
customTimeStart?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue