├── .github └── workflows │ └── release.yml ├── Dockerfile ├── LICENSE ├── NOTICE ├── README.md ├── docker-compose.yaml ├── docker └── start.sh ├── go.mod ├── go.sum ├── licences └── ffmpeg-go_LICENCE ├── sample.env └── src ├── debug └── debug.go ├── emby.go ├── jellyfin.go ├── listenbrainz.go ├── main.go ├── playlist.go ├── plex.go ├── subsonic.go └── youtube.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Latest Release 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | lint: 15 | name: Lint files 16 | runs-on: 'ubuntu-latest' 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.21.6' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v3.7.0 24 | with: 25 | version: latest 26 | release: 27 | permissions: 28 | contents: write 29 | name: Create Release 30 | runs-on: 'ubuntu-latest' 31 | strategy: 32 | matrix: 33 | # List of GOOS and GOARCH pairs from `go tool dist list` 34 | goosarch: 35 | - 'linux/amd64' 36 | - 'linux/386' 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | - uses: actions/setup-go@v4 43 | with: 44 | go-version: '1.21.6' 45 | - name: Get OS and arch info 46 | run: | 47 | GOOSARCH=${{matrix.goosarch}} 48 | GOOS=${GOOSARCH%/*} 49 | GOARCH=${GOOSARCH#*/} 50 | BINARY_NAME=explo-$GOOS-$GOARCH 51 | echo "BINARY_NAME=$BINARY_NAME" >> $GITHUB_ENV 52 | echo "GOOS=$GOOS" >> $GITHUB_ENV 53 | echo "GOARCH=$GOARCH" >> $GITHUB_ENV 54 | - name: Build binary 55 | run: | 56 | go build -o "$BINARY_NAME" -v ./src/ 57 | - name: Release 58 | uses: softprops/action-gh-release@v1 59 | with: 60 | draft: true 61 | files: ${{env.BINARY_NAME}} 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | build: 67 | name: Build/publish container image 68 | runs-on: ubuntu-latest 69 | permissions: 70 | contents: read 71 | packages: write 72 | id-token: write 73 | if: "!contains(github.ref, 'dev')" # skip building container for dev 74 | needs: release 75 | 76 | steps: 77 | - name: Checkout repository 78 | uses: actions/checkout@v4 79 | with: 80 | fetch-depth: 0 81 | 82 | - name: Login to ghcr.io 83 | uses: docker/login-action@v2 84 | with: 85 | registry: ghcr.io 86 | username: ${{ github.actor }} 87 | password: ${{ secrets.GITHUB_TOKEN }} 88 | 89 | - name: Convert repository name to lowercase 90 | run: | 91 | REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 92 | echo "REPO_LC=$REPO_LC" >> $GITHUB_ENV 93 | 94 | - name: Set up Docker Buildx 95 | uses: docker/setup-buildx-action@v2 96 | 97 | - name: Build and push 98 | id: build-and-push 99 | uses: docker/build-push-action@v5 100 | with: 101 | context: . 102 | push: true 103 | tags: | 104 | ghcr.io/${{ env.REPO_LC }}:latest 105 | ghcr.io/${{ env.REPO_LC }}:${{ github.ref_name }} 106 | cache-from: type=gha 107 | cache-to: type=gha,mode=max 108 | platforms: linux/amd64,linux/386 109 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine AS builder 2 | 3 | # Set the working directory 4 | WORKDIR /app 5 | 6 | # Copy the Go source code into the container 7 | COPY ./ . 8 | 9 | # Build the Go binary based on the target architecture 10 | ARG TARGETARCH 11 | RUN GOOS=linux GOARCH=${TARGETARCH} go build -o explo ./src/ 12 | 13 | FROM alpine 14 | 15 | RUN apk add --no-cache libc6-compat ffmpeg yt-dlp 16 | 17 | WORKDIR /opt/explo/ 18 | COPY ./docker/start.sh /start.sh 19 | COPY --from=builder /app/explo . 20 | RUN chmod +x /start.sh 21 | RUN chmod +x ./explo 22 | 23 | # Can be defined from compose as well 24 | ENV CRON_SCHEDULE="15 0 * * 2" 25 | 26 | CMD ["/start.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Markus Kuuse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NOTICE 2 | ------ 3 | This project includes the following third-party libraries: 4 | 5 | - ffmpeg-go (https://github.com/u2takey/ffmpeg-go) 6 | Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0). 7 | Copyright (c) 2017 Karl Kroening 8 | 9 | - goutubedl (https://github.com/wader/goutubedl) 10 | Licensed under the MIT License. 11 | Copyright (c) 2019 Mattias Wadman 12 | 13 | - godotenv (https://github.com/joho/godotenv) 14 | Licensed under the MIT License. 15 | Copyright (c) 2013 John Barton -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Explo - Discover Weekly for Self-Hosted Music Systems 2 | 3 | **Explo** is an alternative to Spotify's "Discover Weekly". It automates music discovery by downloading recommended tracks based on your listening history. Using [ListenBrainz](https://listenbrainz.org/) for recommendations and Youtube for downloading. 4 | 5 | Explo offers two discovery modes: 6 | 7 | 1. Playlist Discovery (default): Retrieves songs from a ListenBrainz-generated playlist. 8 | 2. API Discovery: Uses the ListenBrainz API for recommendations (Note: API recommendations don't update often). 9 | 10 | ## Features 11 | 12 | - Supports **Emby**, **Jellyfin**, **MPD**, **Plex** and **Subsonic-API-based systems**. 13 | - Automatically fetches recommendations and downloads the tracks. 14 | - Adds metadata (title, artist, album) to the downloaded files. 15 | - Creates a "Discover Weekly" playlist with the latest songs. 16 | - Keeps past playlists by default for easy access. 17 | 18 | ## Getting Started 19 | 20 | ### Prerequisites 21 | 22 | - A self-hosted music system like Emby, Jellyfin, MPD, Plex, or any Subsonic-API compatible system (e.g., Navidrome, Airsonic). 23 | - A [YouTube Data API](https://developers.google.com/youtube/v3/getting-started) key. 24 | - [ListenBrainz scrobbling](https://listenbrainz.org/add-data/) set up 25 | 26 | ### Installation 27 | 28 | #### Docker 29 | 30 | 1. Download [docker-compose.yaml](https://github.com/LumePart/Explo/blob/main/docker-compose.yaml) file to your system and configure volume mappings 31 | 2. Make a ``.env`` file in the directory defined in docker-compose and configure it ([refer to sample.env](https://github.com/LumePart/Explo/blob/main/sample.env) for options) 32 | 3. Launch the container with `docker compose up -d` 33 | 34 | #### Binary 35 | 36 | Make sure ffmpeg and yt-dlp are installed on the system and accessible via $PATH. Alternatively, you can specify their paths in the ``.env`` file. 37 | 38 | 1. Download the [latest release](https://github.com/LumePart/Explo/releases/latest) and ensure it's executable 39 | 2. Make a ``.env`` file in the same directory as the binary and configure it ([refer to sample.env](https://github.com/LumePart/Explo/blob/main/sample.env) for options) 40 | 3. Add a Cron job to run Explo weekly: 41 | ```bash 42 | crontab -e 43 | ``` 44 | Insert this to the last line to execute Explo every tuesday at 00:15 (ListenBrainz updates its discovery database on Mondays) 45 | ```bash 46 | 15 0 * * 2 cd /path/to/explo && ./explo-linux-amd64 47 | ``` 48 | **PS!** To test if everything is correct change ``LISTENBRAINZ_DISCOVERY`` to ``test`` and run the program manually 49 | 50 | ## Acknowledgements 51 | 52 | Explo uses the following 3rd-party libraries: 53 | 54 | - [ffmpeg-go](https://github.com/u2takey/ffmpeg-go): A Go wrapper for FFmpeg. 55 | 56 | - [goutubedl](https://github.com/wader/goutubedl): A Go wrapper for yt-dlp. 57 | 58 | - [godotenv](https://github.com/joho/godotenv): A library for loading configuration from .env files. 59 | 60 | ## Contributing 61 | 62 | Contributions are always welcome! If you have any suggestions, bug reports, or feature requests, please open an issue or submit a pull request. 63 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | explo: 3 | image: ghcr.io/lumepart/explo:latest 4 | restart: unless-stopped 5 | container_name: explo 6 | volumes: 7 | - /path/to/.env:/opt/explo/.env 8 | - /path/to/musiclibrary/explo:$DOWNLOAD_DIR # has to be in the same path you have your music system pointed to (it's recommended to put explo under a subfolder) 9 | # - $PLAYLIST_DIR:$PLAYLIST_DIR # for MPD. 10 | environment: 11 | - CRON_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight (UTC time) -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Cron schedule is set by compose or build files 4 | echo "$CRON_SCHEDULE apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1" > /etc/crontabs/root 5 | 6 | crond -f -l 2 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module explo 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.6 6 | 7 | require ( 8 | github.com/ilyakaznacheev/cleanenv v1.5.0 9 | github.com/u2takey/ffmpeg-go v0.5.0 10 | github.com/wader/goutubedl v0.0.0-20241118160803-5e1bb9940f3c 11 | ) 12 | 13 | require ( 14 | github.com/BurntSushi/toml v1.3.2 // indirect 15 | github.com/aws/aws-sdk-go v1.38.20 // indirect 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 17 | github.com/jmespath/go-jmespath v0.4.0 // indirect 18 | github.com/joho/godotenv v1.5.1 // indirect 19 | github.com/kr/pretty v0.3.0 // indirect 20 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 21 | github.com/rogpeppe/go-internal v1.12.0 // indirect 22 | github.com/stretchr/testify v1.9.0 // indirect 23 | github.com/u2takey/go-utils v0.3.1 // indirect 24 | golang.org/x/net v0.22.0 // indirect 25 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 26 | gopkg.in/yaml.v2 v2.4.0 // indirect 27 | gopkg.in/yaml.v3 v3.0.1 // indirect 28 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 3 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/LukeHagar/plexgo v0.16.1 h1:wY4sk4jvV63H64ABbdVQfX5dHhXg/2fs0K9XI/y69xw= 5 | github.com/LukeHagar/plexgo v0.16.1/go.mod h1:XLXk48FLrDH+tmnJzX312C3IweA0JsZeAoQlTQsoCfU= 6 | github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= 7 | github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 14 | github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 15 | github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 16 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 17 | github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= 18 | github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 19 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 20 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 22 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 23 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 24 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 25 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 26 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 27 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 28 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 29 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 30 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 31 | github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 32 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 33 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 34 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 35 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 36 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 37 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 38 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 39 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 40 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 41 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 43 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 44 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 45 | github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= 46 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 47 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 52 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 53 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 54 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 57 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 58 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 59 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 60 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 61 | github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= 62 | github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= 63 | github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= 64 | github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= 65 | github.com/wader/goutubedl v0.0.0-20241118160803-5e1bb9940f3c h1:HCN0cClpdzi+TIf3prxlpLXK4OgK5bg/TY02Wbdkpgc= 66 | github.com/wader/goutubedl v0.0.0-20241118160803-5e1bb9940f3c/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw= 67 | github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071 h1:QkrG4Zr5OVFuC9aaMPmFI0ibfhBZlAgtzDYWfu7tqQk= 68 | github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo= 69 | gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= 70 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 71 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 72 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 73 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 74 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 75 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 76 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 77 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 78 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 79 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 83 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 84 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 85 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 86 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 87 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 88 | golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 92 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 93 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 94 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 95 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 96 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 97 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 98 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 99 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 100 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 101 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 102 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 103 | sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= 104 | -------------------------------------------------------------------------------- /licences/ffmpeg-go_LICENCE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Karl Kroening 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # Music system you use (emby, jellyfin, mpd, plex or subsonic) 2 | EXPLO_SYSTEM=subsonic 3 | # Address of music system (required for emby, jellyfin, plex and subsonic) 4 | SYSTEM_URL=http://127.0.0.1:4533 5 | # User which has admin access to the music server (required for subsonic and plex) 6 | SYSTEM_USERNAME= 7 | # Password for the user (required for subsonic and plex) 8 | SYSTEM_PASSWORD= 9 | # API Key from music server (required for emby, jellyfin) 10 | API_KEY= 11 | # Directory where to download tracks (required) 12 | # PS! It's recommended to make a separate directory (under the music library) for Explo 13 | DOWNLOAD_DIR=/path/to/music/folder/explo 14 | # Directory where to make m3u playlist files (required for mpd) 15 | PLAYLIST_DIR=/path/to/m3u/playlist/folder 16 | # Username for ListenBrain recommendations (required) 17 | LISTENBRAINZ_USER= 18 | # Youtube Data API key (required) 19 | YOUTUBE_API_KEY= 20 | 21 | ## Misc: 22 | 23 | # Assign custom path to the ffmpeg binary 24 | # FFMPEG_PATH= 25 | # Assign a custom path to yt-dlp 26 | # YTDLP_PATH= 27 | # Keywords to ignore on videos downloaded by youtube (separated by only commas) 28 | # FILTER_LIST="live,remix,instrumental,extended" 29 | # Library in Emby/Jellyfin/Plex to use (optional, leave empty to create a new library based on DOWNLOAD_DIR) 30 | # PS! When defining a pre-made library make sure that it doesn't overwrite file metadata. 31 | # LIBRARY_NAME= 32 | # Define a custom filename sepatator for special characters 33 | # FILENAME_SEPARATOR= 34 | # true to keep pervious weeks discoveries, only set to false if the parent folder only contains discovered songs (deletes every file in folder) 35 | # PERSIST=true 36 | # 'playlist' to get tracks from Weekly Exploration playlist, anything else gets it from API (not the best recommendations). 'test' will download 1 song 37 | # LISTENBRAINZ_DISCOVERY=playlist 38 | # Time to sleep (in minutes) between scanning and querying tracks from your system (If using Subsonic, Jellyfin) 39 | # SLEEP=2 40 | # Whether to provide additional info for debugging 41 | # DEBUG=false 42 | 43 | ## Metadata (formatting details, structure, etc.) 44 | 45 | # true keeps only the main artist in the artist field; all featured artists go to the title (helps with keeping library clean, might limit ListenBrainz for recognizing the song) 46 | # SINGLE_ARTIST=true -------------------------------------------------------------------------------- /src/debug/debug.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "log" 5 | "runtime" 6 | ) 7 | 8 | var debugMode bool 9 | 10 | func Init(mode bool) { 11 | debugMode = mode 12 | } 13 | 14 | func Debug(ctx string) { 15 | if debugMode { 16 | _, file, line, ok := runtime.Caller(1) 17 | if ok { 18 | log.Printf("DEBUG: %s:%d %s", file, line, ctx) 19 | } else { 20 | log.Printf("DEBUG: %s", ctx) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/emby.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "explo/src/debug" 6 | "fmt" 7 | "log" 8 | "strings" 9 | ) 10 | 11 | type EmbyPaths []struct { 12 | Name string `json:"Name"` 13 | Locations []string `json:"Locations"` 14 | CollectionType string `json:"CollectionType"` 15 | ItemID string `json:"ItemId"` 16 | RefreshStatus string `json:"RefreshStatus"` 17 | } 18 | 19 | type EmbyItemSearch struct { 20 | Items []Items `json:"Items"` 21 | TotalRecordCount int `json:"TotalRecordCount"` 22 | } 23 | 24 | type EmbyItems struct { 25 | Name string `json:"Name"` 26 | ServerID string `json:"ServerId"` 27 | ID string `json:"Id"` 28 | Path string `json:"Path"` 29 | Album string `json:"Album,omitempty"` 30 | AlbumArtist string `json:"AlbumArtist,omitempty"` 31 | } 32 | 33 | type EmbyPlaylist struct { 34 | ID string `json:"Id"` 35 | } 36 | 37 | func (cfg *Credentials) embyHeader() { 38 | cfg.Headers = make(map[string]string) 39 | 40 | cfg.Headers["X-Emby-Token"] = cfg.APIKey 41 | 42 | } 43 | 44 | func embyAllPaths(cfg Config) (EmbyPaths, error) { 45 | params := "/emby/Library/VirtualFolders" 46 | 47 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 48 | if err != nil { 49 | return nil, fmt.Errorf("embyAllPaths(): %s", err.Error()) 50 | } 51 | 52 | var paths EmbyPaths 53 | if err = parseResp(body, &paths); err != nil { 54 | return nil, fmt.Errorf("embyAllPaths(): %s", err.Error()) 55 | } 56 | return paths, nil 57 | } 58 | 59 | func (cfg *Config) getEmbyPath() { // Gets Librarys ID 60 | paths, err := embyAllPaths(*cfg) 61 | if err != nil { 62 | log.Fatalf("failed to get Emby paths: %s", err.Error()) 63 | } 64 | 65 | for _, path := range paths { 66 | if path.Name == cfg.Jellyfin.LibraryName { 67 | cfg.Jellyfin.LibraryID = path.ItemID 68 | } 69 | } 70 | } 71 | 72 | func embyAddPath(cfg Config) { // adds Jellyfin library, if not set 73 | params := "/emby/Library/VirtualFolders" 74 | payload := []byte(fmt.Sprintf(`{ 75 | "Name": "%s", 76 | "CollectionType": "Music", 77 | "RefreshLibrary": true, 78 | "Paths": "%s" 79 | "LibraryOptions": { 80 | "Enabled": true, 81 | "EnableRealtimeMonitor": true, 82 | "EnableLUFSScan": false 83 | } 84 | }`, cfg.Jellyfin.LibraryName, cfg.Youtube.DownloadDir)) 85 | 86 | body, err := makeRequest("POST", cfg.URL+params, bytes.NewReader(payload), cfg.Creds.Headers) 87 | if err != nil { 88 | debug.Debug(fmt.Sprintf("response: %s", body)) 89 | log.Fatalf("failed to add library to Emby using the download path, please define a library name using LIBRARY_NAME in .env: %s", err.Error()) 90 | } 91 | } 92 | 93 | func refreshEmbyLibrary(cfg Config) error { 94 | params := fmt.Sprintf("/emby/Items/%s/Refresh", cfg.Jellyfin.LibraryID) 95 | 96 | if _, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 97 | return fmt.Errorf("refreshEmbyLibrary(): %s", err.Error()) 98 | } 99 | return nil 100 | } 101 | 102 | func getEmbySong(cfg Config, track Track) (string, error) { // Gets all files in Explo library and filters out new ones 103 | params := fmt.Sprintf("/emby/Items?parentId=%s&fields=Path", cfg.Jellyfin.LibraryID) 104 | 105 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 106 | if err != nil { 107 | return "", fmt.Errorf("getEmbySong(): %s", err.Error()) 108 | } 109 | 110 | var results Audios 111 | if err = parseResp(body, &results); err != nil { 112 | return "", fmt.Errorf("getEmbySong(): %s", err.Error()) 113 | } 114 | 115 | for _, item := range results.Items { 116 | if strings.Contains(item.Path, track.File) { 117 | return item.ID, nil 118 | } 119 | } 120 | return "", nil 121 | } 122 | 123 | func findEmbyPlaylist(cfg Config) (string, error) { 124 | params := fmt.Sprintf("/emby/Items?SearchTerm=%s&Recursive=true&IncludeItemTypes=Playlist", cfg.PlaylistName) 125 | 126 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 127 | if err != nil { 128 | return "", fmt.Errorf("failed to find playlist: %s", err.Error()) 129 | } 130 | 131 | var results EmbyItemSearch 132 | if err = parseResp(body, &results); err != nil { 133 | return "", fmt.Errorf("findJfPlaylist(): %s", err.Error()) 134 | } 135 | if len(results.Items) != 0 { 136 | return results.Items[0].ID, nil 137 | } else { 138 | return "", fmt.Errorf("no results found for %s", cfg.PlaylistName) 139 | } 140 | } 141 | 142 | func createEmbyPlaylist(cfg Config, tracks []Track) (string, error) { 143 | var songIDs []string 144 | 145 | for _, track := range tracks { 146 | if track.ID == "" { 147 | songID, err := getEmbySong(cfg, track) 148 | if songID == "" || err != nil { 149 | debug.Debug(fmt.Sprintf("could not get %s", track.File)) 150 | continue 151 | } 152 | track.ID = songID 153 | } 154 | songIDs = append(songIDs, track.ID) 155 | } 156 | IDs := strings.Join(songIDs, ",") 157 | 158 | params := fmt.Sprintf("/emby/Playlists?Name=%s&Ids=%s&MediaType=Music", cfg.PlaylistName, IDs) 159 | 160 | 161 | body, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers) 162 | if err != nil { 163 | return "", fmt.Errorf("createEmbyPlaylist(): %s", err.Error()) 164 | } 165 | var playlist EmbyPlaylist 166 | if err = parseResp(body, &playlist); err != nil { 167 | return "", fmt.Errorf("createEmbyPlaylist(): %s", err.Error()) 168 | } 169 | return playlist.ID, nil 170 | } 171 | 172 | /* func updateEmbyPlaylist(cfg Config, ID, overview string) error { 173 | params := fmt.Sprintf("/emby/Items/%s", ID) 174 | 175 | payload := []byte(fmt.Sprintf(` 176 | { 177 | "Id":"%s", 178 | "Name":"%s", 179 | "Overview":"%s", 180 | "Genres":[], 181 | "Tags":[], 182 | "ProviderIds":{} 183 | }`, ID, cfg.PlaylistName, overview)) // the additional fields have to be added, otherwise JF returns code 400 184 | 185 | if _, err := makeRequest("POST", cfg.URL+params, bytes.NewBuffer(payload), cfg.Creds.Headers); err != nil { 186 | return err 187 | } 188 | return nil 189 | } */ 190 | 191 | func deleteEmbyPlaylist(cfg Config, ID string) error { 192 | params := fmt.Sprintf("/emby/Items/Delete?Ids=%s", ID) 193 | 194 | if _, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 195 | return fmt.Errorf("deleteEmbyPlaylist(): %s", err.Error()) 196 | } 197 | return nil 198 | } -------------------------------------------------------------------------------- /src/jellyfin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "explo/src/debug" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | type Paths []struct { 14 | Name string `json:"Name"` 15 | Locations []string `json:"Locations"` 16 | CollectionType string `json:"CollectionType"` 17 | ItemID string `json:"ItemId"` 18 | RefreshStatus string `json:"RefreshStatus"` 19 | } 20 | 21 | type Search struct { 22 | SearchHints []SearchHints `json:"SearchHints"` 23 | TotalRecordCount int `json:"TotalRecordCount"` 24 | } 25 | type SearchHints struct { 26 | ItemID string `json:"ItemId"` 27 | ID string `json:"Id"` 28 | Name string `json:"Name"` 29 | Album string `json:"Album"` 30 | AlbumID string `json:"AlbumId"` 31 | AlbumArtist string `json:"AlbumArtist"` 32 | } 33 | 34 | type Audios struct { 35 | Items []Items `json:"Items"` 36 | TotalRecordCount int `json:"TotalRecordCount"` 37 | StartIndex int `json:"StartIndex"` 38 | } 39 | 40 | type Items struct { 41 | Name string `json:"Name"` 42 | ServerID string `json:"ServerId"` 43 | ID string `json:"Id"` 44 | Path string `json:"Path"` 45 | Album string `json:"Album,omitempty"` 46 | AlbumArtist string `json:"AlbumArtist,omitempty"` 47 | } 48 | 49 | type JFPlaylist struct { 50 | ID string `json:"Id"` 51 | } 52 | 53 | func (cfg *Credentials) jfHeader() { 54 | cfg.Headers = make(map[string]string) 55 | 56 | cfg.Headers["Authorization"] = fmt.Sprintf("MediaBrowser Token=%s, Client=Explo", cfg.APIKey) 57 | 58 | } 59 | 60 | func jfAllPaths(cfg Config) (Paths, error) { 61 | params := "/Library/VirtualFolders" 62 | 63 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 64 | if err != nil { 65 | return nil, fmt.Errorf("jfAllPaths(): %s", err.Error()) 66 | } 67 | 68 | var paths Paths 69 | if err = parseResp(body, &paths); err != nil { 70 | return nil, fmt.Errorf("jfAllPaths(): %s", err.Error()) 71 | } 72 | return paths, nil 73 | } 74 | 75 | func (cfg *Config) getJfPath() { // Gets Librarys ID 76 | paths, err := jfAllPaths(*cfg) 77 | if err != nil { 78 | log.Fatalf("getJfPath(): %s", err.Error()) 79 | } 80 | 81 | for _, path := range paths { 82 | if path.Name == cfg.Jellyfin.LibraryName { 83 | cfg.Jellyfin.LibraryID = path.ItemID 84 | } 85 | } 86 | } 87 | 88 | func jfAddPath(cfg Config) { // adds Jellyfin library, if not set 89 | cleanPath := url.PathEscape(cfg.Youtube.DownloadDir) 90 | params := fmt.Sprintf("/Library/VirtualFolders?name=%s&paths=%s&collectionType=music&refreshLibrary=true", cfg.Jellyfin.LibraryName, cleanPath) 91 | payload := []byte(`{ 92 | "LibraryOptions": { 93 | "Enabled": true, 94 | "EnableRealtimeMonitor": true, 95 | "EnableLUFSScan": false 96 | } 97 | }`) 98 | 99 | body, err := makeRequest("POST", cfg.URL+params, bytes.NewReader(payload), cfg.Creds.Headers) 100 | if err != nil { 101 | debug.Debug(fmt.Sprintf("response: %s", body)) 102 | log.Fatalf("failed to add library to Jellyfin using the download path, please define a library name using LIBRARY_NAME in .env: %s", err.Error()) 103 | } 104 | } 105 | 106 | func refreshJfLibrary(cfg Config) error { 107 | params := fmt.Sprintf("/Items/%s/Refresh", cfg.Jellyfin.LibraryID) 108 | 109 | if _, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 110 | return fmt.Errorf("refreshJfLibrary(): %s", err.Error()) 111 | } 112 | return nil 113 | } 114 | 115 | func getJfSong(cfg Config, track Track) (string, error) { // Gets all files in Explo library and filters out new ones 116 | params := fmt.Sprintf("/Items?parentId=%s&fields=Path", cfg.Jellyfin.LibraryID) 117 | 118 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 119 | if err != nil { 120 | return "", fmt.Errorf("getJfSong(): %s", err.Error()) 121 | } 122 | 123 | var results Audios 124 | if err = parseResp(body, &results); err != nil { 125 | return "", fmt.Errorf("getJfSong(): %s", err.Error()) 126 | } 127 | 128 | for _, item := range results.Items { 129 | if strings.Contains(item.Path, track.File) { 130 | return item.ID, nil 131 | } 132 | } 133 | return "", nil 134 | } 135 | 136 | func findJfPlaylist(cfg Config) (string, error) { 137 | params := fmt.Sprintf("/Search/Hints?searchTerm=%s&mediaTypes=Playlist", cfg.PlaylistName) 138 | 139 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 140 | if err != nil { 141 | return "", fmt.Errorf("findJfPlaylist(): %s", err.Error()) 142 | } 143 | 144 | var results Search 145 | if err = parseResp(body, &results); err != nil { 146 | return "", fmt.Errorf("findJfPlaylist(): %s", err.Error()) 147 | } 148 | 149 | if len(results.SearchHints) != 0 { 150 | return results.SearchHints[0].ID, nil 151 | } else { 152 | return "", fmt.Errorf("no results found for playlist: %s", cfg.PlaylistName) 153 | } 154 | } 155 | 156 | func createJfPlaylist(cfg Config, tracks []Track) (string, error) { 157 | var songIDs []string 158 | 159 | for _, track := range tracks { 160 | if track.ID == "" { 161 | songID, err := getJfSong(cfg, track) 162 | if songID == "" || err != nil { 163 | debug.Debug(fmt.Sprintf("could not get %s", track.File)) 164 | continue 165 | } 166 | track.ID = songID 167 | } 168 | songIDs = append(songIDs, track.ID) 169 | } 170 | 171 | params := "/Playlists" 172 | 173 | IDs, err := json.Marshal(songIDs) 174 | if err != nil { 175 | debug.Debug(fmt.Sprintf("songIDs: %v", songIDs)) 176 | return "", fmt.Errorf("createJfPlaylist(): %s", err.Error()) 177 | } 178 | 179 | payload := []byte(fmt.Sprintf(` 180 | { 181 | "Name": "%s", 182 | "Ids": %s, 183 | "MediaType": "Audio", 184 | "UserId": "%s" 185 | }`, cfg.PlaylistName, IDs, cfg.Creds.APIKey)) 186 | 187 | body, err := makeRequest("POST", cfg.URL+params, bytes.NewReader(payload), cfg.Creds.Headers) 188 | if err != nil { 189 | return "", fmt.Errorf("createJfPlaylist(): %s", err.Error()) 190 | } 191 | var playlist JFPlaylist 192 | if err = parseResp(body, &playlist); err != nil { 193 | return "", fmt.Errorf("createJfPlaylist(): %s", err.Error()) 194 | } 195 | return playlist.ID, nil 196 | } 197 | 198 | func updateJfPlaylist(cfg Config, ID, overview string) error { 199 | params := fmt.Sprintf("/Items/%s", ID) 200 | 201 | payload := []byte(fmt.Sprintf(` 202 | { 203 | "Id":"%s", 204 | "Name":"%s", 205 | "Overview":"%s", 206 | "Genres":[], 207 | "Tags":[], 208 | "ProviderIds":{} 209 | }`, ID, cfg.PlaylistName, overview)) // the additional fields have to be added, otherwise JF returns code 400 210 | 211 | if _, err := makeRequest("POST", cfg.URL+params, bytes.NewBuffer(payload), cfg.Creds.Headers); err != nil { 212 | return err 213 | } 214 | return nil 215 | } 216 | 217 | func deleteJfPlaylist(cfg Config, ID string) error { 218 | params := fmt.Sprintf("/Items/%s", ID) 219 | 220 | if _, err := makeRequest("DELETE", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 221 | return fmt.Errorf("deleyeJfPlaylist(): %s", err.Error()) 222 | } 223 | return nil 224 | } -------------------------------------------------------------------------------- /src/listenbrainz.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Recommendations struct { 12 | Payload struct { 13 | Count int `json:"count"` 14 | Entity string `json:"entity"` 15 | LastUpdated int `json:"last_updated"` 16 | Mbids []struct { 17 | LatestListenedAt time.Time `json:"latest_listened_at"` 18 | RecordingMbid string `json:"recording_mbid"` 19 | Score float64 `json:"score"` 20 | } `json:"mbids"` 21 | TotalMbidCount int `json:"total_mbid_count"` 22 | UserName string `json:"user_name"` 23 | } `json:"payload"` 24 | } 25 | 26 | type Metadata struct { 27 | Artist struct { 28 | ArtistCreditID int `json:"artist_credit_id"` 29 | Artists []struct { 30 | ArtistMbid string `json:"artist_mbid"` 31 | BeginYear int `json:"begin_year"` 32 | EndYear int `json:"end_year,omitempty"` 33 | JoinPhrase string `json:"join_phrase"` 34 | Name string `json:"name"` 35 | } `json:"artists"` 36 | Name string `json:"name"` 37 | } `json:"artist"` 38 | Recording struct { 39 | Length int `json:"length"` 40 | Name string `json:"name"` 41 | Rels []any `json:"rels"` 42 | } `json:"recording"` 43 | Release struct { 44 | AlbumArtistName string `json:"album_artist_name"` 45 | CaaID int64 `json:"caa_id"` 46 | CaaReleaseMbid string `json:"caa_release_mbid"` 47 | Mbid string `json:"mbid"` 48 | Name string `json:"name"` 49 | ReleaseGroupMbid string `json:"release_group_mbid"` 50 | Year int `json:"year"` 51 | } `json:"release"` 52 | } 53 | 54 | type Recordings map[string]Metadata 55 | 56 | type Playlists struct { 57 | Playlist []struct { 58 | Data struct { 59 | Date time.Time `json:"date"` 60 | Identifier string `json:"identifier"` 61 | Title string `json:"title"` 62 | } `json:"playlist"` 63 | } `json:"playlists"` 64 | } 65 | 66 | type Exploration struct { 67 | Playlist struct { 68 | Annotation string `json:"annotation"` 69 | Creator string `json:"creator"` 70 | Date time.Time `json:"date"` 71 | Identifier string `json:"identifier"` 72 | Title string `json:"title"` 73 | Tracks []struct { 74 | Album string `json:"album"` 75 | Creator string `json:"creator"` 76 | Extension struct { 77 | HTTPSMusicbrainzOrgDocJspfTrack struct { 78 | AddedAt time.Time `json:"added_at"` 79 | AddedBy string `json:"added_by"` 80 | AdditionalMetadata struct { 81 | Artists []struct { 82 | ArtistCreditName string `json:"artist_credit_name"` 83 | ArtistMbid string `json:"artist_mbid"` 84 | JoinPhrase string `json:"join_phrase"` 85 | } `json:"artists"` 86 | CaaID int64 `json:"caa_id"` 87 | CaaReleaseMbid string `json:"caa_release_mbid"` 88 | } `json:"additional_metadata"` 89 | ArtistIdentifiers []string `json:"artist_identifiers"` 90 | } `json:"https://musicbrainz.org/doc/jspf#track"` 91 | } `json:"extension"` 92 | Identifier []string `json:"identifier"` 93 | Title string `json:"title"` 94 | } `json:"track"` 95 | } `json:"playlist"` 96 | } 97 | 98 | type Track struct { 99 | Album string 100 | ID string 101 | Artist string // All artists as returned by LB 102 | MainArtist string 103 | CleanTitle string // Title as returned by LB 104 | Title string // Title as built in getTracks() 105 | File string 106 | Present bool 107 | } 108 | 109 | func getReccs(cfg Listenbrainz) []string { 110 | var mbids []string 111 | 112 | body, err := lbRequest(fmt.Sprintf("cf/recommendation/user/%s/recording", cfg.User)) 113 | if err != nil { 114 | log.Fatal(err.Error()) 115 | } 116 | 117 | var reccs Recommendations 118 | err = parseResp(body, &reccs) 119 | if err != nil { 120 | log.Fatalf("getReccs(): %s", err.Error()) 121 | } 122 | 123 | for _, rec := range reccs.Payload.Mbids { 124 | mbids = append(mbids, rec.RecordingMbid) 125 | } 126 | 127 | if len(mbids) == 0 { 128 | log.Fatal("no recommendations found, exiting...") 129 | } 130 | return mbids 131 | } 132 | 133 | func getTracks(mbids []string, seaparator string, singleArtist bool) []Track { 134 | str_mbids := strings.Join(mbids, ",") 135 | 136 | body, err := lbRequest(fmt.Sprintf("metadata/recording/?recording_mbids=%s&inc=release+artist", str_mbids)) 137 | if err != nil { 138 | log.Fatal(err.Error()) 139 | } 140 | 141 | var recordings Recordings 142 | err = parseResp(body, &recordings) 143 | if err != nil { 144 | log.Fatalf("getTracks(): %s", err.Error()) 145 | } 146 | 147 | var tracks []Track 148 | for _, recording := range recordings { 149 | var title string 150 | var artist string 151 | title = recording.Recording.Name 152 | artist = recording.Artist.Name 153 | if singleArtist { // if artist separator is empty, only append the first artist 154 | if len(recording.Artist.Artists) > 1 { 155 | var tempTitle string 156 | joinPhrase := " feat. " 157 | for i, artist := range recording.Artist.Artists[1:] { 158 | if i > 0 { 159 | joinPhrase = ", " 160 | } 161 | tempTitle += fmt.Sprintf("%s%s",joinPhrase, artist.Name) 162 | } 163 | title = fmt.Sprintf("%s%s", recording.Recording.Name, tempTitle) 164 | } 165 | artist = recording.Artist.Artists[0].Name 166 | } 167 | 168 | tracks = append(tracks, Track{ 169 | Album: recording.Release.Name, 170 | Artist: artist, 171 | MainArtist: recording.Artist.Name, 172 | CleanTitle: recording.Recording.Name, 173 | Title: title, 174 | File: getFilename(title, artist, seaparator), 175 | }) 176 | } 177 | 178 | return tracks 179 | 180 | } 181 | 182 | func getWeeklyExploration(cfg Listenbrainz) (string, error) { 183 | body, err := lbRequest(fmt.Sprintf("user/%s/playlists/createdfor", cfg.User)) 184 | if err != nil { 185 | log.Fatal(err.Error()) 186 | } 187 | 188 | var playlists Playlists 189 | err = parseResp(body, &playlists) 190 | if err != nil { 191 | log.Fatalf("getWeeklyExploration(): %s", err.Error()) 192 | } 193 | 194 | for _, playlist := range playlists.Playlist { 195 | 196 | _, currentWeek := time.Now().Local().ISOWeek() 197 | _, creationWeek := playlist.Data.Date.ISOWeek() 198 | 199 | if strings.Contains(playlist.Data.Title, "Weekly Exploration") && currentWeek == creationWeek { 200 | id := strings.Split(playlist.Data.Identifier, "/") 201 | return id[len(id)-1], nil 202 | } 203 | } 204 | return "", fmt.Errorf("failed to get new exploration playlist, check if ListenBrainz has generated one this week") 205 | } 206 | 207 | func parseWeeklyExploration(identifier, separator string, singleArtist bool) []Track { 208 | body, err := lbRequest(fmt.Sprintf("playlist/%s", identifier)) 209 | if err != nil { 210 | log.Fatal(err.Error()) 211 | } 212 | 213 | var exploration Exploration 214 | err = parseResp(body, &exploration) 215 | if err != nil { 216 | log.Fatalf("parseWeeklyExploration(): %s", err.Error()) 217 | } 218 | 219 | var tracks []Track 220 | for _, track := range exploration.Playlist.Tracks { 221 | var title string 222 | var artist string 223 | 224 | title = track.Title 225 | artist = track.Creator 226 | if singleArtist { // if artist separator is empty, only append the first artist 227 | if len(track.Extension.HTTPSMusicbrainzOrgDocJspfTrack.AdditionalMetadata.Artists) > 1 { 228 | var tempTitle string 229 | joinPhrase := " feat. " 230 | for i, artist := range track.Extension.HTTPSMusicbrainzOrgDocJspfTrack.AdditionalMetadata.Artists[1:] { 231 | if i > 0 { 232 | joinPhrase = ", " 233 | } 234 | tempTitle += fmt.Sprintf("%s%s",joinPhrase, artist.ArtistCreditName) 235 | } 236 | title = fmt.Sprintf("%s%s", track.Title, tempTitle) 237 | } 238 | artist = track.Extension.HTTPSMusicbrainzOrgDocJspfTrack.AdditionalMetadata.Artists[0].ArtistCreditName 239 | } 240 | 241 | tracks = append(tracks, Track{ 242 | Album: track.Album, 243 | Artist: artist, 244 | Title: title, 245 | File: getFilename(title, artist, separator), 246 | 247 | }) 248 | } 249 | return tracks 250 | 251 | } 252 | 253 | func getFilename(title, artist, separator string) string { 254 | 255 | // Remove illegal characters for file naming 256 | re := regexp.MustCompile(`[^\p{L}\d._,\-]+`) 257 | t := re.ReplaceAllString(title, separator) 258 | a := re.ReplaceAllString(artist, separator) 259 | 260 | return fmt.Sprintf("%s-%s",t,a) 261 | } 262 | 263 | func lbRequest(path string) ([]byte, error) { // Handle ListenBrainz API requests 264 | 265 | 266 | reqURL := fmt.Sprintf("https://api.listenbrainz.org/1/%s", path) 267 | 268 | body, err := makeRequest("GET", reqURL, nil, nil) 269 | 270 | if err != nil { 271 | return nil, err 272 | } 273 | return body, nil 274 | } -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "explo/src/debug" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path" 11 | "strings" 12 | "encoding/json" 13 | 14 | "github.com/ilyakaznacheev/cleanenv" 15 | ) 16 | 17 | type Config struct { 18 | Subsonic Subsonic 19 | Jellyfin Jellyfin 20 | Plex Plex 21 | Youtube Youtube 22 | Listenbrainz Listenbrainz 23 | Creds Credentials 24 | URL string `env:"SYSTEM_URL"` 25 | Sleep int `env:"SLEEP" env-default:"2"` 26 | PlaylistDir string `env:"PLAYLIST_DIR"` 27 | Persist bool `env:"PERSIST" env-default:"true"` 28 | System string `env:"EXPLO_SYSTEM"` 29 | PlaylistName string 30 | Debug bool `env:"DEBUG" env-default:"false"` 31 | } 32 | 33 | type Credentials struct { 34 | APIKey string `env:"API_KEY"` 35 | User string `env:"SYSTEM_USERNAME"` 36 | Password string `env:"SYSTEM_PASSWORD"` 37 | Headers map[string]string 38 | Token string 39 | Salt string 40 | } 41 | 42 | 43 | type Jellyfin struct { 44 | Source string `env:"JELLYFIN_SOURCE"` 45 | LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` 46 | LibraryID string `env:"LIBRARY_ID"` 47 | } 48 | 49 | type Plex struct { 50 | LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` 51 | LibraryID string `env:"LIBRARY_ID"` 52 | } 53 | 54 | type Subsonic struct { 55 | Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` 56 | ID string `env:"CLIENT" env-default:"explo"` 57 | URL string `env:"SUBSONIC_URL" env-default:"http://127.0.0.1:4533"` 58 | User string `env:"SUBSONIC_USER"` 59 | Password string `env:"SUBSONIC_PASSWORD"` 60 | } 61 | 62 | type Youtube struct { 63 | APIKey string `env:"YOUTUBE_API_KEY"` 64 | DownloadDir string `env:"DOWNLOAD_DIR"` 65 | Separator string `env:"FILENAME_SEPARATOR" env-default:" "` 66 | FfmpegPath string `env:"FFMPEG_PATH"` 67 | YtdlpPath string `env:"YTDLP_PATH"` 68 | FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"` 69 | } 70 | type Listenbrainz struct { 71 | Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` 72 | User string `env:"LISTENBRAINZ_USER"` 73 | SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` 74 | } 75 | 76 | type Song struct { 77 | Title string 78 | Artist string 79 | Album string 80 | } 81 | 82 | func (cfg *Config) handleDeprecation() { // assign deprecared env vars to new ones 83 | // Deprecated since v0.6.0 84 | switch cfg.System { 85 | case "subsonic": 86 | if cfg.Subsonic.User != "" && cfg.Creds.User == "" { 87 | log.Println("Warning: 'SUBSONIC_USER' is deprecated. Please use 'SYSTEM_USER' instead.") 88 | cfg.Creds.User = cfg.Subsonic.User 89 | } 90 | if cfg.Subsonic.Password != "" && cfg.Creds.Password == "" { 91 | log.Println("Warning: 'SUBSONIC_PASSWORD' is deprecated. Please use 'SYSTEM_PASSWORD' instead.") 92 | cfg.Creds.Password = cfg.Subsonic.Password 93 | } 94 | if cfg.Subsonic.URL != "" && cfg.URL == "" { 95 | log.Println("Warning: 'SUBSONIC_URL' is deprecated. Please use 'SYSTEM_URL' instead.") 96 | cfg.URL = cfg.Subsonic.URL 97 | } 98 | default: 99 | return 100 | } 101 | } 102 | 103 | func readEnv() Config { 104 | var cfg Config 105 | 106 | err := cleanenv.ReadConfig("./.env", &cfg) 107 | if err != nil { 108 | err := cleanenv.ReadConfig("./local.env", &cfg) 109 | if err != nil { 110 | panic(err) 111 | } 112 | log.Println("Warning: using old filename, please rename local.env to .env") 113 | } 114 | return cfg 115 | } 116 | 117 | func (cfg *Config) verifyDir(system string) { // verify if dir variables have suffix 118 | 119 | if system == "mpd" { 120 | cfg.PlaylistDir = fixDir(cfg.PlaylistDir) 121 | } 122 | 123 | cfg.Youtube.DownloadDir = fixDir(cfg.Youtube.DownloadDir) 124 | } 125 | 126 | func fixDir(dir string) string { 127 | if !strings.HasSuffix(dir, "/") { 128 | return dir + "/" 129 | } 130 | return dir 131 | } 132 | 133 | 134 | func deleteSongs(cfg Youtube) { // Deletes all files if persist equals false 135 | entries, err := os.ReadDir(cfg.DownloadDir) 136 | if err != nil { 137 | log.Printf("failed to read directory: %s", err.Error()) 138 | } 139 | for _, entry := range entries { 140 | if !(entry.IsDir()) { 141 | err = os.Remove(path.Join(cfg.DownloadDir, entry.Name())) 142 | if err != nil { 143 | log.Printf("failed to remove file: %s", err.Error()) 144 | } 145 | } 146 | } 147 | } 148 | 149 | 150 | func (cfg *Config) detectSystem() { 151 | if cfg.System == "" { 152 | log.Printf("Warning: no EXPLO_SYSTEM variable set, trying to detect automatically..") 153 | if cfg.Subsonic.User != "" && cfg.Subsonic.Password != "" { 154 | log.Println("using Subsonic") 155 | cfg.System = "subsonic" 156 | return 157 | 158 | } else if cfg.PlaylistDir != "" { 159 | log.Println("using Music Player Daemon") 160 | cfg.System = "mpd" 161 | return 162 | 163 | } 164 | log.Fatal("unable to detect system, please define a system using the 'EXPLO_SYSTEM' env variable") 165 | } 166 | log.Printf("using %s", cfg.System) 167 | } 168 | 169 | func (cfg *Config) systemSetup() { // Verifies variables and does setup 170 | 171 | switch cfg.System { 172 | 173 | case "subsonic": 174 | if (cfg.Creds.User == "" && cfg.Creds.Password == "") { 175 | log.Fatal("USER and/or PASSWORD variable not set, exiting") 176 | } 177 | cfg.Creds.genToken() 178 | 179 | case "jellyfin": 180 | if cfg.Creds.APIKey == "" { 181 | log.Fatal("API_KEY variable not set, exiting") 182 | } 183 | cfg.Creds.jfHeader() // Adds auth header 184 | cfg.getJfPath() 185 | 186 | if cfg.Jellyfin.LibraryID == "" { 187 | jfAddPath(*cfg) 188 | cfg.getJfPath() 189 | } 190 | 191 | case "mpd": 192 | if cfg.PlaylistDir == "" { 193 | log.Fatal("PLAYLIST_DIR variable not set, exiting") 194 | } 195 | 196 | case "plex": 197 | if ((cfg.Creds.User == "" || cfg.Creds.Password == "") && cfg.Creds.APIKey == "") { 198 | log.Fatal("USER/PASSWORD or API_KEY variables not set, exiting") 199 | } 200 | if cfg.Creds.APIKey == "" { 201 | cfg.Creds.plexHeader() // Adds client headers 202 | cfg.Creds.getPlexAuth() 203 | } 204 | cfg.Creds.plexHeader() // Adds client headers (again if no api key was set) 205 | cfg.getPlexLibrary() 206 | 207 | case "emby": 208 | if cfg.Creds.APIKey == "" { 209 | log.Fatal("API_KEY variable not set, exiting") 210 | } 211 | cfg.Creds.embyHeader() // Adds auth header 212 | cfg.getEmbyPath() 213 | 214 | if cfg.Jellyfin.LibraryID == "" { 215 | embyAddPath(*cfg) 216 | cfg.getEmbyPath() 217 | } 218 | 219 | default: 220 | log.Fatalf("system: %s not known, please use a supported system (jellyfin, mpd, subsonic or plex)", cfg.System) 221 | } 222 | } 223 | 224 | func makeRequest(method, url string, payload io.Reader, headers map[string]string) ([]byte, error) { 225 | req, err := http.NewRequest(method, url, payload) 226 | if err != nil { 227 | return nil, fmt.Errorf("failed to initialize request: %s", err.Error()) 228 | } 229 | req.Header.Add("Content-Type","application/json") 230 | req.Header.Add("Accept", "application/json") 231 | 232 | for key, value := range headers { 233 | req.Header.Add(key,value) 234 | } 235 | 236 | resp, err := http.DefaultClient.Do(req) 237 | if err != nil { 238 | return nil, fmt.Errorf("failed to make request: %s", err.Error()) 239 | } 240 | defer resp.Body.Close() 241 | 242 | body, err := io.ReadAll(resp.Body) 243 | if err != nil { 244 | return nil, fmt.Errorf("failed to read response body: %s", err.Error()) 245 | } 246 | 247 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 248 | debug.Debug(fmt.Sprintf("response body: %s", string(body))) 249 | return nil, fmt.Errorf("got %d from %s", resp.StatusCode, url) 250 | } 251 | 252 | return body, nil 253 | } 254 | 255 | func parseResp[T any](body []byte, target *T) error { 256 | 257 | if err := json.Unmarshal(body, target); err != nil { 258 | debug.Debug(fmt.Sprintf("full response: %s", string(body))) 259 | return fmt.Errorf("error unmarshaling response body: %s", err.Error()) 260 | } 261 | return nil 262 | } 263 | 264 | 265 | func main() { 266 | cfg := readEnv() 267 | debug.Init(cfg.Debug) 268 | cfg.detectSystem() 269 | cfg.verifyDir(cfg.System) 270 | cfg.handleDeprecation() 271 | cfg.systemSetup() 272 | cfg.getPlaylistName() 273 | 274 | var tracks []Track 275 | 276 | if cfg.Listenbrainz.Discovery == "playlist" { 277 | id, err := getWeeklyExploration(cfg.Listenbrainz) 278 | if err != nil { 279 | log.Fatal(err.Error()) 280 | } 281 | tracks = parseWeeklyExploration(id, cfg.Youtube.Separator, cfg.Listenbrainz.SingleArtist) 282 | } else { 283 | mbids := getReccs(cfg.Listenbrainz) 284 | tracks = getTracks(mbids, cfg.Youtube.Separator, cfg.Listenbrainz.SingleArtist) 285 | } 286 | 287 | if !cfg.Persist { // delete songs and playlist before downloading new ones 288 | if err := handlePlaylistDeletion(cfg); err != nil { 289 | log.Printf("failed to delete playlist: %s", err.Error()) 290 | } 291 | } 292 | 293 | tracks = checkTracks(cfg, tracks) 294 | gatherVideos(cfg, tracks) 295 | 296 | err := createPlaylist(cfg, tracks) 297 | if err != nil { 298 | log.Fatal(err.Error()) 299 | } else { 300 | log.Printf("%s playlist created", cfg.PlaylistName) 301 | } 302 | } -------------------------------------------------------------------------------- /src/playlist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "explo/src/debug" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | ) 10 | 11 | 12 | func createM3U(cfg Config, name string, tracks []Track) error { 13 | f, err := os.OpenFile(cfg.PlaylistDir+name+".m3u", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for _, track := range tracks { 19 | fullFile := fmt.Sprintf("%s%s.mp3\n",cfg.Youtube.DownloadDir, track.File) 20 | _, err := f.Write([]byte(fullFile)) 21 | if err != nil { 22 | log.Printf("failed to write song to file: %s", err.Error()) 23 | } 24 | } 25 | return nil 26 | } 27 | 28 | func (cfg *Config) getPlaylistName() { 29 | playlistName := "Discover-Weekly" 30 | 31 | if cfg.Persist { 32 | year, week := time.Now().ISOWeek() 33 | playlistName = fmt.Sprintf("%s-%d-Week%d", playlistName, year, week) 34 | } 35 | cfg.PlaylistName = playlistName 36 | } 37 | 38 | func checkTracks(cfg Config, tracks []Track) []Track { // Returns updated slice with Present status and song ID (if available) 39 | for i, track := range tracks { 40 | var ID string 41 | switch cfg.System { 42 | case "subsonic": 43 | ID, _ = searchTrack(cfg, track) 44 | 45 | case "jellyfin": 46 | ID, _ = getJfSong(cfg, track) 47 | 48 | case "plex": 49 | ID, _ = searchPlexSong(cfg, track) 50 | 51 | case "emby": 52 | ID, _ = getEmbySong(cfg, track) 53 | } 54 | if ID != "" { 55 | tracks[i].Present = true 56 | tracks[i].ID = ID 57 | } 58 | } 59 | return tracks 60 | } 61 | 62 | func createPlaylist(cfg Config, tracks []Track) error { 63 | if cfg.System == "" { 64 | return fmt.Errorf("could not get music system") 65 | } 66 | 67 | description := "Created by Explo using recommendations from ListenBrainz" // Description to add to playlists 68 | 69 | 70 | // Helper func to sleep after refreshing library 71 | refreshLibrary := func() { 72 | log.Printf("[%s] Refreshing library...", cfg.System) 73 | time.Sleep(time.Duration(cfg.Sleep) * time.Minute) 74 | } 75 | 76 | switch cfg.System { 77 | case "subsonic": 78 | 79 | if err := subsonicScan(cfg); err != nil { 80 | return fmt.Errorf("failed to schedule a library scan: %s", err.Error()) 81 | } 82 | refreshLibrary() 83 | 84 | ID, err := subsonicPlaylist(cfg, tracks) 85 | if err != nil { 86 | return fmt.Errorf("failed to create subsonic playlist: %s", err.Error()) 87 | } 88 | if err := updSubsonicPlaylist(cfg, ID, description); err != nil { 89 | debug.Debug(fmt.Sprintf("failed to add comment to playlist: %s", err.Error())) 90 | } 91 | 92 | return nil 93 | 94 | case "jellyfin": 95 | 96 | if err := refreshJfLibrary(cfg); err != nil { 97 | return fmt.Errorf("failed to refresh library: %s", err.Error()) 98 | } 99 | refreshLibrary() 100 | 101 | ID, err := createJfPlaylist(cfg, tracks) 102 | if err != nil { 103 | return fmt.Errorf("failed to create playlist: %s", err.Error()) 104 | } 105 | if err := updateJfPlaylist(cfg, ID, description); err != nil { 106 | debug.Debug(fmt.Sprintf("failed to add overview to playlist: %s", err.Error())) 107 | } 108 | 109 | return nil 110 | 111 | case "mpd": 112 | 113 | if err := createM3U(cfg, cfg.PlaylistName, tracks); err != nil { 114 | return fmt.Errorf("failed to create M3U playlist: %s", err.Error()) 115 | } 116 | return nil 117 | 118 | case "plex": 119 | if err := refreshPlexLibrary(cfg); err != nil { 120 | return fmt.Errorf("createPlaylist(): %s", err.Error()) 121 | } 122 | refreshLibrary() 123 | 124 | serverID, err := getPlexServer(cfg) 125 | if err != nil { 126 | return fmt.Errorf("createPlaylist(): %s", err.Error()) 127 | } 128 | playlistKey, err := createPlexPlaylist(cfg, serverID) 129 | if err != nil { 130 | return fmt.Errorf("createPlaylist(): %s", err.Error()) 131 | } 132 | addToPlexPlaylist(cfg, playlistKey, serverID, tracks) 133 | 134 | if err := updatePlexPlaylist(cfg, playlistKey, description); err != nil { 135 | debug.Debug(fmt.Sprintf("failed to add summary to playlist: %s", err.Error())) 136 | } 137 | 138 | return nil 139 | 140 | case "emby": 141 | if err := refreshEmbyLibrary(cfg); err != nil { 142 | return fmt.Errorf("failed to refresh library: %s", err.Error()) 143 | } 144 | refreshLibrary() 145 | 146 | _, err := createEmbyPlaylist(cfg, tracks) 147 | if err != nil { 148 | return fmt.Errorf("failed to create playlist: %s", err.Error()) 149 | } 150 | /* if err := updateEmbyPlaylist(cfg, ID, description); err != nil { Not working in emby 151 | debug.Debug(fmt.Sprintf("failed to add overview to playlist: %s", err.Error())) 152 | } */ 153 | 154 | return nil 155 | } 156 | return fmt.Errorf("unsupported system: %s", cfg.System) 157 | } 158 | 159 | func handlePlaylistDeletion(cfg Config) error { 160 | deleteSongs(cfg.Youtube) 161 | 162 | switch cfg.System { 163 | case "subsonic": 164 | playlists, err := getDiscoveryPlaylist(cfg) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | if err := delSubsonicPlaylists(playlists, cfg); err != nil { 170 | return err 171 | } 172 | return nil 173 | case "jellyfin": 174 | ID, err := findJfPlaylist(cfg) 175 | if err != nil { 176 | return err 177 | } 178 | if err := deleteJfPlaylist(cfg, ID); err != nil { 179 | return err 180 | } 181 | return nil 182 | case "mpd": 183 | if err := os.Remove(cfg.PlaylistDir+cfg.PlaylistName+".m3u"); err != nil { 184 | return err 185 | } 186 | case "plex": 187 | key, err := searchPlexPlaylist(cfg) 188 | if err != nil { 189 | return err 190 | } 191 | if err := deletePlexPlaylist(cfg, key); err != nil { 192 | return err 193 | } 194 | 195 | case "emby": 196 | ID, err := findEmbyPlaylist(cfg) 197 | if err != nil { 198 | return err 199 | } 200 | if err := deleteEmbyPlaylist(cfg, ID); err != nil { 201 | return err 202 | } 203 | return nil 204 | } 205 | return nil 206 | } -------------------------------------------------------------------------------- /src/plex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "explo/src/debug" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "time" 11 | ) 12 | 13 | type LoginPayload struct { 14 | User LoginUser `json:"user"` 15 | } 16 | 17 | type LoginUser struct { 18 | Login string `json:"login"` 19 | Password string `json:"password"` 20 | } 21 | 22 | type LoginResponse struct { 23 | User struct { 24 | AuthToken string `json:"authToken"` 25 | } `json:"user"` 26 | } 27 | 28 | type Libraries struct { 29 | MediaContainer struct { 30 | Size int `json:"size"` 31 | AllowSync bool `json:"allowSync"` 32 | Title1 string `json:"title1"` 33 | Library []struct { 34 | Title string `json:"title"` 35 | Key string `json:"key"` 36 | Location []struct { 37 | ID int `json:"id"` 38 | Path string `json:"path"` 39 | } `json:"Location"` 40 | } `json:"Directory"` 41 | } `json:"MediaContainer"` 42 | } 43 | 44 | type PlexSearch struct { 45 | MediaContainer struct { 46 | Size int `json:"size"` 47 | SearchResult []struct { 48 | Score float64 `json:"score"` 49 | Metadata struct { 50 | LibrarySectionTitle string `json:"librarySectionTitle"` 51 | Key string `json:"key"` 52 | Type string `json:"type"` 53 | Title string `json:"title"` // Track 54 | GrandparentTitle string `json:"grandparentTitle"` // Artist 55 | ParentTitle string `json:"parentTitle"` // Album 56 | OriginalTitle string `json:"originalTitle"` 57 | Summary string `json:"summary"` 58 | Duration int `json:"duration"` 59 | AddedAt int `json:"addedAt"` 60 | UpdatedAt int `json:"updatedAt"` 61 | Media []struct { 62 | ID int `json:"id"` 63 | Duration int `json:"duration"` 64 | AudioChannels int `json:"audioChannels"` 65 | AudioCodec string `json:"audioCodec"` 66 | Container string `json:"container"` 67 | } `json:"Media"` 68 | } `json:"Metadata"` 69 | } `json:"SearchResult"` 70 | } `json:"MediaContainer"` 71 | } 72 | 73 | 74 | type PlexServer struct { 75 | MediaContainer struct { 76 | Size int `json:"size"` 77 | APIVersion string `json:"apiVersion"` 78 | Claimed bool `json:"claimed"` 79 | MachineIdentifier string `json:"machineIdentifier"` 80 | Version string `json:"version"` 81 | } `json:"MediaContainer"` 82 | } 83 | 84 | type PlexPlaylist struct { 85 | MediaContainer struct { 86 | Size int `json:"size"` 87 | Metadata []struct { 88 | RatingKey string `json:"ratingKey"` 89 | Key string `json:"key"` 90 | GUID string `json:"guid"` 91 | Type string `json:"type"` 92 | Title string `json:"title"` 93 | Summary string `json:"summary"` 94 | Smart bool `json:"smart"` 95 | PlaylistType string `json:"playlistType"` 96 | AddedAt int `json:"addedAt"` 97 | UpdatedAt int `json:"updatedAt"` 98 | Duration int `json:"duration,omitempty"` 99 | } `json:"Metadata"` 100 | } `json:"MediaContainer"` 101 | } 102 | 103 | func (cfg *Credentials) plexHeader() { 104 | 105 | if cfg.Headers == nil { 106 | cfg.Headers = make(map[string]string) 107 | cfg.Headers["X-Plex-Client-Identifier"] = "explo" 108 | } 109 | 110 | if cfg.APIKey != "" { 111 | cfg.Headers["X-Plex-Token"] = cfg.APIKey 112 | } 113 | } 114 | 115 | 116 | func (cfg *Credentials) getPlexAuth() { // Get user token from plex 117 | payload := LoginPayload{ 118 | User: LoginUser{ 119 | Login: cfg.User, 120 | Password: cfg.Password, 121 | }, 122 | } 123 | 124 | payloadBytes, err := json.Marshal(payload) 125 | if err != nil { 126 | log.Fatalf("failed to marshal payload: %s", err.Error()) 127 | } 128 | 129 | 130 | body, err := makeRequest("POST", "https://plex.tv/users/sign_in.json", bytes.NewBuffer(payloadBytes), cfg.Headers) 131 | if err != nil { 132 | log.Fatalf("failed to make request to plex: %s", err.Error()) 133 | } 134 | 135 | var auth LoginResponse 136 | err = parseResp(body, &auth) 137 | if err != nil { 138 | log.Fatalf("getPlexAuth(): %s", err.Error()) 139 | } 140 | 141 | cfg.APIKey = auth.User.AuthToken 142 | } 143 | 144 | func getPlexLibraries(cfg Config) (Libraries, error) { 145 | params := "/library/sections/" 146 | 147 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 148 | if err != nil { 149 | return Libraries{}, fmt.Errorf("failed to make request to plex: %s", err.Error()) 150 | } 151 | 152 | var libraries Libraries 153 | err = parseResp(body, &libraries) 154 | if err != nil { 155 | debug.Debug(string(body)) 156 | log.Fatalf("getPlexLibraries(): %s", err.Error()) 157 | } 158 | return libraries, nil 159 | } 160 | 161 | func (cfg *Config) getPlexLibrary() { 162 | libraries, err := getPlexLibraries(*cfg) 163 | if err != nil { 164 | log.Fatalf("getPlexLibrary(): failed to fetch libraries: %s", err.Error()) 165 | } 166 | 167 | for _, library := range libraries.MediaContainer.Library { 168 | if cfg.Plex.LibraryName == library.Title { 169 | cfg.Plex.LibraryID = library.Key 170 | return 171 | } 172 | } 173 | if err = cfg.addPlexLibrary(); err != nil { 174 | debug.Debug(err.Error()) 175 | log.Fatalf("library named %s not found and cannot be added, please create it manually and ensure 'Prefer local metadata' is checked", cfg.Plex.LibraryName) 176 | } 177 | log.Printf("created %s library, sleeping for 1 minute to let it sync", cfg.Plex.LibraryName) 178 | time.Sleep(time.Duration(1) * time.Minute) 179 | } 180 | 181 | func (cfg *Config) addPlexLibrary() error { 182 | params := fmt.Sprintf("/library/sections?name=%s&type=artist&scanner=Plex+Music&agent=tv.plex.agents.music&language=en-US&location=%s&prefs[respectTags]=1", cfg.Plex.LibraryName, cfg.Youtube.DownloadDir) 183 | 184 | body, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers) 185 | if err != nil { 186 | return fmt.Errorf("addPlexLibrary(): %s", err.Error()) 187 | } 188 | 189 | var libraries Libraries 190 | if err = parseResp(body, &libraries); err != nil { 191 | return fmt.Errorf("addPlexLibrary(): %s", err.Error()) 192 | } 193 | cfg.Plex.LibraryID = libraries.MediaContainer.Library[0].Key 194 | return nil 195 | } 196 | 197 | func refreshPlexLibrary(cfg Config) error { 198 | params := fmt.Sprintf("/library/sections/%s/refresh", cfg.Plex.LibraryID) 199 | 200 | if _, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 201 | return fmt.Errorf("refreshPlexLibrary(): %s", err.Error()) 202 | } 203 | return nil 204 | } 205 | 206 | func searchPlexSong(cfg Config, track Track) (string, error) { 207 | params := fmt.Sprintf("/library/search?query=%s", url.QueryEscape(track.Title)) 208 | 209 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 210 | if err != nil { 211 | return "", fmt.Errorf("searchPlexSong(): failed request for '%s': %s", track.Title, err.Error()) 212 | } 213 | var searchResults PlexSearch 214 | 215 | if err = parseResp(body, &searchResults); err != nil { 216 | return "", fmt.Errorf("searchPlexSong(): failed to parse response for '%s': %s", track.Title, err.Error()) 217 | } 218 | key, err := getPlexSong(track, searchResults) 219 | if err != nil { 220 | return "", fmt.Errorf("searchPlexSong(): %s", err.Error()) 221 | } 222 | return key, nil 223 | } 224 | 225 | func getPlexSong(track Track, searchResults PlexSearch) (string, error) { 226 | 227 | for _, result := range searchResults.MediaContainer.SearchResult { 228 | if result.Metadata.Type == "track" && result.Metadata.Title == track.Title && result.Metadata.ParentTitle == track.Album { 229 | return result.Metadata.Key, nil 230 | } 231 | } 232 | debug.Debug(fmt.Sprintf("full search result: %v", searchResults.MediaContainer.SearchResult)) 233 | return "", fmt.Errorf("failed to find '%s' by '%s' in %s album", track.Title, track.Artist, track.Album) 234 | } 235 | 236 | func searchPlexPlaylist(cfg Config) (string, error) { 237 | params := "/playlists" 238 | 239 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 240 | if err != nil { 241 | return "", fmt.Errorf("searchPlexPlaylist(): failed to request playlists: %s", err.Error()) 242 | } 243 | 244 | var playlists PlexPlaylist 245 | if err = parseResp(body, &playlists); err != nil { 246 | return "", fmt.Errorf("searchPlexPlaylist(): failed to parse response: %s", err.Error()) 247 | } 248 | 249 | key := getPlexPlaylist(playlists, cfg.PlaylistName) 250 | if key == "" { 251 | debug.Debug("no playlist found") 252 | } 253 | return key, nil 254 | } 255 | 256 | func getPlexPlaylist(playlists PlexPlaylist, playlistName string) string { 257 | 258 | for _, playlist := range playlists.MediaContainer.Metadata { 259 | if playlist.Title == playlistName { 260 | return playlist.RatingKey 261 | } 262 | } 263 | return "" 264 | } 265 | 266 | func getPlexServer(cfg Config) (string, error) { 267 | params := "/identity" 268 | 269 | body, err := makeRequest("GET", cfg.URL+params, nil, cfg.Creds.Headers) 270 | if err != nil { 271 | return "", fmt.Errorf("getPlexServer(): failed to create playlists: %s", err.Error()) 272 | } 273 | 274 | var server PlexServer 275 | 276 | if err = parseResp(body, &server); err != nil { 277 | return "", fmt.Errorf("getPlexServer(): %s", err.Error()) 278 | } 279 | return server.MediaContainer.MachineIdentifier, nil 280 | } 281 | 282 | func createPlexPlaylist(cfg Config, machineID string) (string, error) { 283 | params := fmt.Sprintf("/playlists?title=%s&type=audio&smart=0&uri=server://%s/com.plexapp.plugins.library/%s", cfg.PlaylistName, machineID, cfg.Plex.LibraryID) 284 | 285 | body, err := makeRequest("POST", cfg.URL+params, nil, cfg.Creds.Headers) 286 | if err != nil { 287 | return "", fmt.Errorf("createPlexPlaylist(): failed to create playlists: %s", err.Error()) 288 | } 289 | 290 | var playlist PlexPlaylist 291 | 292 | if err = parseResp(body, &playlist); err != nil { 293 | return "", fmt.Errorf("createPlexPlaylist(): %s", err.Error()) 294 | } 295 | 296 | return playlist.MediaContainer.Metadata[0].RatingKey, nil 297 | } 298 | 299 | func addToPlexPlaylist(cfg Config, playlistKey, machineID string, tracks []Track) { 300 | for i := range tracks { 301 | if !tracks[i].Present { 302 | songID, err := searchPlexSong(cfg, tracks[i]) 303 | if err != nil { 304 | debug.Debug(err.Error()) 305 | } 306 | tracks[i].ID = songID 307 | } 308 | if tracks[i].ID != "" { 309 | params := fmt.Sprintf("/playlists/%s/items?uri=server://%s/com.plexapp.plugins.library%s", playlistKey, machineID, tracks[i].ID) 310 | 311 | if _, err := makeRequest("PUT", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 312 | log.Printf("addToPlexPlaylist(): failed to add %s to playlist: %s", tracks[i].Title, err.Error()) 313 | } 314 | } 315 | } 316 | } 317 | 318 | func updatePlexPlaylist(cfg Config, PlaylistKey, summary string) error { 319 | params := fmt.Sprintf("/playlists/%s?summary=%s", PlaylistKey, url.QueryEscape(summary)) 320 | 321 | if _, err := makeRequest("PUT", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 322 | return err 323 | } 324 | return nil 325 | } 326 | 327 | func deletePlexPlaylist(cfg Config, playlistKey string) error { 328 | params := fmt.Sprintf("/playlists/%s", playlistKey) 329 | 330 | if _, err := makeRequest("DELETE", cfg.URL+params, nil, cfg.Creds.Headers); err != nil { 331 | return fmt.Errorf("deletePlexPlaylist(): failed to delete plex playlist: %s", err.Error()) 332 | } 333 | return nil 334 | } -------------------------------------------------------------------------------- /src/subsonic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | "strings" 8 | 9 | "crypto/md5" 10 | "crypto/rand" 11 | "encoding/base64" 12 | "net/url" 13 | "explo/src/debug" 14 | ) 15 | 16 | type FailedResp struct { 17 | SubsonicResponse struct { 18 | Status string `json:"status"` 19 | Error struct { 20 | Code int `json:"code"` 21 | Message string `json:"message"` 22 | } `json:"error"` 23 | } `json:"subsonic-response"` 24 | } 25 | 26 | type SubResponse struct { 27 | SubsonicResponse struct { 28 | Status string `json:"status"` 29 | Version string `json:"version"` 30 | Type string `json:"type"` 31 | ServerVersion string `json:"serverVersion"` 32 | SearchResult3 struct { 33 | Song []struct { 34 | ID string `json:"id"` 35 | Title string `json:"title"` 36 | } `json:"song"` 37 | } `json:"searchResult3,omitempty"` 38 | Playlists struct { 39 | Playlist []Playlist `json:"playlist,omitempty"` 40 | } `json:"playlists,omitempty"` 41 | Playlist Playlist `json:"playlist,omitempty"` 42 | } `json:"subsonic-response"` 43 | } 44 | 45 | type Playlist struct { 46 | ID string `json:"id"` 47 | Name string `json:"name"` 48 | Comment string `json:"comment,omitempty"` 49 | SongCount int `json:"songCount"` 50 | Duration int `json:"duration"` 51 | Public bool `json:"public"` 52 | Owner string `json:"owner"` 53 | Created time.Time `json:"created"` 54 | Changed time.Time `json:"changed"` 55 | CoverArt string `json:"coverArt"` 56 | } 57 | 58 | 59 | func (cfg *Credentials) genToken() { 60 | 61 | var salt = make([]byte, 6) 62 | 63 | 64 | _, err := rand.Read(salt) 65 | if err != nil { 66 | log.Fatalf("failed to read salt: %s", err.Error()) 67 | } 68 | 69 | saltStr := base64.RawURLEncoding.EncodeToString(salt) 70 | passSalt := fmt.Sprintf("%s%s", cfg.Password, saltStr) 71 | 72 | token := fmt.Sprintf("%x", md5.Sum([]byte(passSalt))) 73 | 74 | cfg.Token = url.PathEscape(token) 75 | cfg.Salt = url.PathEscape(saltStr) 76 | 77 | } 78 | 79 | func searchTrack(cfg Config, track Track) (string, error) { 80 | 81 | searchQuery := fmt.Sprintf("%s %s %s", track.Title, track.Artist, track.Album) 82 | 83 | reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery)) 84 | 85 | body, err := subsonicRequest(reqParam, cfg) 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | var resp SubResponse 91 | if err := parseResp(body, &resp); err != nil { 92 | return "", err 93 | } 94 | 95 | 96 | switch len(resp.SubsonicResponse.SearchResult3.Song) { 97 | case 0: 98 | return "", fmt.Errorf("no results found for %s", searchQuery) 99 | case 1: 100 | return resp.SubsonicResponse.SearchResult3.Song[0].ID, nil 101 | default: 102 | for _, song := range resp.SubsonicResponse.SearchResult3.Song { 103 | if song.Title == track.Title { 104 | return song.ID, nil 105 | } 106 | } 107 | return "", fmt.Errorf("multiple songs found for: %s, but titles do not match with the actual track", searchQuery) 108 | } 109 | } 110 | 111 | func subsonicPlaylist(cfg Config, tracks []Track) (string, error) { 112 | 113 | var trackIDs string 114 | 115 | for _, track := range tracks { // Get track IDs from app and format them 116 | if track.ID == "" { 117 | songID, err := searchTrack(cfg, track) 118 | if songID == "" || err != nil { // if ID is empty, skip song 119 | debug.Debug(fmt.Sprintf("could not get %s", track.File)) 120 | continue 121 | } 122 | track.ID = songID 123 | } 124 | trackIDs += "&songId="+track.ID 125 | } 126 | 127 | reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", cfg.PlaylistName, trackIDs) 128 | 129 | body, err := subsonicRequest(reqParam, cfg) 130 | 131 | var resp SubResponse 132 | if err := parseResp(body, &resp); err != nil { 133 | return "", err 134 | } 135 | if err != nil { 136 | return "", err 137 | } 138 | return resp.SubsonicResponse.Playlist.ID, nil 139 | } 140 | 141 | func subsonicScan(cfg Config) error { 142 | reqParam := "startScan?f=json" 143 | 144 | if _, err := subsonicRequest(reqParam, cfg); err != nil { 145 | return err 146 | } 147 | return nil 148 | } 149 | 150 | func getDiscoveryPlaylist(cfg Config) ([]string, error) { 151 | reqParam := "getPlaylists?f=json" 152 | 153 | body, err := subsonicRequest(reqParam, cfg) 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | var resp SubResponse 159 | if err := parseResp(body, &resp); err != nil { 160 | return nil, err 161 | } 162 | 163 | var playlists []string 164 | for _, playlist := range resp.SubsonicResponse.Playlists.Playlist { 165 | if strings.Contains(playlist.Name, "Discover-Weekly") { 166 | playlists = append(playlists, playlist.ID) 167 | 168 | } 169 | } 170 | return playlists, nil 171 | } 172 | 173 | func updSubsonicPlaylist(cfg Config, ID, comment string) error { 174 | reqParam := fmt.Sprintf("updatePlaylist?playlistId=%s&comment=%s&f=json",ID, url.QueryEscape(comment)) 175 | 176 | if _, err := subsonicRequest(reqParam, cfg); err != nil { 177 | return err 178 | } 179 | return nil 180 | } 181 | 182 | func delSubsonicPlaylists(playlists []string, cfg Config) error { 183 | 184 | for _, id := range playlists { 185 | reqParam := fmt.Sprintf("deletePlaylist?id=%s&f=json", id) 186 | if _, err := subsonicRequest(reqParam, cfg); err != nil { 187 | return err 188 | } 189 | } 190 | return nil 191 | } 192 | 193 | func subsonicRequest(reqParams string, cfg Config) ([]byte, error) { 194 | 195 | reqURL := fmt.Sprintf("%s/rest/%s&u=%s&t=%s&s=%s&v=%s&c=%s", cfg.URL, reqParams, cfg.Creds.User, cfg.Creds.Token, cfg.Creds.Salt, cfg.Subsonic.Version, cfg.Subsonic.ID) 196 | 197 | body, err := makeRequest("GET", reqURL, nil, nil) 198 | if err != nil { 199 | return nil, fmt.Errorf("failed to make request %s", err.Error()) 200 | } 201 | 202 | var checkResp FailedResp 203 | if err = parseResp(body, &checkResp); err != nil { 204 | return nil, fmt.Errorf("failed to unmarshal request %s", err.Error()) 205 | } else if checkResp.SubsonicResponse.Status == "failed" { 206 | return nil, fmt.Errorf("%s", checkResp.SubsonicResponse.Error.Message) 207 | } 208 | return body, nil 209 | } -------------------------------------------------------------------------------- /src/youtube.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/url" 9 | "os" 10 | "strings" 11 | 12 | ffmpeg "github.com/u2takey/ffmpeg-go" 13 | "github.com/wader/goutubedl" 14 | ) 15 | 16 | type Videos struct { 17 | Items []Item `json:"items"` 18 | } 19 | 20 | type ID struct { 21 | VideoID string `json:"videoId"` 22 | } 23 | 24 | type Snippet struct { 25 | Title string `json:"title"` 26 | ChannelTitle string `json:"channelTitle"` 27 | } 28 | 29 | type Item struct { 30 | ID ID `json:"id"` 31 | Snippet Snippet `json:"snippet"` 32 | } 33 | 34 | 35 | 36 | 37 | func queryYT(cfg Youtube, track Track) Videos { // Queries youtube for the song 38 | 39 | escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Title, track.Artist)) 40 | queryURL := fmt.Sprintf("https://youtube.googleapis.com/youtube/v3/search?part=snippet&q=%s&type=video&videoCategoryId=10&key=%s", escQuery, cfg.APIKey) 41 | 42 | body, err := makeRequest("GET", queryURL, nil, nil) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | var videos Videos 47 | if err = parseResp(body, &videos); err != nil { 48 | log.Fatalf("Failed to unmarshal queryYT body: %s", err.Error()) 49 | } 50 | 51 | return videos 52 | 53 | } 54 | 55 | func getTopic(cfg Youtube, videos Videos, track Track) string { // gets song under artist topic or personal channel 56 | 57 | for _, v := range videos.Items { 58 | if (strings.Contains(v.Snippet.ChannelTitle, "- Topic") || v.Snippet.ChannelTitle == track.MainArtist) && filter(track, v.Snippet.Title, cfg.FilterList) { 59 | return v.ID.VideoID 60 | } else { 61 | continue 62 | } 63 | } 64 | return "" 65 | } 66 | 67 | func getVideo(ctx context.Context, cfg Youtube, videoID string) (*goutubedl.DownloadResult, error) { // gets video stream using yt-dlp 68 | 69 | if cfg.YtdlpPath != "" { 70 | goutubedl.Path = cfg.YtdlpPath 71 | } 72 | 73 | result, err := goutubedl.New(ctx, videoID, goutubedl.Options{}) 74 | if err != nil { 75 | return nil, fmt.Errorf("could not create URL for video download: %s", err.Error()) 76 | } 77 | 78 | downloadResult, err := result.Download(ctx, "bestaudio") 79 | if err != nil { 80 | return nil, fmt.Errorf("could not download video: %s", err.Error()) 81 | } 82 | 83 | return downloadResult, nil 84 | 85 | } 86 | 87 | func saveVideo(cfg Youtube, track Track, stream *goutubedl.DownloadResult) bool { 88 | 89 | defer stream.Close() 90 | 91 | input := fmt.Sprintf("%s%s_TEMP.mp3", cfg.DownloadDir, track.File) 92 | file, err := os.Create(input) 93 | if err != nil { 94 | log.Fatalf("Failed to create song file: %s", err.Error()) 95 | } 96 | defer file.Close() 97 | 98 | if _, err = io.Copy(file, stream); err != nil { 99 | log.Printf("Failed to copy stream to file: %s", err.Error()) 100 | os.Remove(input) 101 | return false 102 | } 103 | 104 | cmd := ffmpeg.Input(input).Output(fmt.Sprintf("%s%s.mp3", cfg.DownloadDir, track.File), ffmpeg.KwArgs{ 105 | "map": "0:a", 106 | "metadata": []string{"artist="+track.Artist,"title="+track.Title,"album="+track.Album}, 107 | "loglevel": "error", 108 | }).OverWriteOutput().ErrorToStdOut() 109 | 110 | if cfg.FfmpegPath != "" { 111 | cmd.SetFfmpegPath(cfg.FfmpegPath) 112 | } 113 | 114 | if err = cmd.Run(); err != nil { 115 | log.Printf("Failed to convert audio: %s", err.Error()) 116 | os.Remove(input) 117 | return false 118 | } 119 | os.Remove(input) 120 | return true 121 | } 122 | 123 | func gatherVideos(cfg Config, tracks []Track) { 124 | ctx := context.Background() 125 | 126 | for i := range tracks { 127 | if !tracks[i].Present { 128 | downloaded := gatherVideo(ctx, cfg.Youtube, tracks[i]) 129 | 130 | // If "test" discovery mode is enabled, download just one song and break 131 | if cfg.Listenbrainz.Discovery == "test" && downloaded { 132 | log.Println("Using 'test' discovery method. Downloaded 1 song.") 133 | break 134 | } 135 | } 136 | } 137 | } 138 | 139 | func gatherVideo(ctx context.Context, cfg Youtube, track Track) bool { 140 | // Query YouTube for videos matching the track 141 | videos := queryYT(cfg, track) 142 | 143 | // Try to get the video from the official or topic channel 144 | if id := getTopic(cfg, videos, track); id != "" { 145 | return fetchAndSaveVideo(ctx, cfg, track, id) 146 | 147 | } 148 | 149 | // If official video isn't found, try the first suitable channel 150 | for _, video := range videos.Items { 151 | if filter(track, video.Snippet.Title, cfg.FilterList) { 152 | return fetchAndSaveVideo(ctx, cfg, track, video.ID.VideoID) 153 | } 154 | } 155 | 156 | return false 157 | } 158 | 159 | func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track Track, videoID string) bool { 160 | stream, err := getVideo(ctx, cfg, videoID) 161 | if err != nil { 162 | log.Printf("failed getting stream for video ID %s: %s", videoID, err.Error()) 163 | return false 164 | } 165 | 166 | if stream != nil { 167 | return saveVideo(cfg, track, stream) 168 | } 169 | 170 | log.Printf("stream was nil for video ID %s", videoID) 171 | return false 172 | } 173 | 174 | func filter(track Track, videoTitle string, filterList []string) bool { // ignore artist lives or song remixes 175 | 176 | for _, keyword := range filterList { 177 | if (!contains(track.Title,keyword) && !contains(track.Artist, keyword) && contains(videoTitle, keyword)) { 178 | return false 179 | } 180 | } 181 | return true 182 | } 183 | 184 | func contains(str string, substr string) bool { 185 | 186 | return strings.Contains( 187 | strings.ToLower(str), 188 | strings.ToLower(substr), 189 | ) 190 | } --------------------------------------------------------------------------------