浏览代码

feat: basic vim-like ui with ability to select and play songs

    * feat: determine the result of keybindings in the UI reducer and move logic out of components
    * feat: retake control of master if master goes away
    * fix: don't take control of master until initial data loaded
    * feat: component for current player status
    * feat: utility to format time
    * feat: route to fetch song info by ID
    * chore: use isMaster selector
    * chore: tested selectors
    * feat(initial-ui-demo): switch between artists and play songs
    * feat: take control of master if it goes away
    * fix: added id to songs response
    * feat: start of cmus-type UI with artist / albums / songs browser
    * feat: cors middleware
    * feat: route to fetch songs by artist
    * feat: route to fetch albums by artist
    * feat: route to fetch artists
    * chore: moved business logic out of repository layer
Fela Maslen 5 年之前
父节点
当前提交
a5bb7642fc
共有 64 个文件被更改,包括 3164 次插入495 次删除
  1. 10 0
      gmus/package.json
  2. 13 1
      gmus/src/actions/actions.ts
  3. 2 1
      gmus/src/actions/types.ts
  4. 52 0
      gmus/src/components/app/index.tsx
  5. 0 43
      gmus/src/components/client-list/index.tsx
  6. 0 117
      gmus/src/components/gmus/index.tsx
  7. 4 3
      gmus/src/components/root/index.tsx
  8. 58 0
      gmus/src/components/ui/cmus/actions.ts
  9. 93 0
      gmus/src/components/ui/cmus/artists/index.tsx
  10. 20 0
      gmus/src/components/ui/cmus/artists/styles.ts
  11. 45 0
      gmus/src/components/ui/cmus/index.tsx
  12. 374 0
      gmus/src/components/ui/cmus/reducer.spec.ts
  13. 219 0
      gmus/src/components/ui/cmus/reducer.ts
  14. 39 0
      gmus/src/components/ui/cmus/songs/index.tsx
  15. 13 0
      gmus/src/components/ui/cmus/songs/styles.ts
  16. 102 0
      gmus/src/components/ui/cmus/status/index.spec.tsx
  17. 68 0
      gmus/src/components/ui/cmus/status/index.tsx
  18. 25 0
      gmus/src/components/ui/cmus/status/styles.ts
  19. 43 0
      gmus/src/components/ui/cmus/styled/layout.ts
  20. 14 0
      gmus/src/components/ui/cmus/styled/variables.ts
  21. 23 0
      gmus/src/components/ui/cmus/styles.ts
  22. 28 0
      gmus/src/components/ui/cmus/types.ts
  23. 26 0
      gmus/src/components/ui/cmus/views/library.tsx
  24. 6 0
      gmus/src/components/ui/index.ts
  25. 13 0
      gmus/src/components/ui/types.ts
  26. 12 0
      gmus/src/context/state.ts
  27. 32 0
      gmus/src/effects/effects.spec.ts
  28. 11 0
      gmus/src/effects/effects.ts
  29. 138 0
      gmus/src/hooks/fetch/artists.ts
  30. 217 0
      gmus/src/hooks/master.spec.tsx
  31. 46 0
      gmus/src/hooks/master.ts
  32. 72 0
      gmus/src/hooks/request.ts
  33. 47 0
      gmus/src/hooks/vim.spec.tsx
  34. 39 0
      gmus/src/hooks/vim.ts
  35. 3 0
      gmus/src/index.tsx
  36. 0 6
      gmus/src/reducer/index.ts
  37. 52 0
      gmus/src/reducer/reducer.spec.ts
  38. 13 1
      gmus/src/reducer/reducer.ts
  39. 1 0
      gmus/src/reducer/types.ts
  40. 123 0
      gmus/src/selectors.spec.ts
  41. 9 4
      gmus/src/selectors.ts
  42. 10 0
      gmus/src/setupTests.ts
  43. 2 0
      gmus/src/types/index.ts
  44. 8 0
      gmus/src/types/songs.ts
  45. 7 0
      gmus/src/utils/delta.ts
  46. 3 0
      gmus/src/utils/state.ts
  47. 19 0
      gmus/src/utils/time.spec.ts
  48. 35 0
      gmus/src/utils/time.ts
  49. 5 1
      gmus/src/utils/url.ts
  50. 215 5
      gmus/yarn.lock
  51. 1 0
      music-player/go.mod
  52. 2 0
      music-player/go.sum
  53. 9 0
      music-player/pkg/read/types.go
  54. 0 122
      music-player/pkg/repository/scan.go
  55. 0 152
      music-player/pkg/repository/scan_test.go
  56. 132 0
      music-player/pkg/repository/songs.go
  57. 73 1
      music-player/pkg/repository/songs_test.go
  58. 147 0
      music-player/pkg/server/fetch.go
  59. 1 2
      music-player/pkg/server/handler.go
  60. 10 1
      music-player/pkg/server/server.go
  61. 24 0
      music-player/pkg/services/fetch.go
  62. 136 0
      music-player/pkg/services/fetch_test.go
  63. 53 2
      music-player/pkg/services/scanner.go
  64. 167 33
      music-player/pkg/services/scanner_test.go

+ 10 - 0
gmus/package.json

@@ -28,26 +28,36 @@
     ]
   },
   "dependencies": {
+    "@react-hook/debounce": "^3.0.0",
     "@react-hook/throttle": "^2.2.0",
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
     "@types/jest": "^26.0.15",
+    "@types/nock": "^11.1.0",
     "@types/node": "^12.0.0",
+    "@types/pluralize": "^0.0.29",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "@types/styled-components": "^5.1.5",
+    "axios": "^0.21.0",
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^7.0.0",
     "eslint-plugin-prettier": "^3.3.0",
     "jest-websocket-mock": "^2.2.0",
     "mock-socket": "^9.0.3",
     "nanoid": "^3.1.20",
+    "nock": "^13.0.5",
+    "pluralize": "^8.0.0",
+    "polished": "^4.0.5",
     "prettier": "^2.2.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
+    "styled-components": "^5.2.1",
+    "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",
     "web-vitals": "^0.2.4"
   }

+ 13 - 1
gmus/src/actions/actions.ts

@@ -34,6 +34,18 @@ export const seeked = (time: number): ActionSeeked => ({
   payload: time,
 });
 
-export type LocalAction = ActionErrorOccurred | ActionNameSet | ActionStateSetLocal | ActionSeeked;
+export type ActionMasterRetaken = ActionLocal<ActionTypeLocal.MasterRetaken, null>;
+
+export const masterRetaken = (): ActionMasterRetaken => ({
+  type: ActionTypeLocal.MasterRetaken,
+  payload: null,
+});
+
+export type LocalAction =
+  | ActionErrorOccurred
+  | ActionNameSet
+  | ActionStateSetLocal
+  | ActionSeeked
+  | ActionMasterRetaken;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 2 - 1
gmus/src/actions/types.ts

@@ -10,9 +10,10 @@ export enum ActionTypeLocal {
   NameSet = '@@local/NAME_SET',
   StateSet = '@@local/STATE_SET',
   Seeked = '@@local/SEEKED',
+  MasterRetaken = '@@local/MASTER_RETAKEN',
 }
 
-interface Action<T extends string = string, P = unknown> {
+export interface Action<T extends string = string, P = unknown> {
   type: T;
   payload: P;
 }

+ 52 - 0
gmus/src/components/app/index.tsx

@@ -0,0 +1,52 @@
+import React, { Dispatch, useCallback } from 'react';
+
+import { LocalAction, stateSet } from '../../actions';
+import { DispatchContext, StateContext } from '../../context/state';
+import { useMaster } from '../../hooks/master';
+import { useKeepalive } from '../../hooks/socket';
+import { GlobalState } from '../../reducer/types';
+import { isMaster } from '../../selectors';
+import { getSongUrl } from '../../utils/url';
+import { Player } from '../player';
+import { uiProviders } from '../ui';
+import { UIProvider } from '../ui/types';
+
+export type Props = {
+  socket: WebSocket;
+  state: GlobalState;
+  dispatch: Dispatch<LocalAction>;
+};
+
+const uiProvider = UIProvider.Cmus;
+const UI = uiProviders[uiProvider];
+
+export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
+  useKeepalive(socket);
+  useMaster(state, dispatch);
+
+  const onTimeUpdate = useCallback(
+    (currentTime: number): void => {
+      dispatch(stateSet({ currentTime }));
+    },
+    [dispatch],
+  );
+
+  return (
+    <>
+      {isMaster(state) && !!state.player.songId && (
+        <Player
+          src={getSongUrl(state.player.songId)}
+          playing={state.player.playing}
+          seekTime={state.player.seekTime}
+          onTimeUpdate={onTimeUpdate}
+          timeUpdateFPS={1}
+        />
+      )}
+      <StateContext.Provider value={state}>
+        <DispatchContext.Provider value={dispatch}>
+          <UI isMaster={isMaster(state)} />
+        </DispatchContext.Provider>
+      </StateContext.Provider>
+    </>
+  );
+};

+ 0 - 43
gmus/src/components/client-list/index.tsx

@@ -1,43 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { useCTA } from '../../hooks/cta';
-import { Member } from '../../types/state';
-
-export type Props = {
-  myClientName: string;
-  clients: Member[];
-};
-
-type PropsListItem = {
-  name: string;
-  onSelect: (name: string) => void;
-};
-
-const ClientListItem: React.FC<PropsListItem> = ({ name, onSelect }) => {
-  const onActivate = useCallback(() => {
-    onSelect(name);
-  }, [onSelect, name]);
-
-  const eventProps = useCTA(onActivate);
-
-  return <li {...eventProps}>{name}</li>;
-};
-
-export const ClientList: React.FC<Props> = ({ myClientName, clients }) => {
-  const onSelectClient = useCallback((name: string) => {
-    console.log('Selected client!', name);
-    // TODO
-  }, []);
-
-  return (
-    <div>
-      <h5>Client list</h5>
-      <h6>My name: {myClientName}</h6>
-      <ul>
-        {clients.map(({ name }) => (
-          <ClientListItem key={name} name={name} onSelect={onSelectClient} />
-        ))}
-      </ul>
-    </div>
-  );
-};

+ 0 - 117
gmus/src/components/gmus/index.tsx

@@ -1,117 +0,0 @@
-import React, { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
-
-import { LocalAction, seeked, stateSet } from '../../actions';
-import { masterStateUpdateTimeout } from '../../constants/system';
-import { useKeepalive } from '../../hooks/socket';
-import { GlobalState } from '../../reducer/types';
-import { getSongUrl } from '../../utils/url';
-import { ClientList } from '../client-list';
-import { Player } from '../player';
-
-export type Props = {
-  socket: WebSocket;
-  state: GlobalState;
-  dispatch: Dispatch<LocalAction>;
-};
-
-function useMaster(dispatch: Dispatch<LocalAction>, isMaster: boolean): void {
-  const masterUpdateTimer = useRef<number>(0);
-  useEffect(() => {
-    if (isMaster) {
-      masterUpdateTimer.current = window.setInterval(() => {
-        dispatch(stateSet());
-      }, masterStateUpdateTimeout);
-    }
-
-    return (): void => {
-      window.clearInterval(masterUpdateTimer.current);
-    };
-  }, [dispatch, isMaster]);
-}
-
-export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
-  useKeepalive(socket);
-
-  const { clientList, player, myClientName } = state;
-
-  const isMaster = player.master === myClientName;
-  useMaster(dispatch, isMaster);
-
-  const [tempSongId, setTempSongId] = useState<number>(0);
-
-  const playSong = useCallback((): void => {
-    if (!tempSongId) {
-      return;
-    }
-
-    dispatch(
-      stateSet({
-        songId: tempSongId,
-        currentTime: 0,
-        playing: true,
-      }),
-    );
-  }, [dispatch, tempSongId]);
-
-  const playPause = useCallback(() => {
-    dispatch(stateSet({ playing: !player.playing }));
-  }, [dispatch, player.playing]);
-
-  const takeControl = useCallback(() => {
-    dispatch(stateSet({ master: myClientName }));
-  }, [dispatch, myClientName]);
-
-  const onTimeUpdate = useCallback(
-    (currentTime: number): void => {
-      dispatch(stateSet({ currentTime }));
-    },
-    [dispatch],
-  );
-
-  const seekTo = useCallback(
-    (time: number) => {
-      dispatch(seeked(time));
-    },
-    [dispatch],
-  );
-
-  return (
-    <div>
-      <div>
-        <button onClick={playPause}>{player.playing ? 'Pause' : 'Play'}</button>
-        {!isMaster && <button onClick={takeControl}>Take control</button>}
-      </div>
-      <div>
-        <input
-          onChange={({ target: { value } }): void => setTempSongId(Number(value))}
-          type="number"
-          min={0}
-          step={1}
-        />
-        <button onClick={playSong}>Change track</button>
-      </div>
-      <div>
-        <input
-          type="number"
-          onBlur={({ target: { value } }): void => seekTo(Number(value))}
-          min={0}
-          step={0.01}
-        />
-      </div>
-      <ClientList myClientName={myClientName} clients={clientList} />
-      <div>
-        <h6>Player State</h6>
-        <pre>{JSON.stringify(player, null, 2)}</pre>
-      </div>
-      {isMaster && !!player.songId && (
-        <Player
-          src={getSongUrl(player.songId)}
-          playing={player.playing}
-          seekTime={player.seekTime}
-          onTimeUpdate={onTimeUpdate}
-          timeUpdateFPS={1}
-        />
-      )}
-    </div>
-  );
-};

+ 4 - 3
gmus/src/components/root/index.tsx

@@ -2,8 +2,9 @@ import React, { Reducer, useCallback, useReducer } from 'react';
 import { AnyAction, nameSet } from '../../actions';
 
 import { useDispatchWithEffects, useOnMessage, useSocket } from '../../hooks/socket';
-import { globalReducer, GlobalState, init, initialState } from '../../reducer';
-import { Gmus } from '../gmus';
+import { globalReducer, GlobalState, initialState } from '../../reducer';
+import { init } from '../../utils/state';
+import { App } from '../app';
 import { Identify } from '../identify';
 
 export const Root: React.FC = () => {
@@ -30,5 +31,5 @@ export const Root: React.FC = () => {
     return <Identify connecting={connecting} onIdentify={onIdentify} />;
   }
 
-  return <Gmus socket={socket} state={state} dispatch={dispatchWithEffects} />;
+  return <App socket={socket} state={state} dispatch={dispatchWithEffects} />;
 };

+ 58 - 0
gmus/src/components/ui/cmus/actions.ts

@@ -0,0 +1,58 @@
+import { Action } from '../../../actions';
+import { ActionKeyPressed } from '../../../hooks/vim';
+import { Song } from '../../../types';
+import { LibraryModeWindow } from './types';
+
+export enum CmusUIActionType {
+  ArtistsSet = '@@ui/cmus/ARTISTS_SET',
+  ArtistAlbumsLoaded = '@@ui/cmus/ARTIST_ALBUMS_LOADED',
+  ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
+  LibraryModeSet = '@@ui/cmus/library/MODE_SET',
+}
+
+export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
+
+export const artistsSet = (artists: string[]): ArtistsSet => ({
+  type: CmusUIActionType.ArtistsSet,
+  payload: artists,
+});
+
+export type ArtistAlbumsLoaded = Action<
+  CmusUIActionType.ArtistAlbumsLoaded,
+  {
+    artist: string;
+    albums: string[];
+  }
+>;
+
+export const artistAlbumsLoaded = (artist: string, albums: string[]): ArtistAlbumsLoaded => ({
+  type: CmusUIActionType.ArtistAlbumsLoaded,
+  payload: { artist, albums },
+});
+
+export type ArtistSongsLoaded = Action<
+  CmusUIActionType.ArtistSongsLoaded,
+  {
+    artist: string;
+    songs: Song[];
+  }
+>;
+
+export const artistSongsLoaded = (artist: string, songs: Song[]): ArtistSongsLoaded => ({
+  type: CmusUIActionType.ArtistSongsLoaded,
+  payload: { artist, songs },
+});
+
+export type LibraryModeSet = Action<CmusUIActionType.LibraryModeSet, LibraryModeWindow>;
+
+export const libraryModeSet = (mode: LibraryModeWindow): LibraryModeSet => ({
+  type: CmusUIActionType.LibraryModeSet,
+  payload: mode,
+});
+
+export type CmusUIAction =
+  | ArtistsSet
+  | ArtistAlbumsLoaded
+  | ArtistSongsLoaded
+  | LibraryModeSet
+  | ActionKeyPressed;

