From bef55a6994e0cf35df44339ebea4eeaa16e4a99a Mon Sep 17 00:00:00 2001 From: Sander Vocke Date: Sat, 26 Sep 2020 10:46:53 +0200 Subject: [PATCH] Working on a statistics page. --- client/src/components/MainWindow.tsx | 10 + client/src/components/appbar/AddTabMenu.tsx | 8 + .../src/components/common/StoreLinkIcon.tsx | 16 +- client/src/components/tables/ResultsTable.tsx | 13 +- client/src/components/windows/AlbumWindow.tsx | 6 +- .../src/components/windows/ArtistWindow.tsx | 6 +- client/src/components/windows/SongWindow.tsx | 3 +- .../components/windows/StatisticsWindow.tsx | 251 ++++++++++++++++++ client/src/components/windows/TagWindow.tsx | 6 +- client/src/components/windows/Windows.tsx | 11 + client/src/lib/MusicStore.tsx | 9 + client/src/lib/songGetters.tsx | 17 +- 12 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 client/src/components/windows/StatisticsWindow.tsx create mode 100644 client/src/lib/MusicStore.tsx diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index d3b73f1..46153ca 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -9,6 +9,7 @@ import ArtistWindow from './windows/ArtistWindow'; import AlbumWindow from './windows/AlbumWindow'; import TagWindow from './windows/TagWindow'; import SongWindow from './windows/SongWindow'; +import StatisticsWindow from './windows/StatisticsWindow'; var _ = require('lodash'); const darkTheme = createMuiTheme({ @@ -76,6 +77,7 @@ export default function MainWindow(props: any) { newWindowState[WindowType.Album](), newWindowState[WindowType.Artist](), newWindowState[WindowType.Tag](), + newWindowState[WindowType.Statistics](), ], tabReducers: [ newWindowReducer[WindowType.Query], @@ -83,6 +85,7 @@ export default function MainWindow(props: any) { newWindowReducer[WindowType.Album], newWindowReducer[WindowType.Artist], newWindowReducer[WindowType.Tag], + newWindowReducer[WindowType.Statistics], ], tabTypes: [ WindowType.Query, @@ -90,6 +93,7 @@ export default function MainWindow(props: any) { WindowType.Album, WindowType.Artist, WindowType.Tag, + WindowType.Statistics, ], activeTab: 0 }) @@ -134,6 +138,12 @@ export default function MainWindow(props: any) { dispatch={tabDispatch} mainDispatch={dispatch} /> + case WindowType.Statistics: + return default: throw new Error("Unimplemented window type"); } diff --git a/client/src/components/appbar/AddTabMenu.tsx b/client/src/components/appbar/AddTabMenu.tsx index b001fe1..be3315c 100644 --- a/client/src/components/appbar/AddTabMenu.tsx +++ b/client/src/components/appbar/AddTabMenu.tsx @@ -28,5 +28,13 @@ export default function AddTabMenu(props: IProps) { }) }} >{WindowType.Query} + { + props.onClose(); + props.onCreateTab({ + windowType: WindowType.Statistics, + }) + }} + >{WindowType.Statistics} } \ No newline at end of file diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 7359ab0..90697e6 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -1,26 +1,16 @@ import React from 'react'; import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg'; - -export enum ExternalStore { - GooglePlayMusic = "GPM", -} +import { MusicStore } from '../../lib/MusicStore'; export interface IProps { - whichStore: ExternalStore, -} - -export function whichStore(url: string) { - if(url.includes('play.google.com')) { - return ExternalStore.GooglePlayMusic; - } - return undefined; + whichStore: MusicStore, } export default function StoreLinkIcon(props: any) { const { whichStore, ...restProps } = props; switch(whichStore) { - case ExternalStore.GooglePlayMusic: + case MusicStore.GooglePlayMusic: return ; default: throw new Error("Unknown external store: " + whichStore) diff --git a/client/src/components/tables/ResultsTable.tsx b/client/src/components/tables/ResultsTable.tsx index cce223f..434821e 100644 --- a/client/src/components/tables/ResultsTable.tsx +++ b/client/src/components/tables/ResultsTable.tsx @@ -7,18 +7,7 @@ import PersonIcon from '@material-ui/icons/Person'; import AlbumIcon from '@material-ui/icons/Album'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; -import { songGetters } from '../../lib/songGetters'; - -export interface SongGetters { - getTitle: (song: any) => string, - getId: (song: any) => number, - getArtistNames: (song: any) => string[], - getArtistIds: (song: any) => number[], - getAlbumNames: (song: any) => string[], - getAlbumIds: (song: any) => number[], - getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings. - getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids. -} +import { songGetters, SongGetters } from '../../lib/songGetters'; export interface IProps { songs: any[], diff --git a/client/src/components/windows/AlbumWindow.tsx b/client/src/components/windows/AlbumWindow.tsx index 6cefcb8..658eb8e 100644 --- a/client/src/components/windows/AlbumWindow.tsx +++ b/client/src/components/windows/AlbumWindow.tsx @@ -3,11 +3,13 @@ import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core import AlbumIcon from '@material-ui/icons/Album'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; -import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import StoreLinkIcon from '../common/StoreLinkIcon'; import EditableText from '../common/EditableText'; import SubmitChangesButton from '../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../tables/ResultsTable'; +import SongTable from '../tables/ResultsTable'; import { saveAlbumChanges } from '../../lib/saveChanges'; +import { whichStore } from '../../lib/MusicStore'; +import { SongGetters } from '../../lib/songGetters'; var _ = require('lodash'); export type AlbumMetadata = serverApi.AlbumDetails; diff --git a/client/src/components/windows/ArtistWindow.tsx b/client/src/components/windows/ArtistWindow.tsx index 7b8ed4c..afea3b6 100644 --- a/client/src/components/windows/ArtistWindow.tsx +++ b/client/src/components/windows/ArtistWindow.tsx @@ -3,11 +3,13 @@ import { Box, Typography, IconButton, Button, CircularProgress } from '@material import PersonIcon from '@material-ui/icons/Person'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; -import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import StoreLinkIcon from '../common/StoreLinkIcon'; import EditableText from '../common/EditableText'; import SubmitChangesButton from '../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../tables/ResultsTable'; +import SongTable from '../tables/ResultsTable'; import { saveArtistChanges } from '../../lib/saveChanges'; +import { whichStore } from '../../lib/MusicStore'; +import { SongGetters } from '../../lib/songGetters'; var _ = require('lodash'); export type ArtistMetadata = serverApi.ArtistDetails; diff --git a/client/src/components/windows/SongWindow.tsx b/client/src/components/windows/SongWindow.tsx index bf69c84..dffc371 100644 --- a/client/src/components/windows/SongWindow.tsx +++ b/client/src/components/windows/SongWindow.tsx @@ -7,10 +7,11 @@ import * as serverApi from '../../api'; import { WindowState } from './Windows'; import { ArtistMetadata } from './ArtistWindow'; import { AlbumMetadata } from './AlbumWindow'; -import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import StoreLinkIcon from '../common/StoreLinkIcon'; import EditableText from '../common/EditableText'; import SubmitChangesButton from '../common/SubmitChangesButton'; import { saveSongChanges } from '../../lib/saveChanges'; +import { whichStore } from '../../lib/MusicStore'; export type SongMetadata = serverApi.SongDetails; export type SongMetadataChanges = serverApi.ModifySongRequest; diff --git a/client/src/components/windows/StatisticsWindow.tsx b/client/src/components/windows/StatisticsWindow.tsx new file mode 100644 index 0000000..4b167b9 --- /dev/null +++ b/client/src/components/windows/StatisticsWindow.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useState } from 'react'; +import { WindowState } from "./Windows" +import { MusicStore, whichStore } from '../../lib/MusicStore'; +import { songGetters, SongGetters } from '../../lib/songGetters'; +import { Typography, Box, CircularProgress, Paper } from '@material-ui/core'; +import EqualizerIcon from '@material-ui/icons/Equalizer'; +import * as serverApi from '../../api'; + +var _ = require('lodash'); + +export interface SongStatistics { + numberOf: number, + noAlbum: number, + noArtist: number, + perStore: Record, +} + +export interface AlbumStatistics { + numberOf: number, + noSongs: number, + noArtist: number, + perStore: Record, +} + +export interface ArtistStatistics { + numberOf: number, + noSongs: number, + noAlbums: number, + perStore: Record, +} + +export interface TagStatistics { + numberOf: number, + noItems: number, +} + + +export interface Statistics { + songStats: SongStatistics, + artistStats: ArtistStatistics, + albumStats: AlbumStatistics, + tagStats: TagStatistics, +} +export function newStatistics(): Statistics { + return { + songStats: { + numberOf: 0, + noAlbum: 0, + noArtist: 0, + perStore: { + [MusicStore.GooglePlayMusic]: 0 + } + }, + artistStats: { + numberOf: 0, + noAlbums: 0, + noSongs: 0, + perStore: { + [MusicStore.GooglePlayMusic]: 0 + } + }, + albumStats: { + numberOf: 0, + noArtist: 0, + noSongs: 0, + perStore: { + [MusicStore.GooglePlayMusic]: 0 + } + }, + tagStats: { + numberOf: 0, + noItems: 0, + } + }; +} + +export interface StatisticsWindowState extends WindowState { + stats: Statistics | null, + retrievingStats: boolean, +} + +export enum StatisticsWindowStateActions { + Reset = "Reset", + AddSongs = "AddSongs", + SetRetrieving = "SetRetrieving", +} + +export function StatisticsWindowReducer(state: StatisticsWindowState, action: any) { + switch (action.type) { + case StatisticsWindowStateActions.Reset: + return { + ...state, + stats: null, + retrievingStats: true, + }; + case StatisticsWindowStateActions.AddSongs: + return { + ...state, + stats: addSongs(state.stats, action.songs, songGetters) + }; + case StatisticsWindowStateActions.SetRetrieving: + return { + ...state, + retrievingStats: action.value, + }; + default: + throw new Error("Unimplemented SongWindow state update.") + } +} + +export function addSongs(stats: Statistics | null, songs: any[], songGetters: SongGetters) { + var r: Statistics = stats ? _.cloneDeep(stats) : newStatistics(); + + songs.forEach((song: any) => { + r.songStats.numberOf++; + if (songGetters.getAlbumIds(song) === []) { r.songStats.noAlbum++; } + if (songGetters.getArtistIds(song) === []) { r.songStats.noArtist++; } + songGetters.getStoreLinks(song).forEach((link: string) => { + const which = whichStore(link); + if (which) { + r.songStats.perStore[which]++; + } + }) + }) + + return r; +} + +export async function gatherStats(dispatch: (action: any) => void) { + // Gather 50 songs at a time + for (let i = 0; ; i += 50) { + var q: serverApi.QueryRequest = { + query: {}, + offsetsLimits: { + songOffset: i, + songLimit: 50, + }, + ordering: { + orderBy: { + type: serverApi.OrderByType.Name, + }, + ascending: true, + }, + }; + + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(q), + }; + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + const songs = json.songs; + + if (!songs.length || songs.length === 0) { + // No more songs. + break; + } + dispatch({ + type: StatisticsWindowStateActions.AddSongs, + songs: songs, + }) + } +} + +export function StatisticsDisplay(props: { stats: Statistics }) { + return + + + + Songs + Total: {props.stats.songStats.numberOf} + Without album: {props.stats.songStats.noAlbum} + Without artist: {props.stats.songStats.noArtist} + Linked to Google Play Music: {props.stats.songStats.perStore[MusicStore.GooglePlayMusic]} + + + + + + + Artists + Total: {props.stats.artistStats.numberOf} + Without album: {props.stats.artistStats.noAlbums} + Without songs: {props.stats.artistStats.noSongs} + Linked to Google Play Music: {props.stats.artistStats.perStore[MusicStore.GooglePlayMusic]} + + + + + + + Albums + Total: {props.stats.albumStats.numberOf} + Without album: {props.stats.albumStats.noArtist} + Without songs: {props.stats.albumStats.noSongs} + Linked to Google Play Music: {props.stats.albumStats.perStore[MusicStore.GooglePlayMusic]} + + + + + + + Tags + Total: {props.stats.tagStats.numberOf} + Unused: {props.stats.tagStats.noItems} + + + + +} + +export interface IProps { + state: StatisticsWindowState, + dispatch: (action: any) => void, + mainDispatch: (action: any) => void, +} + +export default function StatisticsWindow(props: IProps) { + useEffect(() => { + if (!props.state.retrievingStats) return; + + props.dispatch({ + type: StatisticsWindowStateActions.Reset + }) + gatherStats(props.dispatch).then(() => { + props.dispatch({ + type: StatisticsWindowStateActions.SetRetrieving, + value: false, + }) + }) + }, [props.state.retrievingStats]) + + return + + + + + + {props.state.stats && } + + +} \ No newline at end of file diff --git a/client/src/components/windows/TagWindow.tsx b/client/src/components/windows/TagWindow.tsx index 676aef5..0c879be 100644 --- a/client/src/components/windows/TagWindow.tsx +++ b/client/src/components/windows/TagWindow.tsx @@ -3,11 +3,13 @@ import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import * as serverApi from '../../api'; import { WindowState } from './Windows'; -import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon'; +import StoreLinkIcon from '../common/StoreLinkIcon'; import EditableText from '../common/EditableText'; import SubmitChangesButton from '../common/SubmitChangesButton'; -import SongTable, { SongGetters } from '../tables/ResultsTable'; +import SongTable from '../tables/ResultsTable'; import { saveTagChanges } from '../../lib/saveChanges'; +import { whichStore } from '../../lib/MusicStore'; +import { SongGetters } from '../../lib/songGetters'; var _ = require('lodash'); export interface FullTagMetadata extends serverApi.TagDetails { diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index d5840e5..e6c1951 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -4,11 +4,13 @@ import { ArtistWindowReducer } from "./ArtistWindow"; import SearchIcon from '@material-ui/icons/Search'; import PersonIcon from '@material-ui/icons/Person'; import AlbumIcon from '@material-ui/icons/Album'; +import EqualizerIcon from '@material-ui/icons/Equalizer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import { SongWindowReducer } from './SongWindow'; import { AlbumWindowReducer } from './AlbumWindow'; import { TagWindowReducer } from './TagWindow'; +import { StatisticsWindowReducer } from './StatisticsWindow'; import { songGetters } from '../../lib/songGetters'; export enum WindowType { @@ -17,6 +19,7 @@ export enum WindowType { Album = "Album", Tag = "Tag", Song = "Song", + Statistics = "Statistics", } export interface WindowState { @@ -29,6 +32,7 @@ export const newWindowReducer = { [WindowType.Album]: AlbumWindowReducer, [WindowType.Song]: SongWindowReducer, [WindowType.Tag]: TagWindowReducer, + [WindowType.Statistics]: StatisticsWindowReducer, } export const newWindowState = { @@ -78,4 +82,11 @@ export const newWindowState = { songsWithTag: null, } }, + [WindowType.Statistics]: () => { + return { + tabLabel: <>Statistics, + stats: null, + retrievingStats: true, + } + } } \ No newline at end of file diff --git a/client/src/lib/MusicStore.tsx b/client/src/lib/MusicStore.tsx new file mode 100644 index 0000000..7a9a638 --- /dev/null +++ b/client/src/lib/MusicStore.tsx @@ -0,0 +1,9 @@ +export enum MusicStore { + GooglePlayMusic = "GPM", +} +export function whichStore(url: string) { + if(url.includes('play.google.com')) { + return MusicStore.GooglePlayMusic; + } + return undefined; +} \ No newline at end of file diff --git a/client/src/lib/songGetters.tsx b/client/src/lib/songGetters.tsx index 4f75cc9..ef2d660 100644 --- a/client/src/lib/songGetters.tsx +++ b/client/src/lib/songGetters.tsx @@ -1,4 +1,16 @@ -export const songGetters = { +export interface SongGetters { + getTitle: (song: any) => string, + getId: (song: any) => number, + getArtistNames: (song: any) => string[], + getArtistIds: (song: any) => number[], + getAlbumNames: (song: any) => string[], + getAlbumIds: (song: any) => number[], + getTagNames: (song: any) => string[][], // Each tag is represented as a series of strings. + getTagIds: (song: any) => number[][], // Each tag is represented as a series of ids. + getStoreLinks: (song: any) => string[], +} + +export const songGetters: SongGetters = { getTitle: (song: any) => song.title, getId: (song: any) => song.songId, getArtistNames: (song: any) => song.artists.map((a: any) => a.name), @@ -25,4 +37,7 @@ export const songGetters = { return song.tags.map((tag: any) => resolveTag(tag)); }, + getStoreLinks: (song: any) => { + return song.storeLinks; + } } \ No newline at end of file