diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml index 10e627aa..5cea07f4 100644 --- a/.github/workflows/nightly-release.yaml +++ b/.github/workflows/nightly-release.yaml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | sudo apt update -qq - sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg libasound-dev zlib1g-dev + sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg mpv zlib1g-dev - name: Lint uses: golangci/golangci-lint-action@v2 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a0671bd4..a940dc3c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | sudo apt update -qq - sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg libasound-dev zlib1g-dev + sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg mpv zlib1g-dev - name: Lint uses: golangci/golangci-lint-action@v2 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4fc5c6e5..ad94f446 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | sudo apt update -qq - sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg libasound-dev zlib1g-dev + sudo apt install -y -qq build-essential git sqlite libtag1-dev ffmpeg mpv zlib1g-dev - name: Lint uses: golangci/golangci-lint-action@v2 with: diff --git a/Dockerfile b/Dockerfile index f27d6453..d939c771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ RUN apk add -U --no-cache \ git \ sqlite \ taglib-dev \ - alsa-lib-dev \ zlib-dev \ go WORKDIR /src @@ -15,10 +14,11 @@ RUN go mod download COPY . . RUN GOOS=linux go build -o gonic cmd/gonic/gonic.go -FROM alpine:3.15 +FROM alpine:3.16 LABEL org.opencontainers.image.source https://github.com/sentriz/gonic RUN apk add -U --no-cache \ ffmpeg \ + mpv \ ca-certificates \ tzdata \ tini diff --git a/Dockerfile.debug b/Dockerfile.debug index 8d14a06b..636b9fec 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -5,6 +5,5 @@ RUN apk add -U --no-cache \ git \ sqlite \ taglib-dev \ - alsa-lib-dev \ zlib-dev WORKDIR /src diff --git a/Dockerfile.dev b/Dockerfile.dev index 0ff13c51..cd6e2401 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -7,7 +7,6 @@ RUN apk add -U --no-cache \ git \ sqlite \ taglib-dev \ - alsa-lib-dev \ zlib-dev WORKDIR /src COPY . . @@ -15,9 +14,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=linux go build -o gonic cmd/gonic/gonic.go -FROM alpine:3.15 +FROM alpine:3.16 RUN apk add -U --no-cache \ ffmpeg \ + mpv \ ca-certificates COPY --from=builder \ /usr/lib/libgcc_s.so.1 \ diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 81622739..ee3b5b5a 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -142,7 +142,7 @@ func main() { g.Add(server.StartScanWatcher()) } if *confJukeboxEnabled { - g.Add(server.StartJukebox()) + g.Add(server.StartJukebox(nil)) } if *confPodcastPurgeAgeDays > 0 { g.Add(server.StartPodcastPurger(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour)) diff --git a/go.mod b/go.mod index 21da2373..1e2cd347 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.19 require ( github.com/Masterminds/sprig v2.22.0+incompatible + github.com/dexterlb/mpvipc v0.0.0-20210824102722-5d27ef06b6c3 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.0 - github.com/faiface/beep v1.1.0 github.com/fsnotify/fsnotify v1.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.0 @@ -16,6 +16,7 @@ require ( github.com/josephburnett/jd v1.5.2 github.com/matryer/is v1.4.0 github.com/mattn/go-sqlite3 v1.14.16 + github.com/mitchellh/mapstructure v1.5.0 github.com/mmcdole/gofeed v1.1.3 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd @@ -24,6 +25,7 @@ require ( github.com/peterbourgon/ff v1.7.1 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 + golang.org/x/exp v0.0.0-20221114191408-850992195362 gopkg.in/gormigrate.v1 v1.6.0 ) @@ -36,10 +38,7 @@ require ( github.com/go-openapi/swag v0.21.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gorilla/context v1.1.1 // indirect - github.com/hajimehoshi/go-mp3 v0.3.4 // indirect - github.com/hajimehoshi/oto v1.0.1 // indirect github.com/huandu/xstrings v1.3.3 // indirect - github.com/icza/bitio v1.1.0 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.2 // indirect @@ -47,21 +46,17 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/lib/pq v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mewkiz/flac v1.0.7 // indirect - github.com/mewkiz/pkg v0.0.0-20220820102221-bbbca16e2a6c // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mmcdole/goxpp v0.0.0-20200921145534-2f3784f67354 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/stretchr/testify v1.7.0 // indirect golang.org/x/crypto v0.2.0 // indirect - golang.org/x/exp/shiny v0.0.0-20221114191408-850992195362 // indirect golang.org/x/image v0.1.0 // indirect - golang.org/x/mobile v0.0.0-20221110043201-43a038452099 // indirect golang.org/x/net v0.2.0 // indirect golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index d99eeb47..6d78fdba 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ cloud.google.com/go v0.33.1/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -15,28 +14,22 @@ github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x0 github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dexterlb/mpvipc v0.0.0-20210824102722-5d27ef06b6c3 h1:uIJS8tUx2f4rciUwL0wEHuwVI2tH9rQHUMnm4gHuhXs= +github.com/dexterlb/mpvipc v0.0.0-20210824102722-5d27ef06b6c3/go.mod h1:CXCwawNJCtFDip7gvbaQVgw0cGjldpyHDIp7oA5prOg= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= -github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= -github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= -github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= -github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= @@ -63,25 +56,10 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= -github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= -github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= -github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= -github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= -github.com/hajimehoshi/oto v1.0.1 h1:8AMnq0Yr2YmzaiqTg/k1Yzd6IygUGk2we9nmjgbgPn4= -github.com/hajimehoshi/oto v1.0.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= -github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= -github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= -github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414 h1:JkXdZo2OKW1t+GcTx5eb1kD2qW5lt1CDLrL2Ep9t+j4= github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= @@ -101,7 +79,6 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -111,7 +88,6 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -119,19 +95,15 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= -github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= -github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= -github.com/mewkiz/pkg v0.0.0-20220820102221-bbbca16e2a6c h1:6AzCfQNCql3Of8ee1JY6dufssFnBWJYuCVrGcES84AA= -github.com/mewkiz/pkg v0.0.0-20220820102221-bbbca16e2a6c/go.mod h1:J/rDzvIiwiVpv72OEP8aJFxLXjGpUdviIIeqJPLIctA= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mmcdole/gofeed v1.1.3 h1:pdrvMb18jMSLidGp8j0pLvc9IGziX4vbmvVqmLH6z8o= @@ -158,9 +130,6 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwU github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= github.com/peterbourgon/ff v1.7.1 h1:xt1lxTG+Nr2+tFtysY7abFgPoH3Lug8CwYJMOmJRXhk= github.com/peterbourgon/ff v1.7.1/go.mod h1:fYI5YA+3RDqQRExmFbHnBjEeWzh9TrS8rnRpEq7XIg0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= @@ -183,21 +152,14 @@ golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp/shiny v0.0.0-20221114191408-850992195362 h1:klJAUGTRrnTvp2+ongrNqLxrl/415DPs2iR9xn/k0ME= -golang.org/x/exp/shiny v0.0.0-20221114191408-850992195362/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8= -golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/exp v0.0.0-20221114191408-850992195362 h1:NoHlPRbyl1VFI6FjwHtPQCN7wAMXI6cKcqrmXhOOfBQ= +golang.org/x/exp v0.0.0-20221114191408-850992195362/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk= golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c= -golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20221110043201-43a038452099 h1:aIu0lKmfdgtn2uTj7JI2oN4TUrQvgB+wzTPO23bCKt8= -golang.org/x/mobile v0.0.0-20221110043201-43a038452099/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -211,16 +173,12 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= @@ -246,6 +204,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/gormigrate.v1 v1.6.0 h1:XpYM6RHQPmzwY7Uyu+t+xxMXc86JYFJn4nEc9HzQjsI= gopkg.in/gormigrate.v1 v1.6.0/go.mod h1:Lf00lQrHqfSYWiTtPcyQabsDdM6ejZaMgV0OU6JMSlw= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= 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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/jukebox/jukebox.go b/jukebox/jukebox.go index f26625d8..b1ec23a4 100644 --- a/jukebox/jukebox.go +++ b/jukebox/jukebox.go @@ -1,205 +1,406 @@ // author: AlexKraak (https://github.com/alexkraak/) +// author: sentriz (https://github.com/sentriz/) package jukebox import ( + "errors" "fmt" - "log" "os" + "os/exec" + "path/filepath" "sync" "time" - "github.com/faiface/beep" - "github.com/faiface/beep/flac" - "github.com/faiface/beep/mp3" - "github.com/faiface/beep/speaker" + "github.com/dexterlb/mpvipc" + "github.com/mitchellh/mapstructure" + "golang.org/x/exp/slices" +) - "go.senan.xyz/gonic/db" +var ( + ErrMPVTimeout = fmt.Errorf("mpv not responding") + ErrMPVNeverStarted = fmt.Errorf("mpv never started") ) -type Status struct { - CurrentIndex int - Playing bool - Gain float64 - Position int +func MPVArg(k string, v any) string { + if v, ok := v.(bool); ok { + if v { + return fmt.Sprintf("%s=yes", k) + } + return fmt.Sprintf("%s=no", k) + } + return fmt.Sprintf("%s=%v", k, v) } type Jukebox struct { - playlist []*db.Track - index int - playing bool - sr beep.SampleRate - // used to notify the player to re read the members - quit chan struct{} - done chan bool - info *strmInfo - speaker chan updateSpeaker - sync.Mutex + cmd *exec.Cmd + conn *mpvipc.Connection + events <-chan *mpvipc.Event + + mu sync.Mutex } -type strmInfo struct { - ctrlStrmr beep.Ctrl - strm beep.StreamSeekCloser - format beep.Format +func New() *Jukebox { + return &Jukebox{} } -type updateSpeaker struct { - index int - offset int +func (j *Jukebox) Start(sockPath string, mpvExtraArgs []string) error { + const mpvName = "mpv" + if _, err := exec.LookPath(mpvName); err != nil { + return fmt.Errorf("look path: %w. did you forget to install it?", err) + } + + var mpvArgs []string + mpvArgs = append(mpvArgs, "--idle", "--no-config", "--no-video", MPVArg("--audio-display", "no"), MPVArg("--input-ipc-server", sockPath)) + mpvArgs = append(mpvArgs, mpvExtraArgs...) + + j.cmd = exec.Command(mpvName, mpvArgs...) + if err := j.cmd.Start(); err != nil { + return fmt.Errorf("start mpv process: %w", err) + } + + ok := waitUntil(5*time.Second, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }) + if !ok { + _ = j.cmd.Process.Kill() + return ErrMPVNeverStarted + } + + j.conn = mpvipc.NewConnection(sockPath) + if err := j.conn.Open(); err != nil { + return fmt.Errorf("open connection: %w", err) + } + if _, err := j.conn.Call("observe_property", 0, "seekable"); err != nil { + return fmt.Errorf("observe property: %w", err) + } + j.events, _ = j.conn.NewEventListener() + + return nil } -func New() *Jukebox { - return &Jukebox{ - sr: beep.SampleRate(48000), - speaker: make(chan updateSpeaker, 1), - done: make(chan bool), - quit: make(chan struct{}), +func (j *Jukebox) Wait() error { + var exitError *exec.ExitError + if err := j.cmd.Wait(); err != nil && !errors.As(err, &exitError) { + return fmt.Errorf("wait jukebox: %w", err) } + return nil } -func (j *Jukebox) Listen() error { - if err := speaker.Init(j.sr, j.sr.N(time.Second/2)); err != nil { - return fmt.Errorf("initing speaker: %w", err) +func (j *Jukebox) GetPlaylist() ([]string, error) { + defer lock(&j.mu)() + + var playlist mpvPlaylist + if err := j.getDecode(&playlist, "playlist"); err != nil { + return nil, fmt.Errorf("get playlist: %w", err) } - for { - select { - case <-j.quit: - return nil - case speaker := <-j.speaker: - if err := j.doUpdateSpeaker(speaker); err != nil { - log.Printf("error in jukebox: %v", err) - } - } + var items []string + for _, item := range playlist { + items = append(items, item.Filename) } + return items, nil } -func (j *Jukebox) Quit() { - j.quit <- struct{}{} -} +func (j *Jukebox) SetPlaylist(items []string) error { + defer lock(&j.mu)() -func (j *Jukebox) doUpdateSpeaker(su updateSpeaker) error { - j.Lock() - defer j.Unlock() - if su.index >= len(j.playlist) { - j.playing = false - speaker.Clear() - return nil + var playlist mpvPlaylist + if err := j.getDecode(&playlist, "playlist"); err != nil { + return fmt.Errorf("get playlist: %w", err) } - j.index = su.index - f, err := os.Open(j.playlist[su.index].AbsPath()) + current, currentIndex := find(playlist, func(item mpvPlaylistItem) bool { + return item.Current + }) + + cwd, _ := os.Getwd() + currFilename, _ := filepath.Rel(cwd, current.Filename) + filteredItems, foundExistingTrack := filter(items, func(filename string) bool { + return filename != currFilename + }) + + tmp, cleanup, err := tmp() if err != nil { - return err + return fmt.Errorf("create temp file: %w", err) } - var streamer beep.Streamer - var format beep.Format - switch j.playlist[su.index].Ext() { - case "mp3": - streamer, format, err = mp3.Decode(f) - case "flac": - streamer, format, err = flac.Decode(f) + defer cleanup() + for _, item := range filteredItems { + item, _ = filepath.Abs(item) + fmt.Fprintln(tmp, item) } + + if !foundExistingTrack { + // easy case - a brand new set of tracks that we can overwrite + if _, err := j.conn.Call("loadlist", tmp.Name(), "replace"); err != nil { + return fmt.Errorf("load list: %w", err) + } + return nil + } + + // not so easy, we need to clear the playlist except what's playing, load everything + // except for what we're playing, then move what's playing back to its original index + // clear all items except what's playing (will be at index 0) + if _, err := j.conn.Call("playlist-clear"); err != nil { + return fmt.Errorf("clear playlist: %w", err) + } + if _, err := j.conn.Call("loadlist", tmp.Name(), "append-play"); err != nil { + return fmt.Errorf("load list: %w", err) + } + if _, err := j.conn.Call("playlist-move", 0, currentIndex+1); err != nil { + return fmt.Errorf("playlist move: %w", err) + } + return nil +} + +func (j *Jukebox) AppendToPlaylist(items []string) error { + defer lock(&j.mu)() + + tmp, cleanup, err := tmp() if err != nil { - return err - } - j.info = &strmInfo{} - j.info.strm = streamer.(beep.StreamSeekCloser) - if su.offset != 0 { - samples := format.SampleRate.N(time.Second * time.Duration(su.offset)) - if err := j.info.strm.Seek(samples); err != nil { - return err + return fmt.Errorf("create temp file: %w", err) + } + defer cleanup() + for _, item := range items { + fmt.Fprintln(tmp, item) + } + if _, err := j.conn.Call("loadlist", tmp.Name(), "append"); err != nil { + return fmt.Errorf("load list: %w", err) + } + return nil +} + +func (j *Jukebox) RemovePlaylistIndex(i int) error { + defer lock(&j.mu)() + + if _, err := j.conn.Call("playlist-remove", i); err != nil { + return fmt.Errorf("playlist remove: %w", err) + } + return nil +} + +func (j *Jukebox) SkipToPlaylistIndex(i int, offsetSecs int) error { + defer lock(&j.mu)() + + matchEventSeekable := func(e *mpvipc.Event) bool { + seekable, _ := e.Data.(bool) + return e.Name == "property-change" && + e.ExtraData["name"] == "seekable" && + seekable + } + + if offsetSecs > 0 { + if err := j.conn.Set("pause", true); err != nil { + return fmt.Errorf("pause: %w", err) + } + } + if _, err := j.conn.Call("playlist-play-index", i); err != nil { + return fmt.Errorf("playlist play index: %w", err) + } + if offsetSecs > 0 { + if err := waitFor(j.events, matchEventSeekable); err != nil { + return fmt.Errorf("waiting for file load: %w", err) + } + if _, err := j.conn.Call("seek", offsetSecs, "absolute"); err != nil { + return fmt.Errorf("seek: %w", err) + } + if err := j.conn.Set("pause", false); err != nil { + return fmt.Errorf("play: %w", err) } } - j.info.ctrlStrmr.Streamer = beep.Resample( - 4, format.SampleRate, - j.sr, j.info.strm, - ) - j.info.format = format - speaker.Play(beep.Seq(&j.info.ctrlStrmr, beep.Callback(func() { - j.speaker <- updateSpeaker{index: su.index + 1} - }))) return nil } -func (j *Jukebox) SetTracks(tracks []*db.Track) { - j.Lock() - defer j.Unlock() - j.playlist = tracks +func (j *Jukebox) ClearPlaylist() error { + defer lock(&j.mu)() + + if _, err := j.conn.Call("playlist-clear"); err != nil { + return fmt.Errorf("seek: %w", err) + } + return nil +} + +func (j *Jukebox) Pause() error { + defer lock(&j.mu)() + + if err := j.conn.Set("pause", true); err != nil { + return fmt.Errorf("pause: %w", err) + } + return nil +} + +func (j *Jukebox) Play() error { + defer lock(&j.mu)() + + if err := j.conn.Set("pause", false); err != nil { + return fmt.Errorf("pause: %w", err) + } + return nil +} + +func (j *Jukebox) SetVolumePct(v int) error { + defer lock(&j.mu)() + + if err := j.conn.Set("volume", v); err != nil { + return fmt.Errorf("set volume: %w", err) + } + return nil +} + +func (j *Jukebox) GetVolumePct() (float64, error) { + defer lock(&j.mu)() + + var volume float64 + if err := j.getDecode(&volume, "volume"); err != nil { + return 0, fmt.Errorf("get volume: %w", err) + } + return volume, nil +} + +type Status struct { + CurrentIndex int + CurrentFilename string + Length int + Playing bool + GainPct int + Position int +} + +func (j *Jukebox) GetStatus() (*Status, error) { + defer lock(&j.mu)() + + var status Status + _ = j.getDecode(&status.Position, "time-pos") // property may not always be there + _ = j.getDecode(&status.GainPct, "volume") // property may not always be there + + var playlist mpvPlaylist + _ = j.getDecode(&playlist, "playlist") + + status.CurrentIndex = slices.IndexFunc(playlist, func(pl mpvPlaylistItem) bool { + return pl.Current + }) + + status.Length = len(playlist) + + if status.CurrentIndex < 0 { + return &status, nil + } + + status.CurrentFilename = playlist[status.CurrentIndex].Filename + + var paused bool + _ = j.getDecode(&paused, "pause") // property may not always be there + status.Playing = !paused + + return &status, nil } -func (j *Jukebox) AddTracks(tracks []*db.Track) { - j.Lock() - if len(j.playlist) == 0 { - j.playlist = tracks - j.playing = true - j.index = 0 - j.Unlock() - j.speaker <- updateSpeaker{index: 0} - return +func (j *Jukebox) Quit() error { + defer lock(&j.mu)() + + if j.conn == nil || j.conn.IsClosed() { + return nil + } + if _, err := j.conn.Call("quit"); err != nil { + return fmt.Errorf("quit: %w", err) + } + if err := j.conn.Close(); err != nil { + return fmt.Errorf("close: %w", err) } - j.playlist = append(j.playlist, tracks...) - j.Unlock() + j.conn.WaitUntilClosed() + return nil } -func (j *Jukebox) RemoveTrack(i int) { - j.Lock() - defer j.Unlock() - if i < 0 || i >= len(j.playlist) { - return +func (j *Jukebox) getDecode(dest any, property string) error { + raw, err := j.conn.Get(property) + if err != nil { + return fmt.Errorf("get property: %w", err) } - j.playlist = append(j.playlist[:i], j.playlist[i+1:]...) + if err := mapstructure.Decode(raw, dest); err != nil { + return fmt.Errorf("decode: %w", err) + } + return nil } -func (j *Jukebox) Skip(i int, offset int) { - speaker.Clear() - j.Lock() - j.index = i - j.playing = true - j.Unlock() - j.speaker <- updateSpeaker{index: j.index, offset: offset} +type mpvPlaylist []mpvPlaylistItem +type mpvPlaylistItem struct { + ID int + Filename string + Current bool + Playing bool } -func (j *Jukebox) ClearTracks() { - speaker.Clear() - j.Lock() - defer j.Unlock() - j.playing = false - j.playlist = []*db.Track{} +func waitUntil(timeout time.Duration, f func() bool) bool { + quit := time.NewTicker(timeout) + defer quit.Stop() + check := time.NewTicker(100 * time.Millisecond) + defer check.Stop() + + for { + select { + case <-quit.C: + return false + case <-check.C: + if f() { + return true + } + } + } } -func (j *Jukebox) Stop() { - j.Lock() - defer j.Unlock() - if j.info != nil { - j.playing = false - j.info.ctrlStrmr.Paused = true +func waitFor[T any](ch <-chan T, match func(e T) bool) error { + quit := time.NewTicker(1 * time.Second) + defer quit.Stop() + + defer time.Sleep(350 * time.Millisecond) + + for { + select { + case <-quit.C: + return ErrMPVTimeout + case ev := <-ch: + if match(ev) { + return nil + } + } } } -func (j *Jukebox) Start() { - if j.info != nil { - j.playing = true - j.info.ctrlStrmr.Paused = false +func tmp() (*os.File, func(), error) { + tmp, err := os.CreateTemp("", "") + if err != nil { + return nil, nil, fmt.Errorf("create temp file: %w", err) } + cleanup := func() { + os.Remove(tmp.Name()) + tmp.Close() + } + return tmp, cleanup, nil } -func (j *Jukebox) GetStatus() Status { - j.Lock() - defer j.Unlock() - position := 0 - if j.info != nil { - length := j.info.format.SampleRate.D(j.info.strm.Position()) - position = int(length.Round(time.Millisecond).Seconds()) +func find[T any](items []T, f func(T) bool) (T, int) { + for i, item := range items { + if f(item) { + return item, i + } } - return Status{ - CurrentIndex: j.index, - Playing: j.playing, - Gain: 0.9, - Position: position, + var t T + return t, -1 +} + +func filter[T comparable](items []T, f func(T) bool) ([]T, bool) { + var found bool + var ret []T + for _, item := range items { + if !f(item) { + found = true + continue + } + ret = append(ret, item) } + return ret, found } -func (j *Jukebox) GetTracks() []*db.Track { - j.Lock() - defer j.Unlock() - return j.playlist +func lock(mu *sync.Mutex) func() { + mu.Lock() + return mu.Unlock } diff --git a/jukebox/jukebox_test.go b/jukebox/jukebox_test.go new file mode 100644 index 00000000..4cb781e2 --- /dev/null +++ b/jukebox/jukebox_test.go @@ -0,0 +1,187 @@ +package jukebox_test + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/matryer/is" + "go.senan.xyz/gonic/jukebox" +) + +func newJukebox(t *testing.T) *jukebox.Jukebox { + sockPath := filepath.Join(t.TempDir(), "mpv.sock") + + j := jukebox.New() + err := j.Start( + sockPath, + []string{jukebox.MPVArg("--ao", "null")}, + ) + if err != nil { + t.Fatalf("start jukebox: %v", err) + } + t.Cleanup(func() { + j.Quit() + }) + return j +} + +func TestPlaySkipReset(t *testing.T) { + t.Parallel() + j := newJukebox(t) + is := is.New(t) + + is.NoErr(j.SetPlaylist([]string{ + testPath("tr_0.mp3"), + testPath("tr_1.mp3"), + testPath("tr_2.mp3"), + testPath("tr_3.mp3"), + testPath("tr_4.mp3"), + })) + + status, err := j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 0) + is.Equal(status.CurrentFilename, testPath("tr_0.mp3")) + is.Equal(status.Length, 5) + is.Equal(status.Playing, true) + + items, err := j.GetPlaylist() + is.NoErr(err) + + itemsSorted := append([]string(nil), items...) + sort.Strings(itemsSorted) + is.Equal(items, itemsSorted) + + is.NoErr(j.Play()) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.Playing, true) + + is.NoErr(j.Pause()) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.Playing, false) + + is.NoErr(j.Play()) + + // skip to 2 + is.NoErr(j.SkipToPlaylistIndex(2, 0)) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 2) + is.Equal(status.CurrentFilename, testPath("tr_2.mp3")) + is.Equal(status.Length, 5) + is.Equal(status.Playing, true) + + // skip to 3 + is.NoErr(j.SkipToPlaylistIndex(3, 0)) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 3) + is.Equal(status.CurrentFilename, testPath("tr_3.mp3")) + is.Equal(status.Length, 5) + is.Equal(status.Playing, true) + + // just add one more by overwriting the playlist like some clients do + // we should keep the current track unchaned if we find it + is.NoErr(j.SetPlaylist([]string{ + "testdata/tr_0.mp3", + "testdata/tr_1.mp3", + "testdata/tr_2.mp3", + "testdata/tr_3.mp3", + "testdata/tr_4.mp3", + "testdata/tr_5.mp3", + })) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 3) // index unchanged + is.Equal(status.CurrentFilename, testPath("tr_3.mp3")) + is.Equal(status.Length, 6) // we added one more track + is.Equal(status.Playing, true) + + // skip to 3 again + is.NoErr(j.SkipToPlaylistIndex(3, 0)) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 3) + is.Equal(status.CurrentFilename, testPath("tr_3.mp3")) + is.Equal(status.Length, 6) + is.Equal(status.Playing, true) + + // remove all but 3 + is.NoErr(j.SetPlaylist([]string{ + "testdata/tr_0.mp3", + "testdata/tr_1.mp3", + "testdata/tr_2.mp3", + "testdata/tr_3.mp3", + })) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 3) // index unchanged + is.Equal(status.CurrentFilename, testPath("tr_3.mp3")) + is.Equal(status.Length, 4) + is.Equal(status.Playing, true) + + // skip to 2 (5s long) in the middle of the track + is.NoErr(j.SkipToPlaylistIndex(2, 2)) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 2) // index unchanged + is.Equal(status.CurrentFilename, testPath("tr_2.mp3")) + is.Equal(status.Length, 4) + is.Equal(status.Playing, true) + is.Equal(status.Position, 2) // at new position + + // overwrite completely + is.NoErr(j.SetPlaylist([]string{ + "testdata/tr_5.mp3", + "testdata/tr_6.mp3", + "testdata/tr_7.mp3", + "testdata/tr_8.mp3", + "testdata/tr_9.mp3", + })) + + status, err = j.GetStatus() + is.NoErr(err) + is.Equal(status.CurrentIndex, 0) // index unchanged + is.Equal(status.CurrentFilename, testPath("tr_5.mp3")) + is.Equal(status.Length, 5) + is.Equal(status.Playing, true) +} + +func TestVolume(t *testing.T) { + t.Parallel() + j := newJukebox(t) + is := is.New(t) + + vol, err := j.GetVolumePct() + is.NoErr(err) + is.Equal(vol, 100.0) + + is.NoErr(j.SetVolumePct(69.0)) + + vol, err = j.GetVolumePct() + is.NoErr(err) + is.Equal(vol, 69.0) + + is.NoErr(j.SetVolumePct(0.0)) + + vol, err = j.GetVolumePct() + is.NoErr(err) + is.Equal(vol, 0.0) +} + +func testPath(path string) string { + cwd, _ := os.Getwd() + return filepath.Join(cwd, "testdata", path) +} diff --git a/jukebox/testdata/10s.mp3 b/jukebox/testdata/10s.mp3 new file mode 100644 index 00000000..af09ea83 Binary files /dev/null and b/jukebox/testdata/10s.mp3 differ diff --git a/jukebox/testdata/5s.mp3 b/jukebox/testdata/5s.mp3 new file mode 100644 index 00000000..27ba3355 Binary files /dev/null and b/jukebox/testdata/5s.mp3 differ diff --git a/jukebox/testdata/tr_0.mp3 b/jukebox/testdata/tr_0.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_0.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_1.mp3 b/jukebox/testdata/tr_1.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_1.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_2.mp3 b/jukebox/testdata/tr_2.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_2.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_3.mp3 b/jukebox/testdata/tr_3.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_3.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_4.mp3 b/jukebox/testdata/tr_4.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_4.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_5.mp3 b/jukebox/testdata/tr_5.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_5.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_6.mp3 b/jukebox/testdata/tr_6.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_6.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_7.mp3 b/jukebox/testdata/tr_7.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_7.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_8.mp3 b/jukebox/testdata/tr_8.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_8.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/jukebox/testdata/tr_9.mp3 b/jukebox/testdata/tr_9.mp3 new file mode 120000 index 00000000..3d55f165 --- /dev/null +++ b/jukebox/testdata/tr_9.mp3 @@ -0,0 +1 @@ +5s.mp3 \ No newline at end of file diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index ed621352..3a3c4191 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -2,8 +2,11 @@ package ctrlsubsonic import ( "errors" + "fmt" "log" + "math" "net/http" + "os" "path/filepath" "time" "unicode" @@ -270,80 +273,135 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { return sub } -func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { +func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { // nolint:gocyclo params := r.Context().Value(CtxParams).(params.Params) user := r.Context().Value(CtxUser).(*db.User) - getTracks := func() []*db.Track { - var tracks []*db.Track - ids, err := params.GetIDList("id") - if err != nil { - return tracks - } + trackPaths := func(ids []specid.ID) ([]string, error) { + var paths []string for _, id := range ids { - track := &db.Track{} - c.DB. - Preload("Album"). - Preload("TrackStar", "user_id=?", user.ID). - Preload("TrackRating", "user_id=?", user.ID). - First(track, id.Value) - if track.ID != 0 { - tracks = append(tracks, track) + var track db.Track + if err := c.DB.Preload("Album").Preload("TrackStar", "user_id=?", user.ID).Preload("TrackRating", "user_id=?", user.ID).First(&track, id.Value).Error; err != nil { + return nil, fmt.Errorf("find track by id: %w", err) } + paths = append(paths, track.AbsPath()) } - return tracks + return paths, nil } - getStatus := func() spec.JukeboxStatus { - status := c.Jukebox.GetStatus() - return spec.JukeboxStatus{ + getSpecStatus := func() (*spec.JukeboxStatus, error) { + status, err := c.Jukebox.GetStatus() + if err != nil { + return nil, fmt.Errorf("get status: %w", err) + } + return &spec.JukeboxStatus{ CurrentIndex: status.CurrentIndex, Playing: status.Playing, - Gain: status.Gain, + Gain: float64(status.GainPct) / 100.0, Position: status.Position, - } + }, nil } - getStatusTracks := func() []*spec.TrackChild { - tracks := c.Jukebox.GetTracks() - ret := make([]*spec.TrackChild, len(tracks)) - for i, track := range tracks { - ret[i] = spec.NewTrackByTags(track, track.Album) + getSpecPlaylist := func() ([]*spec.TrackChild, error) { + var ret []*spec.TrackChild + playlist, err := c.Jukebox.GetPlaylist() + if err != nil { + return nil, fmt.Errorf("get playlist: %w", err) + } + for _, path := range playlist { + cwd, _ := os.Getwd() + path, _ = filepath.Rel(cwd, path) + var track db.Track + err := c.DB. + Preload("Album"). + Where(`(albums.root_dir || ? || albums.left_path || albums.right_path || ? || tracks.filename)=?`, + string(filepath.Separator), string(filepath.Separator), path). + Joins(`JOIN albums ON tracks.album_id=albums.id`). + First(&track). + Error + if err != nil { + return nil, fmt.Errorf("fetch track: %w", err) + } + ret = append(ret, spec.NewTrackByTags(&track, track.Album)) } - return ret + return ret, nil } + switch act, _ := params.Get("action"); act { case "set": - c.Jukebox.SetTracks(getTracks()) + ids := params.GetOrIDList("id", nil) + paths, err := trackPaths(ids) + if err != nil { + return spec.NewError(0, "error creating playlist items: %v", err) + } + if err := c.Jukebox.SetPlaylist(paths); err != nil { + return spec.NewError(0, "error setting playlist: %v", err) + } case "add": - c.Jukebox.AddTracks(getTracks()) + ids := params.GetOrIDList("id", nil) + paths, err := trackPaths(ids) + if err != nil { + return spec.NewError(10, "error creating playlist items: %v", err) + } + if err := c.Jukebox.AppendToPlaylist(paths); err != nil { + return spec.NewError(0, "error appending to playlist: %v", err) + } case "clear": - c.Jukebox.ClearTracks() + if err := c.Jukebox.ClearPlaylist(); err != nil { + return spec.NewError(0, "error clearing playlist: %v", err) + } case "remove": index, err := params.GetInt("index") if err != nil { return spec.NewError(10, "please provide an id for remove actions") } - c.Jukebox.RemoveTrack(index) + if err := c.Jukebox.RemovePlaylistIndex(index); err != nil { + return spec.NewError(0, "error removing: %v", err) + } case "stop": - c.Jukebox.Stop() + if err := c.Jukebox.Pause(); err != nil { + return spec.NewError(0, "error stopping: %v", err) + } case "start": - c.Jukebox.Start() + if err := c.Jukebox.Play(); err != nil { + return spec.NewError(0, "error starting: %v", err) + } case "skip": index, err := params.GetInt("index") if err != nil { return spec.NewError(10, "please provide an index for skip actions") } offset, _ := params.GetInt("offset") - c.Jukebox.Skip(index, offset) + if err := c.Jukebox.SkipToPlaylistIndex(index, offset); err != nil { + return spec.NewError(0, "error skipping: %v", err) + } case "get": + specPlaylist, err := getSpecPlaylist() + if err != nil { + return spec.NewError(10, "error getting status tracks: %v", err) + } + status, err := getSpecStatus() + if err != nil { + return spec.NewError(10, "error getting status: %v", err) + } sub := spec.NewResponse() sub.JukeboxPlaylist = &spec.JukeboxPlaylist{ - JukeboxStatus: getStatus(), - List: getStatusTracks(), + JukeboxStatus: status, + List: specPlaylist, } return sub + case "setGain": + gain, err := params.GetFloat("gain") + if err != nil { + return spec.NewError(10, "please provide a valid gain param") + } + if err := c.Jukebox.SetVolumePct(int(math.Min(gain, 1) * 100)); err != nil { + return spec.NewError(0, "error setting gain: %v", err) + } } // all actions except get are expected to return a status + status, err := getSpecStatus() + if err != nil { + return spec.NewError(10, "error getting status: %v", err) + } sub := spec.NewResponse() - status := getStatus() - sub.JukeboxStatus = &status + sub.JukeboxStatus = status return sub } diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 397f917a..bb3268ef 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -313,7 +313,7 @@ type JukeboxStatus struct { type JukeboxPlaylist struct { List []*TrackChild `xml:"entry,omitempty" json:"entry,omitempty"` - JukeboxStatus + *JukeboxStatus } type Podcasts struct { diff --git a/server/server.go b/server/server.go index 56696e45..730739a1 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "os" "path/filepath" "time" @@ -100,7 +101,6 @@ func New(opts Options) (*Server, error) { CoverCachePath: opts.CoverCachePath, PodcastsPath: opts.PodcastPath, MusicPaths: opts.MusicPaths, - Jukebox: &jukebox.Jukebox{}, Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}}, Podcasts: podcast, Transcoder: cacheTranscoder, @@ -360,13 +360,29 @@ func (s *Server) StartScanWatcher() (FuncExecute, FuncInterrupt) { } } -func (s *Server) StartJukebox() (FuncExecute, FuncInterrupt) { +func (s *Server) StartJukebox(mpvExtraArgs []string) (FuncExecute, FuncInterrupt) { + var sockFile *os.File return func() error { log.Printf("starting job 'jukebox'\n") - return s.jukebox.Listen() + var err error + sockFile, err = os.CreateTemp("", "gonic-jukebox-*.sock") + if err != nil { + return fmt.Errorf("create tmp sock file: %w", err) + } + if err := s.jukebox.Start(sockFile.Name(), mpvExtraArgs); err != nil { + return fmt.Errorf("start jukebox: %w", err) + } + if err := s.jukebox.Wait(); err != nil { + return fmt.Errorf("start jukebox: %w", err) + } + return nil }, func(_ error) { // stop job - s.jukebox.Quit() + if err := s.jukebox.Quit(); err != nil { + log.Printf("error quitting jukebox: %v", err) + } + _ = sockFile.Close() + _ = os.Remove(sockFile.Name()) } }