+ 93 - 0
gmus/src/components/ui/cmus/artists/index.tsx

@@ -0,0 +1,93 @@
+import { useDebounce } from '@react-hook/debounce';
+import React, { useContext, useEffect } from 'react';
+
+import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
+import { Song } from '../../../../types/songs';
+import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+
+import * as Styled from './styles';
+
+type Props = {
+  active: boolean;
+};
+
+type PropsArtist = {
+  artist: string;
+  albums?: string[];
+  songs?: Song[];
+  active: boolean;
+  parentActive: boolean;
+  expanded: boolean;
+  activeAlbum: string | null;
+};
+
+const Artist: React.FC<PropsArtist> = ({
+  artist,
+  albums,
+  active,
+  parentActive,
+  expanded,
+  activeAlbum,
+}) => (
+  <Styled.ArtistRow key={artist}>
+    <Styled.ArtistTitle active={active} parentActive={parentActive}>
+      {artist || 'Unknown Artist'}
+    </Styled.ArtistTitle>
+    {expanded && (
+      <Styled.ArtistAlbums>
+        {albums?.map((album) => (
+          <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
+            {album || 'Unknown Album'}
+          </Styled.AlbumTitle>
+        ))}
+      </Styled.ArtistAlbums>
+    )}
+  </Styled.ArtistRow>
+);
+
+export const Artists: React.FC<Props> = ({ active }) => {
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const {
+    artists,
+    artistAlbums,
+    library: { expandedArtists, activeArtist, activeAlbum },
+  } = useContext(CmusUIStateContext);
+
+  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
+  useEffect(() => {
+    setDebouncedActiveArtist(activeArtist);
+  }, [activeArtist, setDebouncedActiveArtist]);
+
+  const { albums, songs } = useArtistsAlbumsAndSongs(
+    debouncedActiveArtist ?? '',
+    !(debouncedActiveArtist && expandedArtists.includes(debouncedActiveArtist)),
+    !debouncedActiveArtist,
+  );
+  useEffect(() => {
+    if (albums) {
+      dispatchUI(artistAlbumsLoaded(albums.artist, albums.albums));
+    }
+  }, [dispatchUI, albums]);
+  useEffect(() => {
+    if (songs) {
+      dispatchUI(artistSongsLoaded(songs.artist, songs.songs));
+    }
+  }, [dispatchUI, songs]);
+
+  return (
+    <Styled.Container>
+      {artists.map((artist) => (
+        <Artist
+          key={artist}
+          artist={artist}
+          albums={artistAlbums[artist]}
+          active={artist === activeArtist}
+          parentActive={active}
+          expanded={expandedArtists.includes(artist)}
+          activeAlbum={activeAlbum}
+        />
+      ))}
+    </Styled.Container>
+  );
+};

+ 20 - 0
gmus/src/components/ui/cmus/artists/styles.ts

@@ -0,0 +1,20 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
+
+export const Container = styled(FlexColumn)`
+  flex: 1;
+`;
+
+export const ArtistRow = styled(FlexColumn)``;
+
+export const ArtistTitle = styled(ActiveHighlightRow)``;
+
+export const ArtistAlbums = styled.ul`
+  list-style: none;
+  margin: 0;
+  padding: 0 0 0 ${rem(16)};
+`;
+
+export const AlbumTitle = styled(ActiveHighlightRow)``;

+ 45 - 0
gmus/src/components/ui/cmus/index.tsx

@@ -0,0 +1,45 @@
+import React, { Reducer, useContext, useEffect, useReducer } from 'react';
+
+import { DispatchContext } from '../../../context/state';
+import { useVimBindings } from '../../../hooks/vim';
+
+import { UIProviderComponent } from '../types';
+import { CmusUIAction } from './actions';
+import {
+  CmusUIDispatchContext,
+  cmusUIReducer,
+  CmusUIStateContext,
+  initialCmusUIState,
+} from './reducer';
+import { PlayerStatus } from './status';
+
+import * as Styled from './styles';
+import { CmusUIState, View } from './types';
+import { ViewLibrary } from './views/library';
+
+export const CmusUIProvider: UIProviderComponent = () => {
+  const dispatch = useContext(DispatchContext);
+  const [stateUI, dispatchUI] = useReducer<Reducer<CmusUIState, CmusUIAction>>(
+    cmusUIReducer,
+    initialCmusUIState,
+  );
+
+  useEffect(() => {
+    if (stateUI.globalAction) {
+      dispatch(stateUI.globalAction);
+    }
+  }, [dispatch, stateUI.globalAction, stateUI.globalActionSerialNumber]);
+
+  useVimBindings(dispatchUI);
+
+  return (
+    <CmusUIStateContext.Provider value={stateUI}>
+      <CmusUIDispatchContext.Provider value={dispatchUI}>
+        <Styled.Wrapper>
+          <Styled.View>{stateUI.view === View.Library && <ViewLibrary />}</Styled.View>
+          <PlayerStatus />
+        </Styled.Wrapper>
+      </CmusUIDispatchContext.Provider>
+    </CmusUIStateContext.Provider>
+  );
+};

+ 374 - 0
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -0,0 +1,374 @@
+import { stateSet } from '../../../actions';
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
+import { Song } from '../../../types';
+import {
+  artistAlbumsLoaded,
+  artistSongsLoaded,
+  artistsSet,
+  CmusUIActionType,
+  libraryModeSet,
+} from './actions';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+import { CmusUIState, LibraryModeWindow, View } from './types';
+
+describe(cmusUIReducer.name, () => {
+  const stateLibrary: CmusUIState = {
+    ...initialCmusUIState,
+    view: View.Library,
+  };
+
+  describe(CmusUIActionType.ArtistsSet, () => {
+    const action = artistsSet(['Artist A', 'Artist B']);
+
+    it('should set the artists array', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artists).toStrictEqual(['Artist A', 'Artist B']);
+    });
+
+    it('should set the active artist to the first artist in the list', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.library.activeArtist).toBe('Artist A');
+    });
+  });
+
+  describe(CmusUIActionType.ArtistAlbumsLoaded, () => {
+    const action = artistAlbumsLoaded('My artist', ['Album A', 'Album B']);
+
+    it('should set the albums for the given artist', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artistAlbums).toStrictEqual(
+        expect.objectContaining({
+          'My artist': ['Album A', 'Album B'],
+        }),
+      );
+    });
+  });
+
+  describe(CmusUIActionType.ArtistSongsLoaded, () => {
+    const action = artistSongsLoaded('My artist', [
+      { id: 12, track: 23, title: 'Title A', artist: 'My artist', album: 'Album A', time: 123 },
+      { id: 73, track: 17, title: 'Title B', artist: 'My artist', album: 'Album B', time: 456 },
+    ]);
+
+    it('should set the songs for the given artist', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.artistSongs).toStrictEqual(
+        expect.objectContaining({
+          'My artist': [
+            {
+              id: 12,
+              track: 23,
+              title: 'Title A',
+              artist: 'My artist',
+              album: 'Album A',
+              time: 123,
+            },
+            {
+              id: 73,
+              track: 17,
+              title: 'Title B',
+              artist: 'My artist',
+              album: 'Album B',
+              time: 456,
+            },
+          ],
+        }),
+      );
+    });
+
+    describe('when the artist is the active artist', () => {
+      const stateWithActiveArtist: CmusUIState = {
+        ...initialCmusUIState,
+        library: {
+          ...initialCmusUIState.library,
+          activeArtist: 'My artist',
+        },
+      };
+
+      it('should set the active song ID to the first song ID in the list', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateWithActiveArtist, action);
+        expect(result.library.activeSongId).toBe(12);
+      });
+    });
+  });
+
+  describe(CmusUIActionType.LibraryModeSet, () => {
+    const action = libraryModeSet(LibraryModeWindow.SongList);
+
+    it('should set the library mode window', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.library.modeWindow).toBe(LibraryModeWindow.SongList);
+    });
+  });
+
+  describe('Keypress actions', () => {
+    describe(Keys['1'], () => {
+      it('should set the view to Library', () => {
+        expect.assertions(1);
+        const state = ({ ...initialCmusUIState, view: undefined } as unknown) as CmusUIState;
+        const result = cmusUIReducer(state, { type: ActionTypeKeyPressed, key: Keys['1'] });
+
+        expect(result.view).toBe(View.Library);
+      });
+    });
+
+    describe(Keys.tab, () => {
+      describe('when in library view', () => {
+        describe.each`
+          fromModeWindow                  | toModeWindow
+          ${LibraryModeWindow.ArtistList} | ${LibraryModeWindow.SongList}
+          ${LibraryModeWindow.SongList}   | ${LibraryModeWindow.ArtistList}
+        `('when the mode window is $fromModeWindow', ({ fromModeWindow, toModeWindow }) => {
+          const stateFromMode: CmusUIState = {
+            ...stateLibrary,
+            library: {
+              ...stateLibrary.library,
+              modeWindow: fromModeWindow,
+            },
+          };
+
+          it(`should set the mode window to ${toModeWindow}`, () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateFromMode, {
+              type: ActionTypeKeyPressed,
+              key: Keys.tab,
+            });
+
+            expect(result.library.modeWindow).toBe(toModeWindow);
+          });
+        });
+      });
+    });
+
+    describe(Keys.J, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          const stateArtistMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A', 'Artist B'],
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              modeWindow: LibraryModeWindow.ArtistList,
+            },
+          };
+
+          it('should set the active artist to the next available artist', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistMode, action);
+
+            expect(result.library.activeArtist).toBe('Artist B');
+          });
+
+          it('should set the active song ID to the first by the artist', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {
+                'Artist B': [{ id: 123 } as Song, { id: 456 } as Song],
+              },
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+
+          describe('when there are no songs loaded for the artist', () => {
+            it('should set the active song ID to null', () => {
+              expect.assertions(1);
+              const state: CmusUIState = {
+                ...stateArtistMode,
+                artistSongs: {},
+              };
+              const result = cmusUIReducer(state, action);
+
+              expect(result.library.activeSongId).toBeNull();
+            });
+          });
+        });
+
+        describe('when in the song list mode', () => {
+          const stateSongsMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A'],
+            artistSongs: {
+              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+            },
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              activeSongId: 123,
+              modeWindow: LibraryModeWindow.SongList,
+            },
+          };
+
+          it('should set the active song ID to the next available song', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateSongsMode, action);
+
+            expect(result.library.activeSongId).toBe(456);
+          });
+        });
+      });
+    });
+
+    describe(Keys.K, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.K };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          const stateArtistMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A', 'Artist B'],
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist B',
+              modeWindow: LibraryModeWindow.ArtistList,
+            },
+          };
+
+          it('should set the active artist to the previous available artist', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistMode, action);
+
+            expect(result.library.activeArtist).toBe('Artist A');
+          });
+
+          it('should set the active song ID to the first by the artist', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {
+                'Artist A': [{ id: 123 } as Song, { id: 456 } as Song],
+              },
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+
+          describe('when there are no songs loaded for the artist', () => {
+            it('should set the active song ID to null', () => {
+              expect.assertions(1);
+              const state: CmusUIState = {
+                ...stateArtistMode,
+                artistSongs: {},
+              };
+              const result = cmusUIReducer(state, action);
+
+              expect(result.library.activeSongId).toBeNull();
+            });
+          });
+        });
+
+        describe('when in the song list mode', () => {
+          const stateSongsMode: CmusUIState = {
+            ...stateLibrary,
+            artists: ['Artist A'],
+            artistSongs: {
+              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+            },
+            library: {
+              ...stateLibrary.library,
+              activeArtist: 'Artist A',
+              activeSongId: 456,
+              modeWindow: LibraryModeWindow.SongList,
+            },
+          };
+
+          it('should set the active song ID to the previous available song', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateSongsMode, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+    });
+
+    describe(Keys.space, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
+
+      describe('when in library view', () => {
+        describe('when in the artist list mode', () => {
+          it('should toggle the active artist', () => {
+            expect.assertions(2);
+
+            const state: CmusUIState = {
+              ...initialCmusUIState,
+              library: {
+                ...initialCmusUIState.library,
+                expandedArtists: [],
+                activeArtist: 'Some artist',
+              },
+            };
+
+            const firstResult = cmusUIReducer(state, action);
+            expect(firstResult.library.expandedArtists).toStrictEqual(['Some artist']);
+
+            const secondResult = cmusUIReducer(firstResult, action);
+            expect(secondResult.library.expandedArtists).toStrictEqual([]);
+          });
+
+          describe('when there is no active artist', () => {
+            it('should return the state', () => {
+              expect.assertions(1);
+              const stateNoActive: CmusUIState = {
+                ...initialCmusUIState,
+                library: {
+                  ...initialCmusUIState.library,
+                  activeArtist: null,
+                },
+              };
+
+              const result = cmusUIReducer(stateNoActive, action);
+              expect(result).toStrictEqual(stateNoActive);
+            });
+          });
+        });
+      });
+    });
+
+    describe(Keys.enter, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.enter };
+
+      describe('when in library view', () => {
+        describe('when in the songs list mode', () => {
+          const state: CmusUIState = {
+            ...initialCmusUIState,
+            globalActionSerialNumber: 1875,
+            library: {
+              ...initialCmusUIState.library,
+              modeWindow: LibraryModeWindow.SongList,
+              activeSongId: 713,
+            },
+          };
+
+          it('should set the globalAction to play the active song and increment the serial', () => {
+            expect.assertions(2);
+
+            const result = cmusUIReducer(state, action);
+
+            expect(result.globalAction).toStrictEqual(
+              stateSet({
+                playing: true,
+                songId: 713,
+                currentTime: 0,
+                seekTime: 0,
+              }),
+            );
+            expect(result.globalActionSerialNumber).toBe(1876);
+          });
+        });
+      });
+    });
+  });
+});

+ 219 - 0
gmus/src/components/ui/cmus/reducer.ts

