├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug-or-issue.md ├── logo.png ├── scrot_1.png ├── scrot_2.png ├── scrot_3.png ├── scrot_4.png ├── scrot_5.png └── workflows │ ├── debug-release.yaml │ ├── nightly-release.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.debug ├── Dockerfile.dev ├── LICENSE ├── README.md ├── alpine └── taglib │ └── APKBUILD ├── cmd └── gonic │ ├── default.pgo │ └── gonic.go ├── contrib ├── config ├── gonic.service ├── gonic.sysusers └── gonic.tmpfiles ├── db ├── db.go ├── db_test.go ├── migrations.go └── migrations_old_models.go ├── fileutil ├── fileutil.go └── fileutil_test.go ├── go.mod ├── go.sum ├── handlerutil └── handlerutil.go ├── infocache ├── albuminfocache │ └── albuminfocache.go └── artistinfocache │ ├── artistinfocache.go │ └── artistinfocache_test.go ├── jukebox ├── jukebox.go ├── jukebox_test.go └── testdata │ ├── 10s.mp3 │ ├── 5s.mp3 │ ├── tr_0.mp3 │ ├── tr_1.mp3 │ ├── tr_2.mp3 │ ├── tr_3.mp3 │ ├── tr_4.mp3 │ ├── tr_5.mp3 │ ├── tr_6.mp3 │ ├── tr_7.mp3 │ ├── tr_8.mp3 │ └── tr_9.mp3 ├── lastfm ├── client.go ├── client_test.go ├── mockclient │ ├── artist_get_info_response.xml │ ├── artist_get_similar_response.xml │ ├── artist_get_top_tracks_response.xml │ ├── get_session_response.xml │ ├── mockclient.go │ └── track_get_similar_response.xml └── model.go ├── listenbrainz ├── listenbrainz.go ├── listenbrainz_test.go ├── model.go └── testdata │ └── submit_listens_request.json ├── mockfs └── mockfs.go ├── playlist ├── playlist.go └── playlist_test.go ├── podcast ├── podcast.go ├── podcast_test.go └── testdata │ └── rss.new ├── scanner ├── scanner.go ├── scanner_benchmark_test.go ├── scanner_fuzz_test.go └── scanner_test.go ├── scrobble └── scrobble.go ├── server ├── ctrladmin │ ├── adminui │ │ ├── adminui.go │ │ ├── components.tmpl │ │ ├── pages │ │ │ ├── change_avatar.tmpl │ │ │ ├── change_password.tmpl │ │ │ ├── change_username.tmpl │ │ │ ├── create_user.tmpl │ │ │ ├── delete_user.tmpl │ │ │ ├── home.tmpl │ │ │ ├── login.tmpl │ │ │ ├── not_found.tmpl │ │ │ └── update_lastfm_api_key.tmpl │ │ ├── static │ │ │ ├── favicon.ico │ │ │ ├── gonic.png │ │ │ ├── inconsolata-v31-latin-500.woff │ │ │ ├── inconsolata-v31-latin-500.woff2 │ │ │ ├── inconsolata-v31-latin-600.woff │ │ │ ├── inconsolata-v31-latin-600.woff2 │ │ │ ├── main.js │ │ │ └── style.css │ │ ├── style.css │ │ └── tailwind.config.js │ ├── ctrl.go │ ├── handlers.go │ └── handlers_raw.go └── ctrlsubsonic │ ├── ctrl.go │ ├── ctrl_test.go │ ├── handlers_bookmark.go │ ├── handlers_by_folder.go │ ├── handlers_by_folder_test.go │ ├── handlers_by_tags.go │ ├── handlers_by_tags_test.go │ ├── handlers_common.go │ ├── handlers_internet_radio.go │ ├── handlers_internet_radio_test.go │ ├── handlers_playlist.go │ ├── handlers_podcast.go │ ├── handlers_raw.go │ ├── params │ └── params.go │ ├── spec │ ├── construct_by_folder.go │ ├── construct_by_tags.go │ ├── construct_internet_radio.go │ ├── construct_podcast.go │ └── spec.go │ ├── specid │ ├── ids.go │ └── ids_test.go │ ├── specidpaths │ └── specidpaths.go │ └── testdata │ ├── audio │ ├── 10s.flac │ └── 5s.flac │ ├── test_get_album_list_alpha_artist │ ├── test_get_album_list_alpha_name │ ├── test_get_album_list_newest │ ├── test_get_album_list_random │ ├── test_get_album_list_two_alpha_artist │ ├── test_get_album_list_two_alpha_name │ ├── test_get_album_list_two_newest │ ├── test_get_album_list_two_random │ ├── test_get_album_with_cover │ ├── test_get_album_without_cover │ ├── test_get_artist_id_one │ ├── test_get_artist_id_three │ ├── test_get_artist_id_two │ ├── test_get_artists_no_args │ ├── test_get_artists_with_music_folder_1 │ ├── test_get_artists_with_music_folder_2 │ ├── test_get_indexes_no_args │ ├── test_get_indexes_with_music_folder_1 │ ├── test_get_indexes_with_music_folder_2 │ ├── test_get_music_directory_with_tracks │ ├── test_get_music_directory_without_tracks │ ├── test_search_three_q_alb │ ├── test_search_three_q_art │ ├── test_search_three_q_tra │ ├── test_search_two_q_alb │ ├── test_search_two_q_art │ └── test_search_two_q_tra ├── tags ├── tagcommon │ └── tagcommmon.go └── taglib │ └── taglib.go ├── transcode ├── testdata │ └── 5s.flac ├── transcode.go ├── transcode_test.go ├── transcoder_caching.go ├── transcoder_ffmpeg.go └── transcoder_none.go ├── version.go └── version.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .gitignore 4 | .golangci.yml 5 | *testdata* 6 | *_test.go 7 | *.db 8 | README.md 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [sentriz] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-or-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: bug or issue 3 | about: report a new bug or issue 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | gonic version: 12 | 13 | if from docker, docker tag: 14 | if from source, git tag/branch: 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/logo.png -------------------------------------------------------------------------------- /.github/scrot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/scrot_1.png -------------------------------------------------------------------------------- /.github/scrot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/scrot_2.png -------------------------------------------------------------------------------- /.github/scrot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/scrot_3.png -------------------------------------------------------------------------------- /.github/scrot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/scrot_4.png -------------------------------------------------------------------------------- /.github/scrot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/.github/scrot_5.png -------------------------------------------------------------------------------- /.github/workflows/debug-release.yaml: -------------------------------------------------------------------------------- 1 | name: Debug Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tag: 6 | description: "Tag of image to build" 7 | required: true 8 | jobs: 9 | build-release: 10 | name: Build and release Docker image 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v1 17 | with: 18 | image: tonistiigi/binfmt:latest 19 | platforms: all 20 | - name: Set up Docker Buildx 21 | id: buildx 22 | uses: docker/setup-buildx-action@v1 23 | with: 24 | install: true 25 | version: latest 26 | driver-opts: image=moby/buildkit:master 27 | - name: Login into DockerHub 28 | run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 29 | - name: Login into GitHub Container Registry 30 | run: echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin 31 | - name: Build and Push 32 | uses: docker/build-push-action@v2 33 | with: 34 | context: . 35 | file: ./Dockerfile 36 | platforms: linux/amd64,linux/arm64,linux/arm/v7 37 | push: true 38 | tags: | 39 | ghcr.io/${{ github.repository }}:${{ github.event.inputs.tag }} 40 | ${{ github.repository }}:${{ github.event.inputs.tag }} 41 | -------------------------------------------------------------------------------- /.github/workflows/nightly-release.yaml: -------------------------------------------------------------------------------- 1 | name: Nightly Release 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | workflow_dispatch: {} 6 | jobs: 7 | check-date: 8 | runs-on: ubuntu-latest 9 | name: Check latest commit 10 | outputs: 11 | should_run: ${{ steps.check.outputs.should_run }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | - id: check 15 | run: | 16 | test -n "$(git rev-list --after="24 hours" ${{ github.sha }})" \ 17 | && echo "should_run=true" >>$GITHUB_OUTPUT \ 18 | || echo "should_run=false" >>$GITHUB_OUTPUT 19 | test: 20 | name: Lint and test 21 | needs: check-date 22 | if: ${{ needs.check-date.outputs.should_run == 'true' }} 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v3 27 | - name: Setup Go 28 | uses: actions/setup-go@v4 29 | with: 30 | go-version-file: go.mod 31 | - name: Install dependencies 32 | run: | 33 | sudo apt update -qq 34 | sudo apt install -y -qq build-essential git sqlite3 libtag1-dev ffmpeg mpv zlib1g-dev 35 | - name: Lint 36 | uses: golangci/golangci-lint-action@v6 37 | with: 38 | version: v1.60 39 | args: --timeout=5m 40 | - name: Test 41 | run: go test ./... 42 | build-release: 43 | name: Build and release Docker image 44 | needs: [check-date, test] 45 | if: ${{ needs.check-date.outputs.should_run == 'true' }} 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v3 50 | - name: Set up QEMU 51 | uses: docker/setup-qemu-action@v1 52 | with: 53 | image: tonistiigi/binfmt:latest 54 | platforms: all 55 | - name: Set up Docker Buildx 56 | id: buildx 57 | uses: docker/setup-buildx-action@v1 58 | with: 59 | install: true 60 | version: latest 61 | driver-opts: image=moby/buildkit:master 62 | - name: Login into DockerHub 63 | run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 64 | - name: Login into GitHub Container Registry 65 | run: echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin 66 | - name: Generate short hash 67 | run: | 68 | _short_hash=${{ github.sha }} 69 | echo "SHORT_HASH=${_short_hash:0:7}" >> $GITHUB_ENV 70 | - name: Build and Push 71 | uses: docker/build-push-action@v2 72 | with: 73 | context: . 74 | file: ./Dockerfile 75 | platforms: linux/amd64,linux/arm64,linux/arm/v7 76 | push: true 77 | tags: | 78 | ghcr.io/${{ github.repository }}:${{ env.SHORT_HASH }} 79 | ghcr.io/${{ github.repository }}:nightly 80 | ${{ github.repository }}:${{ env.SHORT_HASH }} 81 | ${{ github.repository }}:nightly 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test: 8 | name: Lint and test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Setup Go 14 | uses: actions/setup-go@v4 15 | with: 16 | go-version-file: go.mod 17 | - name: Install dependencies 18 | run: | 19 | sudo apt update -qq 20 | sudo apt install -y -qq build-essential git sqlite3 libtag1-dev ffmpeg mpv zlib1g-dev 21 | - name: Lint 22 | uses: golangci/golangci-lint-action@v6 23 | with: 24 | version: v1.60 25 | args: --timeout=5m 26 | - name: Test 27 | run: go test ./... 28 | release-please: 29 | name: Run Release Please 30 | runs-on: ubuntu-latest 31 | needs: [test] 32 | outputs: 33 | release_created: ${{ steps.release.outputs.release_created }} 34 | tag_name: ${{ steps.release.outputs.tag_name }} 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v3 38 | - name: Setup Release Please 39 | uses: google-github-actions/release-please-action@v2 40 | id: release 41 | with: 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | release-type: simple 44 | changelog-path: CHANGELOG.md 45 | package-name: gonic 46 | build-release: 47 | name: Build, tag, and publish Docker image 48 | runs-on: ubuntu-latest 49 | needs: [release-please] 50 | if: ${{ needs.release-please.outputs.release_created }} 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v3 54 | - name: Set up QEMU 55 | uses: docker/setup-qemu-action@v1 56 | with: 57 | image: tonistiigi/binfmt:latest 58 | platforms: all 59 | - name: Set up Docker Buildx 60 | id: buildx 61 | uses: docker/setup-buildx-action@v1 62 | with: 63 | install: true 64 | version: latest 65 | driver-opts: image=moby/buildkit:master 66 | - name: Login into DockerHub 67 | run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 68 | - name: Login into GitHub Container Registry 69 | run: echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin 70 | - name: Build and Push 71 | uses: docker/build-push-action@v2 72 | with: 73 | context: . 74 | file: ./Dockerfile 75 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 76 | push: true 77 | tags: | 78 | ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 79 | ghcr.io/${{ github.repository }}:latest 80 | ${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 81 | ${{ github.repository }}:latest 82 | notify-irc: 83 | needs: [release-please] 84 | name: Notify IRC 85 | runs-on: ubuntu-latest 86 | steps: 87 | - name: Checkout repository 88 | uses: actions/checkout@v3 89 | - name: Notify 90 | run: | 91 | set +x e 92 | git log -1 --pretty="push to master (@%an) %s" | curl "${{ secrets.IRC_NOTIFY_URL }}" -F target=#gonic -F message=@- >/dev/null 2>&1 93 | exit 0 94 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | pull_request: 7 | jobs: 8 | test: 9 | name: Lint and test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Setup Go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version-file: go.mod 18 | - name: Install dependencies 19 | run: | 20 | sudo apt update -qq 21 | sudo apt install -y -qq build-essential git sqlite3 libtag1-dev ffmpeg mpv zlib1g-dev 22 | - name: Lint 23 | uses: golangci/golangci-lint-action@v6 24 | with: 25 | version: v1.60 26 | args: --timeout=5m 27 | - name: Test 28 | run: go test ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *db-wal 3 | *db-shm 4 | *.sql 5 | *_bytes.go 6 | _test* 7 | dist 8 | *.sql 9 | ./gonic 10 | .devcontainer 11 | .vscode 12 | *.swp 13 | .tags* 14 | *.test 15 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - asasalint 5 | - asciicheck 6 | - bidichk 7 | - bodyclose 8 | - containedctx 9 | - decorder 10 | - dogsled 11 | - dupword 12 | - durationcheck 13 | - errcheck 14 | - errchkjson 15 | - errname 16 | - errorlint 17 | - execinquery 18 | - exportloopref 19 | - forbidigo 20 | - ginkgolinter 21 | - gocheckcompilerdirectives 22 | - gochecknoglobals 23 | - gochecknoinits 24 | - goconst 25 | - gocritic 26 | - gocyclo 27 | - gofmt 28 | - goheader 29 | - goimports 30 | - gomodguard 31 | - goprintffuncname 32 | - gosec 33 | - gosimple 34 | - gosmopolitan 35 | - govet 36 | - grouper 37 | - importas 38 | - ineffassign 39 | - loggercheck 40 | - makezero 41 | - mirror 42 | - misspell 43 | - nakedret 44 | - nestif 45 | - nilerr 46 | - nosprintfhostport 47 | - paralleltest 48 | - predeclared 49 | - promlinter 50 | - reassign 51 | - rowserrcheck 52 | - sqlclosecheck 53 | - staticcheck 54 | - stylecheck 55 | - tenv 56 | - testableexamples 57 | - thelper 58 | - tparallel 59 | - typecheck 60 | - unconvert 61 | - unparam 62 | - unused 63 | - wastedassign 64 | - whitespace 65 | - zerologlint 66 | 67 | issues: 68 | exclude-rules: 69 | - path: _test\.go 70 | linters: 71 | - errcheck 72 | - gochecknoglobals 73 | - text: "weak cryptographic primitive" 74 | linters: 75 | - gosec 76 | - text: "weak random number generator" 77 | linters: 78 | - gosec 79 | - text: "integer overflow conversion" 80 | linters: 81 | - gosec 82 | - text: "at least one file in a package should have a package comment" 83 | linters: 84 | - stylecheck 85 | - text: "should rewrite switch" 86 | linters: 87 | - gocritic 88 | exclude-dirs: 89 | - server/assets 90 | exclude-dirs-use-default: true 91 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.20 AS builder-taglib 2 | WORKDIR /tmp 3 | COPY alpine/taglib/APKBUILD . 4 | RUN apk update && \ 5 | apk add --no-cache abuild doas && \ 6 | echo "permit nopass root" > /etc/doas.conf && \ 7 | abuild-keygen -a -n -i && \ 8 | REPODEST=/pkgs abuild -F -r 9 | 10 | FROM golang:1.23-alpine AS builder 11 | RUN apk add -U --no-cache \ 12 | build-base \ 13 | ca-certificates \ 14 | git \ 15 | sqlite \ 16 | zlib-dev \ 17 | go 18 | 19 | # TODO: delete this block when taglib v2 is on alpine packages 20 | COPY --from=builder-taglib /pkgs/*/*.apk /pkgs/ 21 | RUN apk add --no-cache --allow-untrusted /pkgs/* 22 | 23 | WORKDIR /src 24 | COPY go.mod . 25 | COPY go.sum . 26 | RUN go mod download 27 | COPY . . 28 | RUN GOOS=linux go build -o gonic cmd/gonic/gonic.go 29 | 30 | FROM alpine:3.20 31 | LABEL org.opencontainers.image.source https://github.com/sentriz/gonic 32 | RUN apk add -U --no-cache \ 33 | ffmpeg \ 34 | mpv \ 35 | ca-certificates \ 36 | tzdata \ 37 | tini \ 38 | shared-mime-info 39 | 40 | COPY --from=builder \ 41 | /usr/lib/libgcc_s.so.1 \ 42 | /usr/lib/libstdc++.so.6 \ 43 | /usr/lib/libtag.so.2 \ 44 | /usr/lib/ 45 | COPY --from=builder \ 46 | /src/gonic \ 47 | /bin/ 48 | VOLUME ["/cache", "/data", "/music", "/podcasts"] 49 | EXPOSE 80 50 | ENV TZ "" 51 | ENV GONIC_DB_PATH /data/gonic.db 52 | ENV GONIC_LISTEN_ADDR :80 53 | ENV GONIC_MUSIC_PATH /music 54 | ENV GONIC_PODCAST_PATH /podcasts 55 | ENV GONIC_CACHE_PATH /cache 56 | ENV GONIC_PLAYLISTS_PATH /playlists 57 | ENTRYPOINT ["/sbin/tini", "--"] 58 | CMD ["gonic"] 59 | -------------------------------------------------------------------------------- /Dockerfile.debug: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | RUN apk add -U --no-cache \ 3 | build-base \ 4 | ca-certificates \ 5 | git \ 6 | sqlite \ 7 | taglib-dev \ 8 | zlib-dev \ 9 | shared-mime-info 10 | WORKDIR /src 11 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:experimental 2 | 3 | FROM golang:1.23-alpine AS builder 4 | RUN apk add -U --no-cache \ 5 | build-base \ 6 | ca-certificates \ 7 | git \ 8 | sqlite \ 9 | taglib-dev \ 10 | zlib-dev 11 | WORKDIR /src 12 | COPY . . 13 | RUN --mount=type=cache,target=/go/pkg/mod \ 14 | --mount=type=cache,target=/root/.cache/go-build \ 15 | GOOS=linux go build -o gonic cmd/gonic/gonic.go 16 | 17 | FROM alpine:3.20 18 | RUN apk add -U --no-cache \ 19 | ffmpeg \ 20 | mpv \ 21 | ca-certificates \ 22 | shared-mime-info 23 | COPY --from=builder \ 24 | /usr/lib/libgcc_s.so.1 \ 25 | /usr/lib/libstdc++.so.6 \ 26 | /usr/lib/libtag.so.1 \ 27 | /usr/lib/ 28 | COPY --from=builder \ 29 | /src/gonic \ 30 | /bin/ 31 | VOLUME ["/cache", "/data", "/music", "/podcasts"] 32 | EXPOSE 80 33 | ENV GONIC_DB_PATH /data/gonic.db 34 | ENV GONIC_LISTEN_ADDR :80 35 | ENV GONIC_MUSIC_PATH /music 36 | ENV GONIC_PODCAST_PATH /podcasts 37 | ENV GONIC_PLAYLISTS_PATH /playlists 38 | ENV GONIC_CACHE_PATH /cache 39 | CMD ["gonic"] 40 | -------------------------------------------------------------------------------- /alpine/taglib/APKBUILD: -------------------------------------------------------------------------------- 1 | # Contributor: Leo 2 | # Maintainer: Natanael Copa 3 | pkgname=taglib2 4 | pkgver=2.0.1 5 | pkgrel=0 6 | pkgdesc="Library for reading and editing metadata of several popular audio formats" 7 | url="https://taglib.github.io/" 8 | arch="all" 9 | license="LGPL-2.1-only OR MPL-1.1" 10 | makedepends="zlib-dev utfcpp cmake samurai" 11 | checkdepends="cppunit-dev" 12 | subpackages=" 13 | $pkgname-dev 14 | libtag:_lib 15 | libtag_c:_lib 16 | " 17 | source="https://taglib.github.io/releases/taglib-$pkgver.tar.gz" 18 | builddir="$srcdir/taglib-$pkgver" 19 | 20 | # secfixes: 21 | # 1.11.1-r2: 22 | # - CVE-2017-12678 23 | # - CVE-2018-11439 24 | 25 | build() { 26 | CFLAGS="$CFLAGS -flto=auto" \ 27 | CXXFLAGS="$CXXFLAGS -flto=auto" \ 28 | cmake -B build -G Ninja \ 29 | -DCMAKE_INSTALL_PREFIX=/usr \ 30 | -DCMAKE_BUILD_TYPE=MinSizeRel \ 31 | -DWITH_ZLIB=ON \ 32 | -DBUILD_SHARED_LIBS=ON \ 33 | -DBUILD_EXAMPLES=OFF \ 34 | -DBUILD_TESTING="$(want_check && echo ON || echo OFF)" \ 35 | -DVISIBILITY_HIDDEN=ON 36 | CPLUS_INCLUDE_PATH="/usr/include/utf8cpp" \ 37 | cmake --build build 38 | } 39 | 40 | check() { 41 | ctest --test-dir build --output-on-failure 42 | } 43 | 44 | package() { 45 | DESTDIR="$pkgdir" cmake --install build 46 | } 47 | 48 | _lib() { 49 | pkgdesc="$pkgdesc ($subpkgname lib)" 50 | 51 | amove usr/lib/$subpkgname.so.* 52 | } 53 | 54 | sha512sums=" 55 | 25ee89293a96d7f8dca6276f822bdaef01fd98503b78c20ffeac8e1d9821de7273a5127146aa798d304c6a995cb2b7229a205aff1cc261b5d4fa9e499dda0439 taglib-2.0.1.tar.gz 56 | " 57 | -------------------------------------------------------------------------------- /cmd/gonic/default.pgo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/cmd/gonic/default.pgo -------------------------------------------------------------------------------- /contrib/config: -------------------------------------------------------------------------------- 1 | # This is the gonic server system-wide configuration file. For other 2 | # administrative and per user settings go to the web UI. 3 | # 4 | # The strategy used for options in the default config shipped with gonic is to 5 | # specify options with their default value where possible, and leave optional 6 | # ones commented. Uncommented options override the default value. Options that 7 | # are mandatory and need setting before first run have a placeholder for their 8 | # values in . 9 | 10 | # Interface and port to listen on. Defaults to 0.0.0.0:4747 11 | listen-addr 127.0.0.1:4747 12 | 13 | # HTTP(S) request logging 14 | #http-log true 15 | # URL path prefix to use if behind reverse proxy 16 | #proxy-prefix 17 | 18 | # Secure connection settings. Recommended to set up unless gonic sits behind an 19 | # SSL enabled reverse proxy server. Disabled by default. 20 | #tls-cert 21 | #tls-key 22 | 23 | # gonic's internal state database location 24 | db-path /var/lib/gonic/gonic.db 25 | 26 | # Path to music files. Must be specified at least once, but can be specified 27 | # multiple times if the collection is split into different 28 | # directories. E.g.: 29 | # music-path /srv/audio/music 30 | music-path 31 | 32 | # Path to downloaded podcast files. Must be specified. E.g.: 33 | # podcast-path /var/cache/podcast 34 | podcast-path 35 | 36 | # gonic manages playlists as m3u files. This way users can also edit these 37 | # playlists themselves or add custom ones via other tools. Items in the 38 | # directory should be placed in subdirectories matching user IDs in the 39 | # database, in the format `/.m3u`. For example the admin user could 40 | # have 1/my-playlist.m3u. E.g.: 41 | # playlists-path /srv/audio/playlists 42 | playlists-path 43 | 44 | # Age (in days) to purge podcast episodes if not accessed. Disabled by default. 45 | #podcast-purge-age 0 46 | 47 | # Directory where transcoded audio files and covers are stored. It's safe to 48 | # delete contents periodically (see tmpfilesd configuration), as it will be 49 | # regenerated. 50 | cache-path /var/cache/gonic 51 | 52 | # Option to eject least recently used items from transcode cache. 53 | #transcode-cache-size 5000 # in Mb (0 = no limit) 54 | #transcode-eject-interval 1440 # in minutes (0 = never eject) 55 | 56 | # Interval (in minutes) to check for new music. Default: don't scan 57 | #scan-interval 0 58 | #scan-at-start-enabled false 59 | #scan-watcher-enabled false 60 | #jukebox-enabled false 61 | #jukebox-mpv-extra-args 62 | 63 | # Metadata multi-value handling 64 | # gonic supports parsing mult-valued tags in the metadata thus assigning a song 65 | # to several genres or album artists. This improves search and reduces clutter 66 | # in the artist/genre listing. Accepted options are: 67 | # multi: gonic will explicitly look for multi value fields the audio metadata 68 | # delim : gonic will look at the regular audio metadata 69 | # fields like "genre" or "album_artist", but split 70 | # them on a delimiter. For example to split on 71 | # semicolon set the option value to be "delim ;" 72 | # none: default setting, gonic won't attempt to do any multi value parsing 73 | #multi-value-genre none 74 | #multi-value-album-artist none 75 | -------------------------------------------------------------------------------- /contrib/gonic.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gonic service 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=gonic 8 | Group=gonic 9 | 10 | StateDirectory=gonic 11 | CacheDirectory=gonic 12 | 13 | Restart=on-failure 14 | RestartSec=10 15 | 16 | ExecStart=/usr/local/bin/gonic -config-path /etc/gonic/config 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /contrib/gonic.sysusers: -------------------------------------------------------------------------------- 1 | u gonic - "user for gonic daemon" 2 | -------------------------------------------------------------------------------- /contrib/gonic.tmpfiles: -------------------------------------------------------------------------------- 1 | d /var/cache/gonic 0775 gonic gonic 7d 2 | -------------------------------------------------------------------------------- /db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "testing" 9 | 10 | _ "github.com/jinzhu/gorm/dialects/sqlite" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | log.SetOutput(io.Discard) 16 | os.Exit(m.Run()) 17 | } 18 | 19 | func TestGetSetting(t *testing.T) { 20 | t.Parallel() 21 | 22 | key := SettingKey(randKey()) 23 | value := "howdy" 24 | 25 | testDB, err := NewMock() 26 | if err != nil { 27 | t.Fatalf("error creating db: %v", err) 28 | } 29 | if err := testDB.Migrate(MigrationContext{}); err != nil { 30 | t.Fatalf("error migrating db: %v", err) 31 | } 32 | 33 | require.NoError(t, testDB.SetSetting(key, value)) 34 | 35 | actual, err := testDB.GetSetting(key) 36 | require.NoError(t, err) 37 | require.Equal(t, value, actual) 38 | 39 | require.NoError(t, testDB.SetSetting(key, value)) 40 | actual, err = testDB.GetSetting(key) 41 | require.NoError(t, err) 42 | require.Equal(t, value, actual) 43 | } 44 | 45 | func randKey() string { 46 | letters := []rune("abcdef0123456789") 47 | b := make([]rune, 16) 48 | for i := range b { 49 | b[i] = letters[rand.Intn(len(letters))] 50 | } 51 | return string(b) 52 | } 53 | -------------------------------------------------------------------------------- /db/migrations_old_models.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "time" 4 | 5 | type __OldPlaylist struct { //nolint: revive,stylecheck 6 | ID int `gorm:"primary_key"` 7 | CreatedAt time.Time 8 | UpdatedAt time.Time 9 | User *User 10 | UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` 11 | Name string 12 | Comment string 13 | TrackCount int 14 | Items string 15 | IsPublic bool `sql:"default: null"` 16 | } 17 | 18 | func (__OldPlaylist) TableName() string { 19 | return "playlists" 20 | } 21 | -------------------------------------------------------------------------------- /fileutil/fileutil.go: -------------------------------------------------------------------------------- 1 | // TODO: this package shouldn't really exist. we can usually just attempt our normal filesystem operations 2 | // and handle errors atomically. eg. 3 | // - Safe could instead be try create file, handle error 4 | // - Unique could be try create file, on err create file (1), etc 5 | package fileutil 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strings" 13 | ) 14 | 15 | var nonAlphaNumExpr = regexp.MustCompile("[^a-zA-Z0-9_.]+") 16 | 17 | func Safe(filename string) string { 18 | filename = nonAlphaNumExpr.ReplaceAllString(filename, "") 19 | return filename 20 | } 21 | 22 | // try to find a unqiue file (or dir) name. incrementing like "example (1)" 23 | func Unique(base, filename string) (string, error) { 24 | return unique(base, filename, 0) 25 | } 26 | 27 | func unique(base, filename string, count uint) (string, error) { 28 | var suffix string 29 | if count > 0 { 30 | suffix = fmt.Sprintf(" (%d)", count) 31 | } 32 | path := base + suffix 33 | if filename != "" { 34 | noExt := strings.TrimSuffix(filename, filepath.Ext(filename)) 35 | path = filepath.Join(base, noExt+suffix+filepath.Ext(filename)) 36 | } 37 | _, err := os.Stat(path) 38 | if os.IsNotExist(err) { 39 | return path, nil 40 | } 41 | if err != nil { 42 | return "", err 43 | } 44 | return unique(base, filename, count+1) 45 | } 46 | 47 | func First(path ...string) (string, error) { 48 | var err error 49 | for _, p := range path { 50 | _, err = os.Stat(p) 51 | if err == nil { 52 | return p, nil 53 | } 54 | } 55 | return "", err 56 | } 57 | 58 | // HasPrefix checks a path has a prefix, making sure to respect path boundaries. So that /aa & /a does not match, but /a/a & /a does. 59 | func HasPrefix(p, prefix string) bool { 60 | return p == prefix || strings.HasPrefix(p, filepath.Clean(prefix)+string(filepath.Separator)) 61 | } 62 | -------------------------------------------------------------------------------- /fileutil/fileutil_test.go: -------------------------------------------------------------------------------- 1 | package fileutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestUniquePath(t *testing.T) { 12 | t.Parallel() 13 | 14 | unq := func(base, filename string, count uint) string { 15 | r, err := unique(base, filename, count) 16 | require.NoError(t, err) 17 | return r 18 | } 19 | 20 | require.Equal(t, "test/wow.mp3", unq("test", "wow.mp3", 0)) 21 | require.Equal(t, "test/wow (1).mp3", unq("test", "wow.mp3", 1)) 22 | require.Equal(t, "test/wow (2).mp3", unq("test", "wow.mp3", 2)) 23 | 24 | require.Equal(t, "test", unq("test", "", 0)) 25 | require.Equal(t, "test (1)", unq("test", "", 1)) 26 | 27 | base := filepath.Join(t.TempDir(), "a") 28 | 29 | require.NoError(t, os.MkdirAll(base, os.ModePerm)) 30 | 31 | next := base + " (1)" 32 | require.Equal(t, next, unq(base, "", 0)) 33 | 34 | require.NoError(t, os.MkdirAll(next, os.ModePerm)) 35 | 36 | next = base + " (2)" 37 | require.Equal(t, next, unq(base, "", 0)) 38 | 39 | _, err := os.Create(filepath.Join(base, "test.mp3")) 40 | require.NoError(t, err) 41 | require.Equal(t, filepath.Join(base, "test (1).mp3"), unq(base, "test.mp3", 0)) 42 | } 43 | 44 | func TestFirst(t *testing.T) { 45 | t.Parallel() 46 | 47 | base := t.TempDir() 48 | name := filepath.Join(base, "test") 49 | _, err := os.Create(name) 50 | require.NoError(t, err) 51 | 52 | p := func(name string) string { 53 | return filepath.Join(base, name) 54 | } 55 | 56 | r, err := First(p("one"), p("two"), p("test"), p("four")) 57 | require.NoError(t, err) 58 | require.Equal(t, p("test"), r) 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.senan.xyz/gonic 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/Masterminds/sprig v2.22.0+incompatible 7 | github.com/andybalholm/cascadia v1.3.2 8 | github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37 9 | github.com/disintegration/imaging v1.6.2 10 | github.com/djherbis/times v1.6.0 11 | github.com/dustin/go-humanize v1.0.1 12 | github.com/fatih/structs v1.1.0 13 | github.com/fsnotify/fsnotify v1.7.0 14 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 15 | github.com/google/uuid v1.6.0 16 | github.com/gorilla/securecookie v1.1.2 17 | github.com/gorilla/sessions v1.4.0 18 | github.com/jinzhu/gorm v1.9.17-0.20211120011537-5c235b72a414 19 | github.com/josephburnett/jd v1.9.1 20 | github.com/mattn/go-sqlite3 v1.14.23 21 | github.com/mitchellh/mapstructure v1.5.0 22 | github.com/mmcdole/gofeed v1.3.0 23 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 24 | github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 25 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be 26 | github.com/sentriz/audiotags v0.0.0-20240918190302-048d6470aae6 27 | github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 28 | github.com/stretchr/testify v1.9.0 29 | go.senan.xyz/flagconf v0.1.9 30 | go.senan.xyz/wrtag v0.0.0-20240917222925-5e7c76752ed7 31 | golang.org/x/net v0.29.0 32 | golang.org/x/sync v0.8.0 33 | gopkg.in/gormigrate.v1 v1.6.0 34 | jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 35 | ) 36 | 37 | require ( 38 | github.com/Masterminds/goutils v1.1.1 // indirect 39 | github.com/Masterminds/semver v1.5.0 // indirect 40 | github.com/PuerkitoBio/goquery v1.10.0 // indirect 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 43 | github.com/go-openapi/swag v0.23.0 // indirect 44 | github.com/gorilla/context v1.1.2 // indirect 45 | github.com/huandu/xstrings v1.5.0 // indirect 46 | github.com/imdario/mergo v0.3.16 // indirect 47 | github.com/jinzhu/inflection v1.0.0 // indirect 48 | github.com/jinzhu/now v1.1.2 // indirect 49 | github.com/josharian/intern v1.0.0 // indirect 50 | github.com/json-iterator/go v1.1.12 // indirect 51 | github.com/lib/pq v1.3.0 // indirect 52 | github.com/mailru/easyjson v0.7.7 // indirect 53 | github.com/mattn/go-runewidth v0.0.16 // indirect 54 | github.com/mitchellh/copystructure v1.2.0 // indirect 55 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 56 | github.com/mmcdole/goxpp v1.1.1 // indirect 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 58 | github.com/modern-go/reflect2 v1.0.2 // indirect 59 | github.com/olekukonko/tablewriter v0.0.5 // indirect 60 | github.com/pmezard/go-difflib v1.0.0 // indirect 61 | github.com/rivo/uniseg v0.4.7 // indirect 62 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 63 | golang.org/x/crypto v0.27.0 // indirect 64 | golang.org/x/image v0.20.0 // indirect 65 | golang.org/x/sys v0.25.0 // indirect 66 | golang.org/x/text v0.18.0 // indirect 67 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect 68 | gopkg.in/yaml.v2 v2.4.0 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /handlerutil/handlerutil.go: -------------------------------------------------------------------------------- 1 | package handlerutil 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type Middleware func(http.Handler) http.Handler 11 | 12 | func Chain(middlewares ...Middleware) Middleware { 13 | return func(final http.Handler) http.Handler { 14 | for i := len(middlewares) - 1; i >= 0; i-- { 15 | final = middlewares[i](final) 16 | } 17 | return final 18 | } 19 | } 20 | 21 | func TrimPathSuffix(suffix string) Middleware { 22 | return func(next http.Handler) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | r.URL.Path = strings.TrimSuffix(r.URL.Path, suffix) 25 | next.ServeHTTP(w, r) 26 | }) 27 | } 28 | } 29 | 30 | func Log(next http.Handler) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | sw := &statusWriter{ResponseWriter: w} 33 | next.ServeHTTP(sw, r) 34 | log.Printf("response %s %s %v", statusToBlock(sw.status), r.Method, r.URL) 35 | }) 36 | } 37 | 38 | func BasicCORS(next http.Handler) http.Handler { 39 | allowMethods := strings.Join( 40 | []string{http.MethodPost, http.MethodGet, http.MethodOptions, http.MethodPut, http.MethodDelete}, 41 | ", ", 42 | ) 43 | allowHeaders := strings.Join( 44 | []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, 45 | ", ", 46 | ) 47 | 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | w.Header().Set("Access-Control-Allow-Origin", "*") 50 | w.Header().Set("Access-Control-Allow-Methods", allowMethods) 51 | w.Header().Set("Access-Control-Allow-Headers", allowHeaders) 52 | if r.Method == http.MethodOptions { 53 | return 54 | } 55 | next.ServeHTTP(w, r) 56 | }) 57 | } 58 | 59 | func Message(message string) http.Handler { 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | fmt.Fprintln(w, message) 62 | }) 63 | } 64 | 65 | func BaseURL(r *http.Request) string { 66 | var fallbackScheme = "http" 67 | if r.TLS != nil { 68 | fallbackScheme = "https" 69 | } 70 | scheme := first( 71 | r.Header.Get("X-Forwarded-Proto"), 72 | r.Header.Get("X-Forwarded-Scheme"), 73 | r.URL.Scheme, 74 | fallbackScheme, 75 | ) 76 | host := first( 77 | r.Header.Get("X-Forwarded-Host"), 78 | r.URL.Host, 79 | r.Host, 80 | ) 81 | return fmt.Sprintf("%s://%s", scheme, host) 82 | } 83 | 84 | type statusWriter struct { 85 | http.ResponseWriter 86 | status int 87 | } 88 | 89 | func (w *statusWriter) WriteHeader(status int) { 90 | w.status = status 91 | w.ResponseWriter.WriteHeader(status) 92 | } 93 | 94 | func (w *statusWriter) Write(b []byte) (int, error) { 95 | if w.status == 0 { 96 | w.status = 200 97 | } 98 | return w.ResponseWriter.Write(b) 99 | } 100 | 101 | func (w *statusWriter) Unwrap() http.ResponseWriter { 102 | return w.ResponseWriter 103 | } 104 | 105 | func statusToBlock(code int) string { 106 | var bg int 107 | switch { 108 | case code >= 500: 109 | bg = 41 // bright red 110 | case code >= 400: 111 | bg = 43 // bright orange 112 | case code >= 300: 113 | bg = 46 // bright cyan 114 | case code >= 200: 115 | bg = 42 // bright green 116 | default: 117 | bg = 47 // bright white (grey) 118 | } 119 | return fmt.Sprintf("\u001b[%d;1m %d \u001b[0m", bg, code) 120 | } 121 | 122 | func first[T comparable](vs ...T) T { 123 | var z T 124 | for _, s := range vs { 125 | if s != z { 126 | return s 127 | } 128 | } 129 | return z 130 | } 131 | -------------------------------------------------------------------------------- /infocache/albuminfocache/albuminfocache.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package albuminfocache 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/jinzhu/gorm" 11 | "go.senan.xyz/gonic/db" 12 | "go.senan.xyz/gonic/lastfm" 13 | ) 14 | 15 | const keepFor = 30 * time.Hour * 24 16 | 17 | type AlbumInfoCache struct { 18 | db *db.DB 19 | lastfmClient *lastfm.Client 20 | } 21 | 22 | func New(db *db.DB, lastfmClient *lastfm.Client) *AlbumInfoCache { 23 | return &AlbumInfoCache{db: db, lastfmClient: lastfmClient} 24 | } 25 | 26 | func (a *AlbumInfoCache) GetOrLookup(ctx context.Context, albumID int) (*db.AlbumInfo, error) { 27 | var album db.Album 28 | if err := a.db.Find(&album, "id=?", albumID).Error; err != nil { 29 | return nil, fmt.Errorf("find album in db: %w", err) 30 | } 31 | if album.TagAlbumArtist == "" || album.TagTitle == "" { 32 | return nil, fmt.Errorf("no metadata to look up") 33 | } 34 | 35 | var albumInfo db.AlbumInfo 36 | if err := a.db.Find(&albumInfo, "id=?", albumID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 37 | return nil, fmt.Errorf("find album info in db: %w", err) 38 | } 39 | 40 | if albumInfo.ID == 0 || time.Since(albumInfo.UpdatedAt) > keepFor { 41 | return a.Lookup(ctx, &album) 42 | } 43 | 44 | return &albumInfo, nil 45 | } 46 | 47 | func (a *AlbumInfoCache) Get(ctx context.Context, albumID int) (*db.AlbumInfo, error) { 48 | var albumInfo db.AlbumInfo 49 | if err := a.db.Find(&albumInfo, "id=?", albumID).Error; err != nil { 50 | return nil, fmt.Errorf("find album info in db: %w", err) 51 | } 52 | return &albumInfo, nil 53 | } 54 | 55 | func (a *AlbumInfoCache) Lookup(ctx context.Context, album *db.Album) (*db.AlbumInfo, error) { 56 | var albumInfo db.AlbumInfo 57 | albumInfo.ID = album.ID 58 | 59 | if err := a.db.FirstOrCreate(&albumInfo, "id=?", albumInfo.ID).Error; err != nil { 60 | return nil, fmt.Errorf("first or create album info: %w", err) 61 | } 62 | if err := a.db.Save(&albumInfo).Error; err != nil { 63 | return nil, fmt.Errorf("bump updated_at time: %w", err) 64 | } 65 | 66 | info, err := a.lastfmClient.AlbumGetInfo(album.TagAlbumArtist, album.TagTitle) 67 | if err != nil { 68 | return nil, fmt.Errorf("get upstream info: %w", err) 69 | } 70 | 71 | albumInfo.ID = album.ID 72 | albumInfo.Notes = info.Wiki.Content 73 | albumInfo.MusicBrainzID = info.MBID 74 | albumInfo.LastFMURL = info.URL 75 | 76 | if err := a.db.Save(&albumInfo).Error; err != nil { 77 | return nil, fmt.Errorf("save upstream info: %w", err) 78 | } 79 | 80 | return &albumInfo, nil 81 | } 82 | -------------------------------------------------------------------------------- /infocache/artistinfocache/artistinfocache.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package artistinfocache 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "time" 10 | 11 | "github.com/jinzhu/gorm" 12 | "go.senan.xyz/gonic/db" 13 | "go.senan.xyz/gonic/lastfm" 14 | ) 15 | 16 | const keepFor = 30 * time.Hour * 24 17 | 18 | type ArtistInfoCache struct { 19 | db *db.DB 20 | lastfmClient *lastfm.Client 21 | } 22 | 23 | func New(db *db.DB, lastfmClient *lastfm.Client) *ArtistInfoCache { 24 | return &ArtistInfoCache{db: db, lastfmClient: lastfmClient} 25 | } 26 | 27 | func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, artistID int) (*db.ArtistInfo, error) { 28 | var artist db.Artist 29 | if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil { 30 | return nil, fmt.Errorf("find artist in db: %w", err) 31 | } 32 | if artist.Name == "" { 33 | return nil, fmt.Errorf("no metadata to look up") 34 | } 35 | 36 | var artistInfo db.ArtistInfo 37 | if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 38 | return nil, fmt.Errorf("find artist info in db: %w", err) 39 | } 40 | 41 | if artistInfo.ID == 0 || artistInfo.Biography == "" /* prev not found maybe */ || time.Since(artistInfo.UpdatedAt) > keepFor { 42 | return a.Lookup(ctx, &artist) 43 | } 44 | 45 | return &artistInfo, nil 46 | } 47 | 48 | func (a *ArtistInfoCache) Get(ctx context.Context, artistID int) (*db.ArtistInfo, error) { 49 | var artistInfo db.ArtistInfo 50 | if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil { 51 | return nil, fmt.Errorf("find artist info in db: %w", err) 52 | } 53 | return &artistInfo, nil 54 | } 55 | 56 | func (a *ArtistInfoCache) Lookup(ctx context.Context, artist *db.Artist) (*db.ArtistInfo, error) { 57 | var artistInfo db.ArtistInfo 58 | artistInfo.ID = artist.ID 59 | 60 | if err := a.db.FirstOrCreate(&artistInfo, "id=?", artistInfo.ID).Error; err != nil { 61 | return nil, fmt.Errorf("first or create artist info: %w", err) 62 | } 63 | if err := a.db.Save(&artistInfo).Error; err != nil { 64 | return nil, fmt.Errorf("bump updated_at time: %w", err) 65 | } 66 | 67 | info, err := a.lastfmClient.ArtistGetInfo(artist.Name) 68 | if err != nil { 69 | return nil, fmt.Errorf("get upstream info: %w", err) 70 | } 71 | 72 | artistInfo.ID = artist.ID 73 | artistInfo.Biography = info.Bio.Summary 74 | artistInfo.MusicBrainzID = info.MBID 75 | artistInfo.LastFMURL = info.URL 76 | 77 | var similar []string 78 | for _, sim := range info.Similar.Artists { 79 | similar = append(similar, sim.Name) 80 | } 81 | artistInfo.SetSimilarArtists(similar) 82 | 83 | url, _ := a.lastfmClient.StealArtistImage(info.URL) 84 | artistInfo.ImageURL = url 85 | 86 | topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(artist.Name) 87 | if err != nil { 88 | return nil, fmt.Errorf("get top tracks: %w", err) 89 | } 90 | var topTracks []string 91 | for _, tr := range topTracksResponse.Tracks { 92 | topTracks = append(topTracks, tr.Name) 93 | } 94 | artistInfo.SetTopTracks(topTracks) 95 | 96 | if err := a.db.Save(&artistInfo).Error; err != nil { 97 | return nil, fmt.Errorf("save upstream info: %w", err) 98 | } 99 | 100 | return &artistInfo, nil 101 | } 102 | 103 | func (a *ArtistInfoCache) Refresh() error { 104 | q := a.db. 105 | Where("artist_infos.id IS NULL OR artist_infos.updated_at 2 | 3 | 4 | Artist 1 5 | 366c1119-ec4f-4312-b729-a5637d148e3e 6 | https://www.last.fm/music/Artist+1 7 | https://last.fm/artist-1-small.png 8 | 0 9 | 0 10 | 11 | 1 12 | 2 13 | 14 | 15 | 16 | Similar Artist 1 17 | https://www.last.fm/music/Similar+Artist+1 18 | https://last.fm/similar-artist-1-small.png 19 | 20 | 21 | 22 | 23 | tag1 24 | https://www.last.fm/tag/tag1 25 | 26 | 27 | 28 | 29 | 30 | 31 | 13 May 2023, 00:24 32 | Summary 33 | Content 34 | 35 | 36 | -------------------------------------------------------------------------------- /lastfm/mockclient/artist_get_similar_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Artist 2 6 | d2addad9-3fc4-4ce8-9cd4-63f2a19bb922 7 | 1 8 | https://www.last.fm/music/Artist+2 9 | https://last.fm/artist-2-small.png 10 | https://last.fm/artist-2-large.png 11 | 0 12 | 13 | 14 | Artist 3 15 | dc95d067-df3e-4b83-a5fe-5ec773b1883f 16 | 0.790991 17 | https://www.last.fm/music/Artist+3 18 | https://last.fm/artist-3-small.png 19 | https://last.fm/artist-3-large.png 20 | 0 21 | 22 | 23 | -------------------------------------------------------------------------------- /lastfm/mockclient/artist_get_top_tracks_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Track 1 6 | 1 7 | 2 8 | fdfc47cb-69d3-4318-ba71-d54fbc20169a 9 | https://www.last.fm/music/Artist+1/_/Track+1 10 | 0 11 | 12 | Artist 1 13 | 366c1119-ec4f-4312-b729-a5637d148e3e 14 | https://www.last.fm/music/Artist+1 15 | 16 | https://last.fm/track-1-small.png 17 | https://last.fm/track-1-large.png 18 | 19 | 20 | Track 2 21 | 2 22 | 3 23 | cf32e694-1ea6-4ba0-9e8b-d5f1950da9c8 24 | https://www.last.fm/music/Artist+1/_/Track+2 25 | 0 26 | 27 | Artist 1 28 | 366c1119-ec4f-4312-b729-a5637d148e3e 29 | https://www.last.fm/music/Artist+1 30 | 31 | https://last.fm/track-2-small.png 32 | https://last.fm/track-2-large.png 33 | 34 | 35 | -------------------------------------------------------------------------------- /lastfm/mockclient/get_session_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | username1 4 | sessionKey1 5 | 0 6 | 7 | 8 | -------------------------------------------------------------------------------- /lastfm/mockclient/mockclient.go: -------------------------------------------------------------------------------- 1 | package mockclient 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | _ "embed" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | ) 12 | 13 | func New(tb testing.TB, handler http.HandlerFunc) *http.Client { 14 | tb.Helper() 15 | 16 | server := httptest.NewTLSServer(handler) 17 | tb.Cleanup(server.Close) 18 | 19 | return &http.Client{ 20 | Transport: &http.Transport{ 21 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 22 | return net.Dial(network, server.Listener.Addr().String()) 23 | }, 24 | TLSClientConfig: &tls.Config{ 25 | InsecureSkipVerify: true, //nolint:gosec 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | //go:embed artist_get_info_response.xml 32 | var ArtistGetInfoResponse []byte 33 | 34 | //go:embed artist_get_top_tracks_response.xml 35 | var ArtistGetTopTracksResponse []byte 36 | 37 | //go:embed artist_get_similar_response.xml 38 | var ArtistGetSimilarResponse []byte 39 | 40 | //go:embed track_get_similar_response.xml 41 | var TrackGetSimilarResponse []byte 42 | 43 | //go:embed get_session_response.xml 44 | var GetSessionResponse []byte 45 | -------------------------------------------------------------------------------- /lastfm/mockclient/track_get_similar_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Track 1 6 | 1 7 | 7096931c-bf82-4896-b1e7-42b60a0e16ea 8 | 1.000 9 | https://www.last.fm/music/Artist+1/_/Track+1 10 | 0 11 | 80 12 | 13 | Artist+1 14 | 366c1119-ec4f-4312-b729-a5637d148e3e 15 | https://www.last.fm/music/Artist+1 16 | 17 | https://last.fm/track-1-small.png 18 | https://last.fm/track-1-large.png 19 | 20 | 21 | Track 2 22 | 2 23 | 2aff1321-149f-4000-8762-3468c917600c 24 | 0.422 25 | https://www.last.fm/music/Artist+2/_/Track+2 26 | 0 27 | 80 28 | 29 | Artist+2 30 | 9842b07f-956b-4c36-8ce1-884b4b96254d 31 | https://www.last.fm/music/Artist+1 32 | 33 | https://last.fm/track-2-small.png 34 | https://last.fm/track-2-large.png 35 | 36 | 37 | -------------------------------------------------------------------------------- /lastfm/model.go: -------------------------------------------------------------------------------- 1 | package lastfm 2 | 3 | import "encoding/xml" 4 | 5 | type ( 6 | LastFM struct { 7 | XMLName xml.Name `xml:"lfm"` 8 | Status string `xml:"status,attr"` 9 | Session Session `xml:"session"` 10 | Error Error `xml:"error"` 11 | Artist Artist `xml:"artist"` 12 | Album Album `xml:"album"` 13 | TopTracks TopTracks `xml:"toptracks"` 14 | SimilarTracks SimilarTracks `xml:"similartracks"` 15 | SimilarArtists SimilarArtists `xml:"similarartists"` 16 | LovedTracks LovedTracks `xml:"lovedtracks"` 17 | User User `xml:"user"` 18 | } 19 | 20 | Session struct { 21 | Name string `xml:"name"` 22 | Key string `xml:"key"` 23 | Subscriber uint `xml:"subscriber"` 24 | } 25 | 26 | Error struct { 27 | Code uint `xml:"code,attr"` 28 | Value string `xml:",chardata"` 29 | } 30 | 31 | SimilarArtist struct { 32 | XMLName xml.Name `xml:"artist"` 33 | Name string `xml:"name"` 34 | MBID string `xml:"mbid"` 35 | URL string `xml:"url"` 36 | Image []Image `xml:"image"` 37 | Streamable string `xml:"streamable"` 38 | } 39 | 40 | Image struct { 41 | Text string `xml:",chardata"` 42 | Size string `xml:"size,attr"` 43 | } 44 | 45 | Artist struct { 46 | XMLName xml.Name `xml:"artist"` 47 | Name string `xml:"name"` 48 | MBID string `xml:"mbid"` 49 | URL string `xml:"url"` 50 | Image []Image `xml:"image"` 51 | Streamable string `xml:"streamable"` 52 | Stats struct { 53 | Listeners string `xml:"listeners"` 54 | Playcount string `xml:"playcount"` 55 | } `xml:"stats"` 56 | Similar struct { 57 | Artists []Artist `xml:"artist"` 58 | } `xml:"similar"` 59 | Tags struct { 60 | Tag []ArtistTag `xml:"tag"` 61 | } `xml:"tags"` 62 | Bio ArtistBio `xml:"bio"` 63 | } 64 | 65 | Album struct { 66 | XMLName xml.Name `xml:"album"` 67 | Name string `xml:"name"` 68 | Artist string `xml:"artist"` 69 | MBID string `xml:"mbid"` 70 | URL string `xml:"url"` 71 | Image []struct { 72 | Text string `xml:",chardata"` 73 | Size string `xml:"size,attr"` 74 | } `xml:"image"` 75 | Listeners string `xml:"listeners"` 76 | Playcount string `xml:"playcount"` 77 | Tracks struct { 78 | Text string `xml:",chardata"` 79 | Track []struct { 80 | Text string `xml:",chardata"` 81 | Rank string `xml:"rank,attr"` 82 | Name string `xml:"name"` 83 | URL string `xml:"url"` 84 | Duration string `xml:"duration"` 85 | Streamable struct { 86 | Text string `xml:",chardata"` 87 | Fulltrack string `xml:"fulltrack,attr"` 88 | } `xml:"streamable"` 89 | Artist struct { 90 | Text string `xml:",chardata"` 91 | Name string `xml:"name"` 92 | Mbid string `xml:"mbid"` 93 | URL string `xml:"url"` 94 | } `xml:"artist"` 95 | } `xml:"track"` 96 | } `xml:"tracks"` 97 | Tags struct { 98 | Text string `xml:",chardata"` 99 | Tag []struct { 100 | Text string `xml:",chardata"` 101 | Name string `xml:"name"` 102 | URL string `xml:"url"` 103 | } `xml:"tag"` 104 | } `xml:"tags"` 105 | Wiki struct { 106 | Text string `xml:",chardata"` 107 | Published string `xml:"published"` 108 | Summary string `xml:"summary"` 109 | Content string `xml:"content"` 110 | } `xml:"wiki"` 111 | } 112 | 113 | ArtistTag struct { 114 | Name string `xml:"name"` 115 | URL string `xml:"url"` 116 | } 117 | 118 | ArtistBio struct { 119 | Published string `xml:"published"` 120 | Summary string `xml:"summary"` 121 | Content string `xml:"content"` 122 | } 123 | 124 | TopTracks struct { 125 | XMLName xml.Name `xml:"toptracks"` 126 | Artist string `xml:"artist,attr"` 127 | Tracks []Track `xml:"track"` 128 | } 129 | 130 | SimilarTracks struct { 131 | XMLName xml.Name `xml:"similartracks"` 132 | Artist string `xml:"artist,attr"` 133 | Track string `xml:"track,attr"` 134 | Tracks []Track `xml:"track"` 135 | } 136 | 137 | SimilarArtists struct { 138 | XMLName xml.Name `xml:"similarartists"` 139 | Artist string `xml:"artist,attr"` 140 | Artists []Artist `xml:"artist"` 141 | } 142 | 143 | Track struct { 144 | Rank int `xml:"rank,attr"` 145 | Tracks []Track `xml:"track"` 146 | Name string `xml:"name"` 147 | MBID string `xml:"mbid"` 148 | PlayCount int `xml:"playcount"` 149 | Listeners int `xml:"listeners"` 150 | URL string `xml:"url"` 151 | Image []Image `xml:"image"` 152 | } 153 | 154 | LovedTracks struct { 155 | XMLName xml.Name `xml:"lovedtracks"` 156 | Tracks []struct { 157 | Track 158 | Date struct { 159 | Text string `xml:",chardata"` 160 | UTS string `xml:"uts,attr"` 161 | } `xml:"date"` 162 | Artist Artist `xml:"artist"` 163 | } `xml:"track"` 164 | } 165 | 166 | User struct { 167 | Text string `xml:",chardata"` 168 | Name string `xml:"name"` 169 | Realname string `xml:"realname"` 170 | Image []struct { 171 | Text string `xml:",chardata"` 172 | Size string `xml:"size,attr"` 173 | } `xml:"image"` 174 | URL string `xml:"url"` 175 | Country string `xml:"country"` 176 | Age string `xml:"age"` 177 | Gender string `xml:"gender"` 178 | Subscriber string `xml:"subscriber"` 179 | Playcount string `xml:"playcount"` 180 | Playlists string `xml:"playlists"` 181 | Bootstrap string `xml:"bootstrap"` 182 | Registered struct { 183 | Text string `xml:",chardata"` 184 | Unixtime string `xml:"unixtime,attr"` 185 | } `xml:"registered"` 186 | Type string `xml:"type"` 187 | ArtistCount string `xml:"artist_count"` 188 | AlbumCount string `xml:"album_count"` 189 | TrackCount string `xml:"track_count"` 190 | } 191 | ) 192 | -------------------------------------------------------------------------------- /listenbrainz/listenbrainz.go: -------------------------------------------------------------------------------- 1 | package listenbrainz 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/http/httputil" 11 | "time" 12 | 13 | "go.senan.xyz/gonic" 14 | "go.senan.xyz/gonic/db" 15 | "go.senan.xyz/gonic/scrobble" 16 | ) 17 | 18 | const ( 19 | BaseURL = "https://api.listenbrainz.org" 20 | 21 | submitPath = "/1/submit-listens" 22 | listenTypeSingle = "single" 23 | listenTypePlayingNow = "playing_now" 24 | ) 25 | 26 | var ErrListenBrainz = errors.New("listenbrainz error") 27 | 28 | type Client struct { 29 | httpClient *http.Client 30 | } 31 | 32 | func NewClient() *Client { 33 | return NewClientCustom(http.DefaultClient) 34 | } 35 | 36 | func NewClientCustom(httpClient *http.Client) *Client { 37 | return &Client{httpClient: httpClient} 38 | } 39 | 40 | func (c *Client) IsUserAuthenticated(user db.User) bool { 41 | return user.ListenBrainzURL != "" && user.ListenBrainzToken != "" 42 | } 43 | 44 | func (c *Client) Scrobble(user db.User, track scrobble.Track, stamp time.Time, submission bool) error { 45 | payload := &Payload{ 46 | TrackMetadata: &TrackMetadata{ 47 | AdditionalInfo: &AdditionalInfo{ 48 | TrackNumber: int(track.TrackNumber), 49 | RecordingMBID: track.MusicBrainzID, 50 | ReleaseMBID: track.MusicBrainzReleaseID, 51 | Duration: int(track.Duration.Seconds()), 52 | SubmissionClient: gonic.Name, 53 | }, 54 | ArtistName: track.Artist, 55 | TrackName: track.Track, 56 | ReleaseName: track.Album, 57 | }, 58 | } 59 | scrobble := Scrobble{ 60 | Payload: []*Payload{payload}, 61 | } 62 | 63 | if submission && len(scrobble.Payload) > 0 { 64 | scrobble.ListenType = listenTypeSingle 65 | scrobble.Payload[0].ListenedAt = int(stamp.Unix()) 66 | } else { 67 | scrobble.ListenType = listenTypePlayingNow 68 | } 69 | 70 | var payloadBuf bytes.Buffer 71 | if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil { 72 | return err 73 | } 74 | 75 | submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath) 76 | authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken) 77 | 78 | req, err := http.NewRequest(http.MethodPost, submitURL, &payloadBuf) 79 | if err != nil { 80 | return fmt.Errorf("create submit request: %w", err) 81 | } 82 | 83 | req.Header.Add("Content-Type", "application/json") 84 | req.Header.Add("Authorization", authHeader) 85 | 86 | resp, err := c.httpClient.Do(req) 87 | if err != nil { 88 | return fmt.Errorf("http post: %w", err) 89 | } 90 | defer resp.Body.Close() 91 | 92 | switch { 93 | case resp.StatusCode == http.StatusUnauthorized: 94 | return fmt.Errorf("unauthorized: %w", ErrListenBrainz) 95 | case resp.StatusCode >= 400: 96 | respBytes, _ := httputil.DumpResponse(resp, true) 97 | log.Printf("received bad listenbrainz response:\n%s", string(respBytes)) 98 | return fmt.Errorf(">= 400: %d: %w", resp.StatusCode, ErrListenBrainz) 99 | } 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /listenbrainz/listenbrainz_test.go: -------------------------------------------------------------------------------- 1 | package listenbrainz_test 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | _ "embed" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/require" 15 | "go.senan.xyz/gonic/db" 16 | "go.senan.xyz/gonic/listenbrainz" 17 | "go.senan.xyz/gonic/scrobble" 18 | ) 19 | 20 | func TestScrobble(t *testing.T) { 21 | t.Parallel() 22 | 23 | client := listenbrainz.NewClientCustom( 24 | newMockClient(t, func(w http.ResponseWriter, r *http.Request) { 25 | require.Equal(t, http.MethodPost, r.Method) 26 | require.Equal(t, "/1/submit-listens", r.URL.Path) 27 | require.Equal(t, "application/json", r.Header.Get("Content-Type")) 28 | require.Equal(t, "Token token1", r.Header.Get("Authorization")) 29 | bodyBytes, err := io.ReadAll(r.Body) 30 | require.NoError(t, err) 31 | require.JSONEq(t, submitListensRequest, string(bodyBytes)) 32 | 33 | w.WriteHeader(http.StatusOK) 34 | w.Write([]byte(`{"accepted": 1}`)) 35 | }), 36 | ) 37 | 38 | err := client.Scrobble( 39 | db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"}, 40 | scrobble.Track{ 41 | Track: "title", 42 | Artist: "artist", 43 | Album: "album", 44 | TrackNumber: 1, 45 | Duration: 242 * time.Second, 46 | MusicBrainzID: "00000000-0000-0000-0000-000000000000", 47 | MusicBrainzReleaseID: "00000000-0000-0000-0000-000000000001", 48 | }, 49 | time.Unix(1683804525, 0), 50 | true, 51 | ) 52 | require.NoError(t, err) 53 | } 54 | 55 | func TestScrobbleUnauthorized(t *testing.T) { 56 | t.Parallel() 57 | 58 | client := listenbrainz.NewClientCustom( 59 | newMockClient(t, func(w http.ResponseWriter, r *http.Request) { 60 | require.Equal(t, http.MethodPost, r.Method) 61 | require.Equal(t, "/1/submit-listens", r.URL.Path) 62 | require.Equal(t, "application/json", r.Header.Get("Content-Type")) 63 | require.Equal(t, "Token token1", r.Header.Get("Authorization")) 64 | 65 | w.WriteHeader(http.StatusUnauthorized) 66 | w.Write([]byte(`{"code": 401, "error": "Invalid authorization token."}`)) 67 | }), 68 | ) 69 | 70 | err := client.Scrobble( 71 | db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"}, 72 | scrobble.Track{Track: "title", Artist: "artist", Album: "album", TrackNumber: 1}, 73 | time.Now(), 74 | true, 75 | ) 76 | 77 | require.ErrorIs(t, err, listenbrainz.ErrListenBrainz) 78 | } 79 | 80 | func TestScrobbleServerError(t *testing.T) { 81 | t.Parallel() 82 | 83 | client := listenbrainz.NewClientCustom( 84 | newMockClient(t, func(w http.ResponseWriter, r *http.Request) { 85 | require.Equal(t, http.MethodPost, r.Method) 86 | require.Equal(t, "/1/submit-listens", r.URL.Path) 87 | require.Equal(t, "application/json", r.Header.Get("Content-Type")) 88 | require.Equal(t, "Token token1", r.Header.Get("Authorization")) 89 | 90 | w.WriteHeader(http.StatusInternalServerError) 91 | }), 92 | ) 93 | 94 | err := client.Scrobble( 95 | db.User{ListenBrainzURL: "https://listenbrainz.org", ListenBrainzToken: "token1"}, 96 | scrobble.Track{Track: "title", Artist: "artist", Album: "album", TrackNumber: 1}, 97 | time.Now(), 98 | true, 99 | ) 100 | 101 | require.ErrorIs(t, err, listenbrainz.ErrListenBrainz) 102 | } 103 | 104 | func newMockClient(tb testing.TB, handler http.HandlerFunc) *http.Client { 105 | tb.Helper() 106 | 107 | server := httptest.NewTLSServer(handler) 108 | tb.Cleanup(server.Close) 109 | 110 | return &http.Client{ 111 | Transport: &http.Transport{ 112 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 113 | return net.Dial(network, server.Listener.Addr().String()) 114 | }, 115 | TLSClientConfig: &tls.Config{ 116 | InsecureSkipVerify: true, //nolint:gosec 117 | }, 118 | }, 119 | } 120 | } 121 | 122 | //go:embed testdata/submit_listens_request.json 123 | var submitListensRequest string 124 | -------------------------------------------------------------------------------- /listenbrainz/model.go: -------------------------------------------------------------------------------- 1 | package listenbrainz 2 | 3 | // https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json 4 | 5 | type ( 6 | Payload struct { 7 | ListenedAt int `json:"listened_at,omitempty"` 8 | TrackMetadata *TrackMetadata `json:"track_metadata"` 9 | } 10 | 11 | AdditionalInfo struct { 12 | TrackNumber int `json:"tracknumber,omitempty"` 13 | TrackMBID string `json:"track_mbid,omitempty"` 14 | RecordingMBID string `json:"recording_mbid,omitempty"` 15 | ReleaseMBID string `json:"release_mbid,omitempty"` 16 | Duration int `json:"duration,omitempty"` 17 | SubmissionClient string `json:"submission_client,omitempty"` 18 | } 19 | 20 | TrackMetadata struct { 21 | AdditionalInfo *AdditionalInfo `json:"additional_info"` 22 | ArtistName string `json:"artist_name,omitempty"` 23 | TrackName string `json:"track_name,omitempty"` 24 | ReleaseName string `json:"release_name,omitempty"` 25 | } 26 | 27 | Scrobble struct { 28 | ListenType string `json:"listen_type,omitempty"` 29 | Payload []*Payload `json:"payload"` 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /listenbrainz/testdata/submit_listens_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "listen_type": "single", 3 | "payload": [ 4 | { 5 | "listened_at": 1683804525, 6 | "track_metadata": { 7 | "additional_info": { 8 | "tracknumber": 1, 9 | "duration": 242, 10 | "recording_mbid": "00000000-0000-0000-0000-000000000000", 11 | "release_mbid": "00000000-0000-0000-0000-000000000001", 12 | "submission_client": "gonic" 13 | }, 14 | "artist_name": "artist", 15 | "track_name": "title", 16 | "release_name": "album" 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /playlist/playlist_test.go: -------------------------------------------------------------------------------- 1 | package playlist_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "go.senan.xyz/gonic/playlist" 8 | ) 9 | 10 | func TestPlaylist(t *testing.T) { 11 | t.Parallel() 12 | 13 | tmp := t.TempDir() 14 | store, err := playlist.NewStore(tmp) 15 | require.NoError(t, err) 16 | 17 | playlistIDs, err := store.List() 18 | require.NoError(t, err) 19 | require.Empty(t, playlistIDs) 20 | 21 | for _, playlistID := range playlistIDs { 22 | playlist, err := store.Read(playlistID) 23 | require.NoError(t, err) 24 | require.NotZero(t, playlist.UpdatedAt) 25 | } 26 | 27 | before := playlist.Playlist{ 28 | UserID: 10, 29 | Name: "Examlpe playlist name", 30 | Comment: ` 31 | Example comment 32 | It has multiple lines 👍 33 | `, 34 | Items: []string{ 35 | "item 1.flac", 36 | "item 2.flac", 37 | "item 3.flac", 38 | }, 39 | IsPublic: true, 40 | } 41 | 42 | newPath := playlist.NewPath(before.UserID, before.Name) 43 | require.NoError(t, store.Write(newPath, &before)) 44 | 45 | after, err := store.Read(newPath) 46 | require.NoError(t, err) 47 | 48 | require.Equal(t, after.UserID, before.UserID) 49 | require.Equal(t, after.Name, before.Name) 50 | require.Equal(t, after.Comment, before.Comment) 51 | require.Equal(t, after.Items, before.Items) 52 | require.Equal(t, after.IsPublic, before.IsPublic) 53 | 54 | playlistIDs, err = store.List() 55 | require.NoError(t, err) 56 | require.True(t, len(playlistIDs) == 1) 57 | } 58 | -------------------------------------------------------------------------------- /podcast/podcast_test.go: -------------------------------------------------------------------------------- 1 | package podcast 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mmcdole/gofeed" 11 | "github.com/stretchr/testify/require" 12 | "go.senan.xyz/gonic/db" 13 | "go.senan.xyz/gonic/mockfs" 14 | ) 15 | 16 | //go:embed testdata/rss.new 17 | var testRSS []byte 18 | 19 | func TestPodcastsAndEpisodesWithSameName(t *testing.T) { 20 | t.Parallel() 21 | 22 | t.Skip("requires network access") 23 | 24 | m := mockfs.New(t) 25 | 26 | base := t.TempDir() 27 | podcasts := New(m.DB(), base, m.TagReader()) 28 | 29 | fp := gofeed.NewParser() 30 | newFeed, err := fp.Parse(bytes.NewReader(testRSS)) 31 | if err != nil { 32 | t.Fatalf("parse test data: %v", err) 33 | } 34 | 35 | podcast, err := podcasts.AddNewPodcast("file://testdata/rss.new", newFeed) 36 | require.NoError(t, err) 37 | 38 | require.Equal(t, podcast.RootDir, filepath.Join(base, "InternetBox")) 39 | 40 | podcast, err = podcasts.AddNewPodcast("file://testdata/rss.new", newFeed) 41 | require.NoError(t, err) 42 | 43 | // check we made a unique podcast name 44 | require.Equal(t, podcast.RootDir, filepath.Join(base, "InternetBox (1)")) 45 | 46 | podcastEpisodes, err := podcasts.GetNewestPodcastEpisodes(10) 47 | require.NoError(t, err) 48 | require.Greater(t, len(podcastEpisodes), 0) 49 | 50 | var pe []*db.PodcastEpisode 51 | require.NoError(t, m.DB().Order("id").Find(&pe, "podcast_id=? AND title=?", podcast.ID, "Episode 126").Error) 52 | require.Len(t, pe, 2) 53 | 54 | require.NoError(t, podcasts.DownloadEpisode(pe[0].ID)) 55 | require.NoError(t, podcasts.DownloadEpisode(pe[1].ID)) 56 | 57 | require.NoError(t, m.DB().Order("id").Preload("Podcast").Find(&pe, "podcast_id=? AND title=?", podcast.ID, "Episode 126").Error) 58 | require.Len(t, pe, 2) 59 | 60 | // check we made a unique podcast episode names 61 | require.Equal(t, "InternetBoxEpisode126.mp3", pe[0].Filename) 62 | require.Equal(t, "InternetBoxEpisode126 (1).mp3", pe[1].Filename) 63 | } 64 | 65 | func TestGetMoreRecentEpisodes(t *testing.T) { 66 | t.Parallel() 67 | 68 | fp := gofeed.NewParser() 69 | newFeed, err := fp.Parse(bytes.NewReader(testRSS)) 70 | if err != nil { 71 | t.Fatalf("parse test data: %v", err) 72 | } 73 | after, err := time.Parse(time.RFC1123, "Mon, 27 Jun 2016 06:33:43 +0000") 74 | if err != nil { 75 | t.Fatalf("parse time: %v", err) 76 | } 77 | entries := getEntriesAfterDate(newFeed.Items, after) 78 | if len(entries) != 2 { 79 | t.Errorf("expected 2 entries, got %d", len(entries)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /scanner/scanner_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package scanner_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "go.senan.xyz/gonic/mockfs" 8 | ) 9 | 10 | func BenchmarkScanIncremental(b *testing.B) { 11 | m := mockfs.New(b) 12 | for i := 0; i < 5; i++ { 13 | m.AddItemsPrefix(fmt.Sprintf("t-%d", i)) 14 | } 15 | m.ScanAndClean() 16 | b.ResetTimer() 17 | 18 | for i := 0; i < b.N; i++ { 19 | m.ScanAndClean() 20 | } 21 | } 22 | 23 | func BenchmarkScanFull(b *testing.B) { 24 | for i := 0; i < b.N; i++ { 25 | m := mockfs.New(b) 26 | for i := 0; i < 5; i++ { 27 | m.AddItemsPrefix(fmt.Sprintf("t-%d", i)) 28 | } 29 | b.StartTimer() 30 | m.ScanAndClean() 31 | b.StopTimer() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /scanner/scanner_fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package scanner_test 5 | 6 | import ( 7 | "fmt" 8 | "math/rand" 9 | "reflect" 10 | "testing" 11 | 12 | _ "github.com/jinzhu/gorm/dialects/sqlite" 13 | "github.com/stretchr/testify/assert" 14 | "go.senan.xyz/gonic/mockfs" 15 | ) 16 | 17 | func FuzzScanner(f *testing.F) { 18 | checkDelta := func(assert *assert.Assertions, m *mockfs.MockFS, expSeen, expNew int) { 19 | st := m.ScanAndClean() 20 | assert.Equal(st.SeenTracks(), expSeen) 21 | assert.Equal(st.SeenTracksNew(), expNew) 22 | assert.Equal(st.TracksMissing(), 0) 23 | assert.Equal(st.AlbumsMissing(), 0) 24 | assert.Equal(st.ArtistsMissing(), 0) 25 | assert.Equal(st.GenresMissing(), 0) 26 | } 27 | 28 | f.Fuzz(func(t *testing.T, data []byte, seed int64) { 29 | assert := assert.New(t) 30 | m := mockfs.New(t) 31 | 32 | const toAdd = 1000 33 | for i := 0; i < toAdd; i++ { 34 | path := fmt.Sprintf("artist-%d/album-%d/track-%d.flac", i/6, i/3, i) 35 | m.AddTrack(path) 36 | m.SetTags(path, func(tags *mockfs.TagInfo) { 37 | fuzzStruct(i, data, seed, tags) 38 | }) 39 | } 40 | 41 | checkDelta(assert, m, toAdd, toAdd) // we added all tracks, 0 delta 42 | checkDelta(assert, m, toAdd, 0) // we added 0 tracks, 0 delta 43 | }) 44 | } 45 | 46 | func fuzzStruct(taken int, data []byte, seed int64, dest interface{}) { 47 | if len(data) == 0 { 48 | return 49 | } 50 | 51 | r := rand.New(rand.NewSource(seed)) 52 | v := reflect.ValueOf(dest) 53 | for i := 0; i < v.Elem().NumField(); i++ { 54 | if r.Float64() < 0.1 { 55 | continue 56 | } 57 | 58 | take := int(r.Float64() * 12) 59 | b := make([]byte, take) 60 | for i := range b { 61 | b[i] = data[(i+taken)%len(data)] 62 | } 63 | taken += take 64 | 65 | switch f := v.Elem().Field(i); f.Kind() { 66 | case reflect.Bool: 67 | f.SetBool(b[0] < 128) 68 | case reflect.String: 69 | f.SetString(string(b)) 70 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 71 | f.SetInt(int64(b[0])) 72 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 73 | f.SetUint(uint64(b[0])) 74 | case reflect.Float32, reflect.Float64: 75 | f.SetFloat(float64(b[0])) 76 | case reflect.Struct: 77 | fuzzStruct(taken, data, seed, f.Addr().Interface()) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scrobble/scrobble.go: -------------------------------------------------------------------------------- 1 | package scrobble 2 | 3 | import ( 4 | "time" 5 | 6 | "go.senan.xyz/gonic/db" 7 | ) 8 | 9 | type Track struct { 10 | Track string 11 | Artist string 12 | Album string 13 | AlbumArtist string 14 | TrackNumber uint 15 | Duration time.Duration 16 | MusicBrainzID string 17 | MusicBrainzReleaseID string 18 | } 19 | 20 | type Scrobbler interface { 21 | IsUserAuthenticated(user db.User) bool 22 | Scrobble(user db.User, track Track, stamp time.Time, submission bool) error 23 | } 24 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/adminui.go: -------------------------------------------------------------------------------- 1 | package adminui 2 | 3 | import "embed" 4 | 5 | //go:embed components.tmpl pages/*.tmpl 6 | var TemplatesFS embed.FS 7 | 8 | //go:generate npx tailwindcss@v3.2.4 --config tailwind.config.js --input style.css --output static/style.css --minify 9 | //go:embed static/* 10 | var StaticFS embed.FS 11 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/change_avatar.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" (printf "changing %s's avatar" .SelectedUser.Name) 7 | ) }} 8 |
9 | {{ if ne (len .SelectedUser.Avatar) 0 }} 10 | 11 |
12 | 13 |
14 | {{ end }} 15 |
16 |
17 | 18 | 19 |
20 |
21 |
22 | {{ end }} 23 | 24 | {{ end }} 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/change_password.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" (printf "changing %s's password" .SelectedUser.Name) 7 | ) }} 8 |
9 | 10 | 11 | 12 |
13 | {{ end }} 14 | 15 | {{ end }} 16 | {{ end }} 17 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/change_username.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" (printf "changing %s's username" .SelectedUser.Name) 7 | ) }} 8 |
9 | 10 | 11 |
12 | {{ end }} 13 | 14 | {{ end }} 15 | {{ end }} 16 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/create_user.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" "creating new user" 7 | ) }} 8 |
9 | 10 | 11 | 12 | 13 |
14 | {{ end }} 15 | 16 | {{ end }} 17 | {{ end }} 18 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/delete_user.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" (printf "deleting user %s" .SelectedUser.Name) 7 | "Desc" "are you sure? this will also delete their plays, playlists, starred, rated, etc." 8 | ) }} 9 |
10 | 11 |
12 | {{ end }} 13 | 14 | {{ end }} 15 | {{ end }} 16 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/login.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "block" (props . 3 | "Icon" "user" 4 | "Name" "login" 5 | "Desc" "if you are logging in as an admin, the default credentials can be found in the readme" 6 | ) }} 7 |
8 | 9 | 10 | 11 |
12 | {{ end }} 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/not_found.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 |

