Ver código fonte

feat: artist/album scrollable tree view

    * feat: scroll container when out of range
    * feat: fetch all artists on initial page load
    * chore: ran go fmt
    * feat: command to format Go code
    * fix: don't highlight artist when album is selected
    * feat: scroll through expanded albums
    * feat: react-vtree implementation for artists list
    * fix: order songs response by album first
Fela Maslen 5 anos atrás
pai
commit
84c08a3531
41 arquivos alterados com 2055 adições e 1492 exclusões
  1. 2 0
      gmus/package.json
  2. 69 0
      gmus/src/components/ui/cmus/reducer.spec.ts
  3. 32 17
      gmus/src/components/ui/cmus/reducer.ts
  4. 257 0
      gmus/src/components/ui/cmus/utils/scroll.spec.ts
  5. 90 0
      gmus/src/components/ui/cmus/utils/scroll.ts
  6. 5 9
      gmus/src/components/ui/cmus/views/artists.styles.ts
  7. 188 45
      gmus/src/components/ui/cmus/views/artists.tsx
  8. 5 30
      gmus/src/hooks/fetch/artists.ts
  9. 27 1
      gmus/yarn.lock
  10. 3 0
      music-player/Makefile
  11. 1 1
      music-player/cmd/migrate/main.go
  12. 6 6
      music-player/cmd/scan/main.go
  13. 1 1
      music-player/cmd/server/main.go
  14. 88 88
      music-player/pkg/config/config.go
  15. 10 10
      music-player/pkg/config/main.go
  16. 21 21
      music-player/pkg/database/main.go
  17. 33 33
      music-player/pkg/logger/logger.go
  18. 29 29
      music-player/pkg/read/audio.go
  19. 24 24
      music-player/pkg/read/audio_test.go
  20. 10 10
      music-player/pkg/read/duration/main.go
  21. 14 14
      music-player/pkg/read/duration/vorbis.go
  22. 5 5
      music-player/pkg/read/duration/vorbis_test.go
  23. 166 166
      music-player/pkg/read/files.go
  24. 107 107
      music-player/pkg/read/files_test.go
  25. 14 14
      music-player/pkg/read/test_file_info.go
  26. 16 16
      music-player/pkg/read/types.go
  27. 60 54
      music-player/pkg/repository/songs.go
  28. 105 105
      music-player/pkg/repository/songs_test.go
  29. 20 20
      music-player/pkg/server/actions.go
  30. 92 92
      music-player/pkg/server/clients.go
  31. 86 105
      music-player/pkg/server/fetch.go
  32. 11 11
      music-player/pkg/server/handler.go
  33. 91 91
      music-player/pkg/server/pubsub.go
  34. 15 15
      music-player/pkg/server/server.go
  35. 11 11
      music-player/pkg/server/state.go
  36. 27 27
      music-player/pkg/server/stream.go
  37. 11 11
      music-player/pkg/services/fetch.go
  38. 98 98
      music-player/pkg/services/fetch_test.go
  39. 53 53
      music-player/pkg/services/scanner.go
  40. 142 142
      music-player/pkg/services/scanner_test.go
  41. 10 10
      music-player/pkg/testing/testing.go

+ 2 - 0
gmus/package.json

@@ -83,6 +83,8 @@
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
+    "react-vtree": "^3.0.0-beta.0",
+    "react-window": "^1.8.6",
     "styled-components": "^5.2.1",
     "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",

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

@@ -175,6 +175,15 @@ describe(cmusUIReducer.name, () => {
           const stateArtistMode: CmusUIState = {
             ...stateLibrary,
             artists: ['Artist A', 'Artist B'],
+            artistAlbums: {
+              'Artist A': ['Album 1', 'Album 2'],
+            },
+            artistSongs: {
+              'Artist A': [
+                { id: 456, album: 'Album 2' } as Song,
+                { id: 123, album: 'Album 1' } as Song,
+              ],
+            },
             library: {
               ...stateLibrary.library,
               activeArtist: 'Artist A',
@@ -214,6 +223,31 @@ describe(cmusUIReducer.name, () => {
               expect(result.library.activeSongId).toBeNull();
             });
           });
+
+          describe('when the current artist is expanded', () => {
+            const stateArtistModeWithAlbums: CmusUIState = {
+              ...stateArtistMode,
+              library: {
+                ...stateArtistMode.library,
+                expandedArtists: ['Artist A'],
+              },
+            };
+
+            it('should select the next album', () => {
+              expect.assertions(2);
+              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+              expect(result.library.activeArtist).toBe('Artist A');
+              expect(result.library.activeAlbum).toBe('Album 1');
+            });
+
+            it('should set the active song ID to the first matching the album', () => {
+              expect.assertions(1);
+              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+              expect(result.library.activeSongId).toBe(123);
+            });
+          });
         });
 
         describe('when in the song list mode', () => {
@@ -249,6 +283,15 @@ describe(cmusUIReducer.name, () => {
           const stateArtistMode: CmusUIState = {
             ...stateLibrary,
             artists: ['Artist A', 'Artist B'],
+            artistAlbums: {
+              'Artist B': ['Album 1', 'Album 2'],
+            },
+            artistSongs: {
+              'Artist B': [
+                { id: 456, album: 'Album 2' } as Song,
+                { id: 123, album: 'Album 1' } as Song,
+              ],
+            },
             library: {
               ...stateLibrary.library,
               activeArtist: 'Artist B',
@@ -288,6 +331,32 @@ describe(cmusUIReducer.name, () => {
               expect(result.library.activeSongId).toBeNull();
             });
           });
+
+          describe('when the current artist is expanded', () => {
+            const stateArtistModeWithAlbums: CmusUIState = {
+              ...stateArtistMode,
+              library: {
+                ...stateArtistMode.library,
+                expandedArtists: ['Artist B'],
+                activeAlbum: 'Album 2',
+              },
+            };
+
+            it('should select the previous album', () => {
+              expect.assertions(2);
+              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+              expect(result.library.activeArtist).toBe('Artist B');
+              expect(result.library.activeAlbum).toBe('Album 1');
+            });
+
+            it('should set the active song ID to the first matching the album', () => {
+              expect.assertions(1);
+              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+              expect(result.library.activeSongId).toBe(123);
+            });
+          });
         });
 
         describe('when in the song list mode', () => {

+ 32 - 17
gmus/src/components/ui/cmus/reducer.ts

@@ -3,6 +3,7 @@ import { createContext, Dispatch } from 'react';
 import { LocalAction, loggedOut, playPaused, stateSet } from '../../../actions';
 import { nullDispatch } from '../../../context/state';
 import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
+import { Song } from '../../../types';
 import { scrollThroughItems } from '../../../utils/delta';
 import {
   ArtistAlbumsLoaded,
@@ -12,6 +13,7 @@ import {
   CmusUIActionType,
 } from './actions';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
+import { getNextActiveArtistAndAlbum } from './utils/scroll';
 
 export const initialCmusUIState: CmusUIState = {
   globalAction: null,
@@ -54,28 +56,41 @@ const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
   },
 });
 
-const setActiveSongIdFromActiveArtist = (state: CmusUIState): CmusUIState => ({
-  ...state,
-  library: {
-    ...state.library,
-    activeSongId: state.library.activeArtist
-      ? state.artistSongs[state.library.activeArtist]?.[0]?.id ?? null
-      : null,
-  },
-});
+function getActiveSongIdFromActiveArtistAlbum(
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  artistSongs: Record<string, Song[]>,
+): number | null {
+  if (!activeArtist) {
+    return null;
+  }
+  const songs = artistSongs[activeArtist] ?? [];
+  if (!activeAlbum) {
+    return songs[0]?.id ?? null;
+  }
+  return songs.find((compare) => compare.album === activeAlbum)?.id ?? null;
+}
 
-const scrollArtists = (state: CmusUIState, delta: number): CmusUIState =>
-  setActiveSongIdFromActiveArtist({
+const scrollArtists = (state: CmusUIState, delta: 1 | -1): CmusUIState => {
+  const { artist, album } = getNextActiveArtistAndAlbum(
+    state.artists,
+    state.artistAlbums,
+    state.library.activeArtist,
+    state.library.activeAlbum,
+    state.library.expandedArtists,
+    delta,
+  );
+
+  return {
     ...state,
     library: {
       ...state.library,
-      activeArtist: scrollThroughItems(
-        state.artists,
-        (compare) => compare === state.library.activeArtist,
-        delta,
-      ),
+      activeArtist: artist,
+      activeAlbum: album,
+      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
     },
-  });
+  };
+};
 
 const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
   state.library.activeArtist

+ 257 - 0
gmus/src/components/ui/cmus/utils/scroll.spec.ts

@@ -0,0 +1,257 @@
+import { getNextActiveArtistAndAlbum, getScrollIndex } from './scroll';
+
+describe(getNextActiveArtistAndAlbum.name, () => {
+  const artists: string[] = ['A', 'B', 'C'];
+
+  const artistAlbums: Record<string, string[]> = {
+    A: ['a1', 'a2', 'a3'],
+    C: ['c1', 'c2'],
+  };
+
+  describe('scrolling down', () => {
+    describe('when nothing is active', () => {
+      it('switch to the first artist', () => {
+        expect.assertions(1);
+        expect(getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, [], 1)).toStrictEqual(
+          {
+            artist: 'A',
+            album: null,
+          },
+        );
+      });
+    });
+
+    describe('when the first artist is active', () => {
+      describe('when its albums are not expanded', () => {
+        it('should switch to the second artist', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, [], 1),
+          ).toStrictEqual({
+            artist: 'B',
+            album: null,
+          });
+        });
+      });
+
+      describe('when its albums are expanded', () => {
+        it('should switch to the first album', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, ['A'], 1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: 'a1',
+          });
+        });
+      });
+    });
+
+    describe('when the last artist is active', () => {
+      describe('when its albums are expanded', () => {
+        it.each`
+          activeAlbum | nextAlbum
+          ${null}     | ${'c1'}
+          ${'c1'}     | ${'c2'}
+        `('should switch to the next album ($nextAlbum)', ({ activeAlbum, nextAlbum }) => {
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', activeAlbum, ['C'], 1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: nextAlbum,
+          });
+        });
+
+        describe('when the last album is active', () => {
+          it('should keep the current selection', () => {
+            expect.assertions(1);
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', 'c2', ['C'], 1),
+            ).toStrictEqual({
+              artist: 'C',
+              album: 'c2',
+            });
+          });
+        });
+      });
+
+      describe('when its albums are not expanded', () => {
+        it('should keep the current selection', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', null, ['A'], 1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: null,
+          });
+        });
+      });
+    });
+
+    describe('when the second-to-last artist is active', () => {
+      describe('when the last album is active', () => {
+        it('should select the last artist', () => {
+          expect.assertions(1);
+
+          const artistAlbums2 = {
+            B: ['b1', 'b2', 'b3'],
+          };
+
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums2, 'B', 'b3', ['B'], 1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: null,
+          });
+        });
+      });
+    });
+  });
+
+  describe('scrolling up', () => {
+    describe('when nothing is active', () => {
+      it('should switch to the last artist', () => {
+        expect.assertions(1);
+        expect(
+          getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, [], -1),
+        ).toStrictEqual({
+          artist: 'C',
+          album: null,
+        });
+      });
+
+      describe('when the last albums are expanded', () => {
+        it('should switch to the last album', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, null, null, ['C'], -1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: 'c2',
+          });
+        });
+      });
+    });
+
+    describe('when the first artist is active', () => {
+      describe('when its albums are not expanded', () => {
+        it('should keep the current selection', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, [], -1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: null,
+          });
+        });
+      });
+
+      describe('when its albums are expanded', () => {
+        it('should switch to the previous album', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', 'a3', ['A'], -1),
+          ).toStrictEqual({
+            artist: 'A',
+            album: 'a2',
+          });
+        });
+
+        describe('when the first album is active', () => {
+          it('should switch to the artist', () => {
+            expect.assertions(1);
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', 'a1', ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+
+        describe('when no album is active', () => {
+          it('should keep the current selection', () => {
+            expect.assertions(1);
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'A', null, ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+      });
+    });
+
+    describe('when a middle or last artist is active', () => {
+      describe('when no album is active', () => {
+        describe('when the previous albums are expanded', () => {
+          it('should switch to the last album of the previous artist', () => {
+            expect.assertions(1);
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'B', null, ['A'], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: 'a3',
+            });
+          });
+        });
+
+        describe('when the previous albums are not expanded', () => {
+          it('should switch to the previous artist', () => {
+            expect.assertions(1);
+            expect(
+              getNextActiveArtistAndAlbum(artists, artistAlbums, 'B', null, [], -1),
+            ).toStrictEqual({
+              artist: 'A',
+              album: null,
+            });
+          });
+        });
+      });
+
+      describe('when an album is active', () => {
+        it('should switch to the previous album', () => {
+          expect.assertions(1);
+          expect(
+            getNextActiveArtistAndAlbum(artists, artistAlbums, 'C', 'c2', ['C'], -1),
+          ).toStrictEqual({
+            artist: 'C',
+            album: 'c1',
+          });
+        });
+      });
+    });
+  });
+});
+
+describe(getScrollIndex.name, () => {
+  describe('when on the Nth artist', () => {
+    it('should return N', () => {
+      expect.assertions(3);
+
+      expect(getScrollIndex(['A', 'B', 'C'], {}, 'A', null, [])).toBe(0);
+      expect(getScrollIndex(['A', 'B', 'C'], {}, 'B', null, [])).toBe(1);
+      expect(getScrollIndex(['A', 'B', 'C'], {}, 'C', null, [])).toBe(2);
+    });
+  });
+
+  describe('when an artist has its albums expanded', () => {
+    it('should return the correct row number', () => {
+      expect.assertions(5);
+
+      const artistAlbums: Record<string, string[]> = {
+        A: ['a1', 'a2'],
+        B: ['b1'],
+      };
+
+      expect(getScrollIndex(['A', 'B'], artistAlbums, 'A', 'a1', ['A'])).toBe(1);
+      expect(getScrollIndex(['A', 'B'], artistAlbums, 'A', 'a2', ['A'])).toBe(2);
+      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', null, ['A'])).toBe(3);
+
+      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['B'])).toBe(2);
+
+      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['A', 'B'])).toBe(4);
+    });
+  });
+});