@@ -0,0 +1,219 @@
+import { createContext, Dispatch } from 'react';
+
+import { stateSet } from '../../../actions';
+import { nullDispatch } from '../../../context/state';
+import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
+import { scrollThroughItems } from '../../../utils/delta';
+import {
+  ArtistAlbumsLoaded,
+  ArtistSongsLoaded,
+  ArtistsSet,
+  CmusUIAction,
+  CmusUIActionType,
+} from './actions';
+import { CmusUIState, LibraryModeWindow, View } from './types';
+
+export const initialCmusUIState: CmusUIState = {
+  globalAction: null,
+  globalActionSerialNumber: 0,
+  view: View.Library,
+  artists: [],
+  artistAlbums: {},
+  artistSongs: {},
+  library: {
+    modeWindow: LibraryModeWindow.ArtistList,
+    expandedArtists: [],
+    activeArtist: null,
+    activeAlbum: null,
+    activeSongId: null,
+    visibleSongs: [],
+  },
+};
+
+export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
+export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
+
+const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
+
+const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
+  ...state,
+  library: {
+    ...state.library,
+    modeWindow:
+      libraryModeWindows[
+        (libraryModeWindows.indexOf(state.library.modeWindow) + 1) % libraryModeWindows.length
+      ],
+  },
+});
+
+const setActiveSongIdFromActiveArtist = (state: CmusUIState): CmusUIState => ({
+  ...state,
+  library: {
+    ...state.library,
+    activeSongId: state.library.activeArtist
+      ? state.artistSongs[state.library.activeArtist]?.[0]?.id ?? null
+      : null,
+  },
+});
+
+const scrollArtists = (state: CmusUIState, delta: number): CmusUIState =>
+  setActiveSongIdFromActiveArtist({
+    ...state,
+    library: {
+      ...state.library,
+      activeArtist: scrollThroughItems(
+        state.artists,
+        (compare) => compare === state.library.activeArtist,
+        delta,
+      ),
+    },
+  });
+
+const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
+  state.library.activeArtist
+    ? {
+        ...state,
+        library: {
+          ...state.library,
+          activeSongId: scrollThroughItems(
+            state.artistSongs[state.library.activeArtist] ?? [],
+            (compare) => compare.id === state.library.activeSongId,
+            delta,
+          ).id,
+        },
+      }
+    : state;
+
+function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
+  if (!library.activeArtist) {
+    return library;
+  }
+  if (library.expandedArtists.includes(library.activeArtist)) {
+    return {
+      ...library,
+      expandedArtists: library.expandedArtists.filter(
+        (compare) => compare !== library.activeArtist,
+      ),
+    };
+  }
+  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
+}
+
+function handleScrollDown(state: CmusUIState): CmusUIState {
+  if (state.view === View.Library) {
+    if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+      return scrollArtists(state, 1);
+    }
+    if (state.library.modeWindow === LibraryModeWindow.SongList) {
+      return scrollSongs(state, 1);
+    }
+  }
+
+  return state;
+}
+
+function handleScrollUp(state: CmusUIState): CmusUIState {
+  if (state.view === View.Library) {
+    if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+      return scrollArtists(state, -1);
+    }
+    if (state.library.modeWindow === LibraryModeWindow.SongList) {
+      return scrollSongs(state, -1);
+    }
+  }
+
+  return state;
+}
+
+function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
+  switch (key) {
+    case Keys['1']:
+      return { ...state, view: View.Library };
+    case Keys.tab:
+      if (state.view === View.Library) {
+        return switchLibraryMode(state);
+      }
+      return state;
+
+    case Keys.space:
+      if (state.view === View.Library) {
+        if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+          return { ...state, library: toggleExpandArtist(state.library) };
+        }
+      }
+      return state;
+
+    case Keys.enter:
+      if (state.view === View.Library) {
+        if (state.library.modeWindow === LibraryModeWindow.SongList) {
+          if (!state.library.activeSongId) {
+            return state;
+          }
+
+          return {
+            ...state,
+            globalAction: stateSet({
+              playing: true,
+              songId: state.library.activeSongId,
+              currentTime: 0,
+              seekTime: 0,
+            }),
+            globalActionSerialNumber: state.globalActionSerialNumber + 1,
+          };
+        }
+      }
+
+      return state;
+
+    case Keys.J:
+      return handleScrollDown(state);
+    case Keys.K:
+      return handleScrollUp(state);
+
+    default:
+      return state;
+  }
+}
+
+const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
+  ...state,
+  artists: action.payload,
+  library: { ...state.library, activeArtist: action.payload[0] ?? null },
+});
+
+const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
+  ...state,
+  artistAlbums: { ...state.artistAlbums, [action.payload.artist]: action.payload.albums },
+});
+
+const setArtistSongs = (state: CmusUIState, action: ArtistSongsLoaded): CmusUIState => ({
+  ...state,
+  artistSongs: { ...state.artistSongs, [action.payload.artist]: action.payload.songs },
+  library: {
+    ...state.library,
+    activeSongId:
+      state.library.activeArtist === action.payload.artist
+        ? action.payload.songs[0]?.id ?? null
+        : state.library.activeSongId,
+  },
+});
+
+export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
+  switch (action.type) {
+    case ActionTypeKeyPressed:
+      return handleKeyPress(state, action.key);
+
+    case CmusUIActionType.ArtistsSet:
+      return setArtists(state, action);
+    case CmusUIActionType.ArtistAlbumsLoaded:
+      return setArtistAlbums(state, action);
+    case CmusUIActionType.ArtistSongsLoaded:
+      return setArtistSongs(state, action);
+
+    case CmusUIActionType.LibraryModeSet:
+      return { ...state, library: { ...state.library, modeWindow: action.payload } };
+
+    default:
+      return state;
+  }
+}

+ 39 - 0
gmus/src/components/ui/cmus/songs/index.tsx

@@ -0,0 +1,39 @@
+import React, { useContext, useMemo } from 'react';
+
+import { Song } from '../../../../types';
+import { CmusUIStateContext } from '../reducer';
+
+import * as Styled from './styles';
+
+type Props = {
+  active: boolean;
+};
+
+const emptyArray: Song[] = [];
+
+export const Songs: React.FC<Props> = ({ active }) => {
+  const {
+    artistSongs,
+    library: { activeArtist, activeAlbum, activeSongId },
+  } = useContext(CmusUIStateContext);
+
+  const activeArtistSongs = activeArtist ? artistSongs[activeArtist] ?? emptyArray : emptyArray;
+
+  const songs = useMemo<Song[]>(
+    () =>
+      activeAlbum
+        ? activeArtistSongs.filter(({ album }) => album === activeAlbum)
+        : activeArtistSongs,
+    [activeArtistSongs, activeAlbum],
+  );
+
+  return (
+    <Styled.Container>
+      {songs.map((song) => (
+        <Styled.Song key={song.id} active={song.id === activeSongId} parentActive={active}>
+          {song.track} - {song.title || 'Untitled Track'}
+        </Styled.Song>
+      ))}
+    </Styled.Container>
+  );
+};

+ 13 - 0
gmus/src/components/ui/cmus/songs/styles.ts

@@ -0,0 +1,13 @@
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexRow } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const Container = styled(FlexRow)`
+  border-left: 1px solid ${colors.border};
+  flex: 2;
+  flex-flow: column;
+  height: 100%;
+`;
+
+export const Song = styled(ActiveHighlightRow)``;

+ 102 - 0
gmus/src/components/ui/cmus/status/index.spec.tsx

@@ -0,0 +1,102 @@
+import { render, RenderResult, waitFor } from '@testing-library/react';
+import nock from 'nock';
+import React from 'react';
+
+import { StateContext } from '../../../../context/state';
+import { GlobalState, initialState, nullPlayer } from '../../../../reducer';
+
+import { PlayerStatus } from '.';
+
+describe(PlayerStatus.name, () => {
+  const nockSongInfo = (id: number): nock.Scope =>
+    nock('http://my-api.url:1234').get(`/song-info?id=${id}`).reply(
+      200,
+      {
+        track: 12,
+        title: 'My song',
+        artist: 'My artist',
+        album: 'My album',
+        time: 374,
+      },
+      { 'Access-Control-Allow-Origin': '*' },
+    );
+
+  const testSongId = 23;
+
+  const setup = (globalState: Partial<GlobalState> = {}): RenderResult =>
+    render(
+      <StateContext.Provider value={{ ...initialState, ...globalState }}>
+        <PlayerStatus />
+      </StateContext.Provider>,
+    );
+
+  describe('when a song is active', () => {
+    beforeEach(() => {
+      nockSongInfo(testSongId);
+    });
+
+    const stateWithSongActive: Partial<GlobalState> = {
+      player: {
+        ...nullPlayer,
+        currentTime: 128,
+        songId: testSongId,
+      },
+    };
+
+    it.each`
+      property                                   | expectedValue
+      ${'artist, album, title and track number'} | ${'My artist - My album - 12. My song'}
+      ${'current and total play time'}           | ${'02:08 / 06:14'}
+    `('should render the $property', async ({ expectedValue }) => {
+      const { getByText } = setup(stateWithSongActive);
+
+      await waitFor(() => {
+        expect(getByText(expectedValue)).toBeInTheDocument();
+      });
+    });
+
+    describe('when playing', () => {
+      const statePlaying: Partial<GlobalState> = {
+        player: {
+          ...initialState.player,
+          songId: testSongId,
+          playing: true,
+        },
+      };
+
+      it('should display a playing indicator', () => {
+        expect.assertions(1);
+        const { getByText } = setup(statePlaying);
+        expect(getByText('>')).toBeInTheDocument();
+      });
+    });
+
+    describe('when not playing', () => {
+      const stateNotPlaying: Partial<GlobalState> = {
+        player: {
+          ...nullPlayer,
+          songId: testSongId,
+          playing: false,
+        },
+      };
+
+      it('should display a paused indicator', () => {
+        expect.assertions(1);
+        const { getByText } = setup(stateNotPlaying);
+        expect(getByText('|')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('when no song is active', () => {
+    const stateWithSongInactive: Partial<GlobalState> = {
+      player: { ...nullPlayer, songId: null },
+    };
+
+    it('should display an inactive indicator', () => {
+      expect.assertions(1);
+      const { getByText } = setup(stateWithSongInactive);
+      expect(getByText('.')).toBeInTheDocument();
+    });
+  });
+});

+ 68 - 0
gmus/src/components/ui/cmus/status/index.tsx

@@ -0,0 +1,68 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import React, { useCallback, useContext, useMemo, useState } from 'react';
+
+import { StateContext } from '../../../../context/state';
+import { useCancellableRequest } from '../../../../hooks/request';
+import { getSongId } from '../../../../selectors';
+import { MusicPlayer, Song } from '../../../../types';
+import { formatTime } from '../../../../utils/time';
+import { getApiUrl } from '../../../../utils/url';
+
+import * as Styled from './styles';
+
+type SongInfoQuery = { id: number };
+
+const sendSongInfoRequest = (
+  axios: AxiosInstance,
+  query: SongInfoQuery,
+): Promise<AxiosResponse<Song>> => axios.get(`${getApiUrl()}/song-info?id=${query.id}`);
+
+function getTrackMetadata(songInfo: Song | null): string {
+  if (!songInfo) {
+    return '';
+  }
+  return `${songInfo.artist} - ${songInfo.album} - ${songInfo.track ? `${songInfo.track}. ` : ''}${
+    songInfo.title
+  }`;
+}
+
+function getPlayPauseIcon(player: MusicPlayer): string {
+  if (!player.songId) {
+    return '.';
+  }
+  if (player.playing) {
+    return '>';
+  }
+  return '|';
+}
+
+export const PlayerStatus: React.FC = () => {
+  const state = useContext(StateContext);
+  const songId = getSongId(state);
+
+  const query = useMemo<SongInfoQuery>(() => (songId ? { id: songId } : { id: 0 }), [songId]);
+
+  const [songInfo, setSongInfo] = useState<Song | null>(null);
+  const handleResponse = useCallback((res: Song) => {
+    setSongInfo(res);
+  }, []);
+
+  useCancellableRequest<SongInfoQuery, Song>({
+    query,
+    pause: !songId,
+    sendRequest: sendSongInfoRequest,
+    handleResponse,
+  });
+
+  return (
+    <Styled.StatusContainer>
+      <Styled.TrackMetadata>{getTrackMetadata(songInfo)}</Styled.TrackMetadata>
+      <Styled.PlayStatus>
+        <span>{getPlayPauseIcon(state.player)}</span>
+        <span>
+          {formatTime(state.player.currentTime)} / {formatTime(songInfo?.time ?? null)}
+        </span>
+      </Styled.PlayStatus>
+    </Styled.StatusContainer>
+  );
+};

+ 25 - 0
gmus/src/components/ui/cmus/status/styles.ts

@@ -0,0 +1,25 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexColumn, FlexRow } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const StatusContainer = styled(FlexColumn)`
+  flex: 0 0 ${rem(32)};
+  width: 100%;
+`;
+
+export const TrackMetadata = styled(FlexRow)`
+  background: ${colors.selected.background};
+  color: ${colors.foreground};
+  flex: 1;
+  white-space: nowrap;
+  width: 100%;
+`;
+
+export const PlayStatus = styled(FlexRow)`
+  background: ${colors.selected.inactive};
+  color: ${colors.background};
+  flex: 1;
+  width: 100%;
+`;

+ 43 - 0
gmus/src/components/ui/cmus/styled/layout.ts

@@ -0,0 +1,43 @@
+import { CSSProperties } from 'react';
+import styled from 'styled-components';
+
+import { colors } from './variables';
+
+export const FlexRow = styled.div`
+  display: flex;
+`;
+
+export const FlexColumn = styled(FlexRow)`
+  flex-flow: column;
+`;
+
+export const ActiveHighlightRow = styled(FlexRow)<{
+  active?: boolean;
+  highlight?: boolean;
+  parentActive?: boolean;
+}>`
+  background: ${({ active, parentActive }): string => {
+    if (active) {
+      if (parentActive) {
+        return colors.selected.background;
+      }
+      return colors.selected.inactive;
+    }
+    return 'none';
+  }};
+
+  color: ${({ active, highlight, parentActive }): string => {
+    if (highlight) {
+      return colors.active.color;
+    }
+    if (active && !parentActive) {
+      return colors.background;
+    }
+    return colors.foreground;
+  }};
+
+  font-weight: ${({ active, highlight }): CSSProperties['fontWeight'] =>
+    active || highlight ? 'bold' : 'normal'};
+
+  width: 100%;
+`;

+ 14 - 0
gmus/src/components/ui/cmus/styled/variables.ts

@@ -0,0 +1,14 @@
+import { rgb } from 'polished';
+
+export const colors = {
+  background: rgb(0, 0, 0),
+  border: rgb(0, 0, 180),
+  foreground: rgb(255, 255, 255),
+  selected: {
+    background: rgb(0, 0, 180),
+    inactive: rgb(210, 210, 210),
+  },
+  active: {
+    color: rgb(255, 255, 130),
+  },
+};

+ 23 - 0
gmus/src/components/ui/cmus/styles.ts

@@ -0,0 +1,23 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexColumn, FlexRow } from './styled/layout';
+import { colors } from './styled/variables';
+
+export const Wrapper = styled(FlexColumn)`
+  background: ${colors.background};
+  bottom: 0;
+  color: ${colors.foreground};
+  font-size: ${rem(14)};
+  font-family: Hack, monospace;
+  left: 0;
+  line-height: ${rem(16)};
+  position: absolute;
+  right: 0;
+  top: 0;
+  user-select: none;
+`;
+
+export const View = styled(FlexRow)`
+  flex: 1;
+`;

+ 28 - 0
gmus/src/components/ui/cmus/types.ts

@@ -0,0 +1,28 @@
+import { LocalAction } from '../../../actions';
+import { Song } from '../../../types/songs';
+
+export enum View {
+  Library,
+}
+
+export enum LibraryModeWindow {
+  ArtistList = 'ArtistList',
+  SongList = 'SongList',
+}
+
+export type CmusUIState = {
+  globalAction: LocalAction | null;
+  globalActionSerialNumber: number;
+  view: View;
+  artists: string[];
+  artistAlbums: Record<string, string[]>;
+  artistSongs: Record<string, Song[]>;
+  library: {
+    expandedArtists: string[];
+    activeArtist: string | null;
+    activeAlbum: string | null;
+    activeSongId: number | null;
+    modeWindow: LibraryModeWindow;
+    visibleSongs: Song[];
+  };
+};

+ 26 - 0
gmus/src/components/ui/cmus/views/library.tsx

@@ -0,0 +1,26 @@
+import React, { useContext, useEffect } from 'react';
+
+import { useArtists } from '../../../../hooks/fetch/artists';
+import { artistsSet } from '../actions';
+
+import { Artists } from '../artists';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { Songs } from '../songs';
+import { LibraryModeWindow } from '../types';
+
+export const ViewLibrary: React.FC = () => {
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const { library } = useContext(CmusUIStateContext);
+
+  const { artists } = useArtists();
+  useEffect(() => {
+    dispatchUI(artistsSet(artists));
+  }, [dispatchUI, artists]);
+
+  return (
+    <>
+      <Artists active={library.modeWindow === LibraryModeWindow.ArtistList} />
+      <Songs active={library.modeWindow === LibraryModeWindow.SongList} />
+    </>
+  );
+};

+ 6 - 0
gmus/src/components/ui/index.ts

@@ -0,0 +1,6 @@
+import { CmusUIProvider } from './cmus';
+import { UIProvider, UIProviders } from './types';
+
+export const uiProviders: UIProviders = {
+  [UIProvider.Cmus]: CmusUIProvider,
+};

+ 13 - 0
gmus/src/components/ui/types.ts

@@ -0,0 +1,13 @@
+import React from 'react';
+
+export enum UIProvider {
+  Cmus = 'Cmus',
+}
+
+export type UIProps = {
+  isMaster: boolean;
+};
+
+export type UIProviderComponent = React.FC<UIProps>;
+
+export type UIProviders = Record<UIProvider, UIProviderComponent>;

+ 12 - 0
gmus/src/context/state.ts

@@ -0,0 +1,12 @@
+import { createContext, Dispatch } from 'react';
+import { LocalAction } from '../actions';
+
+import { GlobalState, initialState } from '../reducer';
+
+export const StateContext = createContext<GlobalState>(initialState);
+
+export const nullDispatch: Dispatch<unknown> = (): void => {
+  // pass
+};
+
+export const DispatchContext = createContext<Dispatch<LocalAction>>(nullDispatch);

+ 32 - 0
gmus/src/effects/effects.spec.ts

@@ -2,6 +2,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
+  masterRetaken,
   seeked,
   stateSet,
 } from '../actions';
@@ -72,4 +73,35 @@ describe(globalEffects.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.MasterRetaken, () => {
+    const stateMasterWentAway: GlobalState = {
+      ...initialState,
+      clientList: [{ name: 'my-client-name', lastPing: 0 }],
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 83,
+        seekTime: 5,
+        master: 'some-master-went-away',
+      },
+      myClientName: 'my-client-name',
+    };
+
+    it('should return a StateSet action informing other clients that we are the new master', () => {
+      expect.assertions(1);
+      const result = globalEffects(stateMasterWentAway, masterRetaken());
+
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          songId: 123,
+          playing: false,
+          currentTime: 83,
+          seekTime: -1,
+          master: 'my-client-name',
+        },
+      });
+    });
+  });
 });

