Procházet zdrojové kódy

feat: scanner cronjob (#2)

* chore: makefile command to make a new migration

* feat: ginkgo for testing and sqlx

* feat: unique constraint on full filename

* feat: only scan files which have new paths or updated modification dates

* feat: batch filtering of scanned files

* feat: library directory environment variable

* feat: updated dockerfile

* feat: log an error and don't panic if a directory fails to scan

* feat: track number

* chore: moved config ceremony functions into separate file

* chore: moved batching logic into subscriber with better logging and naming

* chore: squashed migrations for this branch

* feat: batch insertion

* fix: close vorbis file after getting duration

* feat: manifest
Fela Maslen před 5 roky
rodič
revize
9c43ce622a
37 změnil soubory, kde provedl 1085 přidání a 449 odebrání
  1. 148 0
      k8s/manifest.yml
  2. 4 0
      music-player/.dockerignore
  3. 9 3
      music-player/Dockerfile
  4. 3 1
      music-player/Makefile
  5. 1 1
      music-player/README.md
  6. 10 7
      music-player/cmd/scan/main.go
  7. 8 2
      music-player/go.mod
  8. 30 1
      music-player/go.sum
  9. 2 0
      music-player/migrations/000002_unique_filename.down.sql
  10. 2 0
      music-player/migrations/000002_unique_filename.up.sql
  11. 3 0
      music-player/migrations/000003_song_file_modified_date.down.sql
  12. 5 0
      music-player/migrations/000003_song_file_modified_date.up.sql
  13. 2 0
      music-player/migrations/000004_track_number.down.sql
  14. 2 0
      music-player/migrations/000004_track_number.up.sql
  15. 103 0
      music-player/pkg/config/config.go
  16. 2 100
      music-player/pkg/config/main.go
  17. 52 0
      music-player/pkg/database/main.go
  18. 0 62
      music-player/pkg/db/main.go
  19. 14 11
      music-player/pkg/read/audio.go
  20. 31 20
      music-player/pkg/read/audio_test.go
  21. 13 0
      music-player/pkg/read/duration/duration_suite_test.go
  22. 4 4
      music-player/pkg/read/duration/main.go
  23. 16 10
      music-player/pkg/read/duration/vorbis.go
  24. 21 0
      music-player/pkg/read/duration/vorbis_test.go
  25. 147 24
      music-player/pkg/read/files.go
  26. 107 37
      music-player/pkg/read/files_test.go
  27. 13 0
      music-player/pkg/read/read_suite_test.go
  28. 2 2
      music-player/pkg/read/test_file_info.go
  29. binární
      music-player/pkg/read/testdata/file_example_OOG_1MG.ogg
  30. 13 4
      music-player/pkg/read/types.go
  31. 13 0
      music-player/pkg/repository/repository_suite_test.go
  32. 97 26
      music-player/pkg/repository/scan.go
  33. 143 74
      music-player/pkg/repository/scan_test.go
  34. 45 44
      music-player/pkg/services/scanner_test.go
  35. 13 0
      music-player/pkg/services/services_suite_test.go
  36. 5 15
      music-player/pkg/testing/testing.go
  37. 2 1
      music-player/test.env

+ 148 - 0
k8s/manifest.yml

@@ -0,0 +1,148 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: gmusic
+spec:
+  type: LoadBalancer
+  selector:
+    app: gmusic
+  ports:
+    - name: http
+      protocol: TCP
+      port: 8080
+      targetPort: 80
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gmusic-ingress
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+  rules:
+    - http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: gmusic
+                port:
+                  number: 80
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: gmusic-database
+  labels:
+    app: gmusic-database
+spec:
+  ports:
+    - port: 5432
+  selector:
+    app: gmusic-database
+    tier: postgres
+  clusterIP: None
+---
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+  name: standard
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: WaitForFirstConsumer
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+  name: postgres-pv
+  labels:
+    app: gmusic-database
+spec:
+  storageClassName: manual
+  capacity:
+    storage: 1Gi
+  accessModes:
+    - ReadWriteOnce
+  hostPath:
+    path: /var/local/gmusic-database
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: postgres-pv-claim
+  labels:
+    app: gmusic-database
+spec:
+  storageClassName: manual
+  accessModes:
+    - ReadWriteOnce
+  resources:
+    requests:
+      storage: 1Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: gmusic-database
+  labels:
+    app: gmusic-database
+spec:
+  selector:
+    matchLabels:
+      app: gmusic-database
+      tier: postgres
+  strategy:
+    type: Recreate
+  template:
+    metadata:
+      labels:
+        app: gmusic-database
+        tier: postgres
+    spec:
+      containers:
+        - image: postgres:10.4
+          name: postgres
+          env:
+            - name: POSTGRES_USER
+              value: gmusic
+            - name: POSTGRES_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  name: postgres-pass
+                  key: password
+          ports:
+            - containerPort: 5432
+              name: postgres
+          volumeMounts:
+            - name: postgres-persistent-storage
+              mountPath: /var/lib/postgresql/data
+      volumes:
+        - name: postgres-persistent-storage
+          persistentVolumeClaim:
+            claimName: postgres-pv-claim
+---
+apiVersion: batch/v1beta1
+kind: CronJob
+metadata:
+  name: gmusic-scan-library
+spec:
+  schedule: "45 5 * * *"
+  jobTemplate:
+    spec:
+      template:
+        spec:
+          containers:
+            - name: gmusic-scan
+              image: docker.fela.space/gmusic:0
+              args:
+                - scan
+              envFrom:
+                - configMapRef:
+                    name: gmusic
+              env:
+                - name: DATABASE_URL
+                  valueFrom:
+                    secretKeyRef:
+                      name: postgres-pass
+                      key: url
+          restartPolicy: Never

+ 4 - 0
music-player/.dockerignore

@@ -0,0 +1,4 @@
+.env
+bin
+Dockerfile
+test.env

+ 9 - 3
music-player/Dockerfile

@@ -1,6 +1,6 @@
-FROM golang:1.15.3-alpine
+FROM golang:1.15-alpine
 
-RUN apk add make
+RUN apk update && apk add make gcc libc-dev libvorbis-dev
 
 WORKDIR /app
 COPY . .
@@ -8,4 +8,10 @@ COPY . .
 RUN make clean
 RUN make build
 
-CMD bin/music-player-scan
+RUN apk del make gcc libc-dev libvorbis-dev && \
+  apk update && apk add libvorbis
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+USER appuser
+
+ENV PATH="/app/bin:${PATH}"

+ 3 - 1
music-player/Makefile

@@ -5,10 +5,12 @@ clean:
 	mkdir -p bin && rm -rf ./bin/*
 
 test:
-	GO_ENV=test go test -v ./...
+	GO_ENV=test ginkgo ./...
 
 migrate:
 	go run ./cmd/migrate
+migrate.make:
+	migrate create -ext sql -dir migrations -seq $(NAME)
 
 run.scan:
 	go run ./cmd/scan

+ 1 - 1
music-player/README.md

@@ -28,7 +28,7 @@ Then, run:
 
 After getting the [migrate CLI tool](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate), run:
 
-- `migrate create -ext sql -dir pkg/db/migrations -seq create_some_table`
+- `NAME=create_some_table make migrate.make`
 
 ## Components
 

+ 10 - 7
music-player/cmd/scan/main.go

@@ -1,15 +1,18 @@
 package main
 
 import (
-	"os"
-
-	"github.com/felamaslen/go-music-player/pkg/read"
+	"github.com/felamaslen/go-music-player/pkg/config"
+	"github.com/felamaslen/go-music-player/pkg/database"
 	"github.com/felamaslen/go-music-player/pkg/services"
 )
 
-const musicDirectory = read.TestDirectory
-
 func main() {
-  services.ScanAndInsert(musicDirectory)
-  os.Exit(0)
+  var libraryDirectory = config.GetConfig().LibraryDirectory
+  if len(libraryDirectory) == 0 {
+    panic("Must set LIBRARY_DIRECTORY")
+  }
+
+  services.ScanAndInsert(libraryDirectory)
+
+  database.EndPool()
 }

+ 8 - 2
music-player/go.mod

@@ -6,7 +6,13 @@ require (
 	github.com/anyhon/engine v0.1.0
 	github.com/dhowden/tag v0.0.0-20200828214007-46e57f75dbfc
 	github.com/golang-migrate/migrate/v4 v4.13.0
-	github.com/jackc/pgx/v4 v4.9.2
+	github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
+	github.com/jackc/pgx v3.6.2+incompatible
+	github.com/jackc/pgx/v4 v4.9.2 // indirect
+	github.com/jmoiron/sqlx v1.2.0
 	github.com/joho/godotenv v1.3.0
-	github.com/stretchr/testify v1.5.1
+	github.com/lib/pq v1.3.0
+	github.com/onsi/ginkgo v1.14.2
+	github.com/onsi/gomega v1.10.1
+	github.com/stretchr/testify v1.5.1 // indirect
 )

+ 30 - 1
music-player/go.sum

@@ -90,11 +90,14 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
+github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
@@ -173,6 +176,7 @@ github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g
 github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
@@ -180,9 +184,12 @@ github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9
 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
 github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
 github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
+github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
 github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
 github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
 github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
+github.com/jackc/pgconn v1.3.2 h1:9UIGICxEAW70RQDGilGwsCG63NCcm5amjuBQCFzrmsw=
 github.com/jackc/pgconn v1.3.2/go.mod h1:LvCquS3HbBKwgl7KbX9KyqEIumJAbm1UMcTvGaIf3bM=
 github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
 github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
@@ -201,6 +208,7 @@ github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod
 github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
 github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
 github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.1 h1:Rdjp4NFjwHnEslx2b66FfCI2S0LhO4itac3hXz6WX9M=
 github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
 github.com/jackc/pgproto3/v2 v2.0.6 h1:b1105ZGEMFe7aCvrT1Cca3VoVb4ZFMaFJLJcg/3zD+8=
 github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
@@ -209,14 +217,18 @@ github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHF
 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
 github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
 github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
+github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59 h1:xOamcCJ9MFJTxR5bvw3ZXmiP8evQMohdt2VJ57C0W8Q=
 github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
 github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
 github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
 github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
 github.com/jackc/pgtype v1.6.1 h1:CAtFD7TS95KrxRAh3bidgLwva48WYxk8YkbHZsSWfbI=
 github.com/jackc/pgtype v1.6.1/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
+github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
+github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
 github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
 github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
+github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186 h1:ZQM8qLT/E/CGD6XX0E6q9FAwxJYmWpJufzmLMaFuzgQ=
 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
 github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
 github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
@@ -227,9 +239,9 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f
 github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
-github.com/jackc/puddle v1.1.2 h1:mpQEXihFnWGDy6X98EOTh81JYuxn7txby8ilJ3iIPGM=
 github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
@@ -262,16 +274,25 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
 github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
 github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/mutecomm/go-sqlcipher/v4 v4.4.0/go.mod h1:PyN04SaWalavxRGH9E8ZftG6Ju7rsPrGmQRjrEaVpiY=
 github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA=
 github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI=
+github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
 github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
+github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
+github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
@@ -403,6 +424,7 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -438,7 +460,9 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -454,6 +478,7 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200817155316-9781c653f443 h1:X18bCaipMcoJGm27Nv7zr4XYPKGUy92GtqboKC2Hxaw=
@@ -610,15 +635,19 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
 gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 2 - 0
music-player/migrations/000002_unique_filename.down.sql

@@ -0,0 +1,2 @@
+ALTER TABLE songs
+DROP CONSTRAINT songs_filename_unique;

+ 2 - 0
music-player/migrations/000002_unique_filename.up.sql

@@ -0,0 +1,2 @@
+ALTER TABLE songs
+ADD CONSTRAINT songs_filename_unique UNIQUE (base_path, relative_path);

+ 3 - 0
music-player/migrations/000003_song_file_modified_date.down.sql

@@ -0,0 +1,3 @@
+DROP INDEX songs_file_time_unique;
+
+ALTER TABLE songs DROP COLUMN modified_date;

+ 5 - 0
music-player/migrations/000003_song_file_modified_date.up.sql

@@ -0,0 +1,5 @@
+ALTER TABLE songs ADD modified_date bigint;
+UPDATE songs SET modified_date = 0;
+ALTER TABLE songs ALTER COLUMN modified_date SET NOT NULL;
+
+CREATE UNIQUE INDEX songs_file_time_unique ON songs (base_path, relative_path, modified_date)

+ 2 - 0
music-player/migrations/000004_track_number.down.sql

@@ -0,0 +1,2 @@
+ALTER TABLE songs
+DROP COLUMN track_number;

+ 2 - 0
music-player/migrations/000004_track_number.up.sql

@@ -0,0 +1,2 @@
+ALTER TABLE songs
+ADD COLUMN track_number int;

+ 103 - 0
music-player/pkg/config/config.go

@@ -0,0 +1,103 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/joho/godotenv"
+
+	"github.com/felamaslen/go-music-player/pkg/logger"
+)
+
+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
+  }
+}
+
+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
+    }
+  }
+}
+
+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
+}
+
+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
+}
+
+type config struct {
+  DatabaseUrl string
+  LogLevel logger.LogLevel
+  LibraryDirectory string
+}

+ 2 - 100
music-player/pkg/config/main.go

@@ -1,105 +1,6 @@
 package config
 
-import (
-	"fmt"
-	"log"
-	"os"
-	"path/filepath"
-	"strconv"
-
-	"github.com/joho/godotenv"
-
-	"github.com/felamaslen/go-music-player/pkg/logger"
-)
-
-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
-  }
-}
-
-var envLoaded = 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
-    }
-  }
-}
-
-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", 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
-}
-
-type config struct {
-  DatabaseUrl string
-  LogLevel logger.LogLevel
-}
+import "os"
 
 func GetConfig() config {
   if !envLoaded {
@@ -109,5 +10,6 @@ func GetConfig() config {
   return config{
     DatabaseUrl: getDatabaseUrl(),
     LogLevel: getLogLevel(),
+    LibraryDirectory: os.Getenv("LIBRARY_DIRECTORY"),
   }
 }

+ 52 - 0
music-player/pkg/database/main.go

@@ -0,0 +1,52 @@
+package database
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+
+	migrate "github.com/golang-migrate/migrate/v4"
+	_ "github.com/golang-migrate/migrate/v4/database/postgres"
+	_ "github.com/golang-migrate/migrate/v4/source/file"
+
+	config "github.com/felamaslen/go-music-player/pkg/config"
+
+	_ "github.com/jackc/pgx/stdlib"
+	"github.com/jmoiron/sqlx"
+)
+
+var db *sqlx.DB
+
+func GetConnection() *sqlx.DB {
+  if (db != nil) {
+    return db
+  }
+
+  db := sqlx.MustConnect("pgx", config.GetConfig().DatabaseUrl)
+
+  return db
+}
+
+func EndPool() {
+  if (db == nil) {
+    return
+  }
+
+  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()
+}

+ 0 - 62
music-player/pkg/db/main.go

@@ -1,62 +0,0 @@
-package db
-
-import (
-	"context"
-	"fmt"
-	"log"
-	"os"
-	"path/filepath"
-
-	"github.com/jackc/pgx/v4/pgxpool"
-
-	migrate "github.com/golang-migrate/migrate/v4"
-	_ "github.com/golang-migrate/migrate/v4/database/postgres"
-	_ "github.com/golang-migrate/migrate/v4/source/file"
-
-	config "github.com/felamaslen/go-music-player/pkg/config"
-)
-
-var pool *pgxpool.Pool
-
-func GetConnection() *pgxpool.Conn {
-  if pool == nil {
-    var err error
-    pool, err = pgxpool.Connect(context.Background(), config.GetConfig().DatabaseUrl)
-
-    if err != nil {
-      fmt.Fprintf(os.Stderr, "Unable to acquire database connection pool: %v\n", err)
-      os.Exit(1)
-    }
-  }
-
-  conn, err := pool.Acquire(context.Background())
-  if err != nil {
-    fmt.Fprintf(os.Stderr, "Unable to acquire connection from pool: %v\n", err)
-    os.Exit(1)
-  }
-
-  return conn
-}
-
-func EndPool() {
-  if pool == nil {
-    return
-  }
-  pool.Close()
-}
-
-func MigrateDatabase() {
-  cwd, err := os.Getwd()
-  if err != nil {
-    log.Fatal("Error getting directory:", err)
-    return
-  }
-  databaseUrl := fmt.Sprintf("%s?sslmode=disable", config.GetConfig().DatabaseUrl)
-  directoryUrl := fmt.Sprintf("file://%s", filepath.Join(cwd, "migrations"))
-
-  m, err := migrate.New(directoryUrl, databaseUrl)
-  if err != nil {
-    log.Fatal("Error setting up: ", err)
-  }
-  m.Up()
-}

+ 14 - 11
music-player/pkg/read/audio.go

@@ -1,20 +1,20 @@
 package read
 
 import (
-  "os"
-  "path/filepath"
+	"os"
+	"path/filepath"
 
-  tag "github.com/dhowden/tag"
-
-  duration "github.com/felamaslen/go-music-player/pkg/read/duration"
+	tag "github.com/dhowden/tag"
+	duration "github.com/felamaslen/go-music-player/pkg/read/duration"
 )
 
-func ReadFile(basePath string, fileName string) (song *Song, err error) {
-  fullPath := filepath.Join(basePath, fileName)
+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)
@@ -22,16 +22,19 @@ func ReadFile(basePath string, fileName string) (song *Song, err error) {
     return &Song{}, errTags
   }
 
-  durationTime, durationOk := duration.GetSongDuration(file, tags)
+  durationSeconds := duration.GetSongDurationSeconds(file, tags)
+
+  trackNumber, _ := tags.Track()
 
   result := Song{
+    TrackNumber: trackNumber,
     Title: tags.Title(),
     Artist: tags.Artist(),
     Album: tags.Album(),
-    Duration: durationTime,
-    DurationOk: durationOk,
+    Duration: durationSeconds,
     BasePath: basePath,
-    RelativePath: fileName,
+    RelativePath: scannedFile.RelativePath,
+    ModifiedDate: scannedFile.ModifiedDate,
   }
 
   return &result, nil

+ 31 - 20
music-player/pkg/read/audio_test.go

@@ -1,29 +1,40 @@
-package read
+package read_test
 
 import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
 	"os"
 	"path"
-	"testing"
 
+	"github.com/felamaslen/go-music-player/pkg/read"
 	_ "github.com/felamaslen/go-music-player/pkg/testing"
-	"github.com/stretchr/testify/assert"
 )
 
-func TestReadFile(t *testing.T) {
+var _ = Describe("reading audio files", func() {
+
   rootDir, _ := os.Getwd()
-  basePath := path.Join(rootDir, TestDirectory)
-
-  result, err := ReadFile(basePath, TestSong.RelativePath)
-
-  assert.Nil(t, err)
-
-  assert.Equal(t, Song{
-    Title: "Impact Moderato",
-    Artist: "Kevin MacLeod",
-    Album: "YouTube Audio Library",
-    Duration: 74,
-    DurationOk: true,
-    BasePath: basePath,
-    RelativePath: "file_example_OOG_1MG.ogg",
-  }, *result)
-}
+  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,
+      }))
+    })
+  })
+})

+ 13 - 0
music-player/pkg/read/duration/duration_suite_test.go

@@ -0,0 +1,13 @@
+package duration_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestRepository(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Audio duration Suite")
+}

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

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

+ 16 - 10
music-player/pkg/read/duration/vorbis.go

@@ -1,22 +1,28 @@
 package duration
 
 import (
-  "fmt"
-  ov "github.com/anyhon/engine/audio/ov"
+	"log"
+	"os"
+
+	ov "github.com/anyhon/engine/audio/ov"
 )
 
-func GetSongDurationVorbis(fileName string) (duration int, ok bool) {
+func GetSongDurationSecondsVorbis(file *os.File) int {
+  fileName := file.Name()
   ovFile, ovErr := ov.Fopen(fileName)
   if ovErr != nil {
-    fmt.Printf("Error opening file for ogg vorbis duration: %s\n", ovErr)
-    return 0, false
+    // TODO: log these errors to the DB
+    log.Printf("[vorbis] Error opening file (%s): %v\n", fileName, ovErr)
+    return 0
   }
 
-  result, errTimeTotal := ov.TimeTotal(ovFile, -1)
-  if errTimeTotal != nil {
-    fmt.Printf("Error calling TimeTotal for ogg vorbis file: %s\n", errTimeTotal)
-    return 0, false
+  result, timeErr := ov.TimeTotal(ovFile, -1)
+  if timeErr != nil {
+    log.Printf("[vorbis] Error getting duration (%s): %v\n", fileName, timeErr)
+    return 0
   }
 
-  return int(result), true
+  ov.Clear(ovFile)
+
+  return int(result)
 }

+ 21 - 0
music-player/pkg/read/duration/vorbis_test.go

@@ -0,0 +1,21 @@
+package duration_test
+
+import (
+	"os"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/felamaslen/go-music-player/pkg/read/duration"
+	_ "github.com/felamaslen/go-music-player/pkg/testing"
+)
+
+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")
+
+    result := duration.GetSongDurationSecondsVorbis(file)
+
+    Expect(result).To(Equal(74))
+  })
+})

+ 147 - 24
music-player/pkg/read/files.go

@@ -5,17 +5,23 @@ import (
 	"path/filepath"
 
 	config "github.com/felamaslen/go-music-player/pkg/config"
+	"github.com/felamaslen/go-music-player/pkg/database"
 	"github.com/felamaslen/go-music-player/pkg/logger"
+	"github.com/jmoiron/sqlx"
+	"github.com/lib/pq"
 )
 
-func ReadMultipleFiles(basePath string, files chan string) chan *Song {
+const BATCH_SIZE = 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("Finished reading files")
+      l.Verbose("[READ] Finished reading files")
       close(songs)
     }()
 
@@ -23,13 +29,13 @@ func ReadMultipleFiles(basePath string, files chan string) chan *Song {
       select {
       case file, more := <- files:
         if more {
-          l.Verbose("Reading file: %s\n", file)
+          l.Debug("[READ] %s\n", file.RelativePath)
           song, err := ReadFile(basePath, file)
 
           if err == nil {
             songs <- song
           } else {
-            l.Error("Error reading file (%s): %s\n", file, err)
+            l.Error("[READ] Error (%s): %v\n", file.RelativePath, err)
           }
         } else {
           return
@@ -46,48 +52,165 @@ func isValidFile(file string) bool {
   return filepath.Ext(file) == ".ogg"
 }
 
-func recursiveDirScan(l *logger.Logger, directory string, output *chan string, root bool, basePath string) {
-  if (root) {
-    l.Verbose("Scanning root directory: %s\n", directory)
+func recursiveDirScan(
+  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("Finished recursive directory scan")
-      close(*output)
+      l.Verbose("[SCAN] Finished scanning directory")
+      close(*allFiles)
     }()
   } else {
-    l.Debug("Scanning subdirectory: %s\n", directory)
+    l.Debug("[SCAN] %s\n", directoryToScan)
   }
 
-
-  files, err := ioutil.ReadDir(directory)
+  files, err := ioutil.ReadDir(directoryToScan)
 
   if err != nil {
-    l.Fatal("Error scanning directory: (%s): %s", directory, err)
-    return
+    l.Error("[SCAN] Error (%s): %v", directoryToScan, err)
+    return // TODO: add this to a table of failed directories
   }
 
   for _, file := range(files) {
-    absolutePath := filepath.Join(directory, file.Name())
-    relativePath := filepath.Join(basePath, file.Name())
+    fileRelativePath := filepath.Join(relativePath, file.Name())
 
     if file.IsDir() {
-      recursiveDirScan(l, absolutePath, output, false, relativePath)
+      recursiveDirScan(
+        db,
+        l,
+        allFiles,
+        rootDirectory,
+        fileRelativePath,
+        false,
+      )
     } else if isValidFile(file.Name()) {
-      l.Verbose("Found file: %s\n", relativePath)
+      *allFiles <- &File{
+        RelativePath: fileRelativePath,
+        ModifiedDate: file.ModTime().Unix(),
+      }
+    }
+  }
+}
+
+func batchFilterFiles(
+  db *sqlx.DB,
+  l *logger.Logger,
+  filteredOutput *chan *File,
+  allFiles *chan *File,
+  basePath string,
+) {
+  defer close(*filteredOutput)
+
+  var batch [BATCH_SIZE]*File
+  var batchSize = 0
+  var numFiltered = 0
+
+  var processBatch = func() {
+    if batchSize == 0 {
+      return
+    }
+
+    l.Debug("[FILTER] Processing batch\n")
+
+    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)
+    }
 
-      *output <- relativePath
+    newOrUpdatedFiles, err := db.Queryx(
+      `
+      select r.relative_path, r.modified_date
+      from (
+        select * from unnest($1::varchar[], $2::bigint[])
+        as t(relative_path, modified_date)
+      ) r
+
+      left join songs on
+        songs.base_path = $3
+        and songs.relative_path = r.relative_path
+        and songs.modified_date = r.modified_date
+
+      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()
+      }
     }
   }
 }
 
-func ScanDirectory(directory string) chan string {
+func ScanDirectory(directory string) chan *File {
+  db := database.GetConnection()
   l := logger.CreateLogger(config.GetConfig().LogLevel)
 
-  files := make(chan string)
-  
+  filteredOutput := make(chan *File)
+  allFiles := make(chan *File)
+
+  go func() {
+    batchFilterFiles(db, l, &filteredOutput, &allFiles, directory)
+  }()
+
   go func() {
-    recursiveDirScan(l, directory, &files, true, "")
+    recursiveDirScan(
+      db,
+      l,
+      &allFiles,
+      directory,
+      "",
+      true,
+    )
   }()
 
-  return files
+  return filteredOutput
 }

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

@@ -1,57 +1,127 @@
-package read
+package read_test
 
 import (
-	"testing"
+	"os"
+	"path"
 
-	_ "github.com/felamaslen/go-music-player/pkg/testing"
-	"github.com/stretchr/testify/assert"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/felamaslen/go-music-player/pkg/database"
+	"github.com/felamaslen/go-music-player/pkg/read"
+	setup "github.com/felamaslen/go-music-player/pkg/testing"
 )
 
-func TestReadMultipleFiles(t *testing.T) {
-  files := make(chan string, 1)
+var _ = Describe("reading files", func() {
+
+  db := database.GetConnection()
+
+  BeforeEach(func() {
+    setup.PrepareDatabaseForTesting()
+  })
 
-  go func() {
-    files <- TestSong.RelativePath
-    close(files)
-  }()
+  Describe("reading file info", func() {
+    var results []*read.Song
 
-  outputChan := ReadMultipleFiles(TestDirectory, files)
+    BeforeEach(func() {
+      results = nil
+      files := make(chan *read.File, 1)
 
-  var results []*Song
+      go func() {
+	defer close(files)
+	files <- &read.File{
+	  RelativePath: read.TestSong.RelativePath,
+	  ModifiedDate: 100123,
+	}
+      }()
 
-  outputDone := false
+      outputChan := read.ReadMultipleFiles(read.TestDirectory, files)
 
-  for !outputDone {
-    select {
-    case result, more := <- outputChan:
-      if more {
-        results = append(results, result)
+      outputDone := false
+
+      for !outputDone {
+	select {
+	case result, more := <- outputChan:
+	  if more {
+	    results = append(results, result)
+	  }
+	  outputDone = !more
+	}
       }
-      outputDone = !more
-    }
-  }
+    })
+
+    It("should return the correct number of results", func() {
+      Expect(results).To(HaveLen(1))
+    })
 
-  assert.Len(t, results, 1)
+    It("should get the correct info from the file", func() {
+      var expectedResult = read.TestSong
+      expectedResult.ModifiedDate = 100123
 
-  assert.Equal(t, TestSong, *results[0])
-}
+      Expect(*results[0]).To(Equal(expectedResult))
+    })
+  })
 
-func TestScanDirectory(t *testing.T) {
-  files := ScanDirectory(TestDirectory)
+  Describe("scanning a directory recursively", func() {
+    var results []*read.File
 
-  var results []string
+    var testScanDirectory = func() {
+      results = nil
+      files := read.ScanDirectory(read.TestDirectory)
 
-  done := false
+      done := false
 
-  for !done {
-    select {
-    case result, more := <- files:
-      if more {
-        results = append(results, result)
+      for !done {
+	select {
+	case result, more := <- files:
+	  if more {
+	    results = append(results, result)
+	  }
+	  done = !more
+	}
       }
-      done = !more
     }
-  }
 
-  assert.Equal(t, results, []string{TestSong.RelativePath, TestSongNested.RelativePath})
-}
+    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))
+      })
+    })
+  })
+})

+ 13 - 0
music-player/pkg/read/read_suite_test.go

@@ -0,0 +1,13 @@
+package read_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestRepository(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Read Suite")
+}

+ 2 - 2
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,
-  DurationOk: true,
   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,
-  DurationOk: true,
   BasePath: TestDirectory,
   RelativePath: "nested/14 Clementi Piano Sonata in D major, Op 25 No 6 - Movement 2 Un poco andante.ogg",
 }

binární
music-player/pkg/read/testdata/file_example_OOG_1MG.ogg


+ 13 - 4
music-player/pkg/read/types.go

@@ -1,8 +1,17 @@
 package read
 
 type Song struct {
-  Title, Artist, Album string
-  Duration int
-  DurationOk bool
-  BasePath, RelativePath string
+  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 File struct {
+  RelativePath string 	`db:"relative_path"`
+  ModifiedDate int64 	`db:"modified_date"`
 }

+ 13 - 0
music-player/pkg/repository/repository_suite_test.go

@@ -0,0 +1,13 @@
+package repository_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestRepository(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Repository Suite")
+}

+ 97 - 26
music-player/pkg/repository/scan.go

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

+ 143 - 74
music-player/pkg/repository/scan_test.go

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

+ 45 - 44
music-player/pkg/services/scanner_test.go

@@ -1,51 +1,52 @@
-package services
+package services_test
 
 import (
-	"context"
-	"testing"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
 
-	"github.com/felamaslen/go-music-player/pkg/db"
+	"github.com/felamaslen/go-music-player/pkg/database"
 	"github.com/felamaslen/go-music-player/pkg/read"
+	"github.com/felamaslen/go-music-player/pkg/services"
 	setup "github.com/felamaslen/go-music-player/pkg/testing"
-	"github.com/stretchr/testify/assert"
 )
 
-func TestIntegrationScanAndInsert(t *testing.T) {
-  setup.PrepareDatabaseForTesting()
-
-  ScanAndInsert(read.TestDirectory)
-
-  conn := db.GetConnection()
-
-  rows, err := conn.Query(
-    context.Background(),
-    `
-    select
-      title as "Title"
-      ,artist as "Artist"
-      ,album as "Album"
-      ,coalesce(duration, 0) as "Duration"
-      ,duration is not null as "DurationOk"
-      ,base_path as "BasePath"
-      ,relative_path as "RelativePath"
-    from songs
-    `,
-  )
-
-  assert.Nil(t, err)
-
-  var song read.Song
-
-  rows.Next()
-  rows.Scan(&song.Title, &song.Artist, &song.Album, &song.Duration, &song.DurationOk, &song.BasePath, &song.RelativePath)
-
-  assert.Equal(t, read.Song{
-    Title: read.TestSong.Title,
-    Artist: read.TestSong.Artist,
-    Album: read.TestSong.Album,
-    Duration: read.TestSong.Duration,
-    DurationOk: true,
-    BasePath: read.TestSong.BasePath,
-    RelativePath: read.TestSong.RelativePath,
-  }, song)
-}
+var _ = Describe("music scanner (integration test)", func() {
+
+  BeforeEach(func() {
+    setup.PrepareDatabaseForTesting()
+  })
+
+  It("should recursively scan files from a directory and add them to the database", func() {
+    services.ScanAndInsert(read.TestDirectory)
+
+    db := database.GetConnection()
+
+    var songs []read.Song
+    err := db.Select(&songs, `
+      select title, artist, album, duration, base_path, relative_path
+      from songs
+    `)
+
+    Expect(err).To(BeNil())
+
+    Expect(songs).To(HaveLen(2))
+
+    Expect(read.Song{
+      Title: read.TestSong.Title,
+      Artist: read.TestSong.Artist,
+      Album: read.TestSong.Album,
+      Duration: read.TestSong.Duration,
+      BasePath: read.TestSong.BasePath,
+      RelativePath: read.TestSong.RelativePath,
+    }).To(BeElementOf(songs))
+
+    Expect(read.Song{
+      Title: read.TestSongNested.Title,
+      Artist: read.TestSongNested.Artist,
+      Album: read.TestSongNested.Album,
+      Duration: read.TestSongNested.Duration,
+      BasePath: read.TestSongNested.BasePath,
+      RelativePath: read.TestSongNested.RelativePath,
+    }).To(BeElementOf(songs))
+  })
+})

+ 13 - 0
music-player/pkg/services/services_suite_test.go

@@ -0,0 +1,13 @@
+package services_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestRepository(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Services Suite")
+}

+ 5 - 15
music-player/pkg/testing/testing.go

@@ -1,20 +1,16 @@
 package testing
 
 import (
-	"context"
-	"fmt"
 	"os"
 	"path"
 	"runtime"
 
-	"github.com/felamaslen/go-music-player/pkg/db"
-	"github.com/jackc/pgx/v4/pgxpool"
+	"github.com/felamaslen/go-music-player/pkg/database"
 )
 
 func ChangeToRootDir() {
   _, filename, _, _ := runtime.Caller(0)
   dir := path.Join(path.Dir(filename), "../..")
-  fmt.Printf("Changing dir to %v\n", dir)
   err := os.Chdir(dir)
   if err != nil {
     panic(err)
@@ -25,16 +21,10 @@ func init() {
   ChangeToRootDir()
 }
 
-func PrepareDatabaseForTesting() *pgxpool.Conn {
-  fmt.Println("Preparing database for testing")
+func PrepareDatabaseForTesting() {
+  database.MigrateDatabase()
 
-  db.MigrateDatabase()
-  conn := db.GetConnection()
+  db := database.GetConnection()
 
-  conn.Query(
-    context.Background(),
-    "truncate table songs",
-  )
-
-  return conn
+  db.MustExec("truncate table songs")
 }

+ 2 - 1
music-player/test.env

@@ -3,4 +3,5 @@ POSTGRES_DATABASE=music_player_test
 POSTGRES_USER=docker
 POSTGRES_PASSWORD=docker
 POSTGRES_PORT=5417
-LOG_LEVEL=5
+LOG_LEVEL=0
+LIBRARY_DIRECTORY=./pkg/read