page not found

3 | {{ end }} 4 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/pages/update_lastfm_api_key.tmpl: -------------------------------------------------------------------------------- 1 | {{ component "layout" . }} 2 | {{ component "layout_user" . }} 3 | 4 | {{ component "block" (props . 5 | "Icon" "user" 6 | "Name" "update last.fm api keys" 7 | "Desc" "you can get an api key from last.fm here here. note, only the application name field is required" 8 | ) }} 9 |
10 |

current key {{ default "not set" .CurrentLastFMAPIKey }}

11 |

current secret {{ default "not set" .CurrentLastFMAPISecret }}

12 |
13 | 14 | 15 | 16 |
17 |
18 | {{ end }} 19 | 20 | {{ end }} 21 | {{ end }} 22 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/favicon.ico -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/gonic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/gonic.png -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2 -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2 -------------------------------------------------------------------------------- /server/ctrladmin/adminui/static/main.js: -------------------------------------------------------------------------------- 1 | for (const input of document.querySelectorAll("input.auto-submit, select.auto-submit") || []) { 2 | input.onchange = (e) => e.target.form.submit(); 3 | } 4 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | form, 4 | input, 5 | select { 6 | all: unset; 7 | appearance: none; 8 | display: block; 9 | } 10 | 11 | a { 12 | text-decoration: none; 13 | } 14 | 15 | @tailwind components; 16 | @tailwind utilities; 17 | 18 | a { 19 | @apply text-blue-500; 20 | } 21 | 22 | input[type], 23 | select { 24 | @apply h-6 px-2 leading-[1.5] w-full min-w-[3rem] md:min-w-[8rem] box-border bg-white text-gray-600 shadow-none border-0 outline outline-1 outline-gray-400/50 cursor-pointer overflow-hidden whitespace-nowrap text-ellipsis; 25 | } 26 | 27 | input[type="button"], 28 | input[type="submit"] { 29 | @apply text-center w-[6rem] md:w-[8rem] font-bold; 30 | } 31 | 32 | .ellipsis { 33 | @apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap; 34 | } 35 | -------------------------------------------------------------------------------- /server/ctrladmin/adminui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["*.tmpl", "**/*.tmpl"], 4 | theme: { 5 | screens: { 6 | sm: "100%", 7 | md: `870px`, 8 | }, 9 | fontFamily: { 10 | mono: ["Inconsolata", "monospace"], 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /server/ctrladmin/handlers_raw.go: -------------------------------------------------------------------------------- 1 | package ctrladmin 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/sessions" 7 | ) 8 | 9 | func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) { 10 | session := r.Context().Value(CtxSession).(*sessions.Session) 11 | username := r.FormValue("username") 12 | password := r.FormValue("password") 13 | if username == "" || password == "" { 14 | sessAddFlashW(session, []string{"please provide username and password"}) 15 | sessLogSave(session, w, r) 16 | http.Redirect(w, r, r.Referer(), http.StatusSeeOther) 17 | return 18 | } 19 | user := c.dbc.GetUserByName(username) 20 | if user == nil || password != user.Password { 21 | sessAddFlashW(session, []string{"invalid username / password"}) 22 | sessLogSave(session, w, r) 23 | http.Redirect(w, r, r.Referer(), http.StatusSeeOther) 24 | return 25 | } 26 | // put the user name into the session. future endpoints after this one 27 | // are wrapped with WithUserSession() which will get the name from the 28 | // session and put the row into the request context 29 | session.Values["user"] = user.ID 30 | sessLogSave(session, w, r) 31 | http.Redirect(w, r, c.resolveProxyPath("/admin/home"), http.StatusSeeOther) 32 | } 33 | 34 | func (c *Controller) ServeLogout(w http.ResponseWriter, r *http.Request) { 35 | session := r.Context().Value(CtxSession).(*sessions.Session) 36 | session.Options.MaxAge = -1 37 | sessLogSave(session, w, r) 38 | http.Redirect(w, r, c.resolveProxyPath("/admin/login"), http.StatusSeeOther) 39 | } 40 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/ctrl_test.go: -------------------------------------------------------------------------------- 1 | //nolint:thelper 2 | package ctrlsubsonic 3 | 4 | import ( 5 | "context" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | "testing" 16 | 17 | jd "github.com/josephburnett/jd/lib" 18 | "github.com/stretchr/testify/require" 19 | 20 | "go.senan.xyz/gonic" 21 | "go.senan.xyz/gonic/db" 22 | "go.senan.xyz/gonic/mockfs" 23 | "go.senan.xyz/gonic/server/ctrlsubsonic/params" 24 | "go.senan.xyz/gonic/transcode" 25 | ) 26 | 27 | func TestMain(m *testing.M) { 28 | gonic.Version = "" 29 | log.SetOutput(io.Discard) 30 | os.Exit(m.Run()) 31 | } 32 | 33 | var testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") 34 | 35 | const ( 36 | mockUsername = "admin" 37 | mockPassword = "admin" 38 | mockClientName = "test" 39 | ) 40 | 41 | const ( 42 | audioPath5s = "testdata/audio/5s.flac" //nolint:deadcode,varcheck 43 | audioPath10s = "testdata/audio/10s.flac" //nolint:deadcode,varcheck 44 | ) 45 | 46 | type queryCase struct { 47 | params url.Values 48 | expectPath string 49 | listSet bool 50 | } 51 | 52 | func makeGoldenPath(test string) string { 53 | // convert test name to query case path 54 | snake := testCamelExpr.ReplaceAllString(test, "${1}_${2}") 55 | lower := strings.ToLower(snake) 56 | relPath := strings.ReplaceAll(lower, "/", "_") 57 | return filepath.Join("testdata", relPath) 58 | } 59 | 60 | func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) { 61 | // ensure the handlers give us json 62 | query.Add("f", "json") 63 | query.Add("u", mockUsername) 64 | query.Add("p", mockPassword) 65 | query.Add("v", "1") 66 | query.Add("c", mockClientName) 67 | // request from the handler in question 68 | req, _ := http.NewRequest("", "", nil) 69 | req.URL.RawQuery = query.Encode() 70 | ctx := req.Context() 71 | ctx = context.WithValue(ctx, CtxParams, params.New(req)) 72 | ctx = context.WithValue(ctx, CtxUser, &db.User{}) 73 | req = req.WithContext(ctx) 74 | rr := httptest.NewRecorder() 75 | return rr, req 76 | } 77 | 78 | func makeHTTPMockWithAdmin(query url.Values) (*httptest.ResponseRecorder, *http.Request) { 79 | rr, req := makeHTTPMock(query) 80 | ctx := req.Context() 81 | ctx = context.WithValue(ctx, CtxUser, &db.User{IsAdmin: true}) 82 | req = req.WithContext(ctx) 83 | 84 | return rr, req 85 | } 86 | 87 | func runQueryCases(t *testing.T, h handlerSubsonic, cases []*queryCase) { 88 | t.Helper() 89 | for _, qc := range cases { 90 | qc := qc 91 | t.Run(qc.expectPath, func(t *testing.T) { 92 | t.Helper() 93 | t.Parallel() 94 | 95 | rr, req := makeHTTPMock(qc.params) 96 | resp(h).ServeHTTP(rr, req) 97 | body := rr.Body.String() 98 | if status := rr.Code; status != http.StatusOK { 99 | t.Fatalf("didn't give a 200\n%s", body) 100 | } 101 | 102 | goldenPath := makeGoldenPath(t.Name()) 103 | goldenRegen := os.Getenv("GONIC_REGEN") 104 | if goldenRegen == "*" || (goldenRegen != "" && strings.HasPrefix(t.Name(), goldenRegen)) { 105 | _ = os.WriteFile(goldenPath, []byte(body), 0o600) 106 | t.Logf("golden file %q regenerated for %s", goldenPath, t.Name()) 107 | t.SkipNow() 108 | } 109 | 110 | // read case to differ with handler result 111 | expected, err := jd.ReadJsonFile(goldenPath) 112 | if err != nil { 113 | t.Fatalf("parsing expected: %v", err) 114 | } 115 | actual, err := jd.ReadJsonString(body) 116 | if err != nil { 117 | t.Fatalf("parsing actual: %v", err) 118 | } 119 | diffOpts := []jd.Metadata{} 120 | if qc.listSet { 121 | diffOpts = append(diffOpts, jd.SET) 122 | } 123 | diff := expected.Diff(actual, diffOpts...) 124 | 125 | if len(diff) > 0 { 126 | t.Errorf("\u001b[31;1mhandler json differs from test json\u001b[0m") 127 | t.Errorf("\u001b[33;1mif you want to regenerate it, re-run with GONIC_REGEN=%s\u001b[0m\n", t.Name()) 128 | t.Error(diff.Render()) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func makeController(tb testing.TB) *Controller { return makec(tb, []string{""}, false) } 135 | func makeControllerRoots(tb testing.TB, r []string) *Controller { return makec(tb, r, false) } 136 | 137 | func makec(tb testing.TB, roots []string, audio bool) *Controller { 138 | tb.Helper() 139 | 140 | m := mockfs.NewWithDirs(tb, roots) 141 | for _, root := range roots { 142 | m.AddItemsPrefixWithCovers(root) 143 | if !audio { 144 | continue 145 | } 146 | m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-0.flac"), 10, audioPath10s) 147 | m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-1.flac"), 10, audioPath10s) 148 | m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-2.flac"), 10, audioPath10s) 149 | } 150 | 151 | m.ScanAndClean() 152 | m.ResetDates() 153 | 154 | var absRoots []MusicPath 155 | for _, root := range roots { 156 | absRoots = append(absRoots, MusicPath{Path: filepath.Join(m.TmpDir(), root)}) 157 | } 158 | 159 | contr := &Controller{ 160 | dbc: m.DB(), 161 | musicPaths: absRoots, 162 | transcoder: transcode.NewFFmpegTranscoder(), 163 | } 164 | 165 | return contr 166 | } 167 | 168 | func TestParams(t *testing.T) { 169 | t.Parallel() 170 | 171 | handler := withParams(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 | params := r.Context().Value(CtxParams).(params.Params) 173 | require.Equal(t, "Client", params.GetOr("c", "")) 174 | })) 175 | params := url.Values{} 176 | params.Set("c", "Client") 177 | 178 | r, err := http.NewRequest(http.MethodGet, "/?"+params.Encode(), nil) 179 | require.NoError(t, err) 180 | handler.ServeHTTP(nil, r) 181 | 182 | r, err = http.NewRequest(http.MethodPost, "/", strings.NewReader(params.Encode())) 183 | require.NoError(t, err) 184 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 185 | handler.ServeHTTP(nil, r) 186 | } 187 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/handlers_bookmark.go: -------------------------------------------------------------------------------- 1 | package ctrlsubsonic 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/jinzhu/gorm" 8 | 9 | "go.senan.xyz/gonic/db" 10 | "go.senan.xyz/gonic/server/ctrlsubsonic/params" 11 | "go.senan.xyz/gonic/server/ctrlsubsonic/spec" 12 | "go.senan.xyz/gonic/server/ctrlsubsonic/specid" 13 | ) 14 | 15 | func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response { 16 | user := r.Context().Value(CtxUser).(*db.User) 17 | bookmarks := []*db.Bookmark{} 18 | err := c.dbc. 19 | Where("user_id=?", user.ID). 20 | Find(&bookmarks). 21 | Error 22 | if errors.Is(err, gorm.ErrRecordNotFound) { 23 | return spec.NewResponse() 24 | } 25 | 26 | sub := spec.NewResponse() 27 | sub.Bookmarks = &spec.Bookmarks{ 28 | List: []*spec.Bookmark{}, 29 | } 30 | 31 | for _, bookmark := range bookmarks { 32 | respBookmark := &spec.Bookmark{ 33 | Username: user.Name, 34 | Position: bookmark.Position, 35 | Comment: bookmark.Comment, 36 | Created: bookmark.CreatedAt, 37 | Changed: bookmark.UpdatedAt, 38 | } 39 | 40 | switch specid.IDT(bookmark.EntryIDType) { 41 | case specid.Track: 42 | var track db.Track 43 | err := c.dbc. 44 | Preload("Album"). 45 | Find(&track, "id=?", bookmark.EntryID). 46 | Error 47 | if err != nil { 48 | /* 49 | * We get here if we have a bookmark for a Track that no longer exists, this should be an 50 | * error because tracks can disappear if the files are moved etc. Just skip the not found 51 | * entry and move on. 52 | */ 53 | continue 54 | } 55 | respBookmark.Entry = spec.NewTrackByTags(&track, track.Album) 56 | case specid.PodcastEpisode: 57 | var podcastEpisode db.PodcastEpisode 58 | err := c.dbc. 59 | Preload("Podcast"). 60 | Find(&podcastEpisode, "id=?", bookmark.EntryID). 61 | Error 62 | if err != nil { 63 | /* Same as with the missing track above. */ 64 | continue 65 | } 66 | respBookmark.Entry = spec.NewTCPodcastEpisode(&podcastEpisode) 67 | default: 68 | continue 69 | } 70 | 71 | sub.Bookmarks.List = append(sub.Bookmarks.List, respBookmark) 72 | } 73 | 74 | return sub 75 | } 76 | 77 | func (c *Controller) ServeCreateBookmark(r *http.Request) *spec.Response { 78 | params := r.Context().Value(CtxParams).(params.Params) 79 | user := r.Context().Value(CtxUser).(*db.User) 80 | id, err := params.GetID("id") 81 | if err != nil { 82 | return spec.NewError(10, "please provide an `id` parameter") 83 | } 84 | bookmark := &db.Bookmark{} 85 | c.dbc.FirstOrCreate(bookmark, db.Bookmark{ 86 | UserID: user.ID, 87 | EntryIDType: string(id.Type), 88 | EntryID: id.Value, 89 | }) 90 | bookmark.Comment = params.GetOr("comment", "") 91 | bookmark.Position = params.GetOrInt("position", 0) 92 | c.dbc.Save(bookmark) 93 | return spec.NewResponse() 94 | } 95 | 96 | func (c *Controller) ServeDeleteBookmark(r *http.Request) *spec.Response { 97 | params := r.Context().Value(CtxParams).(params.Params) 98 | user := r.Context().Value(CtxUser).(*db.User) 99 | id, err := params.GetID("id") 100 | if err != nil { 101 | return spec.NewError(10, "please provide an `id` parameter") 102 | } 103 | c.dbc. 104 | Where("user_id=? AND entry_id_type=? AND entry_id=?", user.ID, id.Type, id.Value). 105 | Delete(&db.Bookmark{}) 106 | return spec.NewResponse() 107 | } 108 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/handlers_by_folder_test.go: -------------------------------------------------------------------------------- 1 | package ctrlsubsonic 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | _ "github.com/jinzhu/gorm/dialects/sqlite" 8 | ) 9 | 10 | func TestGetIndexes(t *testing.T) { 11 | t.Parallel() 12 | contr := makeControllerRoots(t, []string{"m-0", "m-1"}) 13 | runQueryCases(t, contr.ServeGetIndexes, []*queryCase{ 14 | {url.Values{}, "no_args", false}, 15 | {url.Values{"musicFolderId": {"0"}}, "with_music_folder_1", false}, 16 | {url.Values{"musicFolderId": {"1"}}, "with_music_folder_2", false}, 17 | }) 18 | } 19 | 20 | func TestGetMusicDirectory(t *testing.T) { 21 | t.Parallel() 22 | contr := makeController(t) 23 | runQueryCases(t, contr.ServeGetMusicDirectory, []*queryCase{ 24 | {url.Values{"id": {"al-2"}}, "without_tracks", false}, 25 | {url.Values{"id": {"al-3"}}, "with_tracks", false}, 26 | }) 27 | } 28 | 29 | func TestGetAlbumList(t *testing.T) { 30 | t.Parallel() 31 | contr := makeController(t) 32 | runQueryCases(t, contr.ServeGetAlbumList, []*queryCase{ 33 | {url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false}, 34 | {url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false}, 35 | {url.Values{"type": {"newest"}}, "newest", false}, 36 | {url.Values{"type": {"random"}, "size": {"15"}}, "random", true}, 37 | }) 38 | } 39 | 40 | func TestSearchTwo(t *testing.T) { 41 | t.Parallel() 42 | contr := makeController(t) 43 | runQueryCases(t, contr.ServeSearchTwo, []*queryCase{ 44 | {url.Values{"query": {"art"}}, "q_art", false}, 45 | {url.Values{"query": {"alb"}}, "q_alb", false}, 46 | {url.Values{"query": {"tra"}}, "q_tra", false}, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/handlers_by_tags_test.go: -------------------------------------------------------------------------------- 1 | package ctrlsubsonic 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func TestGetArtists(t *testing.T) { 9 | t.Parallel() 10 | contr := makeControllerRoots(t, []string{"m-0", "m-1"}) 11 | runQueryCases(t, contr.ServeGetArtists, []*queryCase{ 12 | {url.Values{}, "no_args", false}, 13 | {url.Values{"musicFolderId": {"0"}}, "with_music_folder_1", false}, 14 | {url.Values{"musicFolderId": {"1"}}, "with_music_folder_2", false}, 15 | }) 16 | } 17 | 18 | func TestGetArtist(t *testing.T) { 19 | t.Parallel() 20 | contr := makeController(t) 21 | runQueryCases(t, contr.ServeGetArtist, []*queryCase{ 22 | {url.Values{"id": {"ar-1"}}, "id_one", false}, 23 | {url.Values{"id": {"ar-2"}}, "id_two", false}, 24 | {url.Values{"id": {"ar-3"}}, "id_three", false}, 25 | }) 26 | } 27 | 28 | func TestGetAlbum(t *testing.T) { 29 | t.Parallel() 30 | contr := makeController(t) 31 | runQueryCases(t, contr.ServeGetAlbum, []*queryCase{ 32 | {url.Values{"id": {"al-2"}}, "without_cover", false}, 33 | {url.Values{"id": {"al-3"}}, "with_cover", false}, 34 | }) 35 | } 36 | 37 | func TestGetAlbumListTwo(t *testing.T) { 38 | t.Parallel() 39 | contr := makeController(t) 40 | runQueryCases(t, contr.ServeGetAlbumListTwo, []*queryCase{ 41 | {url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false}, 42 | {url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false}, 43 | {url.Values{"type": {"newest"}}, "newest", false}, 44 | {url.Values{"type": {"random"}, "size": {"15"}}, "random", true}, 45 | }) 46 | } 47 | 48 | func TestSearchThree(t *testing.T) { 49 | t.Parallel() 50 | contr := makeController(t) 51 | runQueryCases(t, contr.ServeSearchThree, []*queryCase{ 52 | {url.Values{"query": {"art"}}, "q_art", false}, 53 | {url.Values{"query": {"alb"}}, "q_alb", false}, 54 | {url.Values{"query": {"tit"}}, "q_tra", false}, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/handlers_internet_radio.go: -------------------------------------------------------------------------------- 1 | package ctrlsubsonic 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "go.senan.xyz/gonic/db" 8 | "go.senan.xyz/gonic/server/ctrlsubsonic/params" 9 | "go.senan.xyz/gonic/server/ctrlsubsonic/spec" 10 | ) 11 | 12 | func (c *Controller) ServeGetInternetRadioStations(_ *http.Request) *spec.Response { 13 | var stations []*db.InternetRadioStation 14 | if err := c.dbc.Find(&stations).Error; err != nil { 15 | return spec.NewError(0, "find stations: %v", err) 16 | } 17 | sub := spec.NewResponse() 18 | sub.InternetRadioStations = &spec.InternetRadioStations{ 19 | List: make([]*spec.InternetRadioStation, len(stations)), 20 | } 21 | for i, station := range stations { 22 | sub.InternetRadioStations.List[i] = spec.NewInternetRadioStation(station) 23 | } 24 | return sub 25 | } 26 | 27 | func (c *Controller) ServeCreateInternetRadioStation(r *http.Request) *spec.Response { 28 | user := r.Context().Value(CtxUser).(*db.User) 29 | if !user.IsAdmin { 30 | return spec.NewError(50, "user not admin") 31 | } 32 | 33 | params := r.Context().Value(CtxParams).(params.Params) 34 | 35 | streamURL, err := params.Get("streamUrl") 36 | if err != nil { 37 | return spec.NewError(10, "no stream URL provided: %v", err) 38 | } 39 | if _, err := url.ParseRequestURI(streamURL); err != nil { 40 | return spec.NewError(70, "bad stream URL provided: %v", err) 41 | } 42 | name, err := params.Get("name") 43 | if err != nil { 44 | return spec.NewError(10, "no name provided: %v", err) 45 | } 46 | homepageURL, err := params.Get("homepageUrl") 47 | if err == nil && homepageURL != "" { 48 | if _, err := url.ParseRequestURI(homepageURL); err != nil { 49 | return spec.NewError(70, "bad homepage URL provided: %v", err) 50 | } 51 | } 52 | 53 | var station db.InternetRadioStation 54 | station.StreamURL = streamURL 55 | station.Name = name 56 | station.HomepageURL = homepageURL 57 | 58 | if err := c.dbc.Save(&station).Error; err != nil { 59 | return spec.NewError(0, "save station: %v", err) 60 | } 61 | 62 | return spec.NewResponse() 63 | } 64 | 65 | func (c *Controller) ServeUpdateInternetRadioStation(r *http.Request) *spec.Response { 66 | user := r.Context().Value(CtxUser).(*db.User) 67 | if !user.IsAdmin { 68 | return spec.NewError(50, "user not admin") 69 | } 70 | params := r.Context().Value(CtxParams).(params.Params) 71 | 72 | stationID, err := params.GetID("id") 73 | if err != nil { 74 | return spec.NewError(10, "no id provided: %v", err) 75 | } 76 | streamURL, err := params.Get("streamUrl") 77 | if err != nil { 78 | return spec.NewError(10, "no stream URL provided: %v", err) 79 | } 80 | if _, err = url.ParseRequestURI(streamURL); err != nil { 81 | return spec.NewError(70, "bad stream URL provided: %v", err) 82 | } 83 | name, err := params.Get("name") 84 | if err != nil { 85 | return spec.NewError(10, "no name provided: %v", err) 86 | } 87 | homepageURL, err := params.Get("homepageUrl") 88 | if err == nil { 89 | if _, err := url.ParseRequestURI(homepageURL); err != nil { 90 | return spec.NewError(70, "bad homepage URL provided: %v", err) 91 | } 92 | } 93 | 94 | var station db.InternetRadioStation 95 | if err := c.dbc.Where("id=?", stationID.Value).First(&station).Error; err != nil { 96 | return spec.NewError(70, "id not found: %v", err) 97 | } 98 | 99 | station.StreamURL = streamURL 100 | station.Name = name 101 | station.HomepageURL = homepageURL 102 | 103 | if err := c.dbc.Save(&station).Error; err != nil { 104 | return spec.NewError(0, "save station: %v", err) 105 | } 106 | return spec.NewResponse() 107 | } 108 | 109 | func (c *Controller) ServeDeleteInternetRadioStation(r *http.Request) *spec.Response { 110 | user := r.Context().Value(CtxUser).(*db.User) 111 | if !user.IsAdmin { 112 | return spec.NewError(50, "user not admin") 113 | } 114 | params := r.Context().Value(CtxParams).(params.Params) 115 | 116 | stationID, err := params.GetID("id") 117 | if err != nil { 118 | return spec.NewError(10, "no id provided: %v", err) 119 | } 120 | 121 | var station db.InternetRadioStation 122 | if err := c.dbc.Where("id=?", stationID.Value).First(&station).Error; err != nil { 123 | return spec.NewError(70, "id not found: %v", err) 124 | } 125 | 126 | if err := c.dbc.Delete(&station).Error; err != nil { 127 | return spec.NewError(70, "id not found: %v", err) 128 | } 129 | 130 | return spec.NewResponse() 131 | } 132 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/handlers_podcast.go: -------------------------------------------------------------------------------- 1 | package ctrlsubsonic 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/mmcdole/gofeed" 7 | 8 | "go.senan.xyz/gonic/db" 9 | "go.senan.xyz/gonic/server/ctrlsubsonic/params" 10 | "go.senan.xyz/gonic/server/ctrlsubsonic/spec" 11 | "go.senan.xyz/gonic/server/ctrlsubsonic/specid" 12 | ) 13 | 14 | func (c *Controller) ServeGetPodcasts(r *http.Request) *spec.Response { 15 | params := r.Context().Value(CtxParams).(params.Params) 16 | isIncludeEpisodes := params.GetOrBool("includeEpisodes", true) 17 | id, _ := params.GetID("id") 18 | podcasts, err := c.podcasts.GetPodcastOrAll(id.Value, isIncludeEpisodes) 19 | if err != nil { 20 | return spec.NewError(10, "failed get podcast(s): %s", err) 21 | } 22 | sub := spec.NewResponse() 23 | sub.Podcasts = &spec.Podcasts{} 24 | for _, podcast := range podcasts { 25 | channel := spec.NewPodcastChannel(podcast) 26 | sub.Podcasts.List = append(sub.Podcasts.List, channel) 27 | } 28 | return sub 29 | } 30 | 31 | func (c *Controller) ServeGetNewestPodcasts(r *http.Request) *spec.Response { 32 | params := r.Context().Value(CtxParams).(params.Params) 33 | count := params.GetOrInt("count", 10) 34 | episodes, err := c.podcasts.GetNewestPodcastEpisodes(count) 35 | if err != nil { 36 | return spec.NewError(10, "failed get podcast(s): %s", err) 37 | } 38 | sub := spec.NewResponse() 39 | sub.NewestPodcasts = &spec.NewestPodcasts{} 40 | for _, episode := range episodes { 41 | sub.NewestPodcasts.List = append(sub.NewestPodcasts.List, spec.NewPodcastEpisode(episode)) 42 | } 43 | return sub 44 | } 45 | 46 | func (c *Controller) ServeDownloadPodcastEpisode(r *http.Request) *spec.Response { 47 | user := r.Context().Value(CtxUser).(*db.User) 48 | if !user.IsAdmin { 49 | return spec.NewError(50, "user not admin") 50 | } 51 | params := r.Context().Value(CtxParams).(params.Params) 52 | id, err := params.GetID("id") 53 | if err != nil || id.Type != specid.PodcastEpisode { 54 | return spec.NewError(10, "please provide a valid podcast episode id") 55 | } 56 | if err := c.podcasts.DownloadEpisode(id.Value); err != nil { 57 | return spec.NewError(10, "failed to download episode: %s", err) 58 | } 59 | return spec.NewResponse() 60 | } 61 | 62 | func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response { 63 | user := r.Context().Value(CtxUser).(*db.User) 64 | if !user.IsAdmin { 65 | return spec.NewError(50, "user not admin") 66 | } 67 | params := r.Context().Value(CtxParams).(params.Params) 68 | rssURL, _ := params.Get("url") 69 | fp := gofeed.NewParser() 70 | feed, err := fp.ParseURL(rssURL) 71 | if err != nil { 72 | return spec.NewError(10, "failed to parse feed: %s", err) 73 | } 74 | if _, err = c.podcasts.AddNewPodcast(rssURL, feed); err != nil { 75 | return spec.NewError(10, "failed to add feed: %s", err) 76 | } 77 | return spec.NewResponse() 78 | } 79 | 80 | func (c *Controller) ServeRefreshPodcasts(r *http.Request) *spec.Response { 81 | user := r.Context().Value(CtxUser).(*db.User) 82 | if !user.IsAdmin { 83 | return spec.NewError(50, "user not admin") 84 | } 85 | if err := c.podcasts.RefreshPodcasts(); err != nil { 86 | return spec.NewError(10, "failed to refresh feeds: %s", err) 87 | } 88 | return spec.NewResponse() 89 | } 90 | 91 | func (c *Controller) ServeDeletePodcastChannel(r *http.Request) *spec.Response { 92 | user := r.Context().Value(CtxUser).(*db.User) 93 | if !user.IsAdmin { 94 | return spec.NewError(50, "user not admin") 95 | } 96 | params := r.Context().Value(CtxParams).(params.Params) 97 | id, err := params.GetID("id") 98 | if err != nil || id.Type != specid.Podcast { 99 | return spec.NewError(10, "please provide a valid podcast id") 100 | } 101 | if err := c.podcasts.DeletePodcast(id.Value); err != nil { 102 | return spec.NewError(10, "failed to delete podcast: %s", err) 103 | } 104 | return spec.NewResponse() 105 | } 106 | 107 | func (c *Controller) ServeDeletePodcastEpisode(r *http.Request) *spec.Response { 108 | user := r.Context().Value(CtxUser).(*db.User) 109 | if !user.IsAdmin { 110 | return spec.NewError(50, "user not admin") 111 | } 112 | params := r.Context().Value(CtxParams).(params.Params) 113 | id, err := params.GetID("id") 114 | if err != nil || id.Type != specid.PodcastEpisode { 115 | return spec.NewError(10, "please provide a valid podcast episode id") 116 | } 117 | if err := c.podcasts.DeletePodcastEpisode(id.Value); err != nil { 118 | return spec.NewError(10, "failed to delete podcast: %s", err) 119 | } 120 | return spec.NewResponse() 121 | } 122 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/spec/construct_by_folder.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "go.senan.xyz/gonic/db" 7 | ) 8 | 9 | func NewAlbumByFolder(f *db.Album) *Album { 10 | a := &Album{ 11 | Artist: f.Parent.RightPath, 12 | ID: f.SID(), 13 | IsDir: true, 14 | ParentID: f.ParentSID(), 15 | Album: f.RightPath, 16 | Name: f.RightPath, 17 | Title: f.RightPath, 18 | TrackCount: f.ChildCount, 19 | Duration: f.Duration, 20 | Created: f.CreatedAt, 21 | AverageRating: formatRating(f.AverageRating), 22 | } 23 | if f.AlbumStar != nil { 24 | a.Starred = &f.AlbumStar.StarDate 25 | } 26 | if f.AlbumRating != nil { 27 | a.UserRating = f.AlbumRating.Rating 28 | } 29 | if f.Cover != "" { 30 | a.CoverID = f.SID() 31 | } 32 | return a 33 | } 34 | 35 | func NewTCAlbumByFolder(f *db.Album) *TrackChild { 36 | trCh := &TrackChild{ 37 | ID: f.SID(), 38 | IsDir: true, 39 | Title: f.RightPath, 40 | ParentID: f.ParentSID(), 41 | CreatedAt: f.CreatedAt, 42 | AverageRating: formatRating(f.AverageRating), 43 | Year: f.TagYear, 44 | } 45 | if f.AlbumStar != nil { 46 | trCh.Starred = &f.AlbumStar.StarDate 47 | } 48 | if f.AlbumRating != nil { 49 | trCh.UserRating = f.AlbumRating.Rating 50 | } 51 | if f.Cover != "" { 52 | trCh.CoverID = f.SID() 53 | } 54 | return trCh 55 | } 56 | 57 | func NewTCTrackByFolder(t *db.Track, parent *db.Album) *TrackChild { 58 | trCh := &TrackChild{ 59 | ID: t.SID(), 60 | ContentType: t.MIME(), 61 | Suffix: formatExt(t.Ext()), 62 | Size: t.Size, 63 | Artist: t.TagTrackArtist, 64 | Title: t.TagTitle, 65 | TrackNumber: t.TagTrackNumber, 66 | DiscNumber: t.TagDiscNumber, 67 | Path: filepath.Join( 68 | parent.LeftPath, 69 | parent.RightPath, 70 | t.Filename, 71 | ), 72 | ParentID: parent.SID(), 73 | Duration: t.Length, 74 | Year: parent.TagYear, 75 | Bitrate: t.Bitrate, 76 | IsDir: false, 77 | Type: "music", 78 | MusicBrainzID: t.TagBrainzID, 79 | CreatedAt: t.CreatedAt, 80 | AverageRating: formatRating(t.AverageRating), 81 | } 82 | if trCh.Title == "" { 83 | trCh.Title = t.Filename 84 | } 85 | if parent.Cover != "" { 86 | trCh.CoverID = parent.SID() 87 | } 88 | if t.Album != nil { 89 | trCh.Album = t.Album.RightPath 90 | } 91 | if t.TrackStar != nil { 92 | trCh.Starred = &t.TrackStar.StarDate 93 | } 94 | if t.TrackRating != nil { 95 | trCh.UserRating = t.TrackRating.Rating 96 | } 97 | if len(t.Genres) > 0 { 98 | trCh.Genre = t.Genres[0].Name 99 | } 100 | for _, g := range t.Genres { 101 | trCh.Genres = append(trCh.Genres, &GenreRef{Name: g.Name}) 102 | } 103 | for _, a := range t.Artists { 104 | trCh.Artists = append(trCh.Artists, &ArtistRef{ID: a.SID(), Name: a.Name}) 105 | } 106 | if t.ReplayGainTrackGain != 0 || t.ReplayGainAlbumGain != 0 { 107 | trCh.ReplayGain = &ReplayGain{ 108 | TrackGain: t.ReplayGainTrackGain, 109 | TrackPeak: t.ReplayGainTrackPeak, 110 | AlbumGain: t.ReplayGainAlbumGain, 111 | AlbumPeak: t.ReplayGainAlbumPeak, 112 | } 113 | } 114 | return trCh 115 | } 116 | 117 | func NewTCPodcastEpisode(pe *db.PodcastEpisode) *TrackChild { 118 | trCh := &TrackChild{ 119 | ID: pe.SID(), 120 | ContentType: pe.MIME(), 121 | Suffix: pe.Ext(), 122 | Size: pe.Size, 123 | Title: pe.Title, 124 | ParentID: pe.SID(), 125 | Duration: pe.Length, 126 | Bitrate: pe.Bitrate, 127 | IsDir: false, 128 | Type: "podcastepisode", 129 | CreatedAt: pe.CreatedAt, 130 | Album: pe.Album, 131 | Artist: pe.Artist, 132 | CoverID: pe.SID(), 133 | } 134 | if pe.Podcast != nil { 135 | trCh.ParentID = pe.Podcast.SID() 136 | trCh.Path = pe.AbsPath() 137 | } 138 | return trCh 139 | } 140 | 141 | func NewArtistByFolder(f *db.Album) *Artist { 142 | // the db is structured around "browse by tags", and where 143 | // an album is also a folder. so we're constructing an artist 144 | // from an "album" where 145 | // maybe TODO: rename the Album model to Folder 146 | a := &Artist{ 147 | ID: f.SID(), 148 | Name: f.RightPath, 149 | AlbumCount: f.ChildCount, 150 | AverageRating: formatRating(f.AverageRating), 151 | } 152 | if f.AlbumStar != nil { 153 | a.Starred = &f.AlbumStar.StarDate 154 | } 155 | if f.AlbumRating != nil { 156 | a.UserRating = f.AlbumRating.Rating 157 | } 158 | if f.Cover != "" { 159 | a.CoverID = f.SID() 160 | } 161 | return a 162 | } 163 | 164 | func NewDirectoryByFolder(f *db.Album, children []*TrackChild) *Directory { 165 | d := &Directory{ 166 | ID: f.SID(), 167 | Name: f.RightPath, 168 | Children: children, 169 | ParentID: f.ParentSID(), 170 | AverageRating: formatRating(f.AverageRating), 171 | } 172 | if f.AlbumStar != nil { 173 | d.Starred = &f.AlbumStar.StarDate 174 | } 175 | if f.AlbumRating != nil { 176 | d.UserRating = f.AlbumRating.Rating 177 | } 178 | return d 179 | } 180 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/spec/construct_by_tags.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "path/filepath" 5 | "sort" 6 | 7 | "go.senan.xyz/gonic/db" 8 | ) 9 | 10 | func NewAlbumByTags(a *db.Album, artists []*db.Artist) *Album { 11 | ret := &Album{ 12 | ID: a.SID(), 13 | Created: a.CreatedAt, 14 | Artists: []*ArtistRef{}, 15 | DisplayArtist: a.TagAlbumArtist, 16 | Title: a.TagTitle, 17 | Album: a.TagTitle, 18 | Name: a.TagTitle, 19 | TrackCount: a.ChildCount, 20 | Duration: a.Duration, 21 | Genres: []*GenreRef{}, 22 | Year: a.TagYear, 23 | Tracks: []*TrackChild{}, 24 | AverageRating: formatRating(a.AverageRating), 25 | } 26 | if a.Cover != "" { 27 | ret.CoverID = a.SID() 28 | } 29 | if a.AlbumStar != nil { 30 | ret.Starred = &a.AlbumStar.StarDate 31 | } 32 | if a.AlbumRating != nil { 33 | ret.UserRating = a.AlbumRating.Rating 34 | } 35 | sort.Slice(artists, func(i, j int) bool { 36 | return artists[i].ID < artists[j].ID 37 | }) 38 | if len(artists) > 0 { 39 | ret.Artist = artists[0].Name 40 | ret.ArtistID = artists[0].SID() 41 | } 42 | for _, a := range artists { 43 | ret.Artists = append(ret.Artists, &ArtistRef{ 44 | ID: a.SID(), 45 | Name: a.Name, 46 | }) 47 | } 48 | if len(a.Genres) > 0 { 49 | ret.Genre = a.Genres[0].Name 50 | } 51 | for _, g := range a.Genres { 52 | ret.Genres = append(ret.Genres, &GenreRef{Name: g.Name}) 53 | } 54 | if a.Play != nil { 55 | ret.PlayCount = a.Play.Count 56 | } 57 | return ret 58 | } 59 | 60 | func NewTrackByTags(t *db.Track, album *db.Album) *TrackChild { 61 | ret := &TrackChild{ 62 | ID: t.SID(), 63 | Album: album.TagTitle, 64 | AlbumID: album.SID(), 65 | Artist: t.TagTrackArtist, 66 | Artists: []*ArtistRef{}, 67 | DisplayArtist: t.TagTrackArtist, 68 | AlbumArtists: []*ArtistRef{}, 69 | AlbumDisplayArtist: album.TagAlbumArtist, 70 | Bitrate: t.Bitrate, 71 | ContentType: t.MIME(), 72 | CreatedAt: t.CreatedAt, 73 | Duration: t.Length, 74 | Genres: []*GenreRef{}, 75 | ParentID: t.AlbumSID(), 76 | Path: filepath.Join(album.LeftPath, album.RightPath, t.Filename), 77 | Size: t.Size, 78 | Suffix: formatExt(t.Ext()), 79 | Title: t.TagTitle, 80 | TrackNumber: t.TagTrackNumber, 81 | DiscNumber: t.TagDiscNumber, 82 | Type: "music", 83 | MusicBrainzID: t.TagBrainzID, 84 | Year: album.TagYear, 85 | AverageRating: formatRating(t.AverageRating), 86 | TranscodeMeta: TranscodeMeta{}, 87 | } 88 | if album.Cover != "" { 89 | ret.CoverID = album.SID() 90 | } 91 | if t.TrackStar != nil { 92 | ret.Starred = &t.TrackStar.StarDate 93 | } 94 | if t.TrackRating != nil { 95 | ret.UserRating = t.TrackRating.Rating 96 | } 97 | if len(album.Artists) > 0 { 98 | sort.Slice(album.Artists, func(i, j int) bool { 99 | return album.Artists[i].ID < album.Artists[j].ID 100 | }) 101 | ret.ArtistID = album.Artists[0].SID() 102 | } 103 | if len(t.Genres) > 0 { 104 | ret.Genre = t.Genres[0].Name 105 | } 106 | for _, g := range t.Genres { 107 | ret.Genres = append(ret.Genres, &GenreRef{Name: g.Name}) 108 | } 109 | for _, a := range t.Artists { 110 | ret.Artists = append(ret.Artists, &ArtistRef{ID: a.SID(), Name: a.Name}) 111 | } 112 | for _, a := range album.Artists { 113 | ret.AlbumArtists = append(ret.AlbumArtists, &ArtistRef{ID: a.SID(), Name: a.Name}) 114 | } 115 | if t.ReplayGainTrackGain != 0 || t.ReplayGainAlbumGain != 0 { 116 | ret.ReplayGain = &ReplayGain{ 117 | TrackGain: t.ReplayGainTrackGain, 118 | TrackPeak: t.ReplayGainTrackPeak, 119 | AlbumGain: t.ReplayGainAlbumGain, 120 | AlbumPeak: t.ReplayGainAlbumPeak, 121 | } 122 | } 123 | return ret 124 | } 125 | 126 | func NewArtistByTags(a *db.Artist) *Artist { 127 | r := &Artist{ 128 | ID: a.SID(), 129 | Name: a.Name, 130 | AlbumCount: a.AlbumCount, 131 | Albums: []*Album{}, 132 | AverageRating: formatRating(a.AverageRating), 133 | } 134 | if a.Info != nil && a.Info.ImageURL != "" { 135 | r.CoverID = a.SID() 136 | } 137 | if a.ArtistStar != nil { 138 | r.Starred = &a.ArtistStar.StarDate 139 | } 140 | if a.ArtistRating != nil { 141 | r.UserRating = a.ArtistRating.Rating 142 | } 143 | return r 144 | } 145 | 146 | func NewGenre(g *db.Genre) *Genre { 147 | return &Genre{ 148 | Name: g.Name, 149 | AlbumCount: g.AlbumCount, 150 | SongCount: g.TrackCount, 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/spec/construct_internet_radio.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "go.senan.xyz/gonic/db" 4 | 5 | func NewInternetRadioStation(irs *db.InternetRadioStation) *InternetRadioStation { 6 | return &InternetRadioStation{ 7 | ID: irs.SID(), 8 | Name: irs.Name, 9 | StreamURL: irs.StreamURL, 10 | HomepageURL: irs.HomepageURL, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/spec/construct_podcast.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "go.senan.xyz/gonic/db" 5 | ) 6 | 7 | func NewPodcastChannel(p *db.Podcast) *PodcastChannel { 8 | ret := &PodcastChannel{ 9 | ID: p.SID(), 10 | OriginalImageURL: p.ImageURL, 11 | Title: p.Title, 12 | Description: CleanExternalText(p.Description), 13 | URL: p.URL, 14 | CoverArt: p.SID(), 15 | Status: "skipped", 16 | } 17 | for _, episode := range p.Episodes { 18 | specEpisode := NewPodcastEpisode(episode) 19 | ret.Episode = append(ret.Episode, specEpisode) 20 | } 21 | return ret 22 | } 23 | 24 | func NewPodcastEpisode(pe *db.PodcastEpisode) *PodcastEpisode { 25 | if pe == nil { 26 | return nil 27 | } 28 | r := &PodcastEpisode{ 29 | ID: pe.SID(), 30 | StreamID: pe.SID(), 31 | ContentType: pe.MIME(), 32 | ChannelID: pe.PodcastSID(), 33 | Title: pe.Title, 34 | Description: CleanExternalText(pe.Description), 35 | Status: string(pe.Status), 36 | CoverArt: pe.PodcastSID(), 37 | PublishDate: *pe.PublishDate, 38 | Genre: "Podcast", 39 | Duration: pe.Length, 40 | Year: pe.PublishDate.Year(), 41 | Suffix: formatExt(pe.Ext()), 42 | BitRate: pe.Bitrate, 43 | IsDir: false, 44 | Size: pe.Size, 45 | Album: pe.Album, 46 | Artist: pe.Artist, 47 | } 48 | if pe.Podcast != nil { 49 | r.Path = pe.AbsPath() 50 | } 51 | return r 52 | } 53 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/specid/ids.go: -------------------------------------------------------------------------------- 1 | package specid 2 | 3 | // this package is at such a high level in the hierarchy because 4 | // it's used by both `server/db` (for now) and `server/ctrlsubsonic` 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | ErrBadSeparator = errors.New("bad separator") 16 | ErrNotAnInt = errors.New("not an int") 17 | ErrBadPrefix = errors.New("bad prefix") 18 | ErrBadJSON = errors.New("bad JSON") 19 | ) 20 | 21 | type IDT string 22 | 23 | const ( 24 | Artist IDT = "ar" 25 | Album IDT = "al" 26 | Track IDT = "tr" 27 | Podcast IDT = "pd" 28 | PodcastEpisode IDT = "pe" 29 | InternetRadioStation IDT = "ir" 30 | separator = "-" 31 | ) 32 | 33 | //nolint:musttag 34 | type ID struct { 35 | Type IDT 36 | Value int 37 | } 38 | 39 | func New(in string) (ID, error) { 40 | partType, partValue, ok := strings.Cut(in, separator) 41 | if !ok { 42 | return ID{}, ErrBadSeparator 43 | } 44 | val, err := strconv.Atoi(partValue) 45 | if err != nil { 46 | return ID{}, fmt.Errorf("%q: %w", partValue, ErrNotAnInt) 47 | } 48 | switch IDT(partType) { 49 | case Artist: 50 | return ID{Type: Artist, Value: val}, nil 51 | case Album: 52 | return ID{Type: Album, Value: val}, nil 53 | case Track: 54 | return ID{Type: Track, Value: val}, nil 55 | case Podcast: 56 | return ID{Type: Podcast, Value: val}, nil 57 | case PodcastEpisode: 58 | return ID{Type: PodcastEpisode, Value: val}, nil 59 | case InternetRadioStation: 60 | return ID{Type: InternetRadioStation, Value: val}, nil 61 | default: 62 | return ID{}, fmt.Errorf("%q: %w", partType, ErrBadPrefix) 63 | } 64 | } 65 | 66 | func (i ID) String() string { 67 | if i.Value == 0 { 68 | return "-1" 69 | } 70 | return fmt.Sprintf("%s%s%d", i.Type, separator, i.Value) 71 | } 72 | 73 | func (i ID) MarshalJSON() ([]byte, error) { 74 | return json.Marshal(i.String()) 75 | } 76 | 77 | func (i *ID) UnmarshalJSON(data []byte) error { 78 | if len(data) <= 2 { 79 | return fmt.Errorf("too short: %w", ErrBadJSON) 80 | } 81 | id, err := New(string(data[1 : len(data)-1])) // Strip quotes 82 | if err == nil { 83 | *i = id 84 | } 85 | return err 86 | } 87 | 88 | func (i ID) MarshalText() ([]byte, error) { 89 | return []byte(i.String()), nil 90 | } 91 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/specid/ids_test.go: -------------------------------------------------------------------------------- 1 | package specid 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestParseID(t *testing.T) { 9 | t.Parallel() 10 | 11 | tcases := []struct { 12 | param string 13 | expType IDT 14 | expValue int 15 | expErr error 16 | }{ 17 | {param: "al-45", expType: Album, expValue: 45}, 18 | {param: "ar-2", expType: Artist, expValue: 2}, 19 | {param: "tr-43", expType: Track, expValue: 43}, 20 | {param: "al-3", expType: Album, expValue: 3}, 21 | {param: "xx-1", expErr: ErrBadPrefix}, 22 | {param: "1", expErr: ErrBadSeparator}, 23 | {param: "al-howdy", expErr: ErrNotAnInt}, 24 | } 25 | 26 | for _, tcase := range tcases { 27 | tcase := tcase // pin 28 | t.Run(tcase.param, func(t *testing.T) { 29 | t.Parallel() 30 | 31 | act, err := New(tcase.param) 32 | if !errors.Is(err, tcase.expErr) { 33 | t.Fatalf("expected err %q, got %q", tcase.expErr, err) 34 | } 35 | if act.Value != tcase.expValue { 36 | t.Errorf("expected value %d, got %d", tcase.expValue, act.Value) 37 | } 38 | if act.Type != tcase.expType { 39 | t.Errorf("expected type %v, got %v", tcase.expType, act.Type) 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/specidpaths/specidpaths.go: -------------------------------------------------------------------------------- 1 | package specidpaths 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "strings" 7 | 8 | "go.senan.xyz/gonic/db" 9 | "go.senan.xyz/gonic/fileutil" 10 | "go.senan.xyz/gonic/server/ctrlsubsonic/specid" 11 | ) 12 | 13 | var ( 14 | ErrNotAbs = errors.New("not abs") 15 | ErrNotFound = errors.New("not found") 16 | ) 17 | 18 | type Result interface { 19 | SID() *specid.ID 20 | AbsPath() string 21 | } 22 | 23 | // Locate maps a specid to its location on the filesystem 24 | func Locate(dbc *db.DB, id specid.ID) (Result, error) { 25 | switch id.Type { 26 | case specid.Track: 27 | var track db.Track 28 | return &track, dbc.Preload("Album").Where("id=?", id.Value).Find(&track).Error 29 | case specid.PodcastEpisode: 30 | var pe db.PodcastEpisode 31 | return &pe, dbc.Preload("Podcast").Where("id=? AND status=?", id.Value, db.PodcastEpisodeStatusCompleted).Find(&pe).Error 32 | case specid.InternetRadioStation: 33 | var irs db.InternetRadioStation 34 | return &irs, dbc.Where("id=?", id.Value).Find(&irs).Error 35 | default: 36 | return nil, ErrNotFound 37 | } 38 | } 39 | 40 | // Locate maps a location on the filesystem to a specid 41 | func Lookup(dbc *db.DB, musicPaths []string, podcastsPath string, path string) (Result, error) { 42 | if !strings.HasPrefix(path, "http") && !filepath.IsAbs(path) { 43 | return nil, ErrNotAbs 44 | } 45 | 46 | if strings.HasPrefix(path, podcastsPath) { 47 | podcastPath, episodeFilename := filepath.Split(path) 48 | q := dbc. 49 | Joins(`JOIN podcasts ON podcasts.id=podcast_episodes.podcast_id`). 50 | Where(`podcasts.root_dir=? AND podcast_episodes.filename=?`, filepath.Clean(podcastPath), filepath.Clean(episodeFilename)) 51 | 52 | var pe db.PodcastEpisode 53 | if err := q.First(&pe).Error; err == nil { 54 | return &pe, nil 55 | } 56 | return nil, ErrNotFound 57 | } 58 | 59 | // probably internet radio 60 | if strings.HasPrefix(path, "http") { 61 | var irs db.InternetRadioStation 62 | if err := dbc.First(&irs, "stream_url=?", path).Error; err == nil { 63 | return &irs, nil 64 | } 65 | return nil, ErrNotFound 66 | } 67 | 68 | var musicPath string 69 | for _, mp := range musicPaths { 70 | if fileutil.HasPrefix(path, mp) { 71 | musicPath = mp 72 | break 73 | } 74 | } 75 | if musicPath == "" { 76 | return nil, ErrNotFound 77 | } 78 | 79 | relPath, _ := filepath.Rel(musicPath, path) 80 | relDir, filename := filepath.Split(relPath) 81 | leftPath, rightPath := filepath.Split(filepath.Clean(relDir)) 82 | 83 | q := dbc. 84 | Where(`albums.root_dir=? AND albums.left_path=? AND albums.right_path=? AND tracks.filename=?`, musicPath, leftPath, rightPath, filename). 85 | Joins(`JOIN albums ON tracks.album_id=albums.id`). 86 | Preload("Album") 87 | 88 | var track db.Track 89 | if err := q.First(&track).Error; err == nil { 90 | return &track, nil 91 | } 92 | return nil, ErrNotFound 93 | } 94 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/audio/10s.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrlsubsonic/testdata/audio/10s.flac -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/audio/5s.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/server/ctrlsubsonic/testdata/audio/5s.flac -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_alpha_artist: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artist": "artist-0", 14 | "artists": null, 15 | "displayArtist": "", 16 | "title": "album-0", 17 | "album": "album-0", 18 | "parent": "al-2", 19 | "isDir": true, 20 | "coverArt": "al-3", 21 | "name": "album-0", 22 | "songCount": 3, 23 | "duration": 300, 24 | "playCount": 0 25 | }, 26 | { 27 | "id": "al-4", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artist": "artist-0", 30 | "artists": null, 31 | "displayArtist": "", 32 | "title": "album-1", 33 | "album": "album-1", 34 | "parent": "al-2", 35 | "isDir": true, 36 | "coverArt": "al-4", 37 | "name": "album-1", 38 | "songCount": 3, 39 | "duration": 300, 40 | "playCount": 0 41 | }, 42 | { 43 | "id": "al-5", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artist": "artist-0", 46 | "artists": null, 47 | "displayArtist": "", 48 | "title": "album-2", 49 | "album": "album-2", 50 | "parent": "al-2", 51 | "isDir": true, 52 | "coverArt": "al-5", 53 | "name": "album-2", 54 | "songCount": 3, 55 | "duration": 300, 56 | "playCount": 0 57 | }, 58 | { 59 | "id": "al-7", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artist": "artist-1", 62 | "artists": null, 63 | "displayArtist": "", 64 | "title": "album-0", 65 | "album": "album-0", 66 | "parent": "al-6", 67 | "isDir": true, 68 | "coverArt": "al-7", 69 | "name": "album-0", 70 | "songCount": 3, 71 | "duration": 300, 72 | "playCount": 0 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artist": "artist-1", 78 | "artists": null, 79 | "displayArtist": "", 80 | "title": "album-1", 81 | "album": "album-1", 82 | "parent": "al-6", 83 | "isDir": true, 84 | "coverArt": "al-8", 85 | "name": "album-1", 86 | "songCount": 3, 87 | "duration": 300, 88 | "playCount": 0 89 | }, 90 | { 91 | "id": "al-9", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artist": "artist-1", 94 | "artists": null, 95 | "displayArtist": "", 96 | "title": "album-2", 97 | "album": "album-2", 98 | "parent": "al-6", 99 | "isDir": true, 100 | "coverArt": "al-9", 101 | "name": "album-2", 102 | "songCount": 3, 103 | "duration": 300, 104 | "playCount": 0 105 | }, 106 | { 107 | "id": "al-11", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artist": "artist-2", 110 | "artists": null, 111 | "displayArtist": "", 112 | "title": "album-0", 113 | "album": "album-0", 114 | "parent": "al-10", 115 | "isDir": true, 116 | "coverArt": "al-11", 117 | "name": "album-0", 118 | "songCount": 3, 119 | "duration": 300, 120 | "playCount": 0 121 | }, 122 | { 123 | "id": "al-12", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artist": "artist-2", 126 | "artists": null, 127 | "displayArtist": "", 128 | "title": "album-1", 129 | "album": "album-1", 130 | "parent": "al-10", 131 | "isDir": true, 132 | "coverArt": "al-12", 133 | "name": "album-1", 134 | "songCount": 3, 135 | "duration": 300, 136 | "playCount": 0 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artist": "artist-2", 142 | "artists": null, 143 | "displayArtist": "", 144 | "title": "album-2", 145 | "album": "album-2", 146 | "parent": "al-10", 147 | "isDir": true, 148 | "coverArt": "al-13", 149 | "name": "album-2", 150 | "songCount": 3, 151 | "duration": 300, 152 | "playCount": 0 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_alpha_name: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artist": "artist-0", 14 | "artists": null, 15 | "displayArtist": "", 16 | "title": "album-0", 17 | "album": "album-0", 18 | "parent": "al-2", 19 | "isDir": true, 20 | "coverArt": "al-3", 21 | "name": "album-0", 22 | "songCount": 3, 23 | "duration": 300, 24 | "playCount": 0 25 | }, 26 | { 27 | "id": "al-7", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artist": "artist-1", 30 | "artists": null, 31 | "displayArtist": "", 32 | "title": "album-0", 33 | "album": "album-0", 34 | "parent": "al-6", 35 | "isDir": true, 36 | "coverArt": "al-7", 37 | "name": "album-0", 38 | "songCount": 3, 39 | "duration": 300, 40 | "playCount": 0 41 | }, 42 | { 43 | "id": "al-11", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artist": "artist-2", 46 | "artists": null, 47 | "displayArtist": "", 48 | "title": "album-0", 49 | "album": "album-0", 50 | "parent": "al-10", 51 | "isDir": true, 52 | "coverArt": "al-11", 53 | "name": "album-0", 54 | "songCount": 3, 55 | "duration": 300, 56 | "playCount": 0 57 | }, 58 | { 59 | "id": "al-4", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artist": "artist-0", 62 | "artists": null, 63 | "displayArtist": "", 64 | "title": "album-1", 65 | "album": "album-1", 66 | "parent": "al-2", 67 | "isDir": true, 68 | "coverArt": "al-4", 69 | "name": "album-1", 70 | "songCount": 3, 71 | "duration": 300, 72 | "playCount": 0 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artist": "artist-1", 78 | "artists": null, 79 | "displayArtist": "", 80 | "title": "album-1", 81 | "album": "album-1", 82 | "parent": "al-6", 83 | "isDir": true, 84 | "coverArt": "al-8", 85 | "name": "album-1", 86 | "songCount": 3, 87 | "duration": 300, 88 | "playCount": 0 89 | }, 90 | { 91 | "id": "al-12", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artist": "artist-2", 94 | "artists": null, 95 | "displayArtist": "", 96 | "title": "album-1", 97 | "album": "album-1", 98 | "parent": "al-10", 99 | "isDir": true, 100 | "coverArt": "al-12", 101 | "name": "album-1", 102 | "songCount": 3, 103 | "duration": 300, 104 | "playCount": 0 105 | }, 106 | { 107 | "id": "al-5", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artist": "artist-0", 110 | "artists": null, 111 | "displayArtist": "", 112 | "title": "album-2", 113 | "album": "album-2", 114 | "parent": "al-2", 115 | "isDir": true, 116 | "coverArt": "al-5", 117 | "name": "album-2", 118 | "songCount": 3, 119 | "duration": 300, 120 | "playCount": 0 121 | }, 122 | { 123 | "id": "al-9", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artist": "artist-1", 126 | "artists": null, 127 | "displayArtist": "", 128 | "title": "album-2", 129 | "album": "album-2", 130 | "parent": "al-6", 131 | "isDir": true, 132 | "coverArt": "al-9", 133 | "name": "album-2", 134 | "songCount": 3, 135 | "duration": 300, 136 | "playCount": 0 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artist": "artist-2", 142 | "artists": null, 143 | "displayArtist": "", 144 | "title": "album-2", 145 | "album": "album-2", 146 | "parent": "al-10", 147 | "isDir": true, 148 | "coverArt": "al-13", 149 | "name": "album-2", 150 | "songCount": 3, 151 | "duration": 300, 152 | "playCount": 0 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_newest: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artist": "artist-0", 14 | "artists": null, 15 | "displayArtist": "", 16 | "title": "album-0", 17 | "album": "album-0", 18 | "parent": "al-2", 19 | "isDir": true, 20 | "coverArt": "al-3", 21 | "name": "album-0", 22 | "songCount": 3, 23 | "duration": 300, 24 | "playCount": 0 25 | }, 26 | { 27 | "id": "al-4", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artist": "artist-0", 30 | "artists": null, 31 | "displayArtist": "", 32 | "title": "album-1", 33 | "album": "album-1", 34 | "parent": "al-2", 35 | "isDir": true, 36 | "coverArt": "al-4", 37 | "name": "album-1", 38 | "songCount": 3, 39 | "duration": 300, 40 | "playCount": 0 41 | }, 42 | { 43 | "id": "al-5", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artist": "artist-0", 46 | "artists": null, 47 | "displayArtist": "", 48 | "title": "album-2", 49 | "album": "album-2", 50 | "parent": "al-2", 51 | "isDir": true, 52 | "coverArt": "al-5", 53 | "name": "album-2", 54 | "songCount": 3, 55 | "duration": 300, 56 | "playCount": 0 57 | }, 58 | { 59 | "id": "al-7", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artist": "artist-1", 62 | "artists": null, 63 | "displayArtist": "", 64 | "title": "album-0", 65 | "album": "album-0", 66 | "parent": "al-6", 67 | "isDir": true, 68 | "coverArt": "al-7", 69 | "name": "album-0", 70 | "songCount": 3, 71 | "duration": 300, 72 | "playCount": 0 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artist": "artist-1", 78 | "artists": null, 79 | "displayArtist": "", 80 | "title": "album-1", 81 | "album": "album-1", 82 | "parent": "al-6", 83 | "isDir": true, 84 | "coverArt": "al-8", 85 | "name": "album-1", 86 | "songCount": 3, 87 | "duration": 300, 88 | "playCount": 0 89 | }, 90 | { 91 | "id": "al-9", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artist": "artist-1", 94 | "artists": null, 95 | "displayArtist": "", 96 | "title": "album-2", 97 | "album": "album-2", 98 | "parent": "al-6", 99 | "isDir": true, 100 | "coverArt": "al-9", 101 | "name": "album-2", 102 | "songCount": 3, 103 | "duration": 300, 104 | "playCount": 0 105 | }, 106 | { 107 | "id": "al-11", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artist": "artist-2", 110 | "artists": null, 111 | "displayArtist": "", 112 | "title": "album-0", 113 | "album": "album-0", 114 | "parent": "al-10", 115 | "isDir": true, 116 | "coverArt": "al-11", 117 | "name": "album-0", 118 | "songCount": 3, 119 | "duration": 300, 120 | "playCount": 0 121 | }, 122 | { 123 | "id": "al-12", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artist": "artist-2", 126 | "artists": null, 127 | "displayArtist": "", 128 | "title": "album-1", 129 | "album": "album-1", 130 | "parent": "al-10", 131 | "isDir": true, 132 | "coverArt": "al-12", 133 | "name": "album-1", 134 | "songCount": 3, 135 | "duration": 300, 136 | "playCount": 0 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artist": "artist-2", 142 | "artists": null, 143 | "displayArtist": "", 144 | "title": "album-2", 145 | "album": "album-2", 146 | "parent": "al-10", 147 | "isDir": true, 148 | "coverArt": "al-13", 149 | "name": "album-2", 150 | "songCount": 3, 151 | "duration": 300, 152 | "playCount": 0 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_random: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList": { 9 | "album": [ 10 | { 11 | "id": "al-5", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artist": "artist-0", 14 | "artists": null, 15 | "displayArtist": "", 16 | "title": "album-2", 17 | "album": "album-2", 18 | "parent": "al-2", 19 | "isDir": true, 20 | "coverArt": "al-5", 21 | "name": "album-2", 22 | "songCount": 3, 23 | "duration": 300, 24 | "playCount": 0 25 | }, 26 | { 27 | "id": "al-7", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artist": "artist-1", 30 | "artists": null, 31 | "displayArtist": "", 32 | "title": "album-0", 33 | "album": "album-0", 34 | "parent": "al-6", 35 | "isDir": true, 36 | "coverArt": "al-7", 37 | "name": "album-0", 38 | "songCount": 3, 39 | "duration": 300, 40 | "playCount": 0 41 | }, 42 | { 43 | "id": "al-13", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artist": "artist-2", 46 | "artists": null, 47 | "displayArtist": "", 48 | "title": "album-2", 49 | "album": "album-2", 50 | "parent": "al-10", 51 | "isDir": true, 52 | "coverArt": "al-13", 53 | "name": "album-2", 54 | "songCount": 3, 55 | "duration": 300, 56 | "playCount": 0 57 | }, 58 | { 59 | "id": "al-3", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artist": "artist-0", 62 | "artists": null, 63 | "displayArtist": "", 64 | "title": "album-0", 65 | "album": "album-0", 66 | "parent": "al-2", 67 | "isDir": true, 68 | "coverArt": "al-3", 69 | "name": "album-0", 70 | "songCount": 3, 71 | "duration": 300, 72 | "playCount": 0 73 | }, 74 | { 75 | "id": "al-4", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artist": "artist-0", 78 | "artists": null, 79 | "displayArtist": "", 80 | "title": "album-1", 81 | "album": "album-1", 82 | "parent": "al-2", 83 | "isDir": true, 84 | "coverArt": "al-4", 85 | "name": "album-1", 86 | "songCount": 3, 87 | "duration": 300, 88 | "playCount": 0 89 | }, 90 | { 91 | "id": "al-9", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artist": "artist-1", 94 | "artists": null, 95 | "displayArtist": "", 96 | "title": "album-2", 97 | "album": "album-2", 98 | "parent": "al-6", 99 | "isDir": true, 100 | "coverArt": "al-9", 101 | "name": "album-2", 102 | "songCount": 3, 103 | "duration": 300, 104 | "playCount": 0 105 | }, 106 | { 107 | "id": "al-12", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artist": "artist-2", 110 | "artists": null, 111 | "displayArtist": "", 112 | "title": "album-1", 113 | "album": "album-1", 114 | "parent": "al-10", 115 | "isDir": true, 116 | "coverArt": "al-12", 117 | "name": "album-1", 118 | "songCount": 3, 119 | "duration": 300, 120 | "playCount": 0 121 | }, 122 | { 123 | "id": "al-8", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artist": "artist-1", 126 | "artists": null, 127 | "displayArtist": "", 128 | "title": "album-1", 129 | "album": "album-1", 130 | "parent": "al-6", 131 | "isDir": true, 132 | "coverArt": "al-8", 133 | "name": "album-1", 134 | "songCount": 3, 135 | "duration": 300, 136 | "playCount": 0 137 | }, 138 | { 139 | "id": "al-11", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artist": "artist-2", 142 | "artists": null, 143 | "displayArtist": "", 144 | "title": "album-0", 145 | "album": "album-0", 146 | "parent": "al-10", 147 | "isDir": true, 148 | "coverArt": "al-11", 149 | "name": "album-0", 150 | "songCount": 3, 151 | "duration": 300, 152 | "playCount": 0 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_artist: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList2": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artistId": "ar-1", 14 | "artist": "artist-0", 15 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 16 | "displayArtist": "artist-0", 17 | "title": "album-0", 18 | "album": "album-0", 19 | "coverArt": "al-3", 20 | "name": "album-0", 21 | "songCount": 3, 22 | "duration": 300, 23 | "playCount": 0, 24 | "year": 2021 25 | }, 26 | { 27 | "id": "al-4", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artistId": "ar-1", 30 | "artist": "artist-0", 31 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 32 | "displayArtist": "artist-0", 33 | "title": "album-1", 34 | "album": "album-1", 35 | "coverArt": "al-4", 36 | "name": "album-1", 37 | "songCount": 3, 38 | "duration": 300, 39 | "playCount": 0, 40 | "year": 2021 41 | }, 42 | { 43 | "id": "al-5", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artistId": "ar-1", 46 | "artist": "artist-0", 47 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 48 | "displayArtist": "artist-0", 49 | "title": "album-2", 50 | "album": "album-2", 51 | "coverArt": "al-5", 52 | "name": "album-2", 53 | "songCount": 3, 54 | "duration": 300, 55 | "playCount": 0, 56 | "year": 2021 57 | }, 58 | { 59 | "id": "al-7", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artistId": "ar-2", 62 | "artist": "artist-1", 63 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 64 | "displayArtist": "artist-1", 65 | "title": "album-0", 66 | "album": "album-0", 67 | "coverArt": "al-7", 68 | "name": "album-0", 69 | "songCount": 3, 70 | "duration": 300, 71 | "playCount": 0, 72 | "year": 2021 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artistId": "ar-2", 78 | "artist": "artist-1", 79 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 80 | "displayArtist": "artist-1", 81 | "title": "album-1", 82 | "album": "album-1", 83 | "coverArt": "al-8", 84 | "name": "album-1", 85 | "songCount": 3, 86 | "duration": 300, 87 | "playCount": 0, 88 | "year": 2021 89 | }, 90 | { 91 | "id": "al-9", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artistId": "ar-2", 94 | "artist": "artist-1", 95 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 96 | "displayArtist": "artist-1", 97 | "title": "album-2", 98 | "album": "album-2", 99 | "coverArt": "al-9", 100 | "name": "album-2", 101 | "songCount": 3, 102 | "duration": 300, 103 | "playCount": 0, 104 | "year": 2021 105 | }, 106 | { 107 | "id": "al-11", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artistId": "ar-3", 110 | "artist": "artist-2", 111 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 112 | "displayArtist": "artist-2", 113 | "title": "album-0", 114 | "album": "album-0", 115 | "coverArt": "al-11", 116 | "name": "album-0", 117 | "songCount": 3, 118 | "duration": 300, 119 | "playCount": 0, 120 | "year": 2021 121 | }, 122 | { 123 | "id": "al-12", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artistId": "ar-3", 126 | "artist": "artist-2", 127 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 128 | "displayArtist": "artist-2", 129 | "title": "album-1", 130 | "album": "album-1", 131 | "coverArt": "al-12", 132 | "name": "album-1", 133 | "songCount": 3, 134 | "duration": 300, 135 | "playCount": 0, 136 | "year": 2021 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artistId": "ar-3", 142 | "artist": "artist-2", 143 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 144 | "displayArtist": "artist-2", 145 | "title": "album-2", 146 | "album": "album-2", 147 | "coverArt": "al-13", 148 | "name": "album-2", 149 | "songCount": 3, 150 | "duration": 300, 151 | "playCount": 0, 152 | "year": 2021 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_two_alpha_name: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList2": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artistId": "ar-1", 14 | "artist": "artist-0", 15 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 16 | "displayArtist": "artist-0", 17 | "title": "album-0", 18 | "album": "album-0", 19 | "coverArt": "al-3", 20 | "name": "album-0", 21 | "songCount": 3, 22 | "duration": 300, 23 | "playCount": 0, 24 | "year": 2021 25 | }, 26 | { 27 | "id": "al-7", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artistId": "ar-2", 30 | "artist": "artist-1", 31 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 32 | "displayArtist": "artist-1", 33 | "title": "album-0", 34 | "album": "album-0", 35 | "coverArt": "al-7", 36 | "name": "album-0", 37 | "songCount": 3, 38 | "duration": 300, 39 | "playCount": 0, 40 | "year": 2021 41 | }, 42 | { 43 | "id": "al-11", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artistId": "ar-3", 46 | "artist": "artist-2", 47 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 48 | "displayArtist": "artist-2", 49 | "title": "album-0", 50 | "album": "album-0", 51 | "coverArt": "al-11", 52 | "name": "album-0", 53 | "songCount": 3, 54 | "duration": 300, 55 | "playCount": 0, 56 | "year": 2021 57 | }, 58 | { 59 | "id": "al-4", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artistId": "ar-1", 62 | "artist": "artist-0", 63 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 64 | "displayArtist": "artist-0", 65 | "title": "album-1", 66 | "album": "album-1", 67 | "coverArt": "al-4", 68 | "name": "album-1", 69 | "songCount": 3, 70 | "duration": 300, 71 | "playCount": 0, 72 | "year": 2021 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artistId": "ar-2", 78 | "artist": "artist-1", 79 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 80 | "displayArtist": "artist-1", 81 | "title": "album-1", 82 | "album": "album-1", 83 | "coverArt": "al-8", 84 | "name": "album-1", 85 | "songCount": 3, 86 | "duration": 300, 87 | "playCount": 0, 88 | "year": 2021 89 | }, 90 | { 91 | "id": "al-12", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artistId": "ar-3", 94 | "artist": "artist-2", 95 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 96 | "displayArtist": "artist-2", 97 | "title": "album-1", 98 | "album": "album-1", 99 | "coverArt": "al-12", 100 | "name": "album-1", 101 | "songCount": 3, 102 | "duration": 300, 103 | "playCount": 0, 104 | "year": 2021 105 | }, 106 | { 107 | "id": "al-5", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artistId": "ar-1", 110 | "artist": "artist-0", 111 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 112 | "displayArtist": "artist-0", 113 | "title": "album-2", 114 | "album": "album-2", 115 | "coverArt": "al-5", 116 | "name": "album-2", 117 | "songCount": 3, 118 | "duration": 300, 119 | "playCount": 0, 120 | "year": 2021 121 | }, 122 | { 123 | "id": "al-9", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artistId": "ar-2", 126 | "artist": "artist-1", 127 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 128 | "displayArtist": "artist-1", 129 | "title": "album-2", 130 | "album": "album-2", 131 | "coverArt": "al-9", 132 | "name": "album-2", 133 | "songCount": 3, 134 | "duration": 300, 135 | "playCount": 0, 136 | "year": 2021 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artistId": "ar-3", 142 | "artist": "artist-2", 143 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 144 | "displayArtist": "artist-2", 145 | "title": "album-2", 146 | "album": "album-2", 147 | "coverArt": "al-13", 148 | "name": "album-2", 149 | "songCount": 3, 150 | "duration": 300, 151 | "playCount": 0, 152 | "year": 2021 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_two_newest: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList2": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artistId": "ar-1", 14 | "artist": "artist-0", 15 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 16 | "displayArtist": "artist-0", 17 | "title": "album-0", 18 | "album": "album-0", 19 | "coverArt": "al-3", 20 | "name": "album-0", 21 | "songCount": 3, 22 | "duration": 300, 23 | "playCount": 0, 24 | "year": 2021 25 | }, 26 | { 27 | "id": "al-4", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artistId": "ar-1", 30 | "artist": "artist-0", 31 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 32 | "displayArtist": "artist-0", 33 | "title": "album-1", 34 | "album": "album-1", 35 | "coverArt": "al-4", 36 | "name": "album-1", 37 | "songCount": 3, 38 | "duration": 300, 39 | "playCount": 0, 40 | "year": 2021 41 | }, 42 | { 43 | "id": "al-5", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artistId": "ar-1", 46 | "artist": "artist-0", 47 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 48 | "displayArtist": "artist-0", 49 | "title": "album-2", 50 | "album": "album-2", 51 | "coverArt": "al-5", 52 | "name": "album-2", 53 | "songCount": 3, 54 | "duration": 300, 55 | "playCount": 0, 56 | "year": 2021 57 | }, 58 | { 59 | "id": "al-7", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artistId": "ar-2", 62 | "artist": "artist-1", 63 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 64 | "displayArtist": "artist-1", 65 | "title": "album-0", 66 | "album": "album-0", 67 | "coverArt": "al-7", 68 | "name": "album-0", 69 | "songCount": 3, 70 | "duration": 300, 71 | "playCount": 0, 72 | "year": 2021 73 | }, 74 | { 75 | "id": "al-8", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artistId": "ar-2", 78 | "artist": "artist-1", 79 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 80 | "displayArtist": "artist-1", 81 | "title": "album-1", 82 | "album": "album-1", 83 | "coverArt": "al-8", 84 | "name": "album-1", 85 | "songCount": 3, 86 | "duration": 300, 87 | "playCount": 0, 88 | "year": 2021 89 | }, 90 | { 91 | "id": "al-9", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artistId": "ar-2", 94 | "artist": "artist-1", 95 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 96 | "displayArtist": "artist-1", 97 | "title": "album-2", 98 | "album": "album-2", 99 | "coverArt": "al-9", 100 | "name": "album-2", 101 | "songCount": 3, 102 | "duration": 300, 103 | "playCount": 0, 104 | "year": 2021 105 | }, 106 | { 107 | "id": "al-11", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artistId": "ar-3", 110 | "artist": "artist-2", 111 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 112 | "displayArtist": "artist-2", 113 | "title": "album-0", 114 | "album": "album-0", 115 | "coverArt": "al-11", 116 | "name": "album-0", 117 | "songCount": 3, 118 | "duration": 300, 119 | "playCount": 0, 120 | "year": 2021 121 | }, 122 | { 123 | "id": "al-12", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artistId": "ar-3", 126 | "artist": "artist-2", 127 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 128 | "displayArtist": "artist-2", 129 | "title": "album-1", 130 | "album": "album-1", 131 | "coverArt": "al-12", 132 | "name": "album-1", 133 | "songCount": 3, 134 | "duration": 300, 135 | "playCount": 0, 136 | "year": 2021 137 | }, 138 | { 139 | "id": "al-13", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artistId": "ar-3", 142 | "artist": "artist-2", 143 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 144 | "displayArtist": "artist-2", 145 | "title": "album-2", 146 | "album": "album-2", 147 | "coverArt": "al-13", 148 | "name": "album-2", 149 | "songCount": 3, 150 | "duration": 300, 151 | "playCount": 0, 152 | "year": 2021 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_list_two_random: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "albumList2": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "created": "2019-11-30T00:00:00Z", 13 | "artistId": "ar-1", 14 | "artist": "artist-0", 15 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 16 | "displayArtist": "artist-0", 17 | "title": "album-0", 18 | "album": "album-0", 19 | "coverArt": "al-3", 20 | "name": "album-0", 21 | "songCount": 3, 22 | "duration": 300, 23 | "playCount": 0, 24 | "year": 2021 25 | }, 26 | { 27 | "id": "al-7", 28 | "created": "2019-11-30T00:00:00Z", 29 | "artistId": "ar-2", 30 | "artist": "artist-1", 31 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 32 | "displayArtist": "artist-1", 33 | "title": "album-0", 34 | "album": "album-0", 35 | "coverArt": "al-7", 36 | "name": "album-0", 37 | "songCount": 3, 38 | "duration": 300, 39 | "playCount": 0, 40 | "year": 2021 41 | }, 42 | { 43 | "id": "al-11", 44 | "created": "2019-11-30T00:00:00Z", 45 | "artistId": "ar-3", 46 | "artist": "artist-2", 47 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 48 | "displayArtist": "artist-2", 49 | "title": "album-0", 50 | "album": "album-0", 51 | "coverArt": "al-11", 52 | "name": "album-0", 53 | "songCount": 3, 54 | "duration": 300, 55 | "playCount": 0, 56 | "year": 2021 57 | }, 58 | { 59 | "id": "al-13", 60 | "created": "2019-11-30T00:00:00Z", 61 | "artistId": "ar-3", 62 | "artist": "artist-2", 63 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 64 | "displayArtist": "artist-2", 65 | "title": "album-2", 66 | "album": "album-2", 67 | "coverArt": "al-13", 68 | "name": "album-2", 69 | "songCount": 3, 70 | "duration": 300, 71 | "playCount": 0, 72 | "year": 2021 73 | }, 74 | { 75 | "id": "al-4", 76 | "created": "2019-11-30T00:00:00Z", 77 | "artistId": "ar-1", 78 | "artist": "artist-0", 79 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 80 | "displayArtist": "artist-0", 81 | "title": "album-1", 82 | "album": "album-1", 83 | "coverArt": "al-4", 84 | "name": "album-1", 85 | "songCount": 3, 86 | "duration": 300, 87 | "playCount": 0, 88 | "year": 2021 89 | }, 90 | { 91 | "id": "al-12", 92 | "created": "2019-11-30T00:00:00Z", 93 | "artistId": "ar-3", 94 | "artist": "artist-2", 95 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 96 | "displayArtist": "artist-2", 97 | "title": "album-1", 98 | "album": "album-1", 99 | "coverArt": "al-12", 100 | "name": "album-1", 101 | "songCount": 3, 102 | "duration": 300, 103 | "playCount": 0, 104 | "year": 2021 105 | }, 106 | { 107 | "id": "al-5", 108 | "created": "2019-11-30T00:00:00Z", 109 | "artistId": "ar-1", 110 | "artist": "artist-0", 111 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 112 | "displayArtist": "artist-0", 113 | "title": "album-2", 114 | "album": "album-2", 115 | "coverArt": "al-5", 116 | "name": "album-2", 117 | "songCount": 3, 118 | "duration": 300, 119 | "playCount": 0, 120 | "year": 2021 121 | }, 122 | { 123 | "id": "al-8", 124 | "created": "2019-11-30T00:00:00Z", 125 | "artistId": "ar-2", 126 | "artist": "artist-1", 127 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 128 | "displayArtist": "artist-1", 129 | "title": "album-1", 130 | "album": "album-1", 131 | "coverArt": "al-8", 132 | "name": "album-1", 133 | "songCount": 3, 134 | "duration": 300, 135 | "playCount": 0, 136 | "year": 2021 137 | }, 138 | { 139 | "id": "al-9", 140 | "created": "2019-11-30T00:00:00Z", 141 | "artistId": "ar-2", 142 | "artist": "artist-1", 143 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 144 | "displayArtist": "artist-1", 145 | "title": "album-2", 146 | "album": "album-2", 147 | "coverArt": "al-9", 148 | "name": "album-2", 149 | "songCount": 3, 150 | "duration": 300, 151 | "playCount": 0, 152 | "year": 2021 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_with_cover: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "album": { 9 | "id": "al-3", 10 | "created": "2019-11-30T00:00:00Z", 11 | "artistId": "ar-1", 12 | "artist": "artist-0", 13 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 14 | "displayArtist": "artist-0", 15 | "title": "album-0", 16 | "album": "album-0", 17 | "coverArt": "al-3", 18 | "name": "album-0", 19 | "songCount": 3, 20 | "duration": 300, 21 | "playCount": 0, 22 | "genre": "Unknown Genre", 23 | "genres": [{ "name": "Unknown Genre" }], 24 | "year": 2021, 25 | "song": [ 26 | { 27 | "id": "tr-1", 28 | "album": "album-0", 29 | "albumId": "al-3", 30 | "artist": "artist-0", 31 | "artistId": "ar-1", 32 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 33 | "displayArtist": "artist-0", 34 | "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], 35 | "displayAlbumArtist": "artist-0", 36 | "bitRate": 100, 37 | "contentType": "audio/flac", 38 | "coverArt": "al-3", 39 | "created": "2019-11-30T00:00:00Z", 40 | "duration": 100, 41 | "isDir": false, 42 | "isVideo": false, 43 | "parent": "al-3", 44 | "path": "artist-0/album-0/track-0.flac", 45 | "suffix": "flac", 46 | "title": "title-0", 47 | "track": 1, 48 | "discNumber": 1, 49 | "type": "music", 50 | "year": 2021, 51 | "musicBrainzId": "", 52 | "replayGain": null 53 | }, 54 | { 55 | "id": "tr-2", 56 | "album": "album-0", 57 | "albumId": "al-3", 58 | "artist": "artist-0", 59 | "artistId": "ar-1", 60 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 61 | "displayArtist": "artist-0", 62 | "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], 63 | "displayAlbumArtist": "artist-0", 64 | "bitRate": 100, 65 | "contentType": "audio/flac", 66 | "coverArt": "al-3", 67 | "created": "2019-11-30T00:00:00Z", 68 | "duration": 100, 69 | "isDir": false, 70 | "isVideo": false, 71 | "parent": "al-3", 72 | "path": "artist-0/album-0/track-1.flac", 73 | "suffix": "flac", 74 | "title": "title-1", 75 | "track": 1, 76 | "discNumber": 1, 77 | "type": "music", 78 | "year": 2021, 79 | "musicBrainzId": "", 80 | "replayGain": null 81 | }, 82 | { 83 | "id": "tr-3", 84 | "album": "album-0", 85 | "albumId": "al-3", 86 | "artist": "artist-0", 87 | "artistId": "ar-1", 88 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 89 | "displayArtist": "artist-0", 90 | "albumArtists": [{ "id": "ar-1", "name": "artist-0" }], 91 | "displayAlbumArtist": "artist-0", 92 | "bitRate": 100, 93 | "contentType": "audio/flac", 94 | "coverArt": "al-3", 95 | "created": "2019-11-30T00:00:00Z", 96 | "duration": 100, 97 | "isDir": false, 98 | "isVideo": false, 99 | "parent": "al-3", 100 | "path": "artist-0/album-0/track-2.flac", 101 | "suffix": "flac", 102 | "title": "title-2", 103 | "track": 1, 104 | "discNumber": 1, 105 | "type": "music", 106 | "year": 2021, 107 | "musicBrainzId": "", 108 | "replayGain": null 109 | } 110 | ] 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_album_without_cover: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "album": { 9 | "id": "al-2", 10 | "created": "2019-11-30T00:00:00Z", 11 | "artist": "", 12 | "artists": [], 13 | "displayArtist": "", 14 | "title": "", 15 | "album": "", 16 | "name": "", 17 | "songCount": 0, 18 | "duration": 0, 19 | "playCount": 0 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artist_id_one: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artist": { 9 | "id": "ar-1", 10 | "name": "artist-0", 11 | "albumCount": 3, 12 | "album": [ 13 | { 14 | "id": "al-3", 15 | "created": "2019-11-30T00:00:00Z", 16 | "artistId": "ar-1", 17 | "artist": "artist-0", 18 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 19 | "displayArtist": "artist-0", 20 | "title": "album-0", 21 | "album": "album-0", 22 | "coverArt": "al-3", 23 | "name": "album-0", 24 | "songCount": 3, 25 | "duration": 300, 26 | "playCount": 0, 27 | "genre": "Unknown Genre", 28 | "genres": [{ "name": "Unknown Genre" }], 29 | "year": 2021 30 | }, 31 | { 32 | "id": "al-4", 33 | "created": "2019-11-30T00:00:00Z", 34 | "artistId": "ar-1", 35 | "artist": "artist-0", 36 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 37 | "displayArtist": "artist-0", 38 | "title": "album-1", 39 | "album": "album-1", 40 | "coverArt": "al-4", 41 | "name": "album-1", 42 | "songCount": 3, 43 | "duration": 300, 44 | "playCount": 0, 45 | "genre": "Unknown Genre", 46 | "genres": [{ "name": "Unknown Genre" }], 47 | "year": 2021 48 | }, 49 | { 50 | "id": "al-5", 51 | "created": "2019-11-30T00:00:00Z", 52 | "artistId": "ar-1", 53 | "artist": "artist-0", 54 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 55 | "displayArtist": "artist-0", 56 | "title": "album-2", 57 | "album": "album-2", 58 | "coverArt": "al-5", 59 | "name": "album-2", 60 | "songCount": 3, 61 | "duration": 300, 62 | "playCount": 0, 63 | "genre": "Unknown Genre", 64 | "genres": [{ "name": "Unknown Genre" }], 65 | "year": 2021 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artist_id_three: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artist": { 9 | "id": "ar-3", 10 | "name": "artist-2", 11 | "albumCount": 3, 12 | "album": [ 13 | { 14 | "id": "al-11", 15 | "created": "2019-11-30T00:00:00Z", 16 | "artistId": "ar-3", 17 | "artist": "artist-2", 18 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 19 | "displayArtist": "artist-2", 20 | "title": "album-0", 21 | "album": "album-0", 22 | "coverArt": "al-11", 23 | "name": "album-0", 24 | "songCount": 3, 25 | "duration": 300, 26 | "playCount": 0, 27 | "genre": "Unknown Genre", 28 | "genres": [{ "name": "Unknown Genre" }], 29 | "year": 2021 30 | }, 31 | { 32 | "id": "al-12", 33 | "created": "2019-11-30T00:00:00Z", 34 | "artistId": "ar-3", 35 | "artist": "artist-2", 36 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 37 | "displayArtist": "artist-2", 38 | "title": "album-1", 39 | "album": "album-1", 40 | "coverArt": "al-12", 41 | "name": "album-1", 42 | "songCount": 3, 43 | "duration": 300, 44 | "playCount": 0, 45 | "genre": "Unknown Genre", 46 | "genres": [{ "name": "Unknown Genre" }], 47 | "year": 2021 48 | }, 49 | { 50 | "id": "al-13", 51 | "created": "2019-11-30T00:00:00Z", 52 | "artistId": "ar-3", 53 | "artist": "artist-2", 54 | "artists": [{ "id": "ar-3", "name": "artist-2" }], 55 | "displayArtist": "artist-2", 56 | "title": "album-2", 57 | "album": "album-2", 58 | "coverArt": "al-13", 59 | "name": "album-2", 60 | "songCount": 3, 61 | "duration": 300, 62 | "playCount": 0, 63 | "genre": "Unknown Genre", 64 | "genres": [{ "name": "Unknown Genre" }], 65 | "year": 2021 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artist_id_two: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artist": { 9 | "id": "ar-2", 10 | "name": "artist-1", 11 | "albumCount": 3, 12 | "album": [ 13 | { 14 | "id": "al-7", 15 | "created": "2019-11-30T00:00:00Z", 16 | "artistId": "ar-2", 17 | "artist": "artist-1", 18 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 19 | "displayArtist": "artist-1", 20 | "title": "album-0", 21 | "album": "album-0", 22 | "coverArt": "al-7", 23 | "name": "album-0", 24 | "songCount": 3, 25 | "duration": 300, 26 | "playCount": 0, 27 | "genre": "Unknown Genre", 28 | "genres": [{ "name": "Unknown Genre" }], 29 | "year": 2021 30 | }, 31 | { 32 | "id": "al-8", 33 | "created": "2019-11-30T00:00:00Z", 34 | "artistId": "ar-2", 35 | "artist": "artist-1", 36 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 37 | "displayArtist": "artist-1", 38 | "title": "album-1", 39 | "album": "album-1", 40 | "coverArt": "al-8", 41 | "name": "album-1", 42 | "songCount": 3, 43 | "duration": 300, 44 | "playCount": 0, 45 | "genre": "Unknown Genre", 46 | "genres": [{ "name": "Unknown Genre" }], 47 | "year": 2021 48 | }, 49 | { 50 | "id": "al-9", 51 | "created": "2019-11-30T00:00:00Z", 52 | "artistId": "ar-2", 53 | "artist": "artist-1", 54 | "artists": [{ "id": "ar-2", "name": "artist-1" }], 55 | "displayArtist": "artist-1", 56 | "title": "album-2", 57 | "album": "album-2", 58 | "coverArt": "al-9", 59 | "name": "album-2", 60 | "songCount": 3, 61 | "duration": 300, 62 | "playCount": 0, 63 | "genre": "Unknown Genre", 64 | "genres": [{ "name": "Unknown Genre" }], 65 | "year": 2021 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artists_no_args: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artists": { 9 | "ignoredArticles": "", 10 | "index": [ 11 | { 12 | "name": "a", 13 | "artist": [ 14 | { "id": "ar-1", "name": "artist-0", "albumCount": 6 }, 15 | { "id": "ar-2", "name": "artist-1", "albumCount": 6 }, 16 | { "id": "ar-3", "name": "artist-2", "albumCount": 6 } 17 | ] 18 | } 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_1: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artists": { 9 | "ignoredArticles": "", 10 | "index": [ 11 | { 12 | "name": "a", 13 | "artist": [ 14 | { "id": "ar-1", "name": "artist-0", "albumCount": 3 }, 15 | { "id": "ar-2", "name": "artist-1", "albumCount": 3 }, 16 | { "id": "ar-3", "name": "artist-2", "albumCount": 3 } 17 | ] 18 | } 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_2: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "artists": { 9 | "ignoredArticles": "", 10 | "index": [ 11 | { 12 | "name": "a", 13 | "artist": [ 14 | { "id": "ar-1", "name": "artist-0", "albumCount": 3 }, 15 | { "id": "ar-2", "name": "artist-1", "albumCount": 3 }, 16 | { "id": "ar-3", "name": "artist-2", "albumCount": 3 } 17 | ] 18 | } 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_indexes_no_args: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "indexes": { 9 | "lastModified": 0, 10 | "ignoredArticles": "", 11 | "index": [ 12 | { 13 | "name": "a", 14 | "artist": [ 15 | { "id": "al-2", "name": "artist-0", "albumCount": 3 }, 16 | { "id": "al-15", "name": "artist-0", "albumCount": 3 }, 17 | { "id": "al-6", "name": "artist-1", "albumCount": 3 }, 18 | { "id": "al-19", "name": "artist-1", "albumCount": 3 }, 19 | { "id": "al-10", "name": "artist-2", "albumCount": 3 }, 20 | { "id": "al-23", "name": "artist-2", "albumCount": 3 } 21 | ] 22 | } 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_1: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "indexes": { 9 | "lastModified": 0, 10 | "ignoredArticles": "", 11 | "index": [ 12 | { 13 | "name": "a", 14 | "artist": [ 15 | { "id": "al-2", "name": "artist-0", "albumCount": 3 }, 16 | { "id": "al-6", "name": "artist-1", "albumCount": 3 }, 17 | { "id": "al-10", "name": "artist-2", "albumCount": 3 } 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_2: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "indexes": { 9 | "lastModified": 0, 10 | "ignoredArticles": "", 11 | "index": [ 12 | { 13 | "name": "a", 14 | "artist": [ 15 | { "id": "al-15", "name": "artist-0", "albumCount": 3 }, 16 | { "id": "al-19", "name": "artist-1", "albumCount": 3 }, 17 | { "id": "al-23", "name": "artist-2", "albumCount": 3 } 18 | ] 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "directory": { 9 | "id": "al-3", 10 | "parent": "al-2", 11 | "name": "album-0", 12 | "child": [ 13 | { 14 | "id": "tr-1", 15 | "album": "album-0", 16 | "artist": "artist-0", 17 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 18 | "displayArtist": "", 19 | "albumArtists": null, 20 | "displayAlbumArtist": "", 21 | "bitRate": 100, 22 | "contentType": "audio/flac", 23 | "coverArt": "al-3", 24 | "created": "2019-11-30T00:00:00Z", 25 | "duration": 100, 26 | "isDir": false, 27 | "isVideo": false, 28 | "parent": "al-3", 29 | "path": "artist-0/album-0/track-0.flac", 30 | "suffix": "flac", 31 | "title": "title-0", 32 | "track": 1, 33 | "discNumber": 1, 34 | "type": "music", 35 | "year": 2021, 36 | "musicBrainzId": "", 37 | "replayGain": null 38 | }, 39 | { 40 | "id": "tr-2", 41 | "album": "album-0", 42 | "artist": "artist-0", 43 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 44 | "displayArtist": "", 45 | "albumArtists": null, 46 | "displayAlbumArtist": "", 47 | "bitRate": 100, 48 | "contentType": "audio/flac", 49 | "coverArt": "al-3", 50 | "created": "2019-11-30T00:00:00Z", 51 | "duration": 100, 52 | "isDir": false, 53 | "isVideo": false, 54 | "parent": "al-3", 55 | "path": "artist-0/album-0/track-1.flac", 56 | "suffix": "flac", 57 | "title": "title-1", 58 | "track": 1, 59 | "discNumber": 1, 60 | "type": "music", 61 | "year": 2021, 62 | "musicBrainzId": "", 63 | "replayGain": null 64 | }, 65 | { 66 | "id": "tr-3", 67 | "album": "album-0", 68 | "artist": "artist-0", 69 | "artists": [{ "id": "ar-1", "name": "artist-0" }], 70 | "displayArtist": "", 71 | "albumArtists": null, 72 | "displayAlbumArtist": "", 73 | "bitRate": 100, 74 | "contentType": "audio/flac", 75 | "coverArt": "al-3", 76 | "created": "2019-11-30T00:00:00Z", 77 | "duration": 100, 78 | "isDir": false, 79 | "isVideo": false, 80 | "parent": "al-3", 81 | "path": "artist-0/album-0/track-2.flac", 82 | "suffix": "flac", 83 | "title": "title-2", 84 | "track": 1, 85 | "discNumber": 1, 86 | "type": "music", 87 | "year": 2021, 88 | "musicBrainzId": "", 89 | "replayGain": null 90 | } 91 | ] 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "directory": { 9 | "id": "al-2", 10 | "parent": "al-1", 11 | "name": "artist-0", 12 | "child": [ 13 | { 14 | "id": "al-3", 15 | "artist": "", 16 | "artists": null, 17 | "displayArtist": "", 18 | "albumArtists": null, 19 | "displayAlbumArtist": "", 20 | "coverArt": "al-3", 21 | "created": "2019-11-30T00:00:00Z", 22 | "isDir": true, 23 | "isVideo": false, 24 | "parent": "al-2", 25 | "title": "album-0", 26 | "year": 2021, 27 | "musicBrainzId": "", 28 | "replayGain": null 29 | }, 30 | { 31 | "id": "al-4", 32 | "artist": "", 33 | "artists": null, 34 | "displayArtist": "", 35 | "albumArtists": null, 36 | "displayAlbumArtist": "", 37 | "coverArt": "al-4", 38 | "created": "2019-11-30T00:00:00Z", 39 | "isDir": true, 40 | "isVideo": false, 41 | "parent": "al-2", 42 | "title": "album-1", 43 | "year": 2021, 44 | "musicBrainzId": "", 45 | "replayGain": null 46 | }, 47 | { 48 | "id": "al-5", 49 | "artist": "", 50 | "artists": null, 51 | "displayArtist": "", 52 | "albumArtists": null, 53 | "displayAlbumArtist": "", 54 | "coverArt": "al-5", 55 | "created": "2019-11-30T00:00:00Z", 56 | "isDir": true, 57 | "isVideo": false, 58 | "parent": "al-2", 59 | "title": "album-2", 60 | "year": 2021, 61 | "musicBrainzId": "", 62 | "replayGain": null 63 | } 64 | ] 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_search_three_q_art: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "searchResult3": { 9 | "artist": [ 10 | { "id": "ar-1", "name": "artist-0", "albumCount": 3 }, 11 | { "id": "ar-2", "name": "artist-1", "albumCount": 3 }, 12 | { "id": "ar-3", "name": "artist-2", "albumCount": 3 } 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_search_two_q_alb: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "searchResult2": { 9 | "album": [ 10 | { 11 | "id": "al-3", 12 | "artist": "", 13 | "artists": null, 14 | "displayArtist": "", 15 | "albumArtists": null, 16 | "displayAlbumArtist": "", 17 | "coverArt": "al-3", 18 | "created": "2019-11-30T00:00:00Z", 19 | "isDir": true, 20 | "isVideo": false, 21 | "parent": "al-2", 22 | "title": "album-0", 23 | "year": 2021, 24 | "musicBrainzId": "", 25 | "replayGain": null 26 | }, 27 | { 28 | "id": "al-4", 29 | "artist": "", 30 | "artists": null, 31 | "displayArtist": "", 32 | "albumArtists": null, 33 | "displayAlbumArtist": "", 34 | "coverArt": "al-4", 35 | "created": "2019-11-30T00:00:00Z", 36 | "isDir": true, 37 | "isVideo": false, 38 | "parent": "al-2", 39 | "title": "album-1", 40 | "year": 2021, 41 | "musicBrainzId": "", 42 | "replayGain": null 43 | }, 44 | { 45 | "id": "al-5", 46 | "artist": "", 47 | "artists": null, 48 | "displayArtist": "", 49 | "albumArtists": null, 50 | "displayAlbumArtist": "", 51 | "coverArt": "al-5", 52 | "created": "2019-11-30T00:00:00Z", 53 | "isDir": true, 54 | "isVideo": false, 55 | "parent": "al-2", 56 | "title": "album-2", 57 | "year": 2021, 58 | "musicBrainzId": "", 59 | "replayGain": null 60 | }, 61 | { 62 | "id": "al-7", 63 | "artist": "", 64 | "artists": null, 65 | "displayArtist": "", 66 | "albumArtists": null, 67 | "displayAlbumArtist": "", 68 | "coverArt": "al-7", 69 | "created": "2019-11-30T00:00:00Z", 70 | "isDir": true, 71 | "isVideo": false, 72 | "parent": "al-6", 73 | "title": "album-0", 74 | "year": 2021, 75 | "musicBrainzId": "", 76 | "replayGain": null 77 | }, 78 | { 79 | "id": "al-8", 80 | "artist": "", 81 | "artists": null, 82 | "displayArtist": "", 83 | "albumArtists": null, 84 | "displayAlbumArtist": "", 85 | "coverArt": "al-8", 86 | "created": "2019-11-30T00:00:00Z", 87 | "isDir": true, 88 | "isVideo": false, 89 | "parent": "al-6", 90 | "title": "album-1", 91 | "year": 2021, 92 | "musicBrainzId": "", 93 | "replayGain": null 94 | }, 95 | { 96 | "id": "al-9", 97 | "artist": "", 98 | "artists": null, 99 | "displayArtist": "", 100 | "albumArtists": null, 101 | "displayAlbumArtist": "", 102 | "coverArt": "al-9", 103 | "created": "2019-11-30T00:00:00Z", 104 | "isDir": true, 105 | "isVideo": false, 106 | "parent": "al-6", 107 | "title": "album-2", 108 | "year": 2021, 109 | "musicBrainzId": "", 110 | "replayGain": null 111 | }, 112 | { 113 | "id": "al-11", 114 | "artist": "", 115 | "artists": null, 116 | "displayArtist": "", 117 | "albumArtists": null, 118 | "displayAlbumArtist": "", 119 | "coverArt": "al-11", 120 | "created": "2019-11-30T00:00:00Z", 121 | "isDir": true, 122 | "isVideo": false, 123 | "parent": "al-10", 124 | "title": "album-0", 125 | "year": 2021, 126 | "musicBrainzId": "", 127 | "replayGain": null 128 | }, 129 | { 130 | "id": "al-12", 131 | "artist": "", 132 | "artists": null, 133 | "displayArtist": "", 134 | "albumArtists": null, 135 | "displayAlbumArtist": "", 136 | "coverArt": "al-12", 137 | "created": "2019-11-30T00:00:00Z", 138 | "isDir": true, 139 | "isVideo": false, 140 | "parent": "al-10", 141 | "title": "album-1", 142 | "year": 2021, 143 | "musicBrainzId": "", 144 | "replayGain": null 145 | }, 146 | { 147 | "id": "al-13", 148 | "artist": "", 149 | "artists": null, 150 | "displayArtist": "", 151 | "albumArtists": null, 152 | "displayAlbumArtist": "", 153 | "coverArt": "al-13", 154 | "created": "2019-11-30T00:00:00Z", 155 | "isDir": true, 156 | "isVideo": false, 157 | "parent": "al-10", 158 | "title": "album-2", 159 | "year": 2021, 160 | "musicBrainzId": "", 161 | "replayGain": null 162 | } 163 | ] 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /server/ctrlsubsonic/testdata/test_search_two_q_art: -------------------------------------------------------------------------------- 1 | { 2 | "subsonic-response": { 3 | "status": "ok", 4 | "version": "1.15.0", 5 | "type": "gonic", 6 | "serverVersion": "", 7 | "openSubsonic": true, 8 | "searchResult2": { 9 | "artist": [ 10 | { "id": "al-2", "parent": "al-1", "name": "artist-0" }, 11 | { "id": "al-6", "parent": "al-1", "name": "artist-1" }, 12 | { "id": "al-10", "parent": "al-1", "name": "artist-2" } 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tags/tagcommon/tagcommmon.go: -------------------------------------------------------------------------------- 1 | package tagcommon 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ErrUnsupported = errors.New("filetype unsupported") 8 | 9 | type Reader interface { 10 | CanRead(absPath string) bool 11 | Read(absPath string) (Info, error) 12 | } 13 | 14 | type Info interface { 15 | Title() string 16 | BrainzID() string // musicbrainz recording ID 17 | Artist() string 18 | Artists() []string 19 | Album() string 20 | AlbumArtist() string 21 | AlbumArtists() []string 22 | AlbumBrainzID() string 23 | Genre() string 24 | Genres() []string 25 | TrackNumber() int 26 | DiscNumber() int 27 | Year() int 28 | 29 | ReplayGainTrackGain() float32 30 | ReplayGainTrackPeak() float32 31 | ReplayGainAlbumGain() float32 32 | ReplayGainAlbumPeak() float32 33 | 34 | Length() int 35 | Bitrate() int 36 | } 37 | 38 | const ( 39 | FallbackAlbum = "Unknown Album" 40 | FallbackArtist = "Unknown Artist" 41 | FallbackGenre = "Unknown Genre" 42 | ) 43 | 44 | func MustAlbum(p Info) string { 45 | if r := p.Album(); r != "" { 46 | return r 47 | } 48 | return FallbackAlbum 49 | } 50 | 51 | func MustArtist(p Info) string { 52 | if r := p.Artist(); r != "" { 53 | return r 54 | } 55 | return FallbackArtist 56 | } 57 | 58 | func MustArtists(p Info) []string { 59 | if r := p.Artists(); len(r) > 0 { 60 | return r 61 | } 62 | return []string{MustArtist(p)} 63 | } 64 | 65 | func MustAlbumArtist(p Info) string { 66 | if r := p.AlbumArtist(); r != "" { 67 | return r 68 | } 69 | return MustArtist(p) 70 | } 71 | 72 | func MustAlbumArtists(p Info) []string { 73 | if r := p.AlbumArtists(); len(r) > 0 { 74 | return r 75 | } 76 | return []string{MustAlbumArtist(p)} 77 | } 78 | 79 | func MustGenre(p Info) string { 80 | if r := p.Genre(); r != "" { 81 | return r 82 | } 83 | return FallbackGenre 84 | } 85 | 86 | func MustGenres(p Info) []string { 87 | if r := p.Genres(); len(r) > 0 { 88 | return r 89 | } 90 | return []string{MustGenre(p)} 91 | } 92 | 93 | type ChainReader []Reader 94 | 95 | func (cr ChainReader) CanRead(absPath string) bool { 96 | for _, reader := range cr { 97 | if reader.CanRead(absPath) { 98 | return true 99 | } 100 | } 101 | return false 102 | } 103 | 104 | func (cr ChainReader) Read(absPath string) (Info, error) { 105 | for _, reader := range cr { 106 | if reader.CanRead(absPath) { 107 | return reader.Read(absPath) 108 | } 109 | } 110 | return nil, ErrUnsupported 111 | } 112 | -------------------------------------------------------------------------------- /tags/taglib/taglib.go: -------------------------------------------------------------------------------- 1 | package taglib 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/sentriz/audiotags" 10 | "go.senan.xyz/gonic/tags/tagcommon" 11 | ) 12 | 13 | type TagLib struct{} 14 | 15 | func (TagLib) CanRead(absPath string) bool { 16 | switch ext := strings.ToLower(filepath.Ext(absPath)); ext { 17 | case ".mp3", ".flac", ".aac", ".m4a", ".m4b", ".ogg", ".opus", ".wma", ".wav", ".wv": 18 | return true 19 | } 20 | return false 21 | } 22 | 23 | func (TagLib) Read(absPath string) (tagcommon.Info, error) { 24 | f, err := audiotags.Open(absPath) 25 | if err != nil { 26 | return nil, fmt.Errorf("open: %w", err) 27 | } 28 | defer f.Close() 29 | props := f.ReadAudioProperties() 30 | raw := f.ReadTags() 31 | return &info{raw, props}, nil 32 | } 33 | 34 | type info struct { 35 | raw map[string][]string 36 | props *audiotags.AudioProperties 37 | } 38 | 39 | // https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html 40 | 41 | func (i *info) Title() string { return first(find(i.raw, "title")) } 42 | func (i *info) BrainzID() string { return first(find(i.raw, "musicbrainz_trackid")) } // musicbrainz recording ID 43 | func (i *info) Artist() string { return first(find(i.raw, "artist")) } 44 | func (i *info) Artists() []string { return find(i.raw, "artists") } 45 | func (i *info) Album() string { return first(find(i.raw, "album")) } 46 | func (i *info) AlbumArtist() string { return first(find(i.raw, "albumartist", "album artist")) } 47 | func (i *info) AlbumArtists() []string { return find(i.raw, "albumartists", "album_artists") } 48 | func (i *info) AlbumBrainzID() string { return first(find(i.raw, "musicbrainz_albumid")) } // musicbrainz release ID 49 | func (i *info) Genre() string { return first(find(i.raw, "genre")) } 50 | func (i *info) Genres() []string { return find(i.raw, "genres") } 51 | func (i *info) TrackNumber() int { return intSep("/", first(find(i.raw, "tracknumber"))) } // eg. 5/12 52 | func (i *info) DiscNumber() int { return intSep("/", first(find(i.raw, "discnumber"))) } // eg. 1/2 53 | func (i *info) Year() int { return intSep("-", first(find(i.raw, "originaldate", "date", "year"))) } // eg. 2023-12-01 54 | 55 | func (i *info) ReplayGainTrackGain() float32 { return dB(first(find(i.raw, "replaygain_track_gain"))) } 56 | func (i *info) ReplayGainTrackPeak() float32 { return flt(first(find(i.raw, "replaygain_track_peak"))) } 57 | func (i *info) ReplayGainAlbumGain() float32 { return dB(first(find(i.raw, "replaygain_album_gain"))) } 58 | func (i *info) ReplayGainAlbumPeak() float32 { return flt(first(find(i.raw, "replaygain_album_peak"))) } 59 | 60 | func (i *info) Length() int { return i.props.Length } 61 | func (i *info) Bitrate() int { return i.props.Bitrate } 62 | 63 | func first[T comparable](is []T) T { 64 | var z T 65 | for _, i := range is { 66 | if i != z { 67 | return i 68 | } 69 | } 70 | return z 71 | } 72 | 73 | func find(m map[string][]string, keys ...string) []string { 74 | for _, k := range keys { 75 | if r := filterStr(m[k]); len(r) > 0 { 76 | return r 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func filterStr(ss []string) []string { 83 | var r []string 84 | for _, s := range ss { 85 | if strings.TrimSpace(s) != "" { 86 | r = append(r, s) 87 | } 88 | } 89 | return r 90 | } 91 | 92 | func flt(in string) float32 { 93 | f, _ := strconv.ParseFloat(in, 32) 94 | return float32(f) 95 | } 96 | func dB(in string) float32 { 97 | in = strings.ToLower(in) 98 | in = strings.TrimSuffix(in, " db") 99 | in = strings.TrimSuffix(in, "db") 100 | return flt(in) 101 | } 102 | 103 | func intSep(sep, in string) int { 104 | start, _, _ := strings.Cut(in, sep) 105 | out, _ := strconv.Atoi(start) 106 | return out 107 | } 108 | -------------------------------------------------------------------------------- /transcode/testdata/5s.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/gonic/b8dfe1449e1f9ee93193b32b1e9d3e233e23706d/transcode/testdata/5s.flac -------------------------------------------------------------------------------- /transcode/transcode.go: -------------------------------------------------------------------------------- 1 | // author: spijet (https://github.com/spijet/) 2 | // author: sentriz (https://github.com/sentriz/) 3 | 4 | //nolint:gochecknoglobals 5 | package transcode 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "io" 11 | "os/exec" 12 | "time" 13 | 14 | "github.com/google/shlex" 15 | ) 16 | 17 | type Transcoder interface { 18 | Transcode(ctx context.Context, profile Profile, in string, out io.Writer) error 19 | } 20 | 21 | var UserProfiles = map[string]Profile{ 22 | "mp3": MP3, 23 | "mp3_320": MP3320, 24 | "mp3_rg": MP3RG, 25 | "opus_car": OpusRGLoud, 26 | "opus": Opus, 27 | "opus_rg": OpusRG, 28 | "opus_128_car": Opus128RGLoud, 29 | "opus_128": Opus128, 30 | "opus_128_rg": Opus128RG, 31 | "opus_192": Opus192, 32 | } 33 | 34 | // Store as simple strings, since we may let the user provide their own profiles soon 35 | var ( 36 | MP3 = NewProfile("audio/mpeg", "mp3", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -f mp3 -`) 37 | MP3320 = NewProfile("audio/mpeg", "mp3", 320, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -f mp3 -`) 38 | MP3RG = NewProfile("audio/mpeg", "mp3", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`) 39 | 40 | Opus = NewProfile("audio/ogg", "opus", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -f opus -`) 41 | OpusRG = NewProfile("audio/ogg", "opus", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) 42 | OpusRGLoud = NewProfile("audio/ogg", "opus", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "aresample=96000:resampler=soxr, volume=replaygain=track:replaygain_preamp=15dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) 43 | 44 | Opus128 = NewProfile("audio/ogg", "opus", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -f opus -`) 45 | Opus128RG = NewProfile("audio/ogg", "opus", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) 46 | Opus128RGLoud = NewProfile("audio/ogg", "opus", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "aresample=96000:resampler=soxr, volume=replaygain=track:replaygain_preamp=15dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) 47 | 48 | Opus192 = NewProfile("audio/ogg", "opus", 192, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -f opus -`) 49 | 50 | PCM16le = NewProfile("audio/wav", "wav", 0, `ffmpeg -v 0 -i -ss -c:a pcm_s16le -ac 2 -ar 48000 -f s16le -`) 51 | ) 52 | 53 | type BitRate uint // kilobits/s 54 | 55 | type Profile struct { 56 | bitrate BitRate // the default bitrate, but the user can request a different one 57 | seek time.Duration 58 | mime string 59 | suffix string 60 | exec string 61 | } 62 | 63 | func (p *Profile) BitRate() BitRate { return p.bitrate } 64 | func (p *Profile) Seek() time.Duration { return p.seek } 65 | func (p *Profile) Suffix() string { return p.suffix } 66 | func (p *Profile) MIME() string { return p.mime } 67 | 68 | func NewProfile(mime string, suffix string, bitrate BitRate, exec string) Profile { 69 | return Profile{mime: mime, suffix: suffix, bitrate: bitrate, exec: exec} 70 | } 71 | 72 | func WithBitrate(p Profile, bitRate BitRate) Profile { 73 | p.bitrate = bitRate 74 | return p 75 | } 76 | 77 | func WithSeek(p Profile, seek time.Duration) Profile { 78 | p.seek = seek 79 | return p 80 | } 81 | 82 | var ErrNoProfileParts = fmt.Errorf("not enough profile parts") 83 | 84 | func parseProfile(profile Profile, in string) (string, []string, error) { 85 | parts, err := shlex.Split(profile.exec) 86 | if err != nil { 87 | return "", nil, fmt.Errorf("split command: %w", err) 88 | } 89 | if len(parts) == 0 { 90 | return "", nil, ErrNoProfileParts 91 | } 92 | name, err := exec.LookPath(parts[0]) 93 | if err != nil { 94 | return "", nil, fmt.Errorf("find name: %w", err) 95 | } 96 | 97 | var args []string 98 | for _, p := range parts[1:] { 99 | switch p { 100 | case "": 101 | args = append(args, in) 102 | case "": 103 | args = append(args, fmt.Sprintf("%dus", profile.Seek().Microseconds())) 104 | case "": 105 | args = append(args, fmt.Sprintf("%dk", profile.BitRate())) 106 | default: 107 | args = append(args, p) 108 | } 109 | } 110 | 111 | return name, args, nil 112 | } 113 | -------------------------------------------------------------------------------- /transcode/transcode_test.go: -------------------------------------------------------------------------------- 1 | package transcode_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "os/exec" 11 | "sync" 12 | "sync/atomic" 13 | "testing" 14 | "time" 15 | 16 | "github.com/stretchr/testify/require" 17 | "go.senan.xyz/gonic/transcode" 18 | ) 19 | 20 | var testProfile = transcode.PCM16le 21 | 22 | const ( 23 | // assuming above profile is 48kHz 16bit stereo 24 | sampleRate = 48_000 25 | bytesPerSample = 2 26 | numChannels = 2 27 | ) 28 | 29 | const bytesPerSec = sampleRate * bytesPerSample * numChannels 30 | 31 | func TestMain(m *testing.M) { 32 | if _, err := exec.LookPath("ffmpeg"); err != nil { 33 | return // no ffmpeg, skip these tests 34 | } 35 | os.Exit(m.Run()) 36 | } 37 | 38 | // TestTranscode starts a web server that transcodes a 5s FLAC file to PCM audio. A client 39 | // consumes the result over a 5 second period. 40 | func TestTranscode(t *testing.T) { 41 | t.Parallel() 42 | 43 | testFile := "testdata/5s.flac" 44 | testFileLen := 5 45 | 46 | tr := transcode.NewFFmpegTranscoder() 47 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | require.NoError(t, tr.Transcode(r.Context(), testProfile, testFile, w)) 49 | w.(http.Flusher).Flush() 50 | })) 51 | defer server.Close() 52 | 53 | resp, err := server.Client().Get(server.URL) 54 | require.NoError(t, err) 55 | defer resp.Body.Close() 56 | 57 | var buf bytes.Buffer 58 | for { 59 | n, err := io.Copy(&buf, io.LimitReader(resp.Body, bytesPerSec)) 60 | require.NoError(t, err) 61 | if n == 0 { 62 | break 63 | } 64 | time.Sleep(1 * time.Second) 65 | } 66 | 67 | // we should have 5 seconds of PCM data 68 | require.Equal(t, testFileLen*bytesPerSec, buf.Len()) 69 | } 70 | 71 | // TestTranscodeWithSeek starts a web server that transcodes a 5s FLAC file to PCM audio, but with a 2 second offset. 72 | // A client consumes the result over a 3 second period. 73 | func TestTranscodeWithSeek(t *testing.T) { 74 | t.Parallel() 75 | 76 | testFile := "testdata/5s.flac" 77 | testFileLen := 5 78 | 79 | seekSecs := 2 80 | profile := transcode.WithSeek(testProfile, time.Duration(seekSecs)*time.Second) 81 | 82 | tr := transcode.NewFFmpegTranscoder() 83 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 | require.NoError(t, tr.Transcode(r.Context(), profile, testFile, w)) 85 | w.(http.Flusher).Flush() 86 | })) 87 | defer server.Close() 88 | 89 | resp, err := server.Client().Get(server.URL) 90 | require.NoError(t, err) 91 | defer resp.Body.Close() 92 | 93 | var buf bytes.Buffer 94 | for { 95 | n, err := io.Copy(&buf, io.LimitReader(resp.Body, bytesPerSec)) 96 | require.NoError(t, err) 97 | if n == 0 { 98 | break 99 | } 100 | time.Sleep(1 * time.Second) 101 | } 102 | 103 | // since we seeked 2 seconds, we should have 5-2 = 3 seconds of PCM data 104 | require.Equal(t, (testFileLen-seekSecs)*bytesPerSec, buf.Len()) 105 | } 106 | 107 | func TestCachingParallelism(t *testing.T) { 108 | t.Parallel() 109 | 110 | var realTranscodeCount atomic.Uint64 111 | transcoder := callbackTranscoder{ 112 | transcoder: transcode.NewFFmpegTranscoder(), 113 | callback: func() { realTranscodeCount.Add(1) }, 114 | } 115 | 116 | cacheTranscoder := transcode.NewCachingTranscoder(transcoder, t.TempDir(), 1024) 117 | 118 | var wg sync.WaitGroup 119 | for i := 0; i < 5; i++ { 120 | wg.Add(1) 121 | go func() { 122 | defer wg.Done() 123 | 124 | var buf bytes.Buffer 125 | require.NoError(t, cacheTranscoder.Transcode(context.Background(), transcode.PCM16le, "testdata/5s.flac", &buf)) 126 | require.Equal(t, 5*bytesPerSec, buf.Len()) 127 | }() 128 | } 129 | 130 | wg.Wait() 131 | 132 | require.Equal(t, 1, int(realTranscodeCount.Load())) 133 | } 134 | 135 | type callbackTranscoder struct { 136 | transcoder transcode.Transcoder 137 | callback func() 138 | } 139 | 140 | func (ct callbackTranscoder) Transcode(ctx context.Context, profile transcode.Profile, in string, out io.Writer) error { 141 | ct.callback() 142 | return ct.transcoder.Transcode(ctx, profile, in, out) 143 | } 144 | -------------------------------------------------------------------------------- /transcode/transcoder_caching.go: -------------------------------------------------------------------------------- 1 | package transcode 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | const perm = 0o644 17 | 18 | type CachingTranscoder struct { 19 | cachePath string 20 | transcoder Transcoder 21 | limitMB int 22 | locks keyedMutex 23 | cleanLock sync.RWMutex 24 | } 25 | 26 | var _ Transcoder = (*CachingTranscoder)(nil) 27 | 28 | func NewCachingTranscoder(t Transcoder, cachePath string, limitMB int) *CachingTranscoder { 29 | return &CachingTranscoder{transcoder: t, cachePath: cachePath, limitMB: limitMB} 30 | } 31 | 32 | func (t *CachingTranscoder) Transcode(ctx context.Context, profile Profile, in string, out io.Writer) error { 33 | t.cleanLock.RLock() 34 | defer t.cleanLock.RUnlock() 35 | 36 | // don't try cache partial transcodes 37 | if profile.Seek() > 0 { 38 | return t.transcoder.Transcode(ctx, profile, in, out) 39 | } 40 | 41 | if err := os.MkdirAll(t.cachePath, perm^0o111); err != nil { 42 | return fmt.Errorf("make cache path: %w", err) 43 | } 44 | 45 | name, args, err := parseProfile(profile, in) 46 | if err != nil { 47 | return fmt.Errorf("split command: %w", err) 48 | } 49 | 50 | key := cacheKey(name, args) 51 | unlock := t.locks.Lock(key) 52 | defer unlock() 53 | 54 | path := filepath.Join(t.cachePath, key) 55 | cf, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) 56 | if err != nil { 57 | return fmt.Errorf("open cache file: %w", err) 58 | } 59 | defer cf.Close() 60 | 61 | if i, err := cf.Stat(); err == nil && i.Size() > 0 { 62 | _, _ = io.Copy(out, cf) 63 | _ = os.Chtimes(path, time.Now(), time.Now()) // Touch for LRU cache purposes 64 | return nil 65 | } 66 | 67 | dest := io.MultiWriter(out, cf) 68 | if err := t.transcoder.Transcode(ctx, profile, in, dest); err != nil { 69 | os.Remove(path) 70 | return fmt.Errorf("internal transcode: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (t *CachingTranscoder) CacheEject() error { 77 | t.cleanLock.Lock() 78 | defer t.cleanLock.Unlock() 79 | 80 | // Delete LRU cache files that exceed size limit. Use last modified time. 81 | type file struct { 82 | path string 83 | info os.FileInfo 84 | } 85 | 86 | var files []file 87 | var total int64 = 0 88 | 89 | err := filepath.WalkDir(t.cachePath, func(path string, de fs.DirEntry, err error) error { 90 | if err != nil { 91 | return err 92 | } 93 | if !de.IsDir() { 94 | info, err := de.Info() 95 | if err != nil { 96 | return fmt.Errorf("walk cache path for eject: %w", err) 97 | } 98 | files = append(files, file{path, info}) 99 | total += info.Size() 100 | } 101 | return nil 102 | }) 103 | 104 | if err != nil { 105 | return fmt.Errorf("walk cache path for eject: %w", err) 106 | } 107 | 108 | sort.Slice(files, func(i, j int) bool { 109 | return files[i].info.ModTime().Before(files[j].info.ModTime()) 110 | }) 111 | 112 | for total > int64(t.limitMB)*1024*1024 { 113 | curFile := files[0] 114 | files = files[1:] 115 | total -= curFile.info.Size() 116 | err = os.Remove(curFile.path) 117 | if err != nil { 118 | return fmt.Errorf("remove cache file: %w", err) 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func cacheKey(cmd string, args []string) string { 126 | // the cache is invalid whenever transcode command (which includes the 127 | // absolute filepath, bit rate args, replay gain args, etc.) changes 128 | sum := md5.New() 129 | _, _ = io.WriteString(sum, cmd) 130 | for _, arg := range args { 131 | _, _ = io.WriteString(sum, arg) 132 | } 133 | return fmt.Sprintf("%x", sum.Sum(nil)) 134 | } 135 | 136 | type keyedMutex struct { 137 | sync.Map 138 | } 139 | 140 | func (km *keyedMutex) Lock(key string) func() { 141 | value, _ := km.LoadOrStore(key, &sync.Mutex{}) 142 | mu := value.(*sync.Mutex) 143 | mu.Lock() 144 | // TODO: remove key entry from map to save some space? 145 | return mu.Unlock 146 | } 147 | -------------------------------------------------------------------------------- /transcode/transcoder_ffmpeg.go: -------------------------------------------------------------------------------- 1 | package transcode 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os/exec" 9 | ) 10 | 11 | type FFmpegTranscoder struct{} 12 | 13 | var _ Transcoder = (*FFmpegTranscoder)(nil) 14 | 15 | func NewFFmpegTranscoder() *FFmpegTranscoder { 16 | return &FFmpegTranscoder{} 17 | } 18 | 19 | var ( 20 | ErrFFmpegKilled = fmt.Errorf("ffmpeg was killed early") 21 | ErrFFmpegExit = fmt.Errorf("ffmpeg exited with non 0 status code") 22 | ) 23 | 24 | func (*FFmpegTranscoder) Transcode(ctx context.Context, profile Profile, in string, out io.Writer) error { 25 | name, args, err := parseProfile(profile, in) 26 | if err != nil { 27 | return fmt.Errorf("split command: %w", err) 28 | } 29 | 30 | cmd := exec.CommandContext(ctx, name, args...) 31 | cmd.Stdout = out 32 | 33 | if err := cmd.Start(); err != nil { 34 | return fmt.Errorf("starting cmd: %w", err) 35 | } 36 | 37 | var exitErr *exec.ExitError 38 | 39 | switch err := cmd.Wait(); { 40 | case errors.As(err, &exitErr): 41 | return fmt.Errorf("waiting cmd: %w: %w", err, ErrFFmpegKilled) 42 | case err != nil: 43 | return fmt.Errorf("waiting cmd: %w", err) 44 | } 45 | if code := cmd.ProcessState.ExitCode(); code > 1 { 46 | return fmt.Errorf("%w: %d", ErrFFmpegExit, code) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /transcode/transcoder_none.go: -------------------------------------------------------------------------------- 1 | package transcode 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | type NoneTranscoder struct{} 11 | 12 | var _ Transcoder = (*NoneTranscoder)(nil) 13 | 14 | func NewNoneTranscoder() *NoneTranscoder { 15 | return &NoneTranscoder{} 16 | } 17 | 18 | func (*NoneTranscoder) Transcode(_ context.Context, _ Profile, in string, out io.Writer) error { 19 | file, err := os.Open(in) 20 | if err != nil { 21 | return fmt.Errorf("open file: %w", err) 22 | } 23 | defer file.Close() 24 | if _, err := io.Copy(out, file); err != nil { 25 | return fmt.Errorf("copy file: %w", err) 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | //nolint:gochecknoglobals,golint,stylecheck 2 | package gonic 3 | 4 | import ( 5 | _ "embed" 6 | "strings" 7 | ) 8 | 9 | //go:embed version.txt 10 | var version string 11 | var Version = strings.TrimSpace(version) 12 | 13 | const ( 14 | Name = "gonic" 15 | NameUpper = "GONIC" 16 | ) 17 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.16.4 2 | --------------------------------------------------------------------------------