+ 11 - 0
gmus/src/effects/effects.ts

@@ -15,6 +15,17 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         payload: { ...prevState.player, seekTime: action.payload },
       };
 
+    case ActionTypeLocal.MasterRetaken:
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          playing: false,
+          seekTime: -1,
+          master: prevState.myClientName,
+        },
+      };
+
     default:
       return null;
   }

+ 138 - 0
gmus/src/hooks/fetch/artists.ts

@@ -0,0 +1,138 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import { useCallback, useMemo, useState } from 'react';
+
+import { Song } from '../../types/songs';
+import { getApiUrl } from '../../utils/url';
+import { useCancellableRequest } from '../request';
+
+type ArtistsResponse = {
+  artists: string[];
+  more: boolean;
+};
+
+type ArtistsQuery = {
+  limit: number;
+  page: number;
+};
+
+const sendArtistsRequest = (
+  axios: AxiosInstance,
+  query: ArtistsQuery,
+): Promise<AxiosResponse<ArtistsResponse>> =>
+  axios.get(`${getApiUrl()}/artists?limit=${query.limit}&page=${query.page}`);
+
+type AlbumsResponse = {
+  artist: string;
+  albums: string[];
+};
+
+type AlbumsQuery = {
+  artist: string;
+};
+
+const sendAlbumsRequest = (
+  axios: AxiosInstance,
+  query: AlbumsQuery,
+): Promise<AxiosResponse<AlbumsResponse>> =>
+  axios.get(`${getApiUrl()}/albums?artist=${query.artist}`);
+
+type SongsResponse = {
+  artist: string;
+  songs: Song[];
+};
+
+type SongsQuery = AlbumsQuery;
+
+const sendSongsRequest = (
+  axios: AxiosInstance,
+  query: SongsQuery,
+): Promise<AxiosResponse<SongsResponse>> =>
+  axios.get(`${getApiUrl()}/songs?artist=${query.artist}`);
+
+export function useArtists(): ArtistsResponse & {
+  fetching: boolean;
+  fetchMore: () => void;
+} {
+  const [limit] = useState<number>(100);
+  const [page, setPage] = useState<number>(0);
+
+  const [artists, setArtists] = useState<string[]>([]);
+  const [more, setMore] = useState<boolean>(true);
+
+  const [pause, setPause] = useState<boolean>(false);
+
+  const query = useMemo<ArtistsQuery>(() => ({ limit, page }), [limit, page]);
+
+  const handleResponse = useCallback((response: ArtistsResponse) => {
+    setMore(response.more);
+    setArtists((last) => Array.from(new Set([...last, ...response.artists])));
+    setPause(true);
+  }, []);
+
+  const fetching = useCancellableRequest<ArtistsQuery, ArtistsResponse>({
+    query,
+    pause,
+    sendRequest: sendArtistsRequest,
+    handleResponse,
+  });
+
+  const fetchMore = useCallback(() => {
+    if (!more) {
+      return;
+    }
+    setPage((last) => last + 1);
+    setPause(false);
+  }, [more]);
+
+  return { artists, more, fetching, fetchMore };
+}
+
+export function useArtistsAlbumsAndSongs(
+  artist: string,
+  pauseAlbums: boolean,
+  pauseSongs: boolean,
+): {
+  albums: AlbumsResponse | undefined;
+  songs: SongsResponse | undefined;
+  fetchingAlbums: boolean;
+  fetchingSongs: boolean;
+} {
+  const [hasLoadedAlbums, setHasLoadedAlbums] = useState<Record<string, boolean>>({});
+  const [hasLoadedSongs, setHasLoadedSongs] = useState<Record<string, boolean>>({});
+
+  const query = useMemo<AlbumsQuery>(() => ({ artist }), [artist]);
+
+  const [albums, setAlbums] = useState<AlbumsResponse | undefined>();
+  const [songs, setSongs] = useState<SongsResponse | undefined>();
+
+  const handleAlbumsResponse = useCallback((response: AlbumsResponse) => {
+    setAlbums(response);
+    setHasLoadedAlbums((last) => ({ ...last, [response.artist]: true }));
+  }, []);
+
+  const handleSongsResponse = useCallback((response: SongsResponse) => {
+    setSongs(response);
+    setHasLoadedSongs((last) => ({ ...last, [response.artist]: true }));
+  }, []);
+
+  const fetchingAlbums = useCancellableRequest<AlbumsQuery, AlbumsResponse>({
+    query,
+    pause: pauseAlbums || hasLoadedAlbums[artist],
+    sendRequest: sendAlbumsRequest,
+    handleResponse: handleAlbumsResponse,
+  });
+
+  const fetchingSongs = useCancellableRequest<SongsQuery, SongsResponse>({
+    query,
+    pause: pauseSongs || hasLoadedSongs[artist],
+    sendRequest: sendSongsRequest,
+    handleResponse: handleSongsResponse,
+  });
+
+  return {
+    albums,
+    songs,
+    fetchingAlbums,
+    fetchingSongs,
+  };
+}

+ 217 - 0
gmus/src/hooks/master.spec.tsx

@@ -0,0 +1,217 @@
+import { act, render } from '@testing-library/react';
+import React from 'react';
+
+import { masterRetaken, stateSet } from '../actions';
+import { masterStateUpdateTimeout } from '../constants/system';
+import { GlobalState, initialState, nullPlayer } from '../reducer';
+
+import { useMaster } from './master';
+
+describe(useMaster.name, () => {
+  const dispatch = jest.fn();
+
+  const TestComponent: React.FC<GlobalState> = (state) => {
+    useMaster(state, dispatch);
+    return null;
+  };
+
+  describe('when there is no master initially', () => {
+    const stateNoMaster: GlobalState = {
+      ...initialState,
+      initialised: true,
+      myClientName: 'my-client-name',
+      player: {
+        ...nullPlayer,
+        master: '',
+      },
+    };
+
+    it('should take control of master', () => {
+      expect.assertions(2);
+      const { unmount } = render(<TestComponent {...stateNoMaster} />);
+
+      expect(dispatch).toHaveBeenCalledTimes(1);
+      expect(dispatch).toHaveBeenCalledWith(stateSet({ master: 'my-client-name' }));
+
+      unmount();
+    });
+
+    describe('when the state is not initialised', () => {
+      const stateNoMasterUninit: GlobalState = {
+        ...stateNoMaster,
+        initialised: false,
+      };
+
+      it('should not take control of master', () => {
+        expect.assertions(1);
+        const { unmount } = render(<TestComponent {...stateNoMasterUninit} />);
+
+        expect(dispatch).not.toHaveBeenCalled();
+
+        unmount();
+      });
+    });
+  });
+
+  describe('when master goes away', () => {
+    const stateWithMaster: GlobalState = {
+      ...initialState,
+      initialised: true,
+      myClientName: 'my-client-name',
+      clientList: [
+        { name: 'master-client-a', lastPing: 0 },
+        { name: 'my-client-name', lastPing: 0 },
+        { name: 'other-slave-client', lastPing: 0 },
+      ],
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 17,
+        seekTime: -1,
+        master: 'master-client-a',
+      },
+    };
+
+    const stateMasterWentAway: GlobalState = {
+      ...stateWithMaster,
+      clientList: [
+        { name: 'my-client-name', lastPing: 0 },
+        { name: 'other-slave-client', lastPing: 0 },
+      ],
+    };
+
+    it('should take control of master after a delay, and pause the client', () => {
+      expect.assertions(2);
+      jest.useFakeTimers();
+
+      const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+
+      act(() => {
+        render(<TestComponent {...stateMasterWentAway} />, { container });
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        jest.runAllTimers();
+      });
+
+      expect(dispatch).toHaveBeenCalledWith(masterRetaken());
+
+      unmount();
+      jest.useRealTimers();
+    });
+
+    describe('and a third client takes over control', () => {
+      const stateMasterWentAwayAnotherTookControl: GlobalState = {
+        ...stateMasterWentAway,
+        clientList: [
+          { name: 'my-client-name', lastPing: 0 },
+          { name: 'other-slave-client', lastPing: 0 },
+        ],
+        player: {
+          ...stateMasterWentAway.player,
+          master: 'other-slave-client',
+        },
+      };
+
+      it('should not take control of master', () => {
+        expect.assertions(1);
+        jest.useFakeTimers();
+
+        const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+        act(() => {
+          render(<TestComponent {...stateMasterWentAway} />, { container });
+        });
+
+        setImmediate(() => {
+          act(() => {
+            render(<TestComponent {...stateMasterWentAwayAnotherTookControl} />, { container });
+          });
+        });
+
+        jest.runAllTimers();
+
+        expect(dispatch).not.toHaveBeenCalled();
+
+        unmount();
+        jest.useRealTimers();
+      });
+    });
+  });
+
+  describe('when the client is master', () => {
+    const stateMaster: GlobalState = {
+      ...initialState,
+      initialised: true,
+      myClientName: 'the-master-client',
+      clientList: [{ name: 'the-master-client', lastPing: 0 }],
+      player: {
+        ...nullPlayer,
+        master: 'the-master-client',
+      },
+    };
+
+    it('should continually refresh the server with the current state', () => {
+      expect.assertions(6);
+      const clock = jest.useFakeTimers();
+      const { unmount } = render(<TestComponent {...stateMaster} />);
+
+      act(() => {
+        clock.runTimersToTime(masterStateUpdateTimeout - 1);
+      });
+
+      expect(dispatch).toHaveBeenCalledTimes(0);
+      act(() => {
+        clock.runTimersToTime(1);
+      });
+
+      expect(dispatch).toHaveBeenCalledTimes(1);
+      expect(dispatch).toHaveBeenCalledWith(stateSet());
+
+      dispatch.mockClear();
+      expect(dispatch).toHaveBeenCalledTimes(0);
+
+      act(() => {
+        clock.runTimersToTime(masterStateUpdateTimeout);
+      });
+
+      expect(dispatch).toHaveBeenCalledTimes(1);
+      expect(dispatch).toHaveBeenCalledWith(stateSet());
+
+      unmount();
+      jest.useRealTimers();
+    });
+  });
+
+  describe('when the client is a slave', () => {
+    const stateSlave: GlobalState = {
+      ...initialState,
+      initialised: true,
+      myClientName: 'a-slave-client',
+      clientList: [
+        { name: 'the-master-client', lastPing: 0 },
+        { name: 'a-slave-client', lastPing: 0 },
+      ],
+      player: {
+        ...nullPlayer,
+        master: 'the-master-client',
+      },
+    };
+
+    it('should not send state updates periodically', () => {
+      expect.assertions(1);
+      const clock = jest.useFakeTimers();
+      const { unmount } = render(<TestComponent {...stateSlave} />);
+
+      act(() => {
+        clock.runTimersToTime(masterStateUpdateTimeout);
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      unmount();
+      jest.useRealTimers();
+    });
+  });
+});

+ 46 - 0
gmus/src/hooks/master.ts

@@ -0,0 +1,46 @@
+import { Dispatch, useEffect, useRef } from 'react';
+
+import { LocalAction, masterRetaken, stateSet } from '../actions';
+import { masterStateUpdateTimeout } from '../constants/system';
+import { GlobalState } from '../reducer';
+import { isMaster } from '../selectors';
+
+export function useMaster(state: GlobalState, dispatch: Dispatch<LocalAction>): void {
+  const clientIsMaster = isMaster(state);
+
+  const masterUpdateTimer = useRef<number>(0);
+  useEffect(() => {
+    if (clientIsMaster) {
+      masterUpdateTimer.current = window.setInterval(() => {
+        dispatch(stateSet());
+      }, masterStateUpdateTimeout);
+    }
+
+    return (): void => {
+      window.clearInterval(masterUpdateTimer.current);
+    };
+  }, [dispatch, clientIsMaster]);
+
+  const shouldInitMaster = !state.player.master && state.initialised;
+  useEffect(() => {
+    if (shouldInitMaster) {
+      dispatch(stateSet({ master: state.myClientName }));
+    }
+  }, [dispatch, shouldInitMaster, state.myClientName]);
+
+  const masterWentAway =
+    state.initialised && !state.clientList.some(({ name }) => name === state.player.master);
+  const retakeControlTimer = useRef<number>(0);
+
+  useEffect(() => {
+    if (masterWentAway) {
+      retakeControlTimer.current = window.setTimeout(() => {
+        dispatch(masterRetaken());
+      }, Math.floor(Math.random() * 1000));
+    }
+
+    return (): void => {
+      clearTimeout(retakeControlTimer.current);
+    };
+  }, [dispatch, masterWentAway, state.myClientName]);
+}

+ 72 - 0
gmus/src/hooks/request.ts

@@ -0,0 +1,72 @@
+import axios, { Canceler, AxiosInstance, AxiosResponse } from 'axios';
+import { useEffect, useRef, useState } from 'react';
+
+type Options<Query, Response> = {
+  query: Query;
+  pause?: boolean;
+  sendRequest: (axiosInstance: AxiosInstance, query: Query) => Promise<AxiosResponse<Response>>;
+  handleResponse: (res: Response, query: Query) => void;
+  onError?: (err: Error) => void;
+  onClear?: () => void;
+  debounceDelay?: number;
+};
+
+export function useCancellableRequest<Query, Response = void>({
+  query,
+  pause,
+  sendRequest,
+  handleResponse,
+  onError,
+  onClear,
+}: Options<Query, Response>): boolean {
+  const [loading, setLoading] = useState<boolean>(false);
+
+  const cancelRequest = useRef<Canceler>();
+
+  useEffect(() => {
+    setLoading(!!query);
+    if (!query) {
+      onClear?.();
+    }
+  }, [query, onClear]);
+
+  useEffect(() => {
+    let cancelled = false;
+    const request = async (): Promise<void> => {
+      try {
+        const axiosWithToken = axios.create({
+          cancelToken: new axios.CancelToken((token): void => {
+            cancelRequest.current = token;
+          }),
+        });
+        const res = await sendRequest(axiosWithToken, query);
+        if (cancelled) {
+          return;
+        }
+
+        handleResponse(res.data, query);
+      } catch (err) {
+        if (!axios.isCancel(err)) {
+          onError?.(err);
+        }
+      } finally {
+        if (!cancelled) {
+          setLoading(false);
+        }
+      }
+    };
+
+    if (!pause) {
+      request();
+    }
+
+    return (): void => {
+      cancelled = true;
+      if (cancelRequest.current) {
+        cancelRequest.current();
+      }
+    };
+  }, [sendRequest, handleResponse, onError, query, pause]);
+
+  return loading;
+}

+ 47 - 0
gmus/src/hooks/vim.spec.tsx

@@ -0,0 +1,47 @@
+import { act, fireEvent, render } from '@testing-library/react';
+import React, { Dispatch } from 'react';
+
+import { ActionKeyPressed, ActionTypeKeyPressed, useVimBindings } from './vim';
+
+describe(useVimBindings.name, () => {
+  const TestComponent: React.FC<{ dispatch: Dispatch<ActionKeyPressed> }> = ({ dispatch }) => {
+    useVimBindings(dispatch);
+    return null;
+  };
+
+  const dispatch = jest.fn();
+
+  describe('when a key is pressed', () => {
+    it('should dispatch a KeyPress action', () => {
+      expect.assertions(2);
+      render(<TestComponent dispatch={dispatch} />);
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        fireEvent.keyDown(window, {
+          key: 'Tab',
+        });
+      });
+
+      expect(dispatch).toHaveBeenCalledWith({ type: ActionTypeKeyPressed, key: 'Tab' });
+    });
+  });
+
+  describe('when the key is unhandled', () => {
+    it('should not dispatch anything', () => {
+      expect.assertions(2);
+      render(<TestComponent dispatch={dispatch} />);
+
+      expect(dispatch).not.toHaveBeenCalled();
+
+      act(() => {
+        fireEvent.keyDown(window, {
+          key: '@',
+        });
+      });
+
+      expect(dispatch).not.toHaveBeenCalled();
+    });
+  });
+});