+ 90 - 0
gmus/src/components/ui/cmus/utils/scroll.ts

@@ -0,0 +1,90 @@
+import { scrollThroughItems } from '../../../../utils/delta';
+
+const getArtistAlbums = (
+  artist: string | null,
+  artistAlbums: Record<string, string[]>,
+  expandedArtists: string[],
+): string[] => (artist && expandedArtists.includes(artist) ? artistAlbums[artist] ?? [] : []);
+
+export function getNextActiveArtistAndAlbum(
+  artists: string[],
+  artistAlbums: Record<string, string[]>,
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  expandedArtists: string[],
+  delta: -1 | 1,
+): { artist: string | null; album: string | null } {
+  if (activeArtist === null) {
+    if (delta === 1) {
+      return { artist: artists[0] ?? null, album: null };
+    }
+    const lastArtist = artists.length > 0 ? artists[artists.length - 1] : null;
+    if (!lastArtist) {
+      return { artist: null, album: null };
+    }
+    const lastArtistAlbums = getArtistAlbums(lastArtist, artistAlbums, expandedArtists);
+
+    const lastAlbum =
+      lastArtistAlbums.length > 0 ? lastArtistAlbums[lastArtistAlbums.length - 1] : null;
+
+    return { artist: lastArtist, album: lastAlbum };
+  }
+
+  const nextArtist = scrollThroughItems(artists, (compare) => compare === activeArtist, delta);
+  const atEnd = nextArtist === activeArtist;
+
+  const activeArtistAlbums = getArtistAlbums(activeArtist, artistAlbums, expandedArtists);
+  const nextArtistAlbums = getArtistAlbums(nextArtist, artistAlbums, expandedArtists);
+
+  if (activeAlbum === null) {
+    if (
+      (delta === 1 && !activeArtistAlbums.length) ||
+      (delta === -1 && (atEnd || !nextArtistAlbums.length))
+    ) {
+      return { artist: nextArtist, album: null };
+    }
+    if (delta === 1) {
+      return { artist: activeArtist, album: activeArtistAlbums[0] };
+    }
+    return { artist: nextArtist, album: nextArtistAlbums[nextArtistAlbums.length - 1] };
+  }
+
+  const nextAlbum = scrollThroughItems(
+    activeArtistAlbums,
+    (compare) => compare === activeAlbum,
+    delta,
+  );
+
+  if (delta === -1 && nextAlbum === activeAlbum) {
+    return { artist: activeArtist, album: null };
+  }
+  if (delta === 1 && nextArtist !== activeArtist && nextAlbum === activeAlbum) {
+    return { artist: nextArtist, album: null };
+  }
+
+  return { artist: activeArtist, album: nextAlbum };
+}
+
+export function getScrollIndex(
+  artists: string[],
+  artistAlbums: Record<string, string[]>,
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  expandedArtists: string[],
+): number {
+  if (activeArtist === null) {
+    return 0;
+  }
+  const artistIndex = artists.indexOf(activeArtist);
+  const activeArtistAlbums = getArtistAlbums(activeArtist, artistAlbums, expandedArtists);
+  const activeAlbumIndex = activeAlbum ? activeArtistAlbums.indexOf(activeAlbum) : -1;
+
+  const result = artists
+    .slice(0, artistIndex)
+    .reduce<number>(
+      (last, artist) => last + 1 + getArtistAlbums(artist, artistAlbums, expandedArtists).length,
+      activeAlbumIndex + 1,
+    );
+
+  return result;
+}

+ 5 - 9
gmus/src/components/ui/cmus/views/artists.styles.ts

@@ -1,20 +1,16 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexColumn, FlexList } from '../styled/layout';
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
 
 export const Container = styled(FlexList)`
   flex: 1 0 0;
 `;
 
-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)`
+  span {
+    padding-left: ${rem(16)};
+  }
 `;
-
-export const AlbumTitle = styled(ActiveHighlightRow)``;

+ 188 - 45
gmus/src/components/ui/cmus/views/artists.tsx

@@ -1,11 +1,21 @@
-import { useDebounce } from '@react-hook/debounce';
-import React, { useContext, useEffect } from 'react';
+import { useDebounce, useDebounceCallback } from '@react-hook/debounce';
+import React, {
+  CSSProperties,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { FixedSizeNodeData, FixedSizeTree as Tree, TreeWalker, TreeWalkerValue } from 'react-vtree';
+import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
-import { Song } from '../../../../types/songs';
-import { namedMemo } from '../../../../utils/component';
 import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+import { CmusUIState } from '../types';
+import { getScrollIndex } from '../utils/scroll';
 
 import * as Styled from './artists.styles';
 
@@ -13,43 +23,123 @@ type Props = {
   active: boolean;
 };
 
-type PropsArtist = {
-  artist: string;
-  albums?: string[];
-  songs?: Song[];
+type TreeNode = {
+  name: string;
+  id: string;
+  focused: boolean;
   active: boolean;
-  parentActive: boolean;
-  expanded: boolean;
-  activeAlbum: string | null;
+  shouldBeOpen?: boolean;
+  children?: TreeNode[];
 };
 
-const Artist = namedMemo<PropsArtist>(
-  'Artist',
-  ({ artist, albums, active, parentActive, expanded, activeAlbum }) => (
-    <Styled.ArtistRow key={artist}>
-      <Styled.ArtistTitle active={active} parentActive={parentActive}>
-        <span>{artist || 'Unknown Artist'}</span>
+type TreeMeta = {
+  node: TreeNode;
+};
+
+type TreeData = FixedSizeNodeData &
+  Omit<TreeNode, 'children'> & {
+    isArtist: boolean;
+  };
+
+function useTreeWalker(
+  { artists, artistAlbums, library: { activeArtist, activeAlbum, expandedArtists } }: CmusUIState,
+  focused: boolean,
+): { treeWalker: TreeWalker<TreeData, TreeMeta>; haveData: boolean } {
+  const treeNodes = useMemo<TreeNode[]>(
+    () =>
+      artists.map<TreeNode>((artist) => ({
+        name: artist,
+        id: artist,
+        focused,
+        active: activeArtist === artist && activeAlbum === null,
+        shouldBeOpen: expandedArtists.includes(artist),
+        children:
+          artistAlbums[artist]?.map<TreeNode>((album) => ({
+            name: album,
+            id: `${artist}-${album}`,
+            focused,
+            active: activeArtist === artist && activeAlbum === album,
+          })) ?? undefined,
+      })),
+    [artists, artistAlbums, focused, activeArtist, activeAlbum, expandedArtists],
+  );
+
+  const getNodeData = useCallback(
+    (node: TreeNode, isArtist: boolean): TreeWalkerValue<TreeData, TreeMeta> => ({
+      data: {
+        id: node.id,
+        name: node.name,
+        focused: node.focused,
+        active: node.active,
+        shouldBeOpen: node.shouldBeOpen,
+        isOpenByDefault: !!node.shouldBeOpen,
+        isArtist,
+      },
+      node,
+    }),
+    [],
+  );
+
+  const treeWalker = useMemo<TreeWalker<TreeData, TreeMeta>>(
+    () =>
+      // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+      function* treeWalkerGenerator() {
+        for (let i = 0; i < treeNodes.length; i += 1) {
+          yield getNodeData(treeNodes[i], true);
+        }
+
+        while (true) {
+          const parent = yield;
+
+          if (parent?.node.children) {
+            for (let i = 0; i < parent.node.children.length; i += 1) {
+              yield getNodeData(parent.node.children[i], false);
+            }
+          }
+        }
+      },
+    [treeNodes, getNodeData],
+  );
+
+  return { treeWalker, haveData: treeNodes.length > 0 };
+}
+
+const Node: React.FC<NodeComponentProps<TreeData, NodePublicState<TreeData>>> = ({
+  data: { name, isArtist, focused, active, shouldBeOpen },
+  isOpen,
+  setOpen,
+  style,
+}) => {
+  useEffect(() => {
+    if (!!isOpen !== !!shouldBeOpen) {
+      setOpen(!!shouldBeOpen);
+    }
+  }, [isOpen, shouldBeOpen, setOpen]);
+
+  if (isArtist) {
+    return (
+      <Styled.ArtistTitle active={active} parentActive={focused} style={style as CSSProperties}>
+        <span>{name || 'Unknown Artist'}</span>
       </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>
-  ),
-);
+    );
+  }
+
+  return (
+    <Styled.AlbumTitle active={active} parentActive={focused} style={style as CSSProperties}>
+      <span>{name || 'Unknown Album'}</span>
+    </Styled.AlbumTitle>
+  );
+};
+
+const lineHeight = 16;
+const scrollThresholdLines = 4;
 
 export const Artists: React.FC<Props> = ({ active }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
+  const state = useContext(CmusUIStateContext);
   const {
-    artists,
-    artistAlbums,
-    library: { expandedArtists, activeArtist, activeAlbum },
-  } = useContext(CmusUIStateContext);
+    library: { activeArtist, expandedArtists },
+  } = state;
 
   const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
   useEffect(() => {
@@ -72,19 +162,72 @@ export const Artists: React.FC<Props> = ({ active }) => {
     }
   }, [dispatchUI, songs]);
 
+  const ref = useRef<HTMLDivElement>(null);
+  const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
+    width: 0,
+    height: 0,
+  });
+  const onResize = useCallback(() => {
+    setWindowDimensions({
+      width: ref.current?.offsetWidth ?? 0,
+      height: ref.current?.offsetHeight ?? 0,
+    });
+  }, []);
+  const resizeHandler = useDebounceCallback(onResize, 100);
+
+  useEffect(() => {
+    onResize();
+    window.addEventListener('resize', resizeHandler);
+    return (): void => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [onResize, resizeHandler]);
+
+  const windowRef = useRef<HTMLDivElement>(null);
+  const scrollIndex = getScrollIndex(
+    state.artists,
+    state.artistAlbums,
+    state.library.activeArtist,
+    state.library.activeAlbum,
+    state.library.expandedArtists,
+  );
+
+  useEffect(() => {
+    if (!windowRef.current) {
+      return;
+    }
+    const heightInLines = Math.floor(windowDimensions.height / lineHeight);
+    if (heightInLines < scrollThresholdLines + 1) {
+      return;
+    }
+
+    const scrollPosLines = Math.floor(windowRef.current.scrollTop / lineHeight);
+
+    const linesBefore = scrollIndex - scrollPosLines;
+    const linesAfter = scrollPosLines + heightInLines - scrollIndex;
+
+    if (linesAfter < scrollThresholdLines) {
+      windowRef.current.scrollTop += lineHeight;
+    } else if (linesBefore < scrollThresholdLines) {
+      windowRef.current.scrollTop -= lineHeight;
+    }
+  }, [windowDimensions.height, scrollIndex]);
+
+  const { treeWalker, haveData } = useTreeWalker(state, active);
+
   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 ref={ref}>
+      {haveData && (
+        <Tree
+          outerRef={windowRef}
+          treeWalker={treeWalker}
+          itemSize={lineHeight}
+          width={windowDimensions.width}
+          height={windowDimensions.height}
+        >
+          {Node}
+        </Tree>
+      )}
     </Styled.Container>
   );
 };