+ 39 - 0
gmus/src/hooks/vim.ts

@@ -0,0 +1,39 @@
+import { Dispatch, useEffect } from 'react';
+
+export const Keys = {
+  tab: 'Tab',
+  enter: 'Enter',
+  space: ' ',
+  '1': '1',
+  J: 'j',
+  K: 'k',
+};
+
+const availableKeys = Object.values(Keys);
+
+export const ActionTypeKeyPressed = '@@vim/KEY_PRESSED';
+
+export type ActionKeyPressed = {
+  type: typeof ActionTypeKeyPressed;
+  key: string;
+};
+
+export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>): void {
+  useEffect(() => {
+    const listener = (event: KeyboardEvent): void => {
+      if (!availableKeys.includes(event.key)) {
+        return;
+      }
+
+      event.preventDefault();
+
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: event.key };
+      dispatch(action);
+    };
+
+    window.addEventListener('keydown', listener);
+    return (): void => {
+      window.removeEventListener('keydown', listener);
+    };
+  }, [dispatch]);
+}

+ 3 - 0
gmus/src/index.tsx

@@ -1,10 +1,13 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { Reset } from 'styled-reset';
+
 import { Root } from './components/root';
 import reportWebVitals from './reportWebVitals';
 
 ReactDOM.render(
   <React.StrictMode>
+    <Reset />
     <Root />
   </React.StrictMode>,
   document.getElementById('root'),

+ 0 - 6
gmus/src/reducer/index.ts

@@ -1,8 +1,2 @@
-import { GlobalState } from './types';
-
 export * from './reducer';
 export * from './types';
-
-export function init(state: GlobalState): GlobalState {
-  return state;
-}

+ 52 - 0
gmus/src/reducer/reducer.spec.ts

@@ -4,6 +4,7 @@ import {
   ActionStateSetRemote,
   ActionTypeLocal,
   ActionTypeRemote,
+  masterRetaken,
   nameSet,
   seeked,
   stateSet,
@@ -14,6 +15,23 @@ import { GlobalState } from './types';
 
 describe(globalReducer.name, () => {
   describe(ActionTypeRemote.StateSet, () => {
+    it('should initialise the state', () => {
+      expect.assertions(1);
+      const state: GlobalState = { ...initialState, initialised: false };
+      const result = globalReducer(state, {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          songId: null,
+          playing: false,
+          currentTime: 0,
+          seekTime: -1,
+          master: 'some-master-client',
+        },
+      });
+
+      expect(result.initialised).toBe(true);
+    });
+
     describe('when the client is master', () => {
       const stateMaster: GlobalState = {
         ...initialState,
@@ -347,4 +365,38 @@ describe(globalReducer.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.MasterRetaken, () => {
+    const action = masterRetaken();
+
+    const stateBefore: GlobalState = {
+      ...initialState,
+      myClientName: 'my-client',
+      player: {
+        songId: 174,
+        playing: true,
+        master: 'some-master-client',
+        currentTime: 13,
+        seekTime: -1,
+      },
+    };
+
+    it('should set the master player to the current client', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.master).toBe('my-client');
+    });
+
+    it('should seek to the current time', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.seekTime).toBe(13);
+    });
+
+    it('should pause the client', () => {
+      expect.assertions(1);
+      const result = globalReducer(stateBefore, action);
+      expect(result.player.playing).toBe(false);
+    });
+  });
 });

+ 13 - 1
gmus/src/reducer/reducer.ts

@@ -18,6 +18,7 @@ export const nullPlayer: MusicPlayer = {
 };
 
 export const initialState: GlobalState = {
+  initialised: false,
   player: nullPlayer,
   clientList: [],
   myClientName: '',
@@ -33,7 +34,7 @@ function onRemoteStateSet(state: GlobalState, action: ActionStateSetRemote): Glo
 
   const nextPlayerWithSeekTime: MusicPlayer = { ...nextPlayer, seekTime };
 
-  return { ...state, player: nextPlayerWithSeekTime };
+  return { ...state, initialised: true, player: nextPlayerWithSeekTime };
 }
 
 function onLocalStateSet(state: GlobalState, action: ActionStateSetLocal): GlobalState {
@@ -76,6 +77,17 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       }
       return { ...state, player: { ...state.player, seekTime: action.payload } };
 
+    case ActionTypeLocal.MasterRetaken:
+      return {
+        ...state,
+        player: {
+          ...state.player,
+          master: state.myClientName,
+          seekTime: state.player.currentTime,
+          playing: false,
+        },
+      };
+
     default:
       return state;
   }

+ 1 - 0
gmus/src/reducer/types.ts

@@ -1,6 +1,7 @@
 import { Member, MusicPlayer } from '../types/state';
 
 export type GlobalState = {
+  initialised: boolean;
   player: MusicPlayer;
   clientList: Member[];
   myClientName: string;

+ 123 - 0
gmus/src/selectors.spec.ts

@@ -0,0 +1,123 @@
+import { ActionTypeRemote, stateSet } from './actions';
+import { GlobalState, initialState } from './reducer';
+import { isFromOurselves, isMaster, willBeMaster } from './selectors';
+
+describe(isMaster.name, () => {
+  describe('when the master player is the current client', () => {
+    it('should return true', () => {
+      expect.assertions(1);
+      expect(
+        isMaster({
+          player: { ...initialState.player, master: 'my-client-name' },
+          myClientName: 'my-client-name',
+        }),
+      ).toBe(true);
+    });
+  });
+
+  describe('when the master player is not the current client', () => {
+    it('should return false', () => {
+      expect.assertions(1);
+      expect(
+        isMaster({
+          player: { ...initialState.player, master: 'other-client-name' },
+          myClientName: 'my-client-name',
+        }),
+      ).toBe(false);
+    });
+  });
+
+  describe('when there is no master player', () => {
+    it('should return false', () => {
+      expect.assertions(1);
+      expect(
+        isMaster({
+          player: { ...initialState.player, master: '' },
+          myClientName: 'my-client-name',
+        }),
+      ).toBe(false);
+    });
+  });
+});
+
+describe(isFromOurselves.name, () => {
+  describe('when an action was dispatched from the current client', () => {
+    it('should return true', () => {
+      expect.assertions(1);
+      expect(
+        isFromOurselves(
+          { myClientName: 'my-client-name' },
+          {
+            type: ActionTypeRemote.StateSet,
+            fromClient: 'my-client-name',
+            payload: {} as GlobalState,
+          },
+        ),
+      ).toBe(true);
+    });
+  });
+
+  describe('when an action was dispatched from a different client', () => {
+    it('should return false', () => {
+      expect.assertions(1);
+      expect(
+        isFromOurselves(
+          { myClientName: 'my-client-name' },
+          {
+            type: ActionTypeRemote.StateSet,
+            fromClient: 'some-other-client-name',
+            payload: {} as GlobalState,
+          },
+        ),
+      ).toBe(false);
+    });
+  });
+
+  describe('when an action was not dispatched from a client', () => {
+    it('should return false', () => {
+      expect.assertions(1);
+      expect(
+        isFromOurselves(
+          { myClientName: 'my-client-name' },
+          {
+            type: ActionTypeRemote.StateSet,
+            fromClient: null,
+            payload: {} as GlobalState,
+          },
+        ),
+      ).toBe(false);
+    });
+  });
+});
+
+describe(willBeMaster.name, () => {
+  describe('when the action will cause the current client to be master', () => {
+    it('should return true', () => {
+      expect.assertions(1);
+      expect(
+        willBeMaster({ myClientName: 'a-slave-client' }, stateSet({ master: 'a-slave-client' })),
+      ).toBe(true);
+    });
+  });
+
+  describe('when the action will not change the master', () => {
+    it('should return false', () => {
+      expect.assertions(1);
+      expect(willBeMaster({ myClientName: 'a-slave-client' }, stateSet({ seekTime: 123 }))).toBe(
+        false,
+      );
+    });
+
+    describe('but the current client is already master', () => {
+      it('should still return false', () => {
+        expect.assertions(1);
+        expect(
+          willBeMaster(
+            { myClientName: 'a-client', player: { ...initialState.player, master: 'a-client' } },
+            stateSet({ seekTime: 123 }),
+          ),
+        ).toBe(false);
+      });
+    });
+  });
+});

+ 9 - 4
gmus/src/selectors.ts

@@ -1,12 +1,17 @@
 import { ActionRemote, ActionStateSetLocal, ActionStateSetRemote } from './actions';
 import { GlobalState } from './reducer/types';
 
-export const isMaster = (state: GlobalState): boolean => state.player.master === state.myClientName;
+export const isMaster = (state: Pick<GlobalState, 'player' | 'myClientName'>): boolean =>
+  state.player.master === state.myClientName;
 
-export const isFromOurselves = (state: GlobalState, action: ActionRemote): boolean =>
-  state.myClientName === action.fromClient;
+export const isFromOurselves = (
+  state: Pick<GlobalState, 'myClientName'>,
+  action: ActionRemote,
+): boolean => state.myClientName === action.fromClient;
 
 export const willBeMaster = (
-  state: GlobalState,
+  state: Partial<GlobalState> & Pick<GlobalState, 'myClientName'>,
   action: ActionStateSetLocal | ActionStateSetRemote,
 ): boolean => state.myClientName === action.payload?.master;
+
+export const getSongId = (state: Pick<GlobalState, 'player'>): number | null => state.player.songId;

+ 10 - 0
gmus/src/setupTests.ts

@@ -3,7 +3,17 @@
 // expect(element).toHaveTextContent(/react/i)
 // learn more: https://github.com/testing-library/jest-dom
 import '@testing-library/jest-dom';
+import nock from 'nock';
 
 beforeEach(() => {
   jest.restoreAllMocks();
 });
+
+beforeAll(() => {
+  nock.disableNetConnect();
+  nock.enableNetConnect('http://my-api.url:1234');
+});
+
+afterAll(() => {
+  nock.enableNetConnect();
+});

+ 2 - 0
gmus/src/types/index.ts

@@ -0,0 +1,2 @@
+export * from './songs';
+export * from './state';

+ 8 - 0
gmus/src/types/songs.ts

@@ -0,0 +1,8 @@
+export type Song = {
+  id: number;
+  track: number | null;
+  title: string;
+  artist: string;
+  album: string;
+  time: number;
+};

+ 7 - 0
gmus/src/utils/delta.ts

@@ -0,0 +1,7 @@
+export function scrollThroughItems<T>(
+  items: T[],
+  predicate: (item: T) => boolean,
+  delta: number,
+): T {
+  return items[Math.max(0, Math.min(items.length - 1, items.findIndex(predicate) + delta))];
+}

+ 3 - 0
gmus/src/utils/state.ts

@@ -0,0 +1,3 @@
+export function init<S>(state: S): S {
+  return state;
+}

+ 19 - 0
gmus/src/utils/time.spec.ts

@@ -0,0 +1,19 @@
+import { formatTime } from './time';
+
+describe(formatTime.name, () => {
+  it.each`
+    case                       | input     | output
+    ${'zero'}                  | ${0}      | ${'00:00'}
+    ${null}                    | ${null}   | ${''}
+    ${'less than ten seconds'} | ${7}      | ${'00:07'}
+    ${'less than one minute'}  | ${26}     | ${'00:26'}
+    ${'less than 10 minutes'}  | ${593}    | ${'09:53'}
+    ${'less than one hour'}    | ${3176}   | ${'52:56'}
+    ${'more than one hour'}    | ${3615}   | ${'1:00:15'}
+    ${'more than one day'}     | ${86465}  | ${'1 day, 01:05'}
+    ${'negative values'}       | ${-86465} | ${'-1 day, 01:05'}
+  `('should handle case: $case', ({ input, output }) => {
+    expect.assertions(1);
+    expect(formatTime(input)).toBe(output);
+  });
+});

+ 35 - 0
gmus/src/utils/time.ts

@@ -0,0 +1,35 @@
+import pluralize from 'pluralize';
+
+export function formatTime(seconds: number | null): string {
+  if (seconds == null) {
+    return '';
+  }
+
+  const totalSecondsAbsolute = Math.abs(seconds);
+  const sign = seconds < 0 ? '-' : '';
+
+  const hours = Math.floor(totalSecondsAbsolute / 3600);
+  const afterHours = totalSecondsAbsolute % 3600;
+
+  const minutes = Math.floor(afterHours / 60);
+  const remainingSeconds = afterHours % 60;
+
+  const minutesSeconds = `${minutes
+    .toFixed()
+    .padStart(2, '0')}:${remainingSeconds.toFixed().padStart(2, '0')}`;
+
+  if (!hours) {
+    return `${sign}${minutesSeconds}`;
+  }
+
+  const days = Math.floor(hours / 24);
+  const remainingHours = hours % 24;
+
+  const time = remainingHours ? `${remainingHours.toFixed()}:${minutesSeconds}` : minutesSeconds;
+
+  if (!days) {
+    return `${sign}${time}`;
+  }
+
+  return `${sign}${pluralize('day', days, true)}, ${time}`;
+}

+ 5 - 1
gmus/src/utils/url.ts

@@ -1,5 +1,9 @@
+export function getApiUrl(): string {
+  return process.env.REACT_APP_API_URL ?? 'http://localhost:3000';
+}
+
 export function getPubsubUrl(): string {
-  const apiUrl = new URL(process.env.REACT_APP_API_URL ?? 'http://localhost:3000');
+  const apiUrl = new URL(getApiUrl());
   return `${apiUrl.protocol === 'https:' ? 'wss' : 'ws'}://${apiUrl.hostname}${
     apiUrl.port ? `:${apiUrl.port}` : ''
   }/pubsub`;

+ 215 - 5
gmus/yarn.lock

@@ -67,6 +67,22 @@
     jsesc "^2.5.1"
     source-map "^0.5.0"
 
+"@babel/generator@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.10.tgz#2b188fc329fb8e4f762181703beffc0fe6df3460"
+  integrity sha512-6mCdfhWgmqLdtTkhXjnIz0LcdVCd26wS2JXRtj2XY0u5klDsXBREA/pG5NVOuVnF2LUrBGNFtQkIqqTbblg0ww==
+  dependencies:
+    "@babel/types" "^7.12.10"
+    jsesc "^2.5.1"
+    source-map "^0.5.0"
+
+"@babel/helper-annotate-as-pure@^7.0.0":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d"
+  integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ==
+  dependencies:
+    "@babel/types" "^7.12.10"
+
 "@babel/helper-annotate-as-pure@^7.10.4":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
@@ -291,6 +307,11 @@
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056"
   integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg==
 
+"@babel/parser@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81"
+  integrity sha512-PJdRPwyoOqFAWfLytxrWwGrAxghCgh/yTNCYciOz8QgjflA7aZhECPZAa2VUedKg2+QMWkI0L9lynh2SNmNEgA==
+
 "@babel/plugin-proposal-async-generator-functions@^7.12.1":
   version "7.12.1"
   resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e"
@@ -1116,6 +1137,21 @@
     globals "^11.1.0"
     lodash "^4.17.19"
 
+"@babel/traverse@^7.4.5":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.10.tgz#2d1f4041e8bf42ea099e5b2dc48d6a594c00017a"
+  integrity sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.12.10"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.11.0"
+    "@babel/parser" "^7.12.10"
+    "@babel/types" "^7.12.10"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.19"
+
 "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
   version "7.12.7"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13"
@@ -1125,6 +1161,15 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@babel/types@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260"
+  integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
+    lodash "^4.17.19"
+    to-fast-properties "^2.0.0"
+
 "@bcoe/v8-coverage@^0.2.3":
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1184,6 +1229,13 @@
   resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
   integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
 
+"@emotion/is-prop-valid@^0.8.8":
+  version "0.8.8"
+  resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
+  integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
+  dependencies:
+    "@emotion/memoize" "0.7.4"
+
 "@emotion/memoize@0.7.4":
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
@@ -1205,12 +1257,12 @@
   resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5"
   integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
 
-"@emotion/stylis@0.8.5":
+"@emotion/stylis@0.8.5", "@emotion/stylis@^0.8.4":
   version "0.8.5"
   resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
   integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
 
-"@emotion/unitless@0.7.5":
+"@emotion/unitless@0.7.5", "@emotion/unitless@^0.7.4":
   version "0.7.5"
   resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
   integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
@@ -1500,6 +1552,13 @@
     schema-utils "^2.6.5"
     source-map "^0.7.3"
 
+"@react-hook/debounce@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@react-hook/debounce/-/debounce-3.0.0.tgz#9eea8b5d81d4cb67cd72dd8657b3ff724afc7cad"
+  integrity sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==
+  dependencies:
+    "@react-hook/latest" "^1.0.2"
+
 "@react-hook/latest@^1.0.2":
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
@@ -1784,6 +1843,14 @@
   dependencies:
     "@types/node" "*"
 
+"@types/hoist-non-react-statics@*":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
+  integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
+  dependencies:
+    "@types/react" "*"
+    hoist-non-react-statics "^3.3.0"
+
 "@types/html-minifier-terser@^5.0.0":
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50"
@@ -1831,6 +1898,13 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
+"@types/nock@^11.1.0":
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538"
+  integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw==
+  dependencies:
+    nock "*"
+
 "@types/node@*":
   version "14.14.9"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.9.tgz#04afc9a25c6ff93da14deabd65dc44485b53c8d6"
@@ -1851,6 +1925,11 @@
   resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
   integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
 
+"@types/pluralize@^0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c"
+  integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==
+
 "@types/prettier@^2.0.0":
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.5.tgz#b6ab3bba29e16b821d84e09ecfaded462b816b00"
@@ -1873,6 +1952,21 @@
   dependencies:
     "@types/react" "^16"
 
+"@types/react-native@*":
+  version "0.63.40"
+  resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.63.40.tgz#66b11824a6aadb0dde0db87bd8aff58165c7a8f8"
+  integrity sha512-y98TQBjfncIrdDrwIhxcmcad1gHKNfUKFnCBk3heYy0Gvt6BNcBeSKRvACV4cf6R1K/cNJMoS3rjaMvYhQyAgw==
+  dependencies:
+    "@types/react" "*"
+
+"@types/react@*":
+  version "17.0.0"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8"
+  integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==
+  dependencies:
+    "@types/prop-types" "*"
+    csstype "^3.0.2"
+
 "@types/react@^16", "@types/react@^16.9.53":
   version "16.14.2"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.2.tgz#85dcc0947d0645349923c04ccef6018a1ab7538c"
@@ -1898,6 +1992,16 @@
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
   integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
 
+"@types/styled-components@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.5.tgz#a98e2ead1a1c7660b966f3aade4df9c0ec8ed75a"
+  integrity sha512-VWwoKm39W783a75oILtdCjnNn+FSV8Yh3O9naIudtZCAkk+rRfwLWzuPjf5k/OilENgZqAkxJ9sukSOZTAD86g==
+  dependencies:
+    "@types/hoist-non-react-statics" "*"
+    "@types/react" "*"
+    "@types/react-native" "*"
+    csstype "^3.0.2"
+
 "@types/tapable@*", "@types/tapable@^1.0.5":
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74"
@@ -2581,6 +2685,13 @@ axe-core@^4.0.2:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.1.tgz#70a7855888e287f7add66002211a423937063eaf"
   integrity sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==
 
+axios@^0.21.0:
+  version "0.21.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.0.tgz#26df088803a2350dff2c27f96fef99fe49442aca"
+  integrity sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==
+  dependencies:
+    follow-redirects "^1.10.0"
+
 axobject-query@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -2688,6 +2799,16 @@ babel-plugin-named-asset-import@^0.3.7:
   resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.7.tgz#156cd55d3f1228a5765774340937afc8398067dd"
   integrity sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw==
 
+"babel-plugin-styled-components@>= 1":
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.12.0.tgz#1dec1676512177de6b827211e9eda5a30db4f9b9"
+  integrity sha512-FEiD7l5ZABdJPpLssKXjBUJMYqzbcNzBowfXDCdJhOpbhWiewapUaY+LZGT8R4Jg2TwOjGjG4RKeyrO5p9sBkA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.0.0"
+    "@babel/helper-module-imports" "^7.0.0"
+    babel-plugin-syntax-jsx "^6.18.0"
+    lodash "^4.17.11"
+
 babel-plugin-syntax-jsx@^6.18.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
@@ -3169,6 +3290,11 @@ camelcase@^6.0.0, camelcase@^6.1.0, camelcase@^6.2.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
+camelize@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
+  integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=
+
 caniuse-api@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@@ -3716,6 +3842,11 @@ css-blank-pseudo@^0.1.4:
   dependencies:
     postcss "^7.0.5"
 
+css-color-keywords@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
+  integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=
+
 css-color-names@0.0.4, css-color-names@^0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@@ -3787,6 +3918,15 @@ css-select@^2.0.0:
     domutils "^1.7.0"
     nth-check "^1.0.2"
 
+css-to-react-native@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.0.0.tgz#62dbe678072a824a689bcfee011fc96e02a7d756"
+  integrity sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==
+  dependencies:
+    camelize "^1.0.0"
+    css-color-keywords "^1.0.0"
+    postcss-value-parser "^4.0.2"
+
 css-tree@1.0.0-alpha.37:
   version "1.0.0-alpha.37"
   resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22"
@@ -5178,6 +5318,11 @@ follow-redirects@^1.0.0:
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
   integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
 
+follow-redirects@^1.10.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
+  integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==
+
 for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -5576,6 +5721,13 @@ hmac-drbg@^1.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.1"
 
+hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
+  integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
+  dependencies:
+    react-is "^16.7.0"
+
 hoopy@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
@@ -6836,7 +6988,7 @@ json-stable-stringify-without-jsonify@^1.0.1:
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
-json-stringify-safe@~5.0.1:
+json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
   integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
@@ -7049,6 +7201,11 @@ lodash.memoize@^4.1.2:
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
   integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
 
+lodash.set@^4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
+  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
+
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -7518,6 +7675,16 @@ no-case@^3.0.3:
     lower-case "^2.0.1"
     tslib "^1.10.0"
 
+nock@*, nock@^13.0.5:
+  version "13.0.5"
+  resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414"
+  integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg==
+  dependencies:
+    debug "^4.1.0"
+    json-stringify-safe "^5.0.1"
+    lodash.set "^4.3.2"
+    propagate "^2.0.0"
+
 node-forge@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@@ -8149,6 +8316,11 @@ pkg-up@3.1.0:
   dependencies:
     find-up "^3.0.0"
 
+pluralize@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
+  integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
+
 pnp-webpack-plugin@1.6.4:
   version "1.6.4"
   resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
@@ -8156,6 +8328,13 @@ pnp-webpack-plugin@1.6.4:
   dependencies:
     ts-pnp "^1.1.6"
 
+polished@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/polished/-/polished-4.0.5.tgz#3f91873c8f72dec1723b3f892f57fbb22645b23d"
+  integrity sha512-BY2+LVtOHQWBQpGN4GPAKpCdsBePOdSdHTpZegRDRCrvGPkRPTx1DEC+vGjIDPhXS7W2qiBxschnwRWTFdMZag==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+
 portfinder@^1.0.26:
   version "1.0.28"
   resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
@@ -8936,6 +9115,11 @@ prop-types@^15.7.2:
     object-assign "^4.1.1"
     react-is "^16.8.1"
 
+propagate@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
+  integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
+
 proxy-addr@~2.0.5:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
@@ -9137,7 +9321,7 @@ react-error-overlay@^6.0.8:
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de"
   integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw==
 
-react-is@^16.8.1:
+react-is@^16.7.0, react-is@^16.8.1:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -9912,6 +10096,11 @@ shallow-clone@^3.0.0:
   dependencies:
     kind-of "^6.0.2"
 
+shallowequal@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+  integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -10414,6 +10603,27 @@ style-loader@1.3.0:
     loader-utils "^2.0.0"
     schema-utils "^2.7.0"
 
+styled-components@^5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.2.1.tgz#6ed7fad2dc233825f64c719ffbdedd84ad79101a"
+  integrity sha512-sBdgLWrCFTKtmZm/9x7jkIabjFNVzCUeKfoQsM6R3saImkUnjx0QYdLwJHBjY9ifEcmjDamJDVfknWm1yxZPxQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.0.0"
+    "@babel/traverse" "^7.4.5"
+    "@emotion/is-prop-valid" "^0.8.8"
+    "@emotion/stylis" "^0.8.4"
+    "@emotion/unitless" "^0.7.4"
+    babel-plugin-styled-components ">= 1"
+    css-to-react-native "^3.0.0"
+    hoist-non-react-statics "^3.0.0"
+    shallowequal "^1.1.0"
+    supports-color "^5.5.0"
+
+styled-reset@^4.3.3:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/styled-reset/-/styled-reset-4.3.3.tgz#51b55fceceaa3569068a48cfce44f717910fea7a"
+  integrity sha512-vjkkcWi1I7Nl7xS80+JO96egcUe1v/8r6nv76GnHNs43IjeoBH30W8jsqwFPZ54Ifv3Ncy9ux1cuwBax0yCfVA==
+
 stylehacks@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5"
@@ -10423,7 +10633,7 @@ stylehacks@^4.0.0:
     postcss "^7.0.0"
     postcss-selector-parser "^3.0.0"
 
-supports-color@^5.3.0:
+supports-color@^5.3.0, supports-color@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==

+ 1 - 0
music-player/go.mod

@@ -18,6 +18,7 @@ require (
 	github.com/nxadm/tail v1.4.5 // indirect
 	github.com/onsi/ginkgo v1.14.2
 	github.com/onsi/gomega v1.10.1
+	github.com/rs/cors v1.7.0
 	github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc // indirect
 	golang.org/x/sys v0.0.0-20201214095126-aec9a390925b // indirect
 )

+ 2 - 0
music-player/go.sum

@@ -289,6 +289,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
+github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
 github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

+ 9 - 0
music-player/pkg/read/types.go

@@ -11,6 +11,15 @@ type Song struct {
   ModifiedDate int64 	`db:"modified_date"` 
 }
 
+type SongExternal struct {
+  Id int 		`db:"id" json:"id"`
+  TrackNumber int 	`db:"track_number" json:"track"`
+  Title string 		`db:"title" json:"title"`
+  Artist string 	`db:"artist" json:"artist"`
+  Album string 		`db:"album" json:"album"`
+  Duration int 		`db:"duration" json:"time"`
+}
+
 type File struct {
   RelativePath string 	`db:"relative_path"`
   ModifiedDate int64 	`db:"modified_date"`

+ 0 - 122
music-player/pkg/repository/scan.go

@@ -1,122 +0,0 @@
-package repository
-
-import (
-	"github.com/felamaslen/go-music-player/pkg/config"
-	"github.com/felamaslen/go-music-player/pkg/database"
-	"github.com/felamaslen/go-music-player/pkg/logger"
-	"github.com/felamaslen/go-music-player/pkg/read"
-	"github.com/lib/pq"
-)
-
-const BATCH_SIZE = 100
-const LOG_EVERY = 100;
-
-func InsertMusicIntoDatabase(songs chan *read.Song) {
-  var l = logger.CreateLogger(config.GetConfig().LogLevel)
-
-  db := database.GetConnection()
-
-  var batch [BATCH_SIZE]*read.Song
-  var batchSize = 0
-  var numAdded = 0
-
-  var processBatch = func() {
-    if batchSize == 0 {
-      return
-    }
-
-    l.Debug("[INSERT] Processing batch\n")
-
-    var trackNumbers pq.Int64Array
-    var titles pq.StringArray
-    var artists pq.StringArray
-    var albums pq.StringArray
-    var durations pq.Int64Array
-
-    var modifiedDates pq.Int64Array
-
-    var basePaths pq.StringArray
-    var relativePaths pq.StringArray
-
-    for i := 0; i < batchSize; i++ {
-      trackNumbers = append(trackNumbers, int64(batch[i].TrackNumber))
-      titles = append(titles, batch[i].Title)
-      artists = append(artists, batch[i].Artist)
-      albums = append(albums, batch[i].Album)
-      durations = append(durations, int64(batch[i].Duration))
-
-      modifiedDates = append(modifiedDates, batch[i].ModifiedDate)
-
-      basePaths = append(basePaths, batch[i].BasePath)
-      relativePaths = append(relativePaths, batch[i].RelativePath)
-    }
-
-    db.MustExec(
-      `
-      insert into songs (
-        track_number
-        ,title
-        ,artist
-        ,album
-        ,duration
-        ,modified_date
-        ,base_path
-        ,relative_path
-      )
-      select * from unnest(
-        $1::integer[]
-        ,$2::varchar[]
-        ,$3::varchar[]
-        ,$4::varchar[]
-        ,$5::integer[]
-        ,$6::bigint[]
-        ,$7::varchar[]
-        ,$8::varchar[]
-      )
-      on conflict (base_path, relative_path) do update
-      set
-        track_number = excluded.track_number
-        ,title = excluded.title
-        ,artist = excluded.artist
-        ,album = excluded.album
-        ,duration = excluded.duration
-        ,modified_date = excluded.modified_date
-      `,
-      trackNumbers,
-      titles,
-      artists,
-      albums,
-      durations,
-      modifiedDates,
-      basePaths,
-      relativePaths,
-    )
-
-    l.Debug("[INSERT] Processed batch\n")
-
-    batchSize = 0
-  }
-
-  for {
-    select {
-    case song, more := <- songs:
-      if !more {
-        processBatch()
-        l.Verbose("[INSERT] Finished inserting %d songs\n", numAdded)
-        return
-      }
-
-      batch[batchSize] = song
-      batchSize++
-
-      numAdded++
-      if numAdded % LOG_EVERY == 0 {
-        l.Verbose("[INSERT] Inserted %d\n", numAdded)
-      }
-
-      if batchSize >= BATCH_SIZE {
-        processBatch()
-      }
-    }
-  }
-}

+ 0 - 152
music-player/pkg/repository/scan_test.go

@@ -1,152 +0,0 @@
-package repository_test
-
-import (
-	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
-
-	"github.com/felamaslen/go-music-player/pkg/database"
-	"github.com/felamaslen/go-music-player/pkg/read"
-	"github.com/felamaslen/go-music-player/pkg/repository"
-	setup "github.com/felamaslen/go-music-player/pkg/testing"
-)
-
-var _ = Describe("scanning repository", func() {
-  db := database.GetConnection()
-
-  BeforeEach(func() {
-    setup.PrepareDatabaseForTesting()
-  })
-
-  Describe("when the channel sends two files", func() {
-    var songs chan *read.Song
-
-    var testInsertSongs = func() {
-      songs = make(chan *read.Song)
-
-      go func() {
-	defer close(songs)
-	songs <- &read.Song{
-	  TrackNumber: 7,
-	  Title: "Hey Jude",
-	  Artist: "The Beatles",
-	  Album: "",
-	  Duration: 431,
-	  BasePath: "/path/to",
-	  RelativePath: "file.ogg",
-	  ModifiedDate: 8876,
-	}
-
-	songs <- &read.Song{
-	  TrackNumber: 11,
-	  Title: "Starman",
-	  Artist: "David Bowie",
-	  Album: "The Rise and Fall of Ziggy Stardust and the Spiders from Mars",
-	  Duration: 256,
-	  BasePath: "/different/path",
-	  RelativePath: "otherFile.ogg",
-	  ModifiedDate: 11883,
-	}
-      }()
-
-      repository.InsertMusicIntoDatabase(songs)
-    }
-
-    Context("when the songs do not already exist in the database", func() {
-      BeforeEach(testInsertSongs)
-
-      It("should insert the correct number of songs", func() {
-	var count int
-	db.Get(&count, "select count(*) from songs")
-	Expect(count).To(Equal(2))
-      })
-
-      It("should insert both songs", func() {
-	var songs []read.Song
-
-	db.Select(&songs, `
-	select track_number, title, artist, album, duration, base_path, relative_path, modified_date
-	from songs
-	order by title
-	`)
-
-	Expect(songs[0]).To(Equal(read.Song{
-	  TrackNumber: 7,
-	  Title: "Hey Jude",
-	  Artist: "The Beatles",
-	  Album: "",
-	  Duration: 431,
-	  BasePath: "/path/to",
-	  RelativePath: "file.ogg",
-	  ModifiedDate: 8876,
-	}))
-
-	Expect(songs[1]).To(Equal(read.Song{
-	  TrackNumber: 11,
-	  Title: "Starman",
-	  Artist: "David Bowie",
-	  Album: "The Rise and Fall of Ziggy Stardust and the Spiders from Mars",
-	  Duration: 256,
-	  BasePath: "/different/path",
-	  RelativePath: "otherFile.ogg",
-	  ModifiedDate: 11883,
-	}))
-      })
-    })
-
-    Context("when there is already a file in the database with the same name", func() {
-      BeforeEach(func() {
-	db.MustExec(
-	  `
-	  insert into songs (title, artist, album, base_path, relative_path, modified_date)
-	  values ($1, $2, $3, $4, $5, $6)
-	  `,
-	  "my title",
-	  "my artist",
-	  "my album",
-	  "/path/to",
-	  "file.ogg",
-	  7782,
-	)
-
-	testInsertSongs()
-      })
-
-      It("should not add an additional row for the same file", func() {
-	var count int
-	db.Get(&count, `
-	select count(*) from songs
-	where base_path = '/path/to' and relative_path = 'file.ogg'
-	`)
-
-	Expect(count).To(Equal(1))
-      })
-
-      It("should upsert the existing item", func() {
-	var songs []read.Song
-	db.Select(&songs, `
-	select
-	  track_number
-	  ,title
-	  ,artist
-	  ,album
-	  ,duration
-	  ,base_path
-	  ,relative_path
-	  ,modified_date
-	from songs
-	where base_path = '/path/to' and relative_path = 'file.ogg'
-	`)
-
-	Expect(songs).To(HaveLen(1))
-	var song = songs[0]
-
-	Expect(song.TrackNumber).To(Equal(7))
-	Expect(song.Title).To(Equal("Hey Jude"))
-	Expect(song.Artist).To(Equal("The Beatles"))
-	Expect(song.Album).To(Equal(""))
-	Expect(song.Duration).To(Equal(431))
-	Expect(song.ModifiedDate).To(Equal(int64(8876)))
-      })
-    })
-  })
-})

+ 132 - 0
music-player/pkg/repository/songs.go

@@ -5,8 +5,11 @@ import (
 
 	"github.com/felamaslen/go-music-player/pkg/read"
 	"github.com/jmoiron/sqlx"
+	"github.com/lib/pq"
 )
 
+const BATCH_SIZE = 100
+
 func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
   var songs []*read.Song
 
@@ -32,3 +35,132 @@ func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
 
   return
 }
+
+func SelectPagedArtists(db *sqlx.DB, limit int, offset int) (artists *[]string, err error) {
+  artists = &[]string{}
+  err = db.Select(artists, `
+  select distinct artist
+  from songs
+  order by artist
+  limit $1
+  offset $2
+  `, limit, offset)
+  return
+}
+
+type CountRow struct {
+  Count int `db:"count"`
+}
+
+func SelectArtistCount(db *sqlx.DB) (count int, err error) {
+  var countRow CountRow
+
+  err = db.QueryRowx(`
+  select count(*) as count from (
+    select distinct artist from songs
+  ) distinct_artists
+  `).StructScan(&countRow)
+
+  count = countRow.Count
+
+  return
+}
+
+func SelectAlbumsByArtist(db *sqlx.DB, artist string) (albums *[]string, err error) {
+  albums = &[]string{}
+  err = db.Select(albums, `
+  select distinct album
+  from songs
+  where artist = $1
+  order by album
+  `, artist)
+
+  return
+}
+
+func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExternal, err error) {
+  songs = &[]*read.SongExternal{}
+  err = db.Select(songs, `
+  select
+    id
+    ,track_number
+    ,title
+    ,artist
+    ,album
+    ,duration
+  from songs
+  where artist = $1
+  order by track_number, title, album
+  `, artist)
+
+  return
+}
+
+func BatchUpsertSongs(db *sqlx.DB, batch *[BATCH_SIZE]*read.Song, batchSize int) error {
+  var trackNumbers pq.Int64Array
+  var titles pq.StringArray
+  var artists pq.StringArray
+  var albums pq.StringArray
+  var durations pq.Int64Array
+
+  var modifiedDates pq.Int64Array
+
+  var basePaths pq.StringArray
+  var relativePaths pq.StringArray
+
+  for i := 0; i < batchSize; i++ {
+    trackNumbers = append(trackNumbers, int64((*batch)[i].TrackNumber))
+    titles = append(titles, (*batch)[i].Title)
+    artists = append(artists, (*batch)[i].Artist)
+    albums = append(albums, (*batch)[i].Album)
+    durations = append(durations, int64((*batch)[i].Duration))
+
+    modifiedDates = append(modifiedDates, (*batch)[i].ModifiedDate)
+
+    basePaths = append(basePaths, (*batch)[i].BasePath)
+    relativePaths = append(relativePaths, (*batch)[i].RelativePath)
+  }
+
+  _, err := db.Exec(
+    `
+    insert into songs (
+      track_number
+      ,title
+      ,artist
+      ,album
+      ,duration
+      ,modified_date
+      ,base_path
+      ,relative_path
+    )
+    select * from unnest(
+      $1::integer[]
+      ,$2::varchar[]
+      ,$3::varchar[]
+      ,$4::varchar[]
+      ,$5::integer[]
+      ,$6::bigint[]
+      ,$7::varchar[]
+      ,$8::varchar[]
+    )
+    on conflict (base_path, relative_path) do update
+    set
+      track_number = excluded.track_number
+      ,title = excluded.title
+      ,artist = excluded.artist
+      ,album = excluded.album
+      ,duration = excluded.duration
+      ,modified_date = excluded.modified_date
+    `,
+    trackNumbers,
+    titles,
+    artists,
+    albums,
+    durations,
+    modifiedDates,
+    basePaths,
+    relativePaths,
+  )
+
+  return err
+}

+ 73 - 1
music-player/pkg/repository/songs_test.go

@@ -17,7 +17,7 @@ var _ = Describe("songs repository", func() {
     setup.PrepareDatabaseForTesting()
   })
 
-  Context("when reading", func() {
+  Describe("SelectSong", func() {
     var id int64
 
     BeforeEach(func() {
@@ -66,4 +66,76 @@ var _ = Describe("songs repository", func() {
       })
     })
   })
+
+  Describe("BatchUpsertSongs", func() {
+    songs := [100]*read.Song{
+      {
+	TrackNumber: 1,
+	Title: "Title A",
+	Artist: "Artist A",
+	Album: "Album A",
+	Duration: 123,
+	BasePath: "/base/path/1",
+	RelativePath: "song1.ogg",
+	ModifiedDate: 8886663103,
+      },
+      {
+	TrackNumber: 2,
+	Title: "Title B",
+	Artist: "Artist B",
+	Album: "Album B",
+	Duration: 456,
+	BasePath: "/base/path/2",
+	RelativePath: "song2.ogg",
+	ModifiedDate: 2711291992,
+      },
+    }
+
+    Context("when the songs do not already exist", func() {
+      BeforeEach(func() {
+	repository.BatchUpsertSongs(db, &songs, 2)
+      })
+
+      It("should insert the batch into the database", func() {
+	var result []*read.Song
+	db.Select(&result, `
+	select track_number, title, artist, album, duration, base_path, relative_path, modified_date
+	from songs
+	`)
+
+	Expect(result).To(HaveLen(2))
+	Expect(songs[0]).To(BeElementOf(result))
+	Expect(songs[1]).To(BeElementOf(result))
+      })
+    })
+
+    Context("when the songs already exist", func() {
+      var result []*read.Song
+      var modifiedBatch [100]*read.Song
+
+      modifiedBatch[0] = songs[0]
+      modifiedBatch[1] = songs[1]
+
+      modifiedBatch[0].Title = "Title A modified"
+
+      BeforeEach(func() {
+	repository.BatchUpsertSongs(db, &songs, 2)
+
+	repository.BatchUpsertSongs(db, &modifiedBatch, 2)
+
+	db.Select(&result, `
+	select track_number, title, artist, album, duration, base_path, relative_path, modified_date
+	from songs
+	`)
+      })
+
+      It("should not create any additional rows", func() {
+	Expect(result).To(HaveLen(2))
+      })
+
+      It("should update the rows with any changes", func() {
+	Expect(modifiedBatch[0]).To(BeElementOf(result))
+      })
+    })
+  })
 })

+ 147 - 0
music-player/pkg/server/fetch.go

@@ -0,0 +1,147 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/felamaslen/go-music-player/pkg/database"
+	"github.com/felamaslen/go-music-player/pkg/logger"
+	"github.com/felamaslen/go-music-player/pkg/read"
+	"github.com/felamaslen/go-music-player/pkg/repository"
+	"github.com/felamaslen/go-music-player/pkg/services"
+	"github.com/go-redis/redis/v7"
+)
+
+type ArtistsResponse struct {
+  Artists []string 	`json:"artists"`
+  More bool 		`json:"more"`
+}
+
+func routeFetchArtists(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+  limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
+  if err != nil {
+    http.Error(w, "Limit must be an integer", http.StatusBadRequest)
+    return nil
+  }
+  if limit < 1 || limit > 1000 {
+    http.Error(w, "Limit must be between 1 and 1000", http.StatusBadRequest)
+    return nil
+  }
+
+  page, err := strconv.Atoi(r.URL.Query().Get("page"))
+  if err != nil {
+    http.Error(w, "Page must be an integer", http.StatusBadRequest)
+    return nil
+  }
+  if page < 0 {
+    http.Error(w, "Page must be non-negative", http.StatusBadRequest)
+    return nil
+  }
+
+  artists, more := services.GetArtists(limit, page)
+
+  response, err := json.Marshal(ArtistsResponse{
+    Artists: *artists,
+    More: more,
+  })
+  if err != nil {
+    return err
+  }
+
+  w.Write(response)
+  return nil
+}
+
+type AlbumsResponse struct {
+  Artist string 	`json:"artist"`
+  Albums []string 	`json:"albums"`
+}
+
+func routeFetchAlbums(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+  artist := r.URL.Query().Get("artist")
+
+  db := database.GetConnection()
+
+  albums, err := repository.SelectAlbumsByArtist(db, artist)
+  if err != nil {
+    return err
+  }
+
+  response, err := json.Marshal(AlbumsResponse{
+    Artist: artist,
+    Albums: *albums,
+  })
+  if err != nil {
+    return err
+  }
+
+  w.Write(response)
+  return nil
+}
+
+type SongsResponse struct {
+  Artist string 		`json:"artist"`
+  Songs *[]*read.SongExternal 	`json:"songs"`
+}
+
+func routeFetchSongs(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+  artist := r.URL.Query().Get("artist")
+
+  db := database.GetConnection()
+
+  songs, err := repository.SelectSongsByArtist(db, artist)
+  if err != nil {
+    return err
+  }
+
+  response, err := json.Marshal(SongsResponse{
+    Artist: artist,
+    Songs: songs,
+  })
+  if err != nil {
+    return err
+  }
+
+  w.Write(response)
+  return nil
+}
+
+func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+  idRaw := r.URL.Query().Get("id")
+  idInt, err := strconv.Atoi(idRaw)
+  if err != nil {
+    http.Error(w, "Must provide a valid id", http.StatusBadRequest)
+    return nil
+  }
+  if idInt < 1 {
+    http.Error(w, "id must be non-negative", http.StatusBadRequest)
+    return nil
+  }
+
+  db := database.GetConnection()
+
+  song, err := repository.SelectSong(db, idInt)
+  if err != nil {
+    if err.Error() == "No such ID" {
+      http.Error(w, "Song not found", http.StatusNotFound)
+      return nil
+    }
+    return err
+  }
+
+  response, err := json.Marshal(read.SongExternal{
+    Id: idInt,
+    TrackNumber: song.TrackNumber,
+    Title: song.Title,
+    Artist: song.Artist,
+    Album: song.Album,
+    Duration: song.Duration,
+  })
+  if err != nil {
+    return err
+  }
+
+  w.Write(response)
+  return nil
+}

+ 1 - 2
music-player/pkg/server/handler.go

@@ -21,8 +21,7 @@ func routeHandler(
     if err != nil {
       l.Error("Unhandled error during request: %v\n", err)
 
-      w.WriteHeader(500)
-      w.Write([]byte("Unhandled error"))
+      http.Error(w, "Unhandled error", http.StatusInternalServerError)
     }
   }
 }

+ 10 - 1
music-player/pkg/server/server.go

@@ -9,6 +9,7 @@ import (
 	"github.com/felamaslen/go-music-player/pkg/logger"
 	"github.com/go-redis/redis/v7"
 	"github.com/gorilla/mux"
+	"github.com/rs/cors"
 )
 
 func StartServer() {
@@ -24,8 +25,16 @@ func StartServer() {
 
   router.Path("/stream").Methods("GET").HandlerFunc(routeHandler(l, rdb, streamSong))
 
+  router.Path("/artists").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchArtists))
+  router.Path("/albums").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchAlbums))
+  router.Path("/songs").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongs))
+
+  router.Path("/song-info").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongInfo))
+
   port := conf.Port
 
+  handler := cors.AllowAll().Handler(router)
+
   l.Info("Starting server on port %d\n", port)
-  log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), router))
+  log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), handler))
 }

+ 24 - 0
music-player/pkg/services/fetch.go

@@ -0,0 +1,24 @@
+package services
+
+import (
+	"github.com/felamaslen/go-music-player/pkg/database"
+	"github.com/felamaslen/go-music-player/pkg/repository"
+)
+
+func GetArtists(limit int, page int) (artists *[]string, more bool) {
+  db := database.GetConnection()
+
+  artists, err := repository.SelectPagedArtists(db, limit, limit * page)
+  if err != nil {
+    panic(err)
+  }
+
+  total, err := repository.SelectArtistCount(db)
+  if err != nil {
+    panic(err)
+  }
+
+  more = limit * (1 + page) < total
+
+  return
+}

+ 136 - 0
music-player/pkg/services/fetch_test.go

@@ -0,0 +1,136 @@
+package services_test
+
+import (
+	"fmt"
+
+	"github.com/lib/pq"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/felamaslen/go-music-player/pkg/database"
+	"github.com/felamaslen/go-music-player/pkg/services"
+	setup "github.com/felamaslen/go-music-player/pkg/testing"
+)
+
+var _ = Describe("Fetching data", func() {
+  db := database.GetConnection()
+
+  BeforeEach(func() {
+    setup.PrepareDatabaseForTesting()
+  })
+
+  Describe("getArtists", func() {
+    var insertArtists = func(artists []string) {
+      var trackNumbers = make([]int, len(artists))
+      var titles = make([]string, len(artists))
+      var albums = make([]string, len(artists))
+      var durations = make([]int, len(artists))
+      var basePaths = make([]string, len(artists))
+      var relativePaths = make([]string, len(artists))
+      var modifiedDates = make([]int, len(artists))
+
+      for i := 0; i < len(artists); i++ {
+	trackNumbers[i] = i + 1
+	titles[i] = fmt.Sprintf("Title %d", i + 1)
+	albums[i] = fmt.Sprintf("Album %d", i + 1)
+	durations[i] = 403 + i
+	basePaths[i] = "/music"
+	relativePaths[i] = fmt.Sprintf("file%d.ogg", i)
+	modifiedDates[i] = 177712347 + i
+      }
+
+      db.MustExec(
+	`
+	insert into songs (
+	  track_number
+	  ,title
+	  ,artist
+	  ,album
+	  ,duration
+	  ,modified_date
+	  ,base_path
+	  ,relative_path
+	)
+	select * from unnest(
+	  $1::integer[]
+	  ,$2::varchar[]
+	  ,$3::varchar[]
+	  ,$4::varchar[]
+	  ,$5::integer[]
+	  ,$6::bigint[]
+	  ,$7::varchar[]
+	  ,$8::varchar[]
+	)
+	`,
+	pq.Array(trackNumbers),
+	pq.Array(titles),
+	pq.Array(artists),
+	pq.Array(albums),
+	pq.Array(durations),
+	pq.Array(modifiedDates),
+	pq.Array(basePaths),
+	pq.Array(relativePaths),
+      )
+    }
+
+    Context("when there are no songs", func() {
+      It("should return an empty slice and set more to false", func() {
+	artists, more := services.GetArtists(100, 0)
+
+	Expect(*artists).To(HaveLen(0))
+	Expect(more).To(BeFalse())
+      })
+    })
+
+    Context("when there are no songs with artists", func() {
+      BeforeEach(func() {
+	insertArtists([]string{"", ""})
+      })
+
+      It("should return an empty string", func() {
+	artists, more := services.GetArtists(100, 0)
+
+	Expect(*artists).To(HaveLen(1))
+	Expect((*artists)[0]).To(Equal(""))
+	Expect(more).To(BeFalse())
+      })
+    })
+
+    Context("when there are fewer artists than the limit given", func() {
+      BeforeEach(func() {
+	insertArtists([]string{"Artist A", "Artist B", "Artist C", "Artist D"})
+      })
+
+      It("should return an ordered set matching the limit", func() {
+	artists, _ := services.GetArtists(3, 0)
+
+	Expect(*artists).To(HaveLen(3))
+
+	Expect((*artists)[0]).To(Equal("Artist A"))
+	Expect((*artists)[1]).To(Equal("Artist B"))
+	Expect((*artists)[2]).To(Equal("Artist C"))
+      })
+
+      It("should set more to true", func() {
+	_, more := services.GetArtists(3, 0)
+
+	Expect(more).To(BeTrue())
+      })
+
+      Context("when paging", func() {
+	It("should return the next set of results", func() {
+	  artists, _ := services.GetArtists(3, 1)
+
+	  Expect(*artists).To(HaveLen(1))
+	  Expect((*artists)[0]).To(Equal("Artist D"))
+	})
+
+	It("should set more to false at the end", func() {
+	  _, more := services.GetArtists(3, 1)
+
+	  Expect(more).To(BeFalse())
+	})
+      })
+    })
+  })
+})