+ 5 - 30
gmus/src/hooks/fetch/artists.ts

@@ -7,19 +7,10 @@ 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}`);
+const sendArtistsRequest = (axios: AxiosInstance): Promise<AxiosResponse<ArtistsResponse>> =>
+  axios.get(`${getApiUrl()}/artists`);
 
 type AlbumsResponse = {
   artist: string;
@@ -51,40 +42,24 @@ const sendSongsRequest = (
 
 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,
+  const fetching = useCancellableRequest<void, ArtistsResponse>({
+    query: undefined,
     pause,
     sendRequest: sendArtistsRequest,
     handleResponse,
   });
 
-  const fetchMore = useCallback(() => {
-    if (!more) {
-      return;
-    }
-    setPage((last) => last + 1);
-    setPause(false);
-  }, [more]);
-
-  return { artists, more, fetching, fetchMore };
+  return { artists, fetching };
 }
 
 export function useArtistsAlbumsAndSongs(

+ 27 - 1
gmus/yarn.lock

@@ -1106,7 +1106,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
   version "7.12.5"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
   integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
@@ -7456,6 +7456,11 @@ media-typer@0.3.0:
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
+"memoize-one@>=3.1.1 <6":
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
+  integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
+
 memory-fs@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
@@ -9460,6 +9465,11 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
   integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
 
+react-merge-refs@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"
+  integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==
+
 react-refresh@^0.8.3:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@@ -9543,6 +9553,22 @@ react-storage-hooks@^4.0.1:
   resolved "https://registry.yarnpkg.com/react-storage-hooks/-/react-storage-hooks-4.0.1.tgz#e30ed5cda48c77c431ecc02ec3824bd615f5b7fb"
   integrity sha512-fetDkT5RDHGruc2NrdD1iqqoLuXgbx6AUpQSQLLkrCiJf8i97EtwJNXNTy3+GRfsATLG8TZgNc9lGRZOaU5yQA==
 
+react-vtree@^3.0.0-beta.0:
+  version "3.0.0-beta.0"
+  resolved "https://registry.yarnpkg.com/react-vtree/-/react-vtree-3.0.0-beta.0.tgz#55425d367d7f66b11c375a080b770bc61ae88c9f"
+  integrity sha512-S7L19EPMkx7CNkqJqy34oACUoPJar0AkMhRTw54VbvJqcytVYJh1nI4W0rqFhbLfdBU1qJL6XPchKm7MMnZafw==
+  dependencies:
+    "@babel/runtime" "^7.11.0"
+    react-merge-refs "^1.1.0"
+
+react-window@^1.8.6:
+  version "1.8.6"
+  resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
+  integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
+  dependencies:
+    "@babel/runtime" "^7.0.0"
+    memoize-one ">=3.1.1 <6"
+
 react@^17.0.1:
   version "17.0.1"
   resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"

+ 3 - 0
music-player/Makefile

@@ -13,6 +13,9 @@ build:
 clean:
 	mkdir -p bin && rm -rf ./bin/*
 
+fmt:
+	go fmt github.com/felamaslen/go-music-player/...
+
 test:
 	GO_ENV=test ginkgo ./...
 

+ 1 - 1
music-player/cmd/migrate/main.go

@@ -5,5 +5,5 @@ import (
 )
 
 func main() {
-  database.MigrateDatabase()
+	database.MigrateDatabase()
 }

+ 6 - 6
music-player/cmd/scan/main.go

@@ -7,12 +7,12 @@ import (
 )
 
 func main() {
-  var libraryDirectory = config.GetConfig().LibraryDirectory
-  if len(libraryDirectory) == 0 {
-    panic("Must set LIBRARY_DIRECTORY")
-  }
+	var libraryDirectory = config.GetConfig().LibraryDirectory
+	if len(libraryDirectory) == 0 {
+		panic("Must set LIBRARY_DIRECTORY")
+	}
 
-  services.ScanAndInsert(libraryDirectory)
+	services.ScanAndInsert(libraryDirectory)
 
-  database.EndPool()
+	database.EndPool()
 }

+ 1 - 1
music-player/cmd/server/main.go

@@ -5,5 +5,5 @@ import (
 )
 
 func main() {
-  server.StartServer()
+	server.StartServer()
 }

+ 88 - 88
music-player/pkg/config/config.go

@@ -15,112 +15,112 @@ import (
 var envLoaded = false
 
 func getEnvFile() (string, bool) {
-  goEnv, _ := os.LookupEnv("GO_ENV")
-
-  switch goEnv {
-  case "test":
-    return "test.env", true
-  case "development":
-    return ".env", true
-  default:
-    return "", false
-  }
+	goEnv, _ := os.LookupEnv("GO_ENV")
+
+	switch goEnv {
+	case "test":
+		return "test.env", true
+	case "development":
+		return ".env", true
+	default:
+		return "", false
+	}
 }
 
 func loadEnv() {
-  envFileBase, loadEnvFile := getEnvFile()
-  cwd, _ := os.Getwd()
-  envFile := filepath.Join(cwd, envFileBase)
-  if loadEnvFile {
-    err := godotenv.Load(envFile)
-    if err != nil {
-      log.Printf("Error loading dotenv file: %v\n", err)
-    } else {
-      envLoaded = true
-    }
-  }
+	envFileBase, loadEnvFile := getEnvFile()
+	cwd, _ := os.Getwd()
+	envFile := filepath.Join(cwd, envFileBase)
+	if loadEnvFile {
+		err := godotenv.Load(envFile)
+		if err != nil {
+			log.Printf("Error loading dotenv file: %v\n", err)
+		} else {
+			envLoaded = true
+		}
+	}
 }
 
 func getDatabaseUrl() string {
-  host, hasHost := os.LookupEnv("POSTGRES_HOST")
-  if !hasHost {
-    log.Fatal("Must set POSTGRES_HOST")
-  }
-
-  user := os.Getenv("POSTGRES_USER")
-  password := os.Getenv("POSTGRES_PASSWORD")
-  port, hasPort := os.LookupEnv("POSTGRES_PORT")
-  if !hasPort {
-    port = "5432"
-  }
-  portNumeric, err := strconv.Atoi(port)
-  if err != nil {
-    log.Fatal("POSTGRES_PORT must be numeric")
-  }
-
-  database, hasDatabase := os.LookupEnv("POSTGRES_DATABASE")
-  if !hasDatabase {
-    log.Fatal("Must set POSTGRES_DATABASE")
-  }
-
-  databaseUrl := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", user, password, host, portNumeric, database)
-
-  return databaseUrl
+	host, hasHost := os.LookupEnv("POSTGRES_HOST")
+	if !hasHost {
+		log.Fatal("Must set POSTGRES_HOST")
+	}
+
+	user := os.Getenv("POSTGRES_USER")
+	password := os.Getenv("POSTGRES_PASSWORD")
+	port, hasPort := os.LookupEnv("POSTGRES_PORT")
+	if !hasPort {
+		port = "5432"
+	}
+	portNumeric, err := strconv.Atoi(port)
+	if err != nil {
+		log.Fatal("POSTGRES_PORT must be numeric")
+	}
+
+	database, hasDatabase := os.LookupEnv("POSTGRES_DATABASE")
+	if !hasDatabase {
+		log.Fatal("Must set POSTGRES_DATABASE")
+	}
+
+	databaseUrl := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", user, password, host, portNumeric, database)
+
+	return databaseUrl
 }
 
 const defaultLogLevel = logger.LevelInfo
 
 func getLogLevel() logger.LogLevel {
-  level, hasLevel := os.LookupEnv("LOG_LEVEL")
-  if !hasLevel {
-    return defaultLogLevel
-  }
-  levelInt, err := strconv.Atoi(level)
-  if err != nil {
-    return defaultLogLevel
-  }
-  switch levelInt {
-  case 0:
-    return logger.LevelNone
-  case 1:
-    return logger.LevelError
-  case 2:
-    return logger.LevelWarn
-  case 3:
-    return logger.LevelInfo
-  case 4:
-    return logger.LevelVerbose
-  case 5:
-    return logger.LevelDebug
-  }
-  return defaultLogLevel
+	level, hasLevel := os.LookupEnv("LOG_LEVEL")
+	if !hasLevel {
+		return defaultLogLevel
+	}
+	levelInt, err := strconv.Atoi(level)
+	if err != nil {
+		return defaultLogLevel
+	}
+	switch levelInt {
+	case 0:
+		return logger.LevelNone
+	case 1:
+		return logger.LevelError
+	case 2:
+		return logger.LevelWarn
+	case 3:
+		return logger.LevelInfo
+	case 4:
+		return logger.LevelVerbose
+	case 5:
+		return logger.LevelDebug
+	}
+	return defaultLogLevel
 }
 
 func getPort() int {
-  var defaultPort = 3000
-  port, hasPort := os.LookupEnv("PORT")
-  if !hasPort {
-    return defaultPort
-  }
-  result, err := strconv.Atoi(port)
-  if err != nil {
-    return defaultPort
-  }
-  return result
+	var defaultPort = 3000
+	port, hasPort := os.LookupEnv("PORT")
+	if !hasPort {
+		return defaultPort
+	}
+	result, err := strconv.Atoi(port)
+	if err != nil {
+		return defaultPort
+	}
+	return result
 }
 
 func getRedisUrl() string {
-  url, hasUrl := os.LookupEnv("REDIS_URL")
-  if !hasUrl {
-    return "localhost:6379"
-  }
-  return url
+	url, hasUrl := os.LookupEnv("REDIS_URL")
+	if !hasUrl {
+		return "localhost:6379"
+	}
+	return url
 }
 
 type config struct {
-  DatabaseUrl string
-  LogLevel logger.LogLevel
-  LibraryDirectory string
-  Port int
-  RedisUrl string
+	DatabaseUrl      string
+	LogLevel         logger.LogLevel
+	LibraryDirectory string
+	Port             int
+	RedisUrl         string
 }

+ 10 - 10
music-player/pkg/config/main.go

@@ -3,15 +3,15 @@ package config
 import "os"
 
 func GetConfig() config {
-  if !envLoaded {
-    loadEnv()
-  }
+	if !envLoaded {
+		loadEnv()
+	}
 
-  return config{
-    DatabaseUrl: getDatabaseUrl(),
-    LogLevel: getLogLevel(),
-    LibraryDirectory: os.Getenv("LIBRARY_DIRECTORY"),
-    Port: getPort(),
-    RedisUrl: getRedisUrl(),
-  }
+	return config{
+		DatabaseUrl:      getDatabaseUrl(),
+		LogLevel:         getLogLevel(),
+		LibraryDirectory: os.Getenv("LIBRARY_DIRECTORY"),
+		Port:             getPort(),
+		RedisUrl:         getRedisUrl(),
+	}
 }

+ 21 - 21
music-player/pkg/database/main.go

@@ -19,34 +19,34 @@ import (
 var db *sqlx.DB
 
 func GetConnection() *sqlx.DB {
-  if (db != nil) {
-    return db
-  }
+	if db != nil {
+		return db
+	}
 
-  db = sqlx.MustConnect("pgx", config.GetConfig().DatabaseUrl)
+	db = sqlx.MustConnect("pgx", config.GetConfig().DatabaseUrl)
 
-  return db
+	return db
 }
 
 func EndPool() {
-  if (db == nil) {
-    return
-  }
+	if db == nil {
+		return
+	}
 
-  db.Close()
+	db.Close()
 }
 
 func MigrateDatabase() {
-  cwd, err := os.Getwd()
-  if err != nil {
-    log.Fatal("Error getting directory:", err)
-    return
-  }
-  directoryUrl := fmt.Sprintf("file://%s", filepath.Join(cwd, "migrations"))
-
-  m, err := migrate.New(directoryUrl, config.GetConfig().DatabaseUrl)
-  if err != nil {
-    log.Fatal("Error setting up: ", err)
-  }
-  m.Up()
+	cwd, err := os.Getwd()
+	if err != nil {
+		log.Fatal("Error getting directory:", err)
+		return
+	}
+	directoryUrl := fmt.Sprintf("file://%s", filepath.Join(cwd, "migrations"))
+
+	m, err := migrate.New(directoryUrl, config.GetConfig().DatabaseUrl)
+	if err != nil {
+		log.Fatal("Error setting up: ", err)
+	}
+	m.Up()
 }

+ 33 - 33
music-player/pkg/logger/logger.go

@@ -7,60 +7,60 @@ import (
 type LogLevel int
 
 const (
-  LevelNone LogLevel = 0
-  LevelError LogLevel = 1
-  LevelWarn LogLevel = 2
-  LevelInfo LogLevel = 3
-  LevelVerbose LogLevel = 4
-  LevelDebug LogLevel = 5
+	LevelNone    LogLevel = 0
+	LevelError   LogLevel = 1
+	LevelWarn    LogLevel = 2
+	LevelInfo    LogLevel = 3
+	LevelVerbose LogLevel = 4
+	LevelDebug   LogLevel = 5
 )
 
 type Logger struct {
-  Level LogLevel
+	Level LogLevel
 }
 
 func (l *Logger) Printf(str string, args ...interface{}) {
-  log.Printf(str, args...)
+	log.Printf(str, args...)
 }
 
 func (l *Logger) Fatal(str string, args ...interface{}) {
-  log.Fatalf(str, args...)
+	log.Fatalf(str, args...)
 }
 
-func (l *Logger) Error(str string, args ...interface {}) {
-  if l.Level >= LevelError {
-    l.Printf(str, args...)
-  }
+func (l *Logger) Error(str string, args ...interface{}) {
+	if l.Level >= LevelError {
+		l.Printf(str, args...)
+	}
 }
 
-func (l *Logger) Warn(str string, args ...interface {}) {
-  if l.Level >= LevelWarn {
-    l.Printf(str, args...)
-  }
+func (l *Logger) Warn(str string, args ...interface{}) {
+	if l.Level >= LevelWarn {
+		l.Printf(str, args...)
+	}
 }
 
-func (l *Logger) Info(str string, args ...interface {}) {
-  if l.Level >= LevelInfo {
-    l.Printf(str, args...)
-  }
+func (l *Logger) Info(str string, args ...interface{}) {
+	if l.Level >= LevelInfo {
+		l.Printf(str, args...)
+	}
 }
 
-func (l *Logger) Verbose(str string, args ...interface {}) {
-  if l.Level >= LevelVerbose {
-    l.Printf(str, args...)
-  }
+func (l *Logger) Verbose(str string, args ...interface{}) {
+	if l.Level >= LevelVerbose {
+		l.Printf(str, args...)
+	}
 }
 
-func (l *Logger) Debug(str string, args ...interface {}) {
-  if l.Level >= LevelDebug {
-    l.Printf(str, args...)
-  }
+func (l *Logger) Debug(str string, args ...interface{}) {
+	if l.Level >= LevelDebug {
+		l.Printf(str, args...)
+	}
 }
 
 func CreateLogger(level LogLevel) *Logger {
-  var l = Logger{
-    Level: level,
-  }
+	var l = Logger{
+		Level: level,
+	}
 
-  return &l
+	return &l
 }

+ 29 - 29
music-player/pkg/read/audio.go

@@ -9,33 +9,33 @@ import (
 )
 
 func ReadFile(basePath string, scannedFile *File) (song *Song, err error) {
-  fullPath := filepath.Join(basePath, scannedFile.RelativePath)
-  file, errFile := os.Open(fullPath)
-  if errFile != nil {
-    return &Song{}, errFile
-  }
-
-  defer file.Close()
-
-  tags, errTags := tag.ReadFrom(file)
-  if errTags != nil {
-    return &Song{}, errTags
-  }
-
-  durationSeconds := duration.GetSongDurationSeconds(file, tags)
-
-  trackNumber, _ := tags.Track()
-
-  result := Song{
-    TrackNumber: trackNumber,
-    Title: tags.Title(),
-    Artist: tags.Artist(),
-    Album: tags.Album(),
-    Duration: durationSeconds,
-    BasePath: basePath,
-    RelativePath: scannedFile.RelativePath,
-    ModifiedDate: scannedFile.ModifiedDate,
-  }
-
-  return &result, nil
+	fullPath := filepath.Join(basePath, scannedFile.RelativePath)
+	file, errFile := os.Open(fullPath)
+	if errFile != nil {
+		return &Song{}, errFile
+	}
+
+	defer file.Close()
+
+	tags, errTags := tag.ReadFrom(file)
+	if errTags != nil {
+		return &Song{}, errTags
+	}
+
+	durationSeconds := duration.GetSongDurationSeconds(file, tags)
+
+	trackNumber, _ := tags.Track()
+
+	result := Song{
+		TrackNumber:  trackNumber,
+		Title:        tags.Title(),
+		Artist:       tags.Artist(),
+		Album:        tags.Album(),
+		Duration:     durationSeconds,
+		BasePath:     basePath,
+		RelativePath: scannedFile.RelativePath,
+		ModifiedDate: scannedFile.ModifiedDate,
+	}
+
+	return &result, nil
 }

+ 24 - 24
music-player/pkg/read/audio_test.go

@@ -13,28 +13,28 @@ import (
 
 var _ = Describe("reading audio files", func() {
 
-  rootDir, _ := os.Getwd()
-  basePath := path.Join(rootDir, read.TestDirectory)
-
-  Context("when the file is ogg vorbis", func() {
-    It("should get the expected info from the file", func() {
-      result, err := read.ReadFile(basePath, &read.File{
-	RelativePath: read.TestSong.RelativePath,
-	ModifiedDate: 102118,
-      })
-
-      Expect(err).To(BeNil())
-
-      Expect(*result).To(Equal(read.Song{
-	TrackNumber: 23,
-	Title: "Impact Moderato",
-	Artist: "Kevin MacLeod",
-	Album: "YouTube Audio Library",
-	Duration: 74,
-	BasePath: basePath,
-	RelativePath: "file_example_OOG_1MG.ogg",
-	ModifiedDate: 102118,
-      }))
-    })
-  })
+	rootDir, _ := os.Getwd()
+	basePath := path.Join(rootDir, read.TestDirectory)
+
+	Context("when the file is ogg vorbis", func() {
+		It("should get the expected info from the file", func() {
+			result, err := read.ReadFile(basePath, &read.File{
+				RelativePath: read.TestSong.RelativePath,
+				ModifiedDate: 102118,
+			})
+
+			Expect(err).To(BeNil())
+
+			Expect(*result).To(Equal(read.Song{
+				TrackNumber:  23,
+				Title:        "Impact Moderato",
+				Artist:       "Kevin MacLeod",
+				Album:        "YouTube Audio Library",
+				Duration:     74,
+				BasePath:     basePath,
+				RelativePath: "file_example_OOG_1MG.ogg",
+				ModifiedDate: 102118,
+			}))
+		})
+	})
 })

+ 10 - 10
music-player/pkg/read/duration/main.go

@@ -1,18 +1,18 @@
 package duration
 
 import (
-  "fmt"
-  "os"
+	"fmt"
+	"os"
 
-  tag "github.com/dhowden/tag"
+	tag "github.com/dhowden/tag"
 )
 
 func GetSongDurationSeconds(file *os.File, tags tag.Metadata) int {
-  switch tags.Format() {
-  case "VORBIS":
-    return GetSongDurationSecondsVorbis(file)
-  default:
-    fmt.Printf("Unrecognised format: %s\n", tags.Format())
-    return 0
-  }
+	switch tags.Format() {
+	case "VORBIS":
+		return GetSongDurationSecondsVorbis(file)
+	default:
+		fmt.Printf("Unrecognised format: %s\n", tags.Format())
+		return 0
+	}
 }

+ 14 - 14
music-player/pkg/read/duration/vorbis.go

@@ -8,21 +8,21 @@ import (
 )
 
 func GetSongDurationSecondsVorbis(file *os.File) int {
-  fileName := file.Name()
-  ovFile, ovErr := ov.Fopen(fileName)
-  if ovErr != nil {
-    // TODO: log these errors to the DB
-    log.Printf("[vorbis] Error opening file (%s): %v\n", fileName, ovErr)
-    return 0
-  }
+	fileName := file.Name()
+	ovFile, ovErr := ov.Fopen(fileName)
+	if ovErr != nil {
+		// TODO: log these errors to the DB
+		log.Printf("[vorbis] Error opening file (%s): %v\n", fileName, ovErr)
+		return 0
+	}
 
-  result, timeErr := ov.TimeTotal(ovFile, -1)
-  if timeErr != nil {
-    log.Printf("[vorbis] Error getting duration (%s): %v\n", fileName, timeErr)
-    return 0
-  }
+	result, timeErr := ov.TimeTotal(ovFile, -1)
+	if timeErr != nil {
+		log.Printf("[vorbis] Error getting duration (%s): %v\n", fileName, timeErr)
+		return 0
+	}
 
-  ov.Clear(ovFile)
+	ov.Clear(ovFile)
 
-  return int(result)
+	return int(result)
 }

+ 5 - 5
music-player/pkg/read/duration/vorbis_test.go

@@ -11,11 +11,11 @@ import (
 )
 
 var _ = Describe("Reading ogg vorbis duration", func() {
-  It("should get the correct duration in seconds", func() {
-    file, _ := os.Open("pkg/read/testdata/file_example_OOG_1MG.ogg")
+	It("should get the correct duration in seconds", func() {
+		file, _ := os.Open("pkg/read/testdata/file_example_OOG_1MG.ogg")
 
-    result := duration.GetSongDurationSecondsVorbis(file)
+		result := duration.GetSongDurationSecondsVorbis(file)
 
-    Expect(result).To(Equal(74))
-  })
+		Expect(result).To(Equal(74))
+	})
 })

+ 166 - 166
music-player/pkg/read/files.go

@@ -12,125 +12,125 @@ import (
 )
 
 const BATCH_SIZE = 100
-const LOG_EVERY = 100;
+const LOG_EVERY = 100
 
 func ReadMultipleFiles(basePath string, files chan *File) chan *Song {
-  var l = logger.CreateLogger(config.GetConfig().LogLevel)
-
-  songs := make(chan *Song)
-
-  go func() {
-    defer func() {
-      l.Verbose("[READ] Finished reading files")
-      close(songs)
-    }()
-
-    for {
-      select {
-      case file, more := <- files:
-        if more {
-          l.Debug("[READ] %s\n", file.RelativePath)
-          song, err := ReadFile(basePath, file)
-
-          if err == nil {
-            songs <- song
-          } else {
-            l.Error("[READ] Error (%s): %v\n", file.RelativePath, err)
-          }
-        } else {
-          return
-        }
-      }
-    }
-  }()
-
-  return songs
+	var l = logger.CreateLogger(config.GetConfig().LogLevel)
+
+	songs := make(chan *Song)
+
+	go func() {
+		defer func() {
+			l.Verbose("[READ] Finished reading files")
+			close(songs)
+		}()
+
+		for {
+			select {
+			case file, more := <-files:
+				if more {
+					l.Debug("[READ] %s\n", file.RelativePath)
+					song, err := ReadFile(basePath, file)
+
+					if err == nil {
+						songs <- song
+					} else {
+						l.Error("[READ] Error (%s): %v\n", file.RelativePath, err)
+					}
+				} else {
+					return
+				}
+			}
+		}
+	}()
+
+	return songs
 }
 
 func isValidFile(file string) bool {
-  // TODO: support FLAC/MP3
-  return filepath.Ext(file) == ".ogg"
+	// TODO: support FLAC/MP3
+	return filepath.Ext(file) == ".ogg"
 }
 
 func recursiveDirScan(
-  db *sqlx.DB,
-  l *logger.Logger,
-  allFiles *chan *File,
-  rootDirectory string,
-  relativePath string,
-  isRoot bool,
+	db *sqlx.DB,
+	l *logger.Logger,
+	allFiles *chan *File,
+	rootDirectory string,
+	relativePath string,
+	isRoot bool,
 ) {
-  directoryToScan := filepath.Join(rootDirectory, relativePath)
-
-  if (isRoot) {
-    l.Verbose("[SCAN] (root): %s\n", directoryToScan)
-
-    defer func() {
-      l.Verbose("[SCAN] Finished scanning directory")
-      close(*allFiles)
-    }()
-  } else {
-    l.Debug("[SCAN] %s\n", directoryToScan)
-  }
-
-  files, err := ioutil.ReadDir(directoryToScan)
-
-  if err != nil {
-    l.Error("[SCAN] Error (%s): %v", directoryToScan, err)
-    return // TODO: add this to a table of failed directories
-  }
-
-  for _, file := range(files) {
-    fileRelativePath := filepath.Join(relativePath, file.Name())
-
-    if file.IsDir() {
-      recursiveDirScan(
-        db,
-        l,
-        allFiles,
-        rootDirectory,
-        fileRelativePath,
-        false,
-      )
-    } else if isValidFile(file.Name()) {
-      *allFiles <- &File{
-        RelativePath: fileRelativePath,
-        ModifiedDate: file.ModTime().Unix(),
-      }
-    }
-  }
+	directoryToScan := filepath.Join(rootDirectory, relativePath)
+
+	if isRoot {
+		l.Verbose("[SCAN] (root): %s\n", directoryToScan)
+
+		defer func() {
+			l.Verbose("[SCAN] Finished scanning directory")
+			close(*allFiles)
+		}()
+	} else {
+		l.Debug("[SCAN] %s\n", directoryToScan)
+	}
+
+	files, err := ioutil.ReadDir(directoryToScan)
+
+	if err != nil {
+		l.Error("[SCAN] Error (%s): %v", directoryToScan, err)
+		return // TODO: add this to a table of failed directories
+	}
+
+	for _, file := range files {
+		fileRelativePath := filepath.Join(relativePath, file.Name())
+
+		if file.IsDir() {
+			recursiveDirScan(
+				db,
+				l,
+				allFiles,
+				rootDirectory,
+				fileRelativePath,
+				false,
+			)
+		} else if isValidFile(file.Name()) {
+			*allFiles <- &File{
+				RelativePath: fileRelativePath,
+				ModifiedDate: file.ModTime().Unix(),
+			}
+		}
+	}
 }
 
 func batchFilterFiles(
-  db *sqlx.DB,
-  l *logger.Logger,
-  filteredOutput *chan *File,
-  allFiles *chan *File,
-  basePath string,
+	db *sqlx.DB,
+	l *logger.Logger,
+	filteredOutput *chan *File,
+	allFiles *chan *File,
+	basePath string,
 ) {
-  defer close(*filteredOutput)
+	defer close(*filteredOutput)
 
-  var batch [BATCH_SIZE]*File
-  var batchSize = 0
-  var numFiltered = 0
+	var batch [BATCH_SIZE]*File
+	var batchSize = 0
+	var numFiltered = 0
 
-  var processBatch = func() {
-    if batchSize == 0 {
-      return
-    }
+	var processBatch = func() {
+		if batchSize == 0 {
+			return
+		}
 
-    l.Debug("[FILTER] Processing batch\n")
+		l.Debug("[FILTER] Processing batch\n")
 
-    var relativePaths pq.StringArray
-    var modifiedDates pq.Int64Array
+		var relativePaths pq.StringArray
+		var modifiedDates pq.Int64Array
 
-    for i := 0; i < batchSize; i++ {
-      relativePaths = append(relativePaths, batch[i].RelativePath)
-      modifiedDates = append(modifiedDates, batch[i].ModifiedDate)
-    }
+		for i := 0; i < batchSize; i++ {
+			relativePaths = append(relativePaths, batch[i].RelativePath)
+			modifiedDates = append(modifiedDates, batch[i].ModifiedDate)
+		}
 
-    newOrUpdatedFiles, err := db.Queryx(
-      `
+		newOrUpdatedFiles, err := db.Queryx(
+			`
       select r.relative_path, r.modified_date
       from (
         select * from unnest($1::varchar[], $2::bigint[])
@@ -144,73 +144,73 @@ func batchFilterFiles(
 
       where songs.id is null
       `,
-      relativePaths,
-      modifiedDates,
-      basePath,
-    )
-
-    if err != nil {
-      l.Fatal("[FILTER] Fatal error! %v\n", err)
-    }
-
-    for newOrUpdatedFiles.Next() {
-      var file File
-      newOrUpdatedFiles.StructScan(&file)
-
-      l.Verbose("[NEW] %s\n", file.RelativePath)
-
-      *filteredOutput <- &file
-    }
-
-    batchSize = 0
-    newOrUpdatedFiles.Close()
-  }
-
-  for {
-    select {
-    case file, more := <- *allFiles:
-      if !more {
-        processBatch()
-        l.Verbose("[FILTER] Finished filtering %d files\n", numFiltered)
-        return
-      }
-
-      batch[batchSize] = file
-      batchSize++
-
-      numFiltered++
-      if numFiltered % LOG_EVERY == 0 {
-        l.Verbose("[FILTER] Processed %d\n", numFiltered)
-      }
-
-      if batchSize >= BATCH_SIZE {
-        processBatch()
-      }
-    }
-  }
+			relativePaths,
+			modifiedDates,
+			basePath,
+		)
+
+		if err != nil {
+			l.Fatal("[FILTER] Fatal error! %v\n", err)
+		}
+
+		for newOrUpdatedFiles.Next() {
+			var file File
+			newOrUpdatedFiles.StructScan(&file)
+
+			l.Verbose("[NEW] %s\n", file.RelativePath)
+
+			*filteredOutput <- &file
+		}
+
+		batchSize = 0
+		newOrUpdatedFiles.Close()
+	}
+
+	for {
+		select {
+		case file, more := <-*allFiles:
+			if !more {
+				processBatch()
+				l.Verbose("[FILTER] Finished filtering %d files\n", numFiltered)
+				return
+			}
+
+			batch[batchSize] = file
+			batchSize++
+
+			numFiltered++
+			if numFiltered%LOG_EVERY == 0 {
+				l.Verbose("[FILTER] Processed %d\n", numFiltered)
+			}
+
+			if batchSize >= BATCH_SIZE {
+				processBatch()
+			}
+		}
+	}
 }
 
 func ScanDirectory(directory string) chan *File {
-  db := database.GetConnection()
-  l := logger.CreateLogger(config.GetConfig().LogLevel)
-
-  filteredOutput := make(chan *File)
-  allFiles := make(chan *File)
-
-  go func() {
-    batchFilterFiles(db, l, &filteredOutput, &allFiles, directory)
-  }()
-
-  go func() {
-    recursiveDirScan(
-      db,
-      l,
-      &allFiles,
-      directory,
-      "",
-      true,
-    )
-  }()
-
-  return filteredOutput
+	db := database.GetConnection()
+	l := logger.CreateLogger(config.GetConfig().LogLevel)
+
+	filteredOutput := make(chan *File)
+	allFiles := make(chan *File)
+
+	go func() {
+		batchFilterFiles(db, l, &filteredOutput, &allFiles, directory)
+	}()
+
+	go func() {
+		recursiveDirScan(
+			db,
+			l,
+			&allFiles,
+			directory,
+			"",
+			true,
+		)
+	}()
+
+	return filteredOutput
 }