+ 53 - 2
music-player/pkg/services/scanner.go

@@ -2,11 +2,63 @@ package services
 
 import (
 	"github.com/felamaslen/go-music-player/pkg/config"
+	"github.com/felamaslen/go-music-player/pkg/database"
 	"github.com/felamaslen/go-music-player/pkg/logger"
 	"github.com/felamaslen/go-music-player/pkg/read"
 	"github.com/felamaslen/go-music-player/pkg/repository"
 )
 
+const LOG_EVERY = 100;
+
+const BATCH_SIZE = 100
+
+func UpsertSongsFromChannel(songs chan *read.Song) {
+  var l = logger.CreateLogger(config.GetConfig().LogLevel)
+
+  db := database.GetConnection()
+
+  var batch [BATCH_SIZE]*read.Song
+  var batchSize = 0
+  var numAdded = 0
+
+  var processBatch = func() {
+    if batchSize == 0 {
+      return
+    }
+
+    l.Debug("[INSERT] Processing batch\n")
+    if err := repository.BatchUpsertSongs(db, &batch, batchSize); err != nil {
+      panic(err)
+    }
+    l.Debug("[INSERT] Processed batch\n")
+
+    batchSize = 0
+  }
+
+  for {
+    select {
+    case song, more := <- songs:
+      if !more {
+        processBatch()
+        l.Verbose("[INSERT] Finished inserting %d songs\n", numAdded)
+        return
+      }
+
+      batch[batchSize] = song
+      batchSize++
+
+      numAdded++
+      if numAdded % LOG_EVERY == 0 {
+        l.Verbose("[INSERT] Inserted %d\n", numAdded)
+      }
+
+      if batchSize >= BATCH_SIZE {
+        processBatch()
+      }
+    }
+  }
+}
+
 func ScanAndInsert(musicDirectory string) {
   var l = logger.CreateLogger(config.GetConfig().LogLevel)
 
@@ -17,8 +69,7 @@ func ScanAndInsert(musicDirectory string) {
   songs := read.ReadMultipleFiles(musicDirectory, files)
 
   l.Info("Inserting data...\n")
-  repository.InsertMusicIntoDatabase(songs)
+  UpsertSongsFromChannel(songs)
 
   l.Info("Finished scan and insert\n")
 }
-

+ 167 - 33
music-player/pkg/services/scanner_test.go

@@ -10,43 +10,177 @@ import (
 	setup "github.com/felamaslen/go-music-player/pkg/testing"
 )
 
-var _ = Describe("music scanner (integration test)", func() {
+var _ = Describe("Music scanner service", func() {
+  db := database.GetConnection()
 
   BeforeEach(func() {
     setup.PrepareDatabaseForTesting()
   })
 
-  It("should recursively scan files from a directory and add them to the database", func() {
-    services.ScanAndInsert(read.TestDirectory)
-
-    db := database.GetConnection()
-
-    var songs []read.Song
-    err := db.Select(&songs, `
-      select title, artist, album, duration, base_path, relative_path
-      from songs
-    `)
-
-    Expect(err).To(BeNil())
-
-    Expect(songs).To(HaveLen(2))
-
-    Expect(read.Song{
-      Title: read.TestSong.Title,
-      Artist: read.TestSong.Artist,
-      Album: read.TestSong.Album,
-      Duration: read.TestSong.Duration,
-      BasePath: read.TestSong.BasePath,
-      RelativePath: read.TestSong.RelativePath,
-    }).To(BeElementOf(songs))
-
-    Expect(read.Song{
-      Title: read.TestSongNested.Title,
-      Artist: read.TestSongNested.Artist,
-      Album: read.TestSongNested.Album,
-      Duration: read.TestSongNested.Duration,
-      BasePath: read.TestSongNested.BasePath,
-      RelativePath: read.TestSongNested.RelativePath,
-    }).To(BeElementOf(songs))
+  Describe("UpsertSongsFromChannel", func() {
+    var songs chan *read.Song
+
+    var testScanSongs = func() {
+      songs = make(chan *read.Song)
+
+      go func() {
+	defer close(songs)
+	songs <- &read.Song{
+	  TrackNumber: 7,
+	  Title: "Hey Jude",
+	  Artist: "The Beatles",
+	  Album: "",
+	  Duration: 431,
+	  BasePath: "/path/to",
+	  RelativePath: "file.ogg",
+	  ModifiedDate: 8876,
+	}
+
+	songs <- &read.Song{
+	  TrackNumber: 11,
+	  Title: "Starman",
+	  Artist: "David Bowie",
+	  Album: "The Rise and Fall of Ziggy Stardust and the Spiders from Mars",
+	  Duration: 256,
+	  BasePath: "/different/path",
+	  RelativePath: "otherFile.ogg",
+	  ModifiedDate: 11883,
+	}
+      }()
+
+      services.UpsertSongsFromChannel(songs)
+    }
+
+    Context("when the songs do not already exist in the database", func() {
+      BeforeEach(testScanSongs)
+
+      It("should insert the correct number of songs", func() {
+	var count int
+	db.Get(&count, "select count(*) from songs")
+	Expect(count).To(Equal(2))
+      })
+
+      It("should insert both songs", func() {
+	var songs []read.Song
+
+	db.Select(&songs, `
+	select track_number, title, artist, album, duration, base_path, relative_path, modified_date
+	from songs
+	order by title
+	`)
+
+	Expect(songs[0]).To(Equal(read.Song{
+	  TrackNumber: 7,
+	  Title: "Hey Jude",
+	  Artist: "The Beatles",
+	  Album: "",
+	  Duration: 431,
+	  BasePath: "/path/to",
+	  RelativePath: "file.ogg",
+	  ModifiedDate: 8876,
+	}))
+
+	Expect(songs[1]).To(Equal(read.Song{
+	  TrackNumber: 11,
+	  Title: "Starman",
+	  Artist: "David Bowie",
+	  Album: "The Rise and Fall of Ziggy Stardust and the Spiders from Mars",
+	  Duration: 256,
+	  BasePath: "/different/path",
+	  RelativePath: "otherFile.ogg",
+	  ModifiedDate: 11883,
+	}))
+      })
+    })
+
+    Context("when there is already a file in the database with the same name", func() {
+      BeforeEach(func() {
+	db.MustExec(
+	  `
+	  insert into songs (title, artist, album, base_path, relative_path, modified_date)
+	  values ($1, $2, $3, $4, $5, $6)
+	  `,
+	  "my title",
+	  "my artist",
+	  "my album",
+	  "/path/to",
+	  "file.ogg",
+	  7782,
+	)
+
+	testScanSongs()
+      })
+
+      It("should not add an additional row for the same file", func() {
+	var count int
+	db.Get(&count, `
+	select count(*) from songs
+	where base_path = '/path/to' and relative_path = 'file.ogg'
+	`)
+
+	Expect(count).To(Equal(1))
+      })
+
+      It("should upsert the existing item", func() {
+	var songs []read.Song
+	db.Select(&songs, `
+	select
+	  track_number
+	  ,title
+	  ,artist
+	  ,album
+	  ,duration
+	  ,base_path
+	  ,relative_path
+	  ,modified_date
+	from songs
+	where base_path = '/path/to' and relative_path = 'file.ogg'
+	`)
+
+	Expect(songs).To(HaveLen(1))
+	var song = songs[0]
+
+	Expect(song.TrackNumber).To(Equal(7))
+	Expect(song.Title).To(Equal("Hey Jude"))
+	Expect(song.Artist).To(Equal("The Beatles"))
+	Expect(song.Album).To(Equal(""))
+	Expect(song.Duration).To(Equal(431))
+	Expect(song.ModifiedDate).To(Equal(int64(8876)))
+      })
+    })
+  })
+
+  Describe("ScanAndInsert", func() {
+    It("should recursively scan files from a directory and add them to the database", func() {
+      services.ScanAndInsert(read.TestDirectory)
+
+      var songs []read.Song
+      err := db.Select(&songs, `
+	select title, artist, album, duration, base_path, relative_path
+	from songs
+      `)
+
+      Expect(err).To(BeNil())
+
+      Expect(songs).To(HaveLen(2))
+
+      Expect(read.Song{
+	Title: read.TestSong.Title,
+	Artist: read.TestSong.Artist,
+	Album: read.TestSong.Album,
+	Duration: read.TestSong.Duration,
+	BasePath: read.TestSong.BasePath,
+	RelativePath: read.TestSong.RelativePath,
+      }).To(BeElementOf(songs))
+
+      Expect(read.Song{
+	Title: read.TestSongNested.Title,
+	Artist: read.TestSongNested.Artist,
+	Album: read.TestSongNested.Album,
+	Duration: read.TestSongNested.Duration,
+	BasePath: read.TestSongNested.BasePath,
+	RelativePath: read.TestSongNested.RelativePath,
+      }).To(BeElementOf(songs))
+    })
   })
 })