+ 107 - 107
music-player/pkg/read/files_test.go

@@ -14,114 +14,114 @@ import (
 
 var _ = Describe("reading files", func() {
 
-  db := database.GetConnection()
-
-  BeforeEach(func() {
-    setup.PrepareDatabaseForTesting()
-  })
-
-  Describe("reading file info", func() {
-    var results []*read.Song
-
-    BeforeEach(func() {
-      results = nil
-      files := make(chan *read.File, 1)
-
-      go func() {
-	defer close(files)
-	files <- &read.File{
-	  RelativePath: read.TestSong.RelativePath,
-	  ModifiedDate: 100123,
-	}
-      }()
-
-      outputChan := read.ReadMultipleFiles(read.TestDirectory, files)
-
-      outputDone := false
-
-      for !outputDone {
-	select {
-	case result, more := <- outputChan:
-	  if more {
-	    results = append(results, result)
-	  }
-	  outputDone = !more
-	}
-      }
-    })
-
-    It("should return the correct number of results", func() {
-      Expect(results).To(HaveLen(1))
-    })
-
-    It("should get the correct info from the file", func() {
-      var expectedResult = read.TestSong
-      expectedResult.ModifiedDate = 100123
-
-      Expect(*results[0]).To(Equal(expectedResult))
-    })
-  })
-
-  Describe("scanning a directory recursively", func() {
-    var results []*read.File
-
-    var testScanDirectory = func() {
-      results = nil
-      files := read.ScanDirectory(read.TestDirectory)
-
-      done := false
-
-      for !done {
-	select {
-	case result, more := <- files:
-	  if more {
-	    results = append(results, result)
-	  }
-	  done = !more
-	}
-      }
-    }
-
-    Context("when the database is empty", func() {
-      BeforeEach(testScanDirectory)
-
-      It("should return a channel with all the files in the directory", func() {
-	Expect(results).To(HaveLen(2))
-
-	if results[0].RelativePath == read.TestSong.RelativePath {
-	  Expect(results[0].RelativePath).To(Equal(read.TestSong.RelativePath))
-	  Expect(results[1].RelativePath).To(Equal(read.TestSongNested.RelativePath))
-	} else {
-	  Expect(results[1].RelativePath).To(Equal(read.TestSong.RelativePath))
-	  Expect(results[0].RelativePath).To(Equal(read.TestSongNested.RelativePath))
-	}
-      })
-    })
-
-    Context("when the database already contains one of the files", func() {
-      BeforeEach(func() {
-	info, _ := os.Stat(path.Join(read.TestSong.BasePath, read.TestSong.RelativePath))
-	
-	db.MustExec(
-	  `
+	db := database.GetConnection()
+
+	BeforeEach(func() {
+		setup.PrepareDatabaseForTesting()
+	})
+
+	Describe("reading file info", func() {
+		var results []*read.Song
+
+		BeforeEach(func() {
+			results = nil
+			files := make(chan *read.File, 1)
+
+			go func() {
+				defer close(files)
+				files <- &read.File{
+					RelativePath: read.TestSong.RelativePath,
+					ModifiedDate: 100123,
+				}
+			}()
+
+			outputChan := read.ReadMultipleFiles(read.TestDirectory, files)
+
+			outputDone := false
+
+			for !outputDone {
+				select {
+				case result, more := <-outputChan:
+					if more {
+						results = append(results, result)
+					}
+					outputDone = !more
+				}
+			}
+		})
+
+		It("should return the correct number of results", func() {
+			Expect(results).To(HaveLen(1))
+		})
+
+		It("should get the correct info from the file", func() {
+			var expectedResult = read.TestSong
+			expectedResult.ModifiedDate = 100123
+
+			Expect(*results[0]).To(Equal(expectedResult))
+		})
+	})
+
+	Describe("scanning a directory recursively", func() {
+		var results []*read.File
+
+		var testScanDirectory = func() {
+			results = nil
+			files := read.ScanDirectory(read.TestDirectory)
+
+			done := false
+
+			for !done {
+				select {
+				case result, more := <-files:
+					if more {
+						results = append(results, result)
+					}
+					done = !more
+				}
+			}
+		}
+
+		Context("when the database is empty", func() {
+			BeforeEach(testScanDirectory)
+
+			It("should return a channel with all the files in the directory", func() {
+				Expect(results).To(HaveLen(2))
+
+				if results[0].RelativePath == read.TestSong.RelativePath {
+					Expect(results[0].RelativePath).To(Equal(read.TestSong.RelativePath))
+					Expect(results[1].RelativePath).To(Equal(read.TestSongNested.RelativePath))
+				} else {
+					Expect(results[1].RelativePath).To(Equal(read.TestSong.RelativePath))
+					Expect(results[0].RelativePath).To(Equal(read.TestSongNested.RelativePath))
+				}
+			})
+		})
+
+		Context("when the database already contains one of the files", func() {
+			BeforeEach(func() {
+				info, _ := os.Stat(path.Join(read.TestSong.BasePath, read.TestSong.RelativePath))
+
+				db.MustExec(
+					`
 	  insert into songs (title, artist, album, base_path, relative_path, modified_date)
 	  values ($1, $2, $3, $4, $5, $6)
 	  `,
-	  "old title",
-	  "old artist",
-	  "old album",
-	  read.TestSong.BasePath,
-	  read.TestSong.RelativePath,
-	  info.ModTime().Unix(),
-	)
-
-	testScanDirectory()
-      })
-
-      It("should only return those files which do not exist in the database", func() {
-	Expect(results).To(HaveLen(1))
-	Expect(results[0].RelativePath).To(Equal(read.TestSongNested.RelativePath))
-      })
-    })
-  })
+					"old title",
+					"old artist",
+					"old album",
+					read.TestSong.BasePath,
+					read.TestSong.RelativePath,
+					info.ModTime().Unix(),
+				)
+
+				testScanDirectory()
+			})
+
+			It("should only return those files which do not exist in the database", func() {
+				Expect(results).To(HaveLen(1))
+				Expect(results[0].RelativePath).To(Equal(read.TestSongNested.RelativePath))
+			})
+		})
+	})
 })

+ 14 - 14
music-player/pkg/read/test_file_info.go

@@ -3,21 +3,21 @@ package read
 const TestDirectory = "pkg/read/testdata"
 
 var TestSong = Song{
-  TrackNumber: 23,
-  Title: "Impact Moderato",
-  Artist: "Kevin MacLeod",
-  Album: "YouTube Audio Library",
-  Duration: 74,
-  BasePath: TestDirectory,
-  RelativePath: "file_example_OOG_1MG.ogg",
+	TrackNumber:  23,
+	Title:        "Impact Moderato",
+	Artist:       "Kevin MacLeod",
+	Album:        "YouTube Audio Library",
+	Duration:     74,
+	BasePath:     TestDirectory,
+	RelativePath: "file_example_OOG_1MG.ogg",
 }
 
 var TestSongNested = Song{
-  TrackNumber: 14,
-  Title: "Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante",
-  Artist: "Howard Shelley",
-  Album: "Clementi: The Complete Piano Sonatas, Vol. 4",
-  Duration: 166,
-  BasePath: TestDirectory,
-  RelativePath: "nested/14 Clementi Piano Sonata in D major, Op 25 No 6 - Movement 2 Un poco andante.ogg",
+	TrackNumber:  14,
+	Title:        "Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante",
+	Artist:       "Howard Shelley",
+	Album:        "Clementi: The Complete Piano Sonatas, Vol. 4",
+	Duration:     166,
+	BasePath:     TestDirectory,
+	RelativePath: "nested/14 Clementi Piano Sonata in D major, Op 25 No 6 - Movement 2 Un poco andante.ogg",
 }

+ 16 - 16
music-player/pkg/read/types.go

@@ -1,26 +1,26 @@
 package read
 
 type Song struct {
-  TrackNumber int 	`db:"track_number"`
-  Title string 		`db:"title"`
-  Artist string 	`db:"artist"`
-  Album string 		`db:"album"`
-  Duration int 		`db:"duration"`
-  BasePath string 	`db:"base_path"`
-  RelativePath string 	`db:"relative_path"`
-  ModifiedDate int64 	`db:"modified_date"` 
+	TrackNumber  int    `db:"track_number"`
+	Title        string `db:"title"`
+	Artist       string `db:"artist"`
+	Album        string `db:"album"`
+	Duration     int    `db:"duration"`
+	BasePath     string `db:"base_path"`
+	RelativePath string `db:"relative_path"`
+	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"`
+	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"`
+	RelativePath string `db:"relative_path"`
+	ModifiedDate int64  `db:"modified_date"`
 }

+ 60 - 54
music-player/pkg/repository/songs.go

@@ -11,9 +11,9 @@ import (
 const BATCH_SIZE = 100
 
 func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
-  var songs []*read.Song
+	var songs []*read.Song
 
-  err = db.Select(&songs, `
+	err = db.Select(&songs, `
   select
     track_number
     ,title
@@ -27,60 +27,66 @@ func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
   where id = $1
   `, int64(id))
 
-  if len(songs) == 0 {
-    err = errors.New("No such ID")
-  } else {
-    song = songs[0]
-  }
+	if len(songs) == 0 {
+		err = errors.New("No such ID")
+	} else {
+		song = songs[0]
+	}
 
-  return
+	return
 }
 
 func SelectPagedArtists(db *sqlx.DB, limit int, offset int) (artists *[]string, err error) {
-  artists = &[]string{}
-  err = db.Select(artists, `
+	artists = &[]string{}
+	err = db.Select(artists, `
   select distinct artist
   from songs
   order by artist
   limit $1
   offset $2
   `, limit, offset)
-  return
+	return
 }
 
 type CountRow struct {
-  Count int `db:"count"`
+	Count int `db:"count"`
 }
 
 func SelectArtistCount(db *sqlx.DB) (count int, err error) {
-  var countRow CountRow
+	var countRow CountRow
 
-  err = db.QueryRowx(`
+	err = db.QueryRowx(`
   select count(*) as count from (
     select distinct artist from songs
   ) distinct_artists
   `).StructScan(&countRow)
 
-  count = countRow.Count
+	count = countRow.Count
 
-  return
+	return
+}
+
+func SelectAllArtists(db *sqlx.DB) (artists *[]string, err error) {
+	artists = &[]string{}
+	err = db.Select(artists, `select distinct artist from songs order by artist`)
+	return
 }
 
 func SelectAlbumsByArtist(db *sqlx.DB, artist string) (albums *[]string, err error) {
-  albums = &[]string{}
-  err = db.Select(albums, `
+	albums = &[]string{}
+	err = db.Select(albums, `
   select distinct album
   from songs
   where artist = $1
   order by album
   `, artist)
 
-  return
+	return
 }
 
 func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExternal, err error) {
-  songs = &[]*read.SongExternal{}
-  err = db.Select(songs, `
+	songs = &[]*read.SongExternal{}
+	err = db.Select(songs, `
   select
     id
     ,track_number
@@ -90,39 +96,39 @@ func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExterna
     ,duration
   from songs
   where artist = $1
-  order by track_number, title, album
+  order by album, track_number, title
   `, artist)
 
-  return
+	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 trackNumbers pq.Int64Array
+	var titles pq.StringArray
+	var artists pq.StringArray
+	var albums pq.StringArray
+	var durations pq.Int64Array
 
-  var modifiedDates pq.Int64Array
+	var modifiedDates pq.Int64Array
 
-  var basePaths pq.StringArray
-  var relativePaths pq.StringArray
+	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))
+	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)
+		modifiedDates = append(modifiedDates, (*batch)[i].ModifiedDate)
 
-    basePaths = append(basePaths, (*batch)[i].BasePath)
-    relativePaths = append(relativePaths, (*batch)[i].RelativePath)
-  }
+		basePaths = append(basePaths, (*batch)[i].BasePath)
+		relativePaths = append(relativePaths, (*batch)[i].RelativePath)
+	}
 
-  _, err := db.Exec(
-    `
+	_, err := db.Exec(
+		`
     insert into songs (
       track_number
       ,title
@@ -152,15 +158,15 @@ func BatchUpsertSongs(db *sqlx.DB, batch *[BATCH_SIZE]*read.Song, batchSize int)
       ,duration = excluded.duration
       ,modified_date = excluded.modified_date
     `,
-    trackNumbers,
-    titles,
-    artists,
-    albums,
-    durations,
-    modifiedDates,
-    basePaths,
-    relativePaths,
-  )
-
-  return err
+		trackNumbers,
+		titles,
+		artists,
+		albums,
+		durations,
+		modifiedDates,
+		basePaths,
+		relativePaths,
+	)
+
+	return err
 }

+ 105 - 105
music-player/pkg/repository/songs_test.go

@@ -11,131 +11,131 @@ import (
 )
 
 var _ = Describe("songs repository", func() {
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  BeforeEach(func() {
-    setup.PrepareDatabaseForTesting()
-  })
+	BeforeEach(func() {
+		setup.PrepareDatabaseForTesting()
+	})
 
-  Describe("SelectSong", func() {
-    var id int64
+	Describe("SelectSong", func() {
+		var id int64
 
-    BeforeEach(func() {
-      db.QueryRowx(
-	`
+		BeforeEach(func() {
+			db.QueryRowx(
+				`
 	insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
 	values ($1, $2, $3, $4, $5, $6, $7, $8)
 	returning id
 	`,
-	7,
-	"Hey Jude",
-	"The Beatles",
-	"",
-	431,
-	8876,
-	"/path/to",
-	"file.ogg",
-      ).Scan(&id)
-    })
-
-    It("should retrieve a song from the database", func() {
-      Expect(id).NotTo(BeZero())
-
-      result, err := repository.SelectSong(db, int(id))
-
-      Expect(err).To(BeNil())
-
-      Expect(result).To(Equal(&read.Song{
-	TrackNumber: 7,
-	Title: "Hey Jude",
-	Artist: "The Beatles",
-	Album: "",
-	Duration: 431,
-	BasePath: "/path/to",
-	RelativePath: "file.ogg",
-	ModifiedDate: 8876,
-      }))
-    })
-
-    Context("when the song does not exist", func() {
-      It("should return an error", func() {
-	result, err := repository.SelectSong(db, 88113)
-
-	Expect(err).To(MatchError("No such ID"))
-	Expect(result).To(BeNil())
-      })
-    })
-  })
-
-  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, `
+				7,
+				"Hey Jude",
+				"The Beatles",
+				"",
+				431,
+				8876,
+				"/path/to",
+				"file.ogg",
+			).Scan(&id)
+		})
+
+		It("should retrieve a song from the database", func() {
+			Expect(id).NotTo(BeZero())
+
+			result, err := repository.SelectSong(db, int(id))
+
+			Expect(err).To(BeNil())
+
+			Expect(result).To(Equal(&read.Song{
+				TrackNumber:  7,
+				Title:        "Hey Jude",
+				Artist:       "The Beatles",
+				Album:        "",
+				Duration:     431,
+				BasePath:     "/path/to",
+				RelativePath: "file.ogg",
+				ModifiedDate: 8876,
+			}))
+		})
+
+		Context("when the song does not exist", func() {
+			It("should return an error", func() {
+				result, err := repository.SelectSong(db, 88113)
+
+				Expect(err).To(MatchError("No such ID"))
+				Expect(result).To(BeNil())
+			})
+		})
+	})
+
+	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))
-      })
-    })
+				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
+		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] = songs[0]
+			modifiedBatch[1] = songs[1]
 
-      modifiedBatch[0].Title = "Title A modified"
+			modifiedBatch[0].Title = "Title A modified"
 
-      BeforeEach(func() {
-	repository.BatchUpsertSongs(db, &songs, 2)
+			BeforeEach(func() {
+				repository.BatchUpsertSongs(db, &songs, 2)
 
-	repository.BatchUpsertSongs(db, &modifiedBatch, 2)
+				repository.BatchUpsertSongs(db, &modifiedBatch, 2)
 
-	db.Select(&result, `
+				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 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))
-      })
-    })
-  })
+			It("should update the rows with any changes", func() {
+				Expect(modifiedBatch[0]).To(BeElementOf(result))
+			})
+		})
+	})
 })

+ 20 - 20
music-player/pkg/server/actions.go

@@ -9,35 +9,35 @@ import (
 type ActionType string
 
 const (
-  StateSet ActionType = "STATE_SET"
-  ClientListUpdated = "CLIENT_LIST_UPDATED"
+	StateSet          ActionType = "STATE_SET"
+	ClientListUpdated            = "CLIENT_LIST_UPDATED"
 )
 
 type Action struct {
-  Type ActionType 	`json:"type"`
-  FromClient *string 	`json:"fromClient"`
-  Payload interface{} 	`json:"payload"`
+	Type       ActionType  `json:"type"`
+	FromClient *string     `json:"fromClient"`
+	Payload    interface{} `json:"payload"`
 }
 
 func broadcastAction(thisPodClients *map[string]*Client, action *Action) []error {
-  var errors []error
+	var errors []error
 
-  for _, client := range(*thisPodClients) {
-    if err := client.send(action); err != nil {
-      errors = append(errors, err)
-    }
-  }
+	for _, client := range *thisPodClients {
+		if err := client.send(action); err != nil {
+			errors = append(errors, err)
+		}
+	}
 
-  return errors
+	return errors
 }
 
 func publishAction(rdb *redis.Client, action *Action) error {
-  pubsubPayload, err := json.Marshal(action)
-  if err != nil {
-    return err
-  }
-  if _, err := rdb.Publish(TOPIC_BROADCAST, pubsubPayload).Result(); err != nil {
-    return err
-  }
-  return nil
+	pubsubPayload, err := json.Marshal(action)
+	if err != nil {
+		return err
+	}
+	if _, err := rdb.Publish(TOPIC_BROADCAST, pubsubPayload).Result(); err != nil {
+		return err
+	}
+	return nil
 }

+ 92 - 92
music-player/pkg/server/clients.go

@@ -9,125 +9,125 @@ import (
 )
 
 func getClientNameFromRequest(r *http.Request) string {
-  return r.URL.Query().Get(CLIENT_QUERY_NAME)
+	return r.URL.Query().Get(CLIENT_QUERY_NAME)
 }
 
 func endSubscription(sub *redis.PubSub) error {
-  if err := sub.Unsubscribe(); err != nil {
-    return err
-  }
-  if err := sub.Close(); err != nil {
-    return err
-  }
-  return nil
+	if err := sub.Unsubscribe(); err != nil {
+		return err
+	}
+	if err := sub.Close(); err != nil {
+		return err
+	}
+	return nil
 }
 
 func publishClientList(l *logger.Logger, rdb *redis.Client) error {
-  clients, err := rdb.ZRangeWithScores(KEY_CLIENT_NAMES, 0, -1).Result()
-  if err != nil {
-    return err
-  }
-
-  var members []*Member
-  for _, m := range(clients) {
-    members = append(members, &Member{
-      Name: m.Member.(string),
-      LastPing: int64(m.Score),
-    })
-  }
-
-  actionClientListUpdated := Action{
-    Type: ClientListUpdated,
-    Payload: members,
-  }
-
-  if err := publishAction(rdb, &actionClientListUpdated); err != nil {
-    return err
-  }
-  return nil
+	clients, err := rdb.ZRangeWithScores(KEY_CLIENT_NAMES, 0, -1).Result()
+	if err != nil {
+		return err
+	}
+
+	var members []*Member
+	for _, m := range clients {
+		members = append(members, &Member{
+			Name:     m.Member.(string),
+			LastPing: int64(m.Score),
+		})
+	}
+
+	actionClientListUpdated := Action{
+		Type:    ClientListUpdated,
+		Payload: members,
+	}
+
+	if err := publishAction(rdb, &actionClientListUpdated); err != nil {
+		return err
+	}
+	return nil
 }
 
 func (c *Client) send(message interface{}) error {
-  c.mu.Lock()
-  defer c.mu.Unlock()
-  return c.conn.WriteJSON(message)
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.conn.WriteJSON(message)
 }
 
 func (c *Client) exposeToNetwork(l *logger.Logger, rdb *redis.Client) error {
-  // Expose the client to all pods running the server
-  now := time.Now().Unix()
-
-  if _, err := rdb.ZAdd(KEY_CLIENT_NAMES, &redis.Z{
-    Score: float64(now),
-    Member: c.name,
-  }).Result(); err != nil {
-    return err
-  }
-  if err := publishClientList(l, rdb); err != nil {
-    return err
-  }
-  return nil
+	// Expose the client to all pods running the server
+	now := time.Now().Unix()
+
+	if _, err := rdb.ZAdd(KEY_CLIENT_NAMES, &redis.Z{
+		Score:  float64(now),
+		Member: c.name,
+	}).Result(); err != nil {
+		return err
+	}
+	if err := publishClientList(l, rdb); err != nil {
+		return err
+	}
+	return nil
 }
 
 func (c *Client) disposeFromNetwork(l *logger.Logger, rdb *redis.Client) error {
-  // Make sure other clients know when one goes away
-  if _, err := rdb.ZRem(KEY_CLIENT_NAMES, c.name).Result(); err != nil {
-    return err
-  }
-  if err := publishClientList(l, rdb); err != nil {
-    return err
-  }
-  return nil
+	// Make sure other clients know when one goes away
+	if _, err := rdb.ZRem(KEY_CLIENT_NAMES, c.name).Result(); err != nil {
+		return err
+	}
+	if err := publishClientList(l, rdb); err != nil {
+		return err
+	}
+	return nil
 }
 
 func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
-  // Subscribe this pod to messages from the client. This pod is responsible for
-  // onward publishing to other pods where necessary, via internal pubsub
-
-  for {
-    var actionFromClient Action
-    if err := c.conn.ReadJSON(&actionFromClient); err != nil {
-      close(c.closeChan)
-      return
-    }
-
-    if actionFromClient.Type == "PING" {
-      c.send(Action{
-	Type: "PONG",
-      })
-      c.exposeToNetwork(l, rdb)
-    } else {
-      l.Debug("[->Client] %s (%s)\n", actionFromClient.Type, c.name)
-
-      actionFromClient.FromClient = &c.name
-
-      if err := publishAction(rdb, &actionFromClient); err != nil {
-	l.Error("Error publishing action from client: %v\n", err)
-      }
-    }
-  }
+	// Subscribe this pod to messages from the client. This pod is responsible for
+	// onward publishing to other pods where necessary, via internal pubsub
+
+	for {
+		var actionFromClient Action
+		if err := c.conn.ReadJSON(&actionFromClient); err != nil {
+			close(c.closeChan)
+			return
+		}
+
+		if actionFromClient.Type == "PING" {
+			c.send(Action{
+				Type: "PONG",
+			})
+			c.exposeToNetwork(l, rdb)
+		} else {
+			l.Debug("[->Client] %s (%s)\n", actionFromClient.Type, c.name)
+
+			actionFromClient.FromClient = &c.name
+
+			if err := publishAction(rdb, &actionFromClient); err != nil {
+				l.Error("Error publishing action from client: %v\n", err)
+			}
+		}
+	}
 }
 
 func (c *Client) onConnect(l *logger.Logger, rdb *redis.Client) error {
-  l.Verbose("[Client connected] %s\n", c.name)
+	l.Verbose("[Client connected] %s\n", c.name)
 
-  if err := c.exposeToNetwork(l, rdb); err != nil {
-    l.Error("Error exposing new client to network: %v\n", err)
-    return err
-  }
+	if err := c.exposeToNetwork(l, rdb); err != nil {
+		l.Error("Error exposing new client to network: %v\n", err)
+		return err
+	}
 
-  c.subscribeToMe(l, rdb)
+	c.subscribeToMe(l, rdb)
 
-  return nil
+	return nil
 }
 
 func (c *Client) onDisconnect(l *logger.Logger, rdb *redis.Client) error {
-  l.Verbose("[Client disconnected] %s\n", c.name)
+	l.Verbose("[Client disconnected] %s\n", c.name)
 
-  if err := c.disposeFromNetwork(l, rdb); err != nil {
-    l.Error("Error disposing client from network: %v\n", err)
-    return err
-  }
+	if err := c.disposeFromNetwork(l, rdb); err != nil {
+		l.Error("Error disposing client from network: %v\n", err)
+		return err
+	}
 
-  return nil
+	return nil
 }

+ 86 - 105
music-player/pkg/server/fetch.go

@@ -9,139 +9,120 @@ import (
 	"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"`
+	Artists []string `json:"artists"`
 }
 
 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
+	db := database.GetConnection()
+	artists, err := repository.SelectAllArtists(db)
+	if err != nil {
+		return err
+	}
+
+	response, err := json.Marshal(ArtistsResponse{
+		Artists: *artists,
+	})
+	if err != nil {
+		return err
+	}
+
+	w.Write(response)
+	return nil
 }
 
 type AlbumsResponse struct {
-  Artist string 	`json:"artist"`
-  Albums []string 	`json:"albums"`
+	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")
+	artist := r.URL.Query().Get("artist")
 
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  albums, err := repository.SelectAlbumsByArtist(db, artist)
-  if err != nil {
-    return err
-  }
+	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
-  }
+	response, err := json.Marshal(AlbumsResponse{
+		Artist: artist,
+		Albums: *albums,
+	})
+	if err != nil {
+		return err
+	}
 
-  w.Write(response)
-  return nil
+	w.Write(response)
+	return nil
 }
 
 type SongsResponse struct {
-  Artist string 		`json:"artist"`
-  Songs *[]*read.SongExternal 	`json:"songs"`
+	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")
+	artist := r.URL.Query().Get("artist")
 
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  songs, err := repository.SelectSongsByArtist(db, artist)
-  if err != nil {
-    return err
-  }
+	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
-  }
+	response, err := json.Marshal(SongsResponse{
+		Artist: artist,
+		Songs:  songs,
+	})
+	if err != nil {
+		return err
+	}
 
-  w.Write(response)
-  return nil
+	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
+	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
 }

+ 11 - 11
music-player/pkg/server/handler.go

@@ -7,21 +7,21 @@ import (
 	"github.com/go-redis/redis/v7"
 )
 
-type RouteHandler func (l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error
+type RouteHandler func(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error
 
 func routeHandler(
-  l *logger.Logger,
-  rdb *redis.Client,
-  handler RouteHandler,
+	l *logger.Logger,
+	rdb *redis.Client,
+	handler RouteHandler,
 ) func(w http.ResponseWriter, r *http.Request) {
 
-  return func(w http.ResponseWriter, r *http.Request) {
-    err := handler(l, rdb, w, r)
+	return func(w http.ResponseWriter, r *http.Request) {
+		err := handler(l, rdb, w, r)
 
-    if err != nil {
-      l.Error("Unhandled error during request: %v\n", err)
+		if err != nil {
+			l.Error("Unhandled error during request: %v\n", err)
 
-      http.Error(w, "Unhandled error", http.StatusInternalServerError)
-    }
-  }
+			http.Error(w, "Unhandled error", http.StatusInternalServerError)
+		}
+	}
 }

+ 91 - 91
music-player/pkg/server/pubsub.go

@@ -13,112 +13,112 @@ import (
 )
 
 var upgrader = websocket.Upgrader{
-  CheckOrigin: func(r *http.Request) bool {
-    return true
-  },
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
 }
 
 func handleClientSubscription(thisPodClients *map[string]*Client) RouteHandler {
-  return func(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
-    clientName := getClientNameFromRequest(r)
-    if len(clientName) == 0 {
-      w.WriteHeader(400)
-      w.Write([]byte("Must set client name in query"))
-      return nil
-    }
-
-    conn, err := upgrader.Upgrade(w, r, nil)
-    if err != nil {
-      w.WriteHeader(400)
-      w.Write([]byte("Incorrect client config to upgrade WS"))
-      return nil
-    }
-
-    client := Client{
-      name: clientName,
-      conn: conn,
-      closeChan: make(chan bool),
-    }
-
-    (*thisPodClients)[client.name] = &client
-
-    defer conn.Close()
-
-    go func() {
-      for {
-	select {
-	case <- client.closeChan:
-	  l.Verbose("Caught closeChan call, closing... %s\n", client.name)
-	  if _, ok := (*thisPodClients)[client.name]; ok {
-	    delete(*thisPodClients, client.name)
-	  }
-	  client.onDisconnect(l, rdb)
-	  return
+	return func(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+		clientName := getClientNameFromRequest(r)
+		if len(clientName) == 0 {
+			w.WriteHeader(400)
+			w.Write([]byte("Must set client name in query"))
+			return nil
+		}
+
+		conn, err := upgrader.Upgrade(w, r, nil)
+		if err != nil {
+			w.WriteHeader(400)
+			w.Write([]byte("Incorrect client config to upgrade WS"))
+			return nil
+		}
+
+		client := Client{
+			name:      clientName,
+			conn:      conn,
+			closeChan: make(chan bool),
+		}
+
+		(*thisPodClients)[client.name] = &client
+
+		defer conn.Close()
+
+		go func() {
+			for {
+				select {
+				case <-client.closeChan:
+					l.Verbose("Caught closeChan call, closing... %s\n", client.name)
+					if _, ok := (*thisPodClients)[client.name]; ok {
+						delete(*thisPodClients, client.name)
+					}
+					client.onDisconnect(l, rdb)
+					return
+				}
+			}
+		}()
+
+		if err := client.onConnect(l, rdb); err != nil {
+			l.Error("Error connecting client: %v\n", err)
+			return err
+		}
+
+		return nil
 	}
-      }
-    }()
-
-    if err := client.onConnect(l, rdb); err != nil {
-      l.Error("Error connecting client: %v\n", err)
-      return err
-    }
-
-    return nil
-  }
 }
 
 func subscribeToBroadcast(
-  l *logger.Logger,
-  rdb *redis.Client,
-  thisPodClients *map[string]*Client,
+	l *logger.Logger,
+	rdb *redis.Client,
+	thisPodClients *map[string]*Client,
 ) {
-  // Subscribe all of this pod's clients to messages from any pod, via internal pubsub
-  subscription := rdb.Subscribe(TOPIC_BROADCAST)
-
-  for {
-    select {
-    case msg, ok := <- subscription.Channel():
-      if !ok {
-	return
-      }
-
-      var actionFromPubsub Action
-
-      if err := json.Unmarshal([]byte(msg.Payload), &actionFromPubsub); err != nil {
-	l.Error("Invalid action from pubsub: %v\n", err)
-
-      } else {
-	if actionFromPubsub.FromClient == nil {
-	  l.Debug("[<-Server] %s\n", actionFromPubsub.Type)
-	} else {
-	  l.Debug("[<-Client] %s (%s)\n", actionFromPubsub.Type, *actionFromPubsub.FromClient)
-	}
-
-	errors := broadcastAction(thisPodClients, &actionFromPubsub)
-
-	if len(errors) > 0 {
-	  l.Warn("Error broadcasting: %v\n", errors)
+	// Subscribe all of this pod's clients to messages from any pod, via internal pubsub
+	subscription := rdb.Subscribe(TOPIC_BROADCAST)
+
+	for {
+		select {
+		case msg, ok := <-subscription.Channel():
+			if !ok {
+				return
+			}
+
+			var actionFromPubsub Action
+
+			if err := json.Unmarshal([]byte(msg.Payload), &actionFromPubsub); err != nil {
+				l.Error("Invalid action from pubsub: %v\n", err)
+
+			} else {
+				if actionFromPubsub.FromClient == nil {
+					l.Debug("[<-Server] %s\n", actionFromPubsub.Type)
+				} else {
+					l.Debug("[<-Client] %s (%s)\n", actionFromPubsub.Type, *actionFromPubsub.FromClient)
+				}
+
+				errors := broadcastAction(thisPodClients, &actionFromPubsub)
+
+				if len(errors) > 0 {
+					l.Warn("Error broadcasting: %v\n", errors)
+				}
+			}
+		}
 	}
-      }
-    }
-  }
 }
 
 func pruneDisappearedClients(l *logger.Logger, rdb *redis.Client) {
-  for {
-    now := time.Now().Unix()
-    rdb.ZRemRangeByScore(KEY_CLIENT_NAMES, "0", fmt.Sprintf("%d", now - CLIENT_TTL_SEC))
+	for {
+		now := time.Now().Unix()
+		rdb.ZRemRangeByScore(KEY_CLIENT_NAMES, "0", fmt.Sprintf("%d", now-CLIENT_TTL_SEC))
 
-    time.Sleep(CLIENT_TTL_SEC * time.Second)
-  }
+		time.Sleep(CLIENT_TTL_SEC * time.Second)
+	}
 }
 
 func initPubsub(l *logger.Logger, rdb *redis.Client, router *mux.Router) {
-  thisPodClients := make(map[string]*Client)
-  go subscribeToBroadcast(l, rdb, &thisPodClients)
-  go pruneDisappearedClients(l, rdb)
+	thisPodClients := make(map[string]*Client)
+	go subscribeToBroadcast(l, rdb, &thisPodClients)
+	go pruneDisappearedClients(l, rdb)
 
-  router.Path("/pubsub").Methods("GET").HandlerFunc(
-    routeHandler(l, rdb, handleClientSubscription(&thisPodClients)),
-  )
+	router.Path("/pubsub").Methods("GET").HandlerFunc(
+		routeHandler(l, rdb, handleClientSubscription(&thisPodClients)),
+	)
 }

+ 15 - 15
music-player/pkg/server/server.go

@@ -13,28 +13,28 @@ import (
 )
 
 func StartServer() {
-  conf := config.GetConfig()
-  l := logger.CreateLogger(conf.LogLevel)
+	conf := config.GetConfig()
+	l := logger.CreateLogger(conf.LogLevel)
 
-  rdb := redis.NewClient(&redis.Options{ Addr: conf.RedisUrl })
-  defer rdb.Close()
+	rdb := redis.NewClient(&redis.Options{Addr: conf.RedisUrl})
+	defer rdb.Close()
 
-  router := mux.NewRouter()
+	router := mux.NewRouter()
 
-  initPubsub(l, rdb, router)
+	initPubsub(l, rdb, router)
 
-  router.Path("/stream").Methods("GET").HandlerFunc(routeHandler(l, rdb, streamSong))
+	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("/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))
+	router.Path("/song-info").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongInfo))
 
-  port := conf.Port
+	port := conf.Port
 
-  handler := cors.AllowAll().Handler(router)
+	handler := cors.AllowAll().Handler(router)
 
-  l.Info("Starting server on port %d\n", port)
-  log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), handler))
+	l.Info("Starting server on port %d\n", port)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), handler))
 }

+ 11 - 11
music-player/pkg/server/state.go

@@ -7,15 +7,15 @@ import (
 )
 
 type Client struct {
-  name string
-  conn *websocket.Conn
-  closeChan chan bool
-  mu sync.Mutex
+	name      string
+	conn      *websocket.Conn
+	closeChan chan bool
+	mu        sync.Mutex
 }
 
 type Member struct {
-  Name string 		`json:"name"`
-  LastPing int64 	`json:"lastPing"`
+	Name     string `json:"name"`
+	LastPing int64  `json:"lastPing"`
 }
 
 // Except for the client list, the application is stateless server-side.
@@ -31,10 +31,10 @@ type Member struct {
 // 2. Keeping the server updated regularly about the current state
 
 type MusicPlayer struct {
-  SongId int 		`json:"songId"`
-  Playing bool 		`json:"playing"`
-  CurrentTime float32 	`json:"currentTime"`
-  SeekTime int 		`json:"setTime"`
+	SongId      int     `json:"songId"`
+	Playing     bool    `json:"playing"`
+	CurrentTime float32 `json:"currentTime"`
+	SeekTime    int     `json:"setTime"`
 
-  Master string 	`json:"master"`
+	Master string `json:"master"`
 }

+ 27 - 27
music-player/pkg/server/stream.go

@@ -14,41 +14,41 @@ import (
 )
 
 func streamSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
-  songIdQuery := r.URL.Query().Get("songid")
-  songId, err := strconv.Atoi(songIdQuery)
-  if err != nil {
-    w.WriteHeader(400)
-    w.Write([]byte("Must set songid to an int"))
-    return nil
-  }
+	songIdQuery := r.URL.Query().Get("songid")
+	songId, err := strconv.Atoi(songIdQuery)
+	if err != nil {
+		w.WriteHeader(400)
+		w.Write([]byte("Must set songid to an int"))
+		return nil
+	}
 
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  song, err := repository.SelectSong(db, songId)
-  if err != nil {
-    if err.Error() == "No such ID" {
-      w.WriteHeader(404)
-      w.Write([]byte("No such song"))
-      return nil
-    }
+	song, err := repository.SelectSong(db, songId)
+	if err != nil {
+		if err.Error() == "No such ID" {
+			w.WriteHeader(404)
+			w.Write([]byte("No such song"))
+			return nil
+		}
 
-    return err
-  }
+		return err
+	}
 
-  fullFilePath := fmt.Sprintf("%s/%s", song.BasePath, song.RelativePath)
+	fullFilePath := fmt.Sprintf("%s/%s", song.BasePath, song.RelativePath)
 
-  l.Debug("Streaming to client: %s\n", fullFilePath)
+	l.Debug("Streaming to client: %s\n", fullFilePath)
 
-  file, err := os.Open(fullFilePath)
-  if err != nil {
-    return err
-  }
+	file, err := os.Open(fullFilePath)
+	if err != nil {
+		return err
+	}
 
-  defer file.Close()
+	defer file.Close()
 
-  w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Cache-Control", "no-cache")
 
-  http.ServeContent(w, r, song.Title, time.Unix(song.ModifiedDate, 0), file)
+	http.ServeContent(w, r, song.Title, time.Unix(song.ModifiedDate, 0), file)
 
-  return nil
+	return nil
 }

+ 11 - 11
music-player/pkg/services/fetch.go

@@ -6,19 +6,19 @@ import (
 )
 
 func GetArtists(limit int, page int) (artists *[]string, more bool) {
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  artists, err := repository.SelectPagedArtists(db, limit, limit * page)
-  if err != nil {
-    panic(err)
-  }
+	artists, err := repository.SelectPagedArtists(db, limit, limit*page)
+	if err != nil {
+		panic(err)
+	}
 
-  total, err := repository.SelectArtistCount(db)
-  if err != nil {
-    panic(err)
-  }
+	total, err := repository.SelectArtistCount(db)
+	if err != nil {
+		panic(err)
+	}
 
-  more = limit * (1 + page) < total
+	more = limit*(1+page) < total
 
-  return
+	return
 }

+ 98 - 98
music-player/pkg/services/fetch_test.go

@@ -13,34 +13,34 @@ import (
 )
 
 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(
-	`
+	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
@@ -62,75 +62,75 @@ var _ = Describe("Fetching data", func() {
 	  ,$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())
+				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 - 53
music-player/pkg/services/scanner.go

@@ -8,68 +8,68 @@ import (
 	"github.com/felamaslen/go-music-player/pkg/repository"
 )
 
-const LOG_EVERY = 100;
+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()
-      }
-    }
-  }
+	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)
+	var l = logger.CreateLogger(config.GetConfig().LogLevel)
 
-  l.Info("Scanning directory for files...\n")
-  files := read.ScanDirectory(musicDirectory)
+	l.Info("Scanning directory for files...\n")
+	files := read.ScanDirectory(musicDirectory)
 
-  l.Info("Reading files...\n")
-  songs := read.ReadMultipleFiles(musicDirectory, files)
+	l.Info("Reading files...\n")
+	songs := read.ReadMultipleFiles(musicDirectory, files)
 
-  l.Info("Inserting data...\n")
-  UpsertSongsFromChannel(songs)
+	l.Info("Inserting data...\n")
+	UpsertSongsFromChannel(songs)
 
-  l.Info("Finished scan and insert\n")
+	l.Info("Finished scan and insert\n")
 }

+ 142 - 142
music-player/pkg/services/scanner_test.go

@@ -11,119 +11,119 @@ import (
 )
 
 var _ = Describe("Music scanner service", func() {
-  db := database.GetConnection()
-
-  BeforeEach(func() {
-    setup.PrepareDatabaseForTesting()
-  })
-
-  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, `
+	db := database.GetConnection()
+
+	BeforeEach(func() {
+		setup.PrepareDatabaseForTesting()
+	})
+
+	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(
-	  `
+				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, `
+					"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))
-      })
+				Expect(count).To(Equal(1))
+			})
 
-      It("should upsert the existing item", func() {
-	var songs []read.Song
-	db.Select(&songs, `
+			It("should upsert the existing item", func() {
+				var songs []read.Song
+				db.Select(&songs, `
 	select
 	  track_number
 	  ,title
@@ -137,50 +137,50 @@ var _ = Describe("Music scanner service", func() {
 	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, `
+				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))
-    })
-  })
+			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))
+		})
+	})
 })

+ 10 - 10
music-player/pkg/testing/testing.go

@@ -9,22 +9,22 @@ import (
 )
 
 func ChangeToRootDir() {
-  _, filename, _, _ := runtime.Caller(0)
-  dir := path.Join(path.Dir(filename), "../..")
-  err := os.Chdir(dir)
-  if err != nil {
-    panic(err)
-  }
+	_, filename, _, _ := runtime.Caller(0)
+	dir := path.Join(path.Dir(filename), "../..")
+	err := os.Chdir(dir)
+	if err != nil {
+		panic(err)
+	}
 }
 
 func init() {
-  ChangeToRootDir()
+	ChangeToRootDir()
 }
 
 func PrepareDatabaseForTesting() {
-  database.MigrateDatabase()
+	database.MigrateDatabase()
 
-  db := database.GetConnection()
+	db := database.GetConnection()
 
-  db.MustExec("truncate table songs")
+	db.MustExec("truncate table songs")
 }