├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── cd.yml │ └── docker-build-push.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.dev ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── _web ├── _config.yml ├── _data │ ├── faq.yml │ ├── nav.yml │ └── var.yml ├── _includes │ ├── footer.html │ ├── go-js.html │ ├── head.html │ ├── manual-analysis-script.js │ ├── manual_analysis.html │ ├── nav.html │ ├── search-js.html │ ├── tweet.html │ └── worker-js.html ├── _layouts │ ├── compress.html │ ├── default.html │ ├── go.html │ ├── retro.html │ └── search.html ├── _plugins │ └── liquify_filter.rb ├── canonizer_go.html ├── canonizer_index.html ├── canonizer_search.html ├── faq.html ├── files │ ├── apple-touch-icon.png │ ├── canonizer_jremix.js │ ├── canonizer_styles.css │ ├── css.css │ ├── eternal.png │ ├── eternal_circle.png │ ├── favicon.png │ ├── jquery-ui.css │ ├── jremix.js │ ├── jukebox.png │ ├── raphael-min.js │ ├── ss.png │ ├── styles.css │ ├── tweet_button.html │ ├── underscore-min.js │ ├── watch.js │ └── widgets.js ├── jukebox_go.html ├── jukebox_index.html ├── jukebox_search.html ├── retro_faq.html ├── retro_index.html └── worker.js ├── build.gradle ├── cliff.toml ├── config_template.json ├── config_template.yaml ├── database └── eternal_jukebox.mv.db.init ├── docker-compose.dev.yaml ├── docker-compose.yml ├── envvar_config.yaml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── release.ps1 ├── release_tag.toml ├── settings.gradle ├── src └── main │ ├── kotlin │ └── org │ │ └── abimon │ │ ├── eternalJukebox │ │ ├── BufferDataSource.kt │ │ ├── CoroutineUtils.kt │ │ ├── EternalJukebox.kt │ │ ├── FuelExtensions.kt │ │ ├── GeneralUtils.kt │ │ ├── LocalisedSnowstorm.kt │ │ ├── MediaWrapper.kt │ │ ├── VertxExtensions.kt │ │ ├── data │ │ │ ├── NodeSource.kt │ │ │ ├── analysis │ │ │ │ ├── IAnalyser.kt │ │ │ │ └── SpotifyAnalyser.kt │ │ │ ├── analytics │ │ │ │ ├── HTTPAnalyticsProvider.kt │ │ │ │ ├── IAnalyticsProvider.kt │ │ │ │ ├── IAnalyticsStorage.kt │ │ │ │ ├── InfluxAnalyticsStorage.kt │ │ │ │ ├── LocalAnalyticStorage.kt │ │ │ │ └── SystemAnalyticsProvider.kt │ │ │ ├── audio │ │ │ │ ├── DownloaderImpl.kt │ │ │ │ ├── IAudioSource.kt │ │ │ │ ├── NodeAudioSource.kt │ │ │ │ └── YoutubeAudioSource.kt │ │ │ ├── database │ │ │ │ ├── H2Database.kt │ │ │ │ ├── HikariDatabase.kt │ │ │ │ ├── IDatabase.kt │ │ │ │ └── JDBCDatabase.kt │ │ │ └── storage │ │ │ │ ├── GoogleStorage.kt │ │ │ │ ├── IStorage.kt │ │ │ │ └── LocalStorage.kt │ │ ├── handlers │ │ │ ├── PopularHandler.kt │ │ │ ├── StaticResources.kt │ │ │ └── api │ │ │ │ ├── AnalysisAPI.kt │ │ │ │ ├── AudioAPI.kt │ │ │ │ ├── IAPI.kt │ │ │ │ ├── NodeAPI.kt │ │ │ │ └── SiteAPI.kt │ │ └── objects │ │ │ ├── ClientInfo.kt │ │ │ ├── ConstantValues.kt │ │ │ ├── CoroutineClientInfo.kt │ │ │ ├── EmptyDataAPI.kt │ │ │ ├── EnumAnalyticType.kt │ │ │ ├── EnumAnalyticsProvider.kt │ │ │ ├── EnumAnalyticsStorage.kt │ │ │ ├── EnumAudioSystem.kt │ │ │ ├── EnumDatabaseType.kt │ │ │ ├── EnumStorageSystem.kt │ │ │ ├── EnumStorageType.kt │ │ │ ├── JukeboxConfig.kt │ │ │ ├── JukeboxInfo.kt │ │ │ ├── SpotifyError.kt │ │ │ ├── SpotifyInfo.kt │ │ │ └── YoutubeVideos.kt │ │ └── visi │ │ ├── io │ │ ├── DataSource.kt │ │ └── VIO.kt │ │ ├── lang │ │ └── VLang.kt │ │ ├── security │ │ └── VSecurity.kt │ │ └── time │ │ └── VTime.kt │ └── resources │ ├── META-INF │ └── MANIFEST.MF │ └── hikari.properties ├── yt.bat └── yt.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore .git folder 2 | .git 3 | 4 | # ignore data directories 5 | data 6 | 7 | # ignore markdown files 8 | *.md 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # only used for docker 2 | 3 | PORT= 4 | 5 | # Spotify Client / Secret; make an application over here: https://developer.spotify.com/my-applications/ 6 | SPOTIFYCLIENT= 7 | SPOTIFYSECRET= 8 | 9 | # This can be obtained from here: https://developers.google.com/youtube/v3/getting-started 10 | YOUTUBE_API_KEY= 11 | 12 | # database setup, can be left untouched if the database isn't manually exposed 13 | #SQL_USERNAME=eternal_user 14 | #SQL_PASSWORD=36ngEC5AmoLT6x 15 | #SQL_TYPE=mysql 16 | #SQL_HOST=db 17 | #SQL_PORT=3306 18 | #SQL_DATABASE_NAME=eternaljukebox 19 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | generate-changelog: 12 | name: Generate changelog 13 | runs-on: ubuntu-22.04 14 | outputs: 15 | changelog_body: ${{ steps.git-cliff.outputs.content }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Generate a changelog 22 | uses: orhun/git-cliff-action@main 23 | id: git-cliff 24 | with: 25 | config: cliff.toml 26 | args: -vv --latest --no-exec --github-repo ${{ github.repository }} 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | publish: 31 | name: Build and publish shadowJar 32 | needs: generate-changelog 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout sources 36 | uses: actions/checkout@v4 37 | - name: Setup Java 38 | uses: actions/setup-java@v4 39 | with: 40 | distribution: 'temurin' 41 | java-version: 17 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v4 44 | - name: Build with Gradle 45 | run: ./gradlew shadowDistTar shadowDistZip 46 | - name: Move to out dir 47 | run: | 48 | mkdir -p out 49 | mv build/distributions/* out/ 50 | mv build/libs/* out/ 51 | - name: Upload Binaries to Release 52 | uses: svenstaro/upload-release-action@v2 53 | with: 54 | repo_token: ${{ secrets.GITHUB_TOKEN }} 55 | tag: ${{ github.ref }} 56 | body: ${{ needs.generate-changelog.outputs.changelog_body }} 57 | file: out/* 58 | file_glob: true 59 | overwrite: true 60 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v3 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v2 18 | - 19 | name: Login to Docker Hub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | - 25 | name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v2 27 | - 28 | name: Build and push 29 | uses: docker/build-push-action@v4 30 | with: 31 | context: . 32 | platforms: linux/amd64,linux/arm64/v8 33 | file: ./Dockerfile 34 | push: true 35 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/eternaljukebox:latest 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/tasks.xml 8 | .idea/dictionaries 9 | 10 | # Sensitive or high-churn files: 11 | .idea/**/dataSources.ids 12 | .idea/**/sqlDataSources.xml 13 | .idea/**/dynamic.xml 14 | .idea/**/uiDesigner.xml 15 | 16 | # Gradle: 17 | # Mongo Explorer plugin: 18 | .idea/**/mongoSettings.xml 19 | 20 | ## File-based project format: 21 | *.iws 22 | 23 | ## Plugin-specific files: 24 | 25 | # IntelliJ 26 | /out/ 27 | 28 | # mpeltonen/sbt-idea plugin 29 | .idea_modules/ 30 | 31 | # JIRA plugin 32 | atlassian-ide-plugin.xml 33 | 34 | # Crashlytics plugin (for Android Studio and IntelliJ) 35 | com_crashlytics_export_strings.xml 36 | crashlytics.properties 37 | crashlytics-build.properties 38 | fabric.properties 39 | 40 | .gradle/ 41 | .idea/ 42 | .vertx/ 43 | EternalJukebox.iml 44 | audio/ 45 | info/ 46 | config.json 47 | logs/ 48 | musicmachinery/ 49 | selfSigned.cert 50 | selfSigned.key 51 | songs/ 52 | ssl/ 53 | target/ 54 | trid/ 55 | trid_audio/ 56 | working/ 57 | !/src/main/kotlin/org/abimon/eternalJukebox/data/audio 58 | /data/ 59 | /database/* 60 | !/database/eternal_jukebox.mv.db.init 61 | web 62 | 63 | # ignore config files 64 | config.yml 65 | config.yaml 66 | config.json 67 | 68 | # docker-compose environment 69 | .env 70 | 71 | build/ 72 | bin/ 73 | 74 | *.log 75 | *.db 76 | referrers.txt -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.0.1] - 2025-02-14 6 | 7 | ### 🚀 Features 8 | 9 | - Add some additional debugging info for coroutines by @UnderMybrella 10 | 11 | 12 | ### 🐛 Bug Fixes 13 | 14 | - *(youtube)* Fix the Windows batch file erroneously splitting YouTube URLs into two parameters by @UnderMybrella 15 | 16 | - *(youtube)* Deserialisation should no longer throw an uncaught exception for Youtube Search. by @UnderMybrella 17 | 18 | 19 | ### ⚙️ Miscellaneous Tasks 20 | 21 | - Add comments for locked versioning by @UnderMybrella 22 | 23 | - Create GitHub Action for publishing changelog and shadowJar 24 | 25 | - Commit cliff.toml and CHANGELOG.md 26 | 27 | - Output changelog to file and include changelog in release 28 | 29 | - Include GitHub usernames, output Markdown for changelog. 30 | 31 | - Simplify push command, hopefully actually resolves now 32 | 33 | - *(release)* Move changelog generation to release.ps1 34 | 35 | - *(release)* Add release_tag.toml 36 | 37 | - *(release)* Add commit message 38 | 39 | 40 | ## [1.0.0] - 2025-01-19 41 | 42 | ### 💼 Other 43 | 44 | - IAnalyser.kt, and add info objects (I'll trim down the Spotify ones soon) by @UnderMybrella 45 | 46 | - FlatFileDatabase and JukeboxAccount by @UnderMybrella 47 | 48 | - HTTPAnalyticsProvider and InfluxAnalyticsStorage; give Vertx workers 90s by default of work time by @UnderMybrella 49 | 50 | - IAnalyser.kt, and add info objects (I'll trim down the Spotify ones soon) by @UnderMybrella 51 | 52 | - GoogleStorage by @UnderMybrella 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # set up the main image with dependencies first, to avoid re-doing this after each build 2 | FROM amazoncorretto:11-alpine as deps 3 | 4 | WORKDIR /EternalJukebox 5 | 6 | RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \ 7 | && chmod a+rx /usr/local/bin/yt-dlp 8 | 9 | RUN apk update \ 10 | && apk add ffmpeg gettext python3 \ 11 | && touch hikari.properties 12 | 13 | # build jar with gradle 14 | 15 | FROM gradle:8-jdk11 as gradle-build 16 | 17 | WORKDIR /home/gradle/project 18 | 19 | # Only copy dependency-related files 20 | COPY build.gradle gradle.propertie* settings.gradle ./EternalJukebox/ 21 | 22 | # Only download dependencies 23 | # Eat the expected build failure since no source code has been copied yet 24 | RUN gradle clean shadowJar --no-daemon > /dev/null 2>&1 || true 25 | 26 | COPY . ./EternalJukebox 27 | 28 | WORKDIR /home/gradle/project/EternalJukebox 29 | 30 | RUN gradle clean shadowJar --no-daemon 31 | 32 | # build web with jekyll 33 | 34 | FROM rockstorm/jekyll:latest as jekyll-build 35 | 36 | WORKDIR /EternalJukebox 37 | 38 | COPY --from=gradle-build /home/gradle/project/EternalJukebox . 39 | 40 | RUN chmod -R 777 . && jekyll build --source _web --destination web 41 | 42 | # copy into main image 43 | 44 | FROM deps as main 45 | 46 | COPY --from=jekyll-build /EternalJukebox/ ./ 47 | COPY --from=gradle-build /home/gradle/project/EternalJukebox/build/libs/* ./ 48 | 49 | # envsubst is used so environment variables can be used instead of a config file 50 | 51 | CMD envsubst < "/EternalJukebox/envvar_config.yaml" > "/EternalJukebox/config.yaml"\ 52 | && java -jar EternalJukebox-all.jar 53 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # set up the main image with dependencies first, to avoid re-doing this after each build 2 | FROM amazoncorretto:11-alpine as deps 3 | 4 | WORKDIR /EternalJukebox 5 | 6 | RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \ 7 | && chmod a+rx /usr/local/bin/yt-dlp 8 | 9 | RUN apk update \ 10 | && apk add ffmpeg gettext python3 \ 11 | && touch hikari.properties 12 | 13 | # build jar with gradle 14 | 15 | FROM gradle:8-jdk11 as gradle-build 16 | 17 | WORKDIR /home/gradle/project 18 | 19 | # Only copy dependency-related files 20 | COPY build.gradle gradle.propertie* settings.gradle ./EternalJukebox/ 21 | 22 | # Only download dependencies 23 | # Eat the expected build failure since no source code has been copied yet 24 | RUN gradle clean shadowJar --no-daemon > /dev/null 2>&1 || true 25 | 26 | COPY . ./EternalJukebox 27 | 28 | WORKDIR /home/gradle/project/EternalJukebox 29 | 30 | # Do not build on the dev docker container 31 | # We'll use the mounted from the host machine becuase that's faster 32 | #RUN gradle clean shadowJar --no-daemon 33 | 34 | # build web with jekyll 35 | 36 | FROM rockstorm/jekyll:latest as jekyll-build 37 | 38 | WORKDIR /EternalJukebox 39 | 40 | COPY --from=gradle-build /home/gradle/project/EternalJukebox . 41 | 42 | RUN chmod -R 777 . && jekyll build --source _web --destination web 43 | 44 | # copy into main image 45 | 46 | FROM deps as main 47 | 48 | COPY --from=jekyll-build /EternalJukebox/ ./ 49 | COPY --from=gradle-build /home/gradle/project/EternalJukebox/build/libs/* ./ 50 | 51 | # envsubst is used so environment variables can be used instead of a config file 52 | 53 | CMD envsubst < "/EternalJukebox/envvar_config.yaml" > "/EternalJukebox/config.yaml"\ 54 | && java -jar EternalJukebox-all.jar 55 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Before 2 | - [ ] I have checked for similar issues already 3 | - [ ] My issue is not a bad syncronisation between the Spotify track and the YouTube audio (Those go [here.](https://github.com/UnderMybrella/EternalJukebox/issues/31)) 4 | 5 | ### Description 6 | [Description of the issue] 7 | 8 | ### Console Logs 9 | [To obtain these, open the JavaScript console and copy the text here] 10 | 11 | ### Song 12 | [Link to the **full** Eternal Jukebox link] 13 | 14 | ### Additional Information 15 | [Any other information that may be able to help me with the problem] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 UnderMybrella 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EternalJukebox 2 | 3 | This repository is a fork which fixes bugs because the upstream repo is unmaintained. 4 | 5 | You can visit the hosted instance of this repository [here](https://eternalbox.floriegl.tech), in case you want to mess around with it without doing all the hard stuff. 6 | 7 | The source files for the EternalJukebox, a rehosting of the Infinite Jukebox. 8 | This repo contains everything you need to host the EternalJukebox on your own server! 9 | 10 | # Docker Install 11 | 12 | ## Prerequisites 13 | 14 | You need to install [docker](https://docs.docker.com/engine/install/) and [docker-compose](https://docs.docker.com/compose/install/) 15 | 16 | ## Configuration 17 | 18 | To configure, grab `.env.example` from this repository, rename it to `.env` and change the appropriate values. 19 | 20 | You'll also need `envvar_config.yaml`, but only edit this if you want some advanced configuration. 21 | 22 | ## Running 23 | 24 | You can use the following `docker-compose.yaml` as a starting point to run the application without needing a database running, if you want to use a db refer to the main `docker-compose.yaml`. 25 | 26 | ```yaml 27 | version: "3" 28 | 29 | services: 30 | main: 31 | image: daviirodrig/eternaljukebox 32 | ports: 33 | - 8080:8080 34 | env_file: 35 | - .env 36 | volumes: 37 | - "./envvar_config.yaml:/EternalJukebox/envvar_config.yaml" 38 | ``` 39 | 40 | To start, run `docker compose up -d` in the folder containing `envvar_config.yaml`, `.env` and `docker-compose.yaml`. To stop, run `docker compose down`. 41 | 42 | If you want to you can upgrade the image by pulling the newest with `docker pull daviirodrig/eternaljukebox` and then restart with `docker compose down` and `docker compose up -d` 43 | 44 | If you want to change the port from 8080, edit `docker-compose.yml` port, to be `- :8080` 45 | 46 | # Manual Install 47 | 48 | ## Prerequisites 49 | 50 | ### Java: 51 | 52 | ##### Windows 53 | 54 | Download and install Java from https://www.java.com/en/download/ 55 | 56 | ##### Debian-based Linux distributions 57 | 58 | For Ubuntu or Debian-based distributions execute `sudo apt-get install default-jre` in the terminal 59 | 60 | ##### Fedora and CentOS 61 | 62 | There is a tutorial for installing java on Fedora and CentOS at https://www.digitalocean.com/community/tutorials/how-to-install-java-on-centos-and-fedora 63 | 64 | ### Yt-dlp (a more up-to-date fork of Youtube-dl): 65 | 66 | ##### Windows 67 | 68 | Download the .exe at https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe and place it in `C:\Windows\`, or in another folder on the PATH. 69 | 70 | ##### Linux 71 | 72 | Use these commands in the terminal to install youtube-dl on Linux: 73 | `sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp` 74 | `sudo chmod a+rx /usr/local/bin/yt-dlp` 75 | 76 | ### ffmpeg: 77 | 78 | ##### Windows 79 | 80 | Download the exe from https://ffmpeg.zeranoe.com/builds/ and place it in `C:\Windows\`, or in another folder on teh PATH. 81 | 82 | ##### Linux 83 | 84 | ffmpeg is available to download in most distributions using `sudo apt-get install ffmpeg` or equivalent 85 | 86 | ## Getting the project files: 87 | 88 | The whole process of obtaining project files is much easier now, as the build process is streamlined through Jenkins. 89 | 90 | The project site is over [here](https://jenkins.abimon.org/job/EternalJukebox/), and contains the individual files to download, or an all-in-one zip for all the files. Alternatively, the files can be found over at a permanent server [here](https://abimon.org/eternal_jukebox) 91 | 92 | ## Configuring 93 | 94 | First thing to do is create a new file called either `config.yaml` or `config.json` (YAML tends to be easier to write, but takes up slightly more space), then open it with notepad/notepad++ on Windows and whatever text editor you like on Linux (for example nano: `nano config.json`) 95 | 96 | Now you should go to https://developer.spotify.com/my-applications/ and log in to your spotify account. 97 | Then click the "Create an app" button and a new page should popup. 98 | There give it a name and description and click create. 99 | It should send you to the new app's page, the only thing you need from here is your Client ID and Client Secret 100 | (Note: Never share these with anyone!) 101 | 102 | You will also need a Youtube Data API key, which you can find about how to obtain [here](https://developers.google.com/youtube/v3/getting-started). 103 | 104 | There are a variety of config options (documentation coming soon) that allow most portions of the EternalJukebox to be configured, and these can be entered here. 105 | 106 | ## Starting the server: 107 | 108 | First you need to open the Terminal or Command Prompt. 109 | Then make sure its running in the folder that your EternalJukebox.jar is in, once again to do this use the `cd` command. 110 | Then execute the jar with `java -jar EternalJukebox.jar` 111 | 112 | If everything went right it should say `Listening at http://0.0.0.0:11037` 113 | 114 | you should now be able to connect to it with a browser through http://localhost:11037 115 | 116 | Congrats you did it! 117 | 118 | ## Manually Building 119 | 120 | This is not recommended unless you're making some modifications, and as such should only be performed by more advanced users 121 | 122 | You'll need to obtain a copy of [Gradle](https://gradle.org/install/), likely a [JDK](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html), and [Jekyll](https://jekyllrb.com/). You'll also need the project files in some capacity, be it `git clone` or downloading the archive from GitHub. 123 | 124 | From there, building in Gradle is simple; just run `gradle clean shadowJar` from the project file directory. That should produce a jar file in `build/libs` that will work for you. In addition, you'll need to build the Jekyll webpages, which can be done by running `jekyll build --source _web --destination web` 125 | -------------------------------------------------------------------------------- /_web/_config.yml: -------------------------------------------------------------------------------- 1 | name: The Eternal Jukebox 2 | url: https://eternalbox.floriegl.tech 3 | 4 | defaults: 5 | - 6 | scope: 7 | path: "" # an empty string here means all files in the project 8 | values: 9 | layout: "default" 10 | 11 | compress_html: 12 | clippings: all 13 | endings: all 14 | ignore: 15 | envs: [] 16 | blanklines: true 17 | profile: false # Enable to see a table at the bottom of the page to show how much it was compressed, make sure to disable for production. 18 | startings: [html, head, body] 19 | -------------------------------------------------------------------------------- /_web/_data/faq.yml: -------------------------------------------------------------------------------- 1 | faq: 2 | - question: What is this? 3 | id: about 4 | answer: "_For when your favorite song just isn't long enough._\n 5 | This web app lets you search a song on Spotify and will then generate a never-ending and ever changing version of the song. It does what Infinite Gangnam Style did but for any song." 6 | 7 | - question: How does it work? 8 | id: technical 9 | answer: "We use the [Spotify API](https://developer.spotify.com/web-api/get-audio-features/) to break the song into beats. We play the song beat by beat, but at every beat there's a chance that we will jump to a different part of song that happens to sound very similar to the current beat. For beat similarity we look at pitch, timbre, loudness, duration and the position of the beat within a bar. There's a nifty visualization that shows all the possible transitions that can occur at any beat.\n\n 10 | The backend is written in [Kotlin](https://kotlinlang.org/) and hosted on a [Google Compute](https://console.cloud.google.com/) server. The source code is available [here.]({{ site.data.var.git }})" 11 | 12 | - question: Are there any ways to control the song? 13 | id: control 14 | answer: "Yes - here are some keys:\n 15 | * **[Space]** - Start and stop playing the song\n 16 | * **[Left Arrow]** - Decrement the current play velocity by one\n 17 | * **[Right Arrow]** - Increment the current play velocity by one\n 18 | * **[Down Arrow]** - Sets the current play velocity to zero\n 19 | * **[Control]** - freeze on the current beat\n 20 | * **[Shift]** - bounce between the current beat and all of the similar sounding beats. These are the branch points.\n 21 | * **'H'** - Bring it on home - toggles infinite mode off/on." 22 | 23 | - question: What do the coloured blocks represent? 24 | id: colours 25 | answer: Each block represents a beat in the song. The colors are related to the timbre of the music for that beat. 26 | 27 | - question: How can I tune the Jukebox? 28 | id: tune 29 | answer: "(For detailed tuning instructions see ['Tuning the Infinite Jukebox'](http://musicmachinery.com/2012/11/26/tuning-the-infinite-jukebox/) on Music Machinery.)\n 30 | This is a mostly experimental feature.\n 31 | 32 | * You can tune by clicking the tune button.\n 33 | * Adjust the slider to the left for higher audio quality, and adjust the slider to the right for more branch points.\n 34 | * You can also delete any edge by clicking on it to select it (when selected the edge turns red).\n 35 | * Delete the edge by pressing the **[del]** key.\n 36 | * The Infinite Jukebox will try hard to maximize the amount of the song that is played when in infinite mode.\n 37 | * This behavior can be turned off by de-selecting the 'Loop Extension Optimization' checkbox.\n 38 | * If we don't get the track right for the song you request, you can change the audio by putting in a YouTube URL into the Audio URL box.\n 39 | * You can also upload an audio track for the current song using the 'Browse' button\n 40 | * You can throw away all of your tunings by pressing the 'reset' button.\n 41 | * You can share your tuned songs, all your edits are encoded in the URL." 42 | 43 | - question: I have an awesome infinite track that I'd like everyone to hear. What do I do? 44 | id: sharing 45 | answer: "You can tweet it with the tags [#EternalJukebox](https://twitter.com/hashtag/eternaljukebox)\n\n 46 | ...or you could submit it to the [InfiniteJukebox subreddit](https://www.reddit.com/r/infinitejukebox)\n\n 47 | ...or you might want to share it in the [Eternal Jukebox Discord](https://discord.gg/KWN5BfD).\n\n 48 | 49 | You can either copy the URL of the page you're on, or you can click the 'Share' button to obtain a nicer link to use." 50 | 51 | - question: Who made the cool logo? 52 | id: logo 53 | answer: The logo was contributed by [Jasper Allijn](http://jasperallijn.crevado.com/) 54 | 55 | - question: Who made this? 56 | id: creator 57 | answer: "The original site was made by [Paul Lamere](http://twitter.com/plamere) at [Music Hack Day Boston](http://boston.musichackday.org/) on November 11, 2012 (More info at [Music Machinery](http://musicmachinery.com/2012/11/12/the-infinite-jukebox/) ), and used to be hosted over [here.](http://labs.echonest.com/Uploader/index.html) 58 | This site is a rework of the original project, and it was hosted by [UnderMybrella](https://github.com/UnderMybrella/EternalJukebox) and [daviirodrig](https://github.com/daviirodrig/EternalJukebox), but this instance is hosted by [floriegl]({{ site.data.var.git }})" 59 | 60 | - question: Help! My audio seems to be jumping randomly! 61 | id: random-jump 62 | answer: "There's a couple of reasons this could be:\n\n 63 | The most likely being that the audio file we found for your song doesn't match up perfectly with the version that Spotify has, which means you'll experience jumps at points that there shouldn't be throughout a song.\n\n 64 | The solution to this is to find a version of it that matches up to the version on Spotify **as close as possible**, or to upload your own track, if you can't find one online.\n\n 65 | If you _do_ have a song that seems to be synchronised perfectly, but still seems to jump randomly, feel free to file an issue [here]({{ site.data.var.git }}/issues)" 66 | 67 | - question: Help! Something broke! 68 | id: issue 69 | answer: This is still a fairly early build, so there may be some bugs in the server. If you do find an issue, please file an issue [here]({{ site.data.var.git }}/issues). 70 | -------------------------------------------------------------------------------- /_web/_data/nav.yml: -------------------------------------------------------------------------------- 1 | main-dropdown: 2 | - title: The Eternal Jukebox 3 | url: jukebox_index.html 4 | 5 | - title: The (Retro) Eternal Jukebox 6 | url: retro_index.html 7 | 8 | - title: The Autocanonizer 9 | url: canonizer_index.html 10 | 11 | social: 12 | - title: Infinite Jukebox Reddit 13 | id: show-reddit 14 | url: https://www.reddit.com/r/infinitejukebox/ 15 | - title: Eternal Jukebox Discord 16 | id: show-discord 17 | url: https://discord.gg/KWN5BfD 18 | 19 | bar: 20 | - title: Main 21 | id: show-main 22 | canonizer: canonizer_index.html 23 | jukebox: jukebox_index.html 24 | - title: Pick a song 25 | id: show-loader 26 | canonizer: canonizer_search.html 27 | jukebox: jukebox_search.html 28 | - title: About 29 | id: show-about 30 | canonizer: http://musicmachinery.com/2014/03/13/the-autocanonizer 31 | jukebox: http://musicmachinery.com/2012/11/12/the-infinite-jukebox 32 | -------------------------------------------------------------------------------- /_web/_data/var.yml: -------------------------------------------------------------------------------- 1 | cdn: "https://maxcdn.bootstrapcdn.com" 2 | git: "https://github.com/floriegl/EternalJukebox" 3 | jquery: "https://code.jquery.com" 4 | version: "1-2-1" 5 | -------------------------------------------------------------------------------- /_web/_includes/footer.html: -------------------------------------------------------------------------------- 1 | {% if page.layout == 'retro' %} 2 | 20 | {% else %} 21 |
22 |
23 | 24 | Originally built at {% if page.app == 'jukebox' %}Music Hack Day @ MIT{% elsif page.app == 'canonizer' %}SXSW Music Hack Championship 2014{% endif %} by 25 | Paul Lamere 26 | This instance is hosted by floriegl and 27 | powered by Spotify. 28 | 29 |
30 | {% endif %} 31 | -------------------------------------------------------------------------------- /_web/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if page.layout == 'go' %}{% endif %} 4 | {% if page.layout == 'go' %}{% endif %} 5 | 6 | {% if page.app == 'jukebox' and page.layout == 'go' %} 7 | {% include worker-js.html %}{% endif %} 8 | 9 | 10 | {% if page.layout == 'go' %}{% endif %} 11 | 12 | 13 | 14 | 15 | {{ page.title }} 16 | 17 | 18 | {% if page.type == 'faq' %} 19 | 29 | {% endif %} 30 | 41 | 42 | {% if page.layout == 'go' %} 43 | 44 | 45 | {% endif %} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /_web/_includes/manual-analysis-script.js: -------------------------------------------------------------------------------- 1 | function downloadAudioAnalysis(trackId, playerSDK) { 2 | const bearerToken = playerSDK._client?._transport?._lastToken; 3 | bearerToken && fetch(`https://api.spotify.com/v1/audio-analysis/${trackId}`, { 4 | headers: { 'Authorization': `Bearer ${bearerToken}`, 'Content-Type': 'application/json' } 5 | }).then(r => r.ok && r.json().then(data => { 6 | const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); 7 | const link = document.createElement('a'); 8 | link.href = url; 9 | link.download = `${trackId}.json`; 10 | link.click(); 11 | URL.revokeObjectURL(url); 12 | })); 13 | } 14 | 15 | function checkForSongChanges(playerSDK) { 16 | let currentTrackId = null; 17 | const updateTrack = (playerState) => { 18 | const currentTrack = playerState?.track_window?.current_track; 19 | if (currentTrack && currentTrack.content_type === 'music' && currentTrack.id !== currentTrackId) { 20 | currentTrackId = currentTrack.id; 21 | downloadAudioAnalysis(currentTrackId, playerSDK); 22 | } 23 | }; 24 | updateTrack(playerSDK._controller?._state); 25 | playerSDK._listeners['state_changed'].push({ listener: (e) => updateTrack(e.data?.state), options: {} }); 26 | } 27 | 28 | const waitForPlayerSDKInterval = setInterval(() => { 29 | const nowPlayingBar = document.querySelector('[data-testid=\'now-playing-bar\']'); 30 | if (nowPlayingBar) { 31 | let fiberNode = null; 32 | for (const key in nowPlayingBar) { 33 | if (key.startsWith('__reactFiber$')) { 34 | fiberNode = nowPlayingBar[key]; 35 | break; 36 | } 37 | } 38 | while (fiberNode && !fiberNode.memoizedProps?.value?._map) fiberNode = fiberNode?.return; 39 | if (fiberNode) for (const [k, v] of fiberNode.memoizedProps.value._map) { 40 | if (k.toString() === 'Symbol(PlayerSDK)') { 41 | const playerSDK = v?.instance?.harmony; 42 | if (playerSDK) { 43 | clearInterval(waitForPlayerSDKInterval); 44 | checkForSongChanges(playerSDK); 45 | break; 46 | } 47 | } 48 | } 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /_web/_includes/manual_analysis.html: -------------------------------------------------------------------------------- 1 | 131 | -------------------------------------------------------------------------------- /_web/_includes/nav.html: -------------------------------------------------------------------------------- 1 | 46 | -------------------------------------------------------------------------------- /_web/_includes/search-js.html: -------------------------------------------------------------------------------- 1 | 111 | -------------------------------------------------------------------------------- /_web/_includes/tweet.html: -------------------------------------------------------------------------------- 1 | 2 | {% if page.layout == 'retro' %} 3 | 7 | {% else %} 8 | 10 | {% endif %} 11 | 21 | 22 | -------------------------------------------------------------------------------- /_web/_includes/worker-js.html: -------------------------------------------------------------------------------- 1 | 58 | -------------------------------------------------------------------------------- /_web/_layouts/compress.html: -------------------------------------------------------------------------------- 1 | --- 2 | # Jekyll layout that compresses HTML 3 | # v3.0.2 4 | # http://jch.penibelst.de/ 5 | # © 2014–2015 Anatol Broder 6 | # MIT License 7 | --- 8 | 9 | {% capture _LINE_FEED %} 10 | {% endcapture %}{% if site.compress_html.ignore.envs contains jekyll.environment %}{{ content }}{% else %}{% capture _content %}{{ content }}{% endcapture %}{% assign _profile = site.compress_html.profile %}{% if site.compress_html.endings == "all" %}{% assign _endings = "html head body li dt dd p rt rp optgroup option colgroup caption thead tbody tfoot tr td th" | split: " " %}{% else %}{% assign _endings = site.compress_html.endings %}{% endif %}{% for _element in _endings %}{% capture _end %}{% endcapture %}{% assign _content = _content | remove: _end %}{% endfor %}{% if _profile and _endings %}{% assign _profile_endings = _content | size | plus: 1 %}{% endif %}{% for _element in site.compress_html.startings %}{% capture _start %}<{{ _element }}>{% endcapture %}{% assign _content = _content | remove: _start %}{% endfor %}{% if _profile and site.compress_html.startings %}{% assign _profile_startings = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.comments == "all" %}{% assign _comments = "" | split: " " %}{% else %}{% assign _comments = site.compress_html.comments %}{% endif %}{% if _comments.size == 2 %}{% capture _comment_befores %}.{{ _content }}{% endcapture %}{% assign _comment_befores = _comment_befores | split: _comments.first %}{% for _comment_before in _comment_befores %}{% if forloop.first %}{% continue %}{% endif %}{% capture _comment_outside %}{% if _carry %}{{ _comments.first }}{% endif %}{{ _comment_before }}{% endcapture %}{% capture _comment %}{% unless _carry %}{{ _comments.first }}{% endunless %}{{ _comment_outside | split: _comments.last | first }}{% if _comment_outside contains _comments.last %}{{ _comments.last }}{% assign _carry = false %}{% else %}{% assign _carry = true %}{% endif %}{% endcapture %}{% assign _content = _content | remove_first: _comment %}{% endfor %}{% if _profile %}{% assign _profile_comments = _content | size | plus: 1 %}{% endif %}{% endif %}{% assign _pre_befores = _content | split: "" %}{% assign _pres_after = "" %}{% if _pres.size != 0 %}{% if site.compress_html.blanklines %}{% assign _lines = _pres.last | split: _LINE_FEED %}{% capture _pres_after %}{% for _line in _lines %}{% assign _trimmed = _line | split: " " | join: " " %}{% if _trimmed != empty or forloop.last %}{% unless forloop.first %}{{ _LINE_FEED }}{% endunless %}{{ _line }}{% endif %}{% endfor %}{% endcapture %}{% else %}{% assign _pres_after = _pres.last | split: " " | join: " " %}{% endif %}{% endif %}{% capture _content %}{{ _content }}{% if _pre_before contains "" %}{% endif %}{% unless _pre_before contains "" and _pres.size == 1 %}{{ _pres_after }}{% endunless %}{% endcapture %}{% endfor %}{% if _profile %}{% assign _profile_collapse = _content | size | plus: 1 %}{% endif %}{% if site.compress_html.clippings == "all" %}{% assign _clippings = "html head title base link meta style body article section nav aside h1 h2 h3 h4 h5 h6 hgroup header footer address p hr blockquote ol ul li dl dt dd figure figcaption main div table caption colgroup col tbody thead tfoot tr td th" | split: " " %}{% else %}{% assign _clippings = site.compress_html.clippings %}{% endif %}{% for _element in _clippings %}{% assign _edges = " ;; ;" | replace: "e", _element | split: ";" %}{% assign _content = _content | replace: _edges[0], _edges[1] | replace: _edges[2], _edges[3] | replace: _edges[4], _edges[5] %}{% endfor %}{% if _profile and _clippings %}{% assign _profile_clippings = _content | size | plus: 1 %}{% endif %}{{ _content }}{% if _profile %}
Step Bytes
raw {{ content | size }}{% if _profile_endings %}
endings {{ _profile_endings }}{% endif %}{% if _profile_startings %}
startings {{ _profile_startings }}{% endif %}{% if _profile_comments %}
comments {{ _profile_comments }}{% endif %}{% if _profile_collapse %}
collapse {{ _profile_collapse }}{% endif %}{% if _profile_clippings %}
clippings {{ _profile_clippings }}{% endif %}
{% endif %}{% endif %} 11 | -------------------------------------------------------------------------------- /_web/_layouts/default.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: compress 3 | --- 4 | 5 | 6 | 7 | {% include head.html %} 8 | 9 | 10 | {% include nav.html %} 11 | {{ content }} 12 | 13 | {% include footer.html %} 14 | 15 | 16 | -------------------------------------------------------------------------------- /_web/_layouts/go.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | 4 | buttons: 5 | - name: Play 6 | id: go 7 | - name: Tune 8 | id: tune 9 | - name: Short URL 10 | id: short-url 11 | - name: Original Audio Source 12 | id: og-audio-source 13 | link: true 14 | --- 15 | 16 |
17 | 18 | {% include tweet.html %} 19 |
20 | 21 | {% include manual_analysis.html %} 22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 | {% for item in layout.buttons %} 30 | {% if item.link %} 31 | {{ item.name }} 32 | {% else %} 33 | 34 | {% endif %} 35 | {% endfor %} 36 | {% if page.app == 'jukebox' %}Auto Canonizer Version{% elsif page.app == 'canonizer' %}Eternal Jukebox Version{% endif %} 37 | 38 |
39 | {{ content }} 40 |
41 |
42 | 43 | {% include go-js.html %} 44 | 45 | {% if page.app == 'jukebox' %} 46 | 162 | {% elsif page.app == 'canonizer' %} 163 | 187 | {% endif %} 188 | -------------------------------------------------------------------------------- /_web/_layouts/retro.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: compress 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% include worker-js.html %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | The (Retro) Eternal Jukebox 24 | 25 | 36 | 37 | 38 | 39 | {% include nav.html %} 40 |
41 | 42 | logo 43 | logo 44 | 45 | {{ content }} 46 | 47 | 48 | {% include footer.html %} 49 | 50 | 51 | -------------------------------------------------------------------------------- /_web/_layouts/search.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 |
7 |

Search for a song

8 |
9 |
10 |
11 | Search for a track: 12 | 13 |
14 |
15 |
16 |
17 |

Use direct Spotify + audio link

18 | 27 |

Or pick one of these favorites

28 | 29 |
30 |
31 |
32 | 33 | {% include search-js.html %} 34 | -------------------------------------------------------------------------------- /_web/_plugins/liquify_filter.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module LiquifyFilter 3 | def liquify(input) 4 | Liquid::Template.parse(input).render(@context) 5 | end 6 | end 7 | end 8 | 9 | Liquid::Template.register_filter(Jekyll::LiquifyFilter) 10 | -------------------------------------------------------------------------------- /_web/canonizer_go.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Autocanonizer' 3 | layout: go 4 | app: canonizer 5 | --- 6 | 7 | 8 | 00:00:00 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /_web/canonizer_index.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Autocanonizer 3 | layout: default 4 | app: canonizer 5 | --- 6 | 7 |
8 |
9 | {% capture x %} 10 | # The Autocanonizer 11 | #### Get Bach to basics and turn your favorite song into a musical canon. 12 | 13 |
14 | [![eternal](files/ss.png)](canonizer_go.html?id=4kflIGfjdZJW4ot2ioixTB) 15 |
16 | **The Autocanonizer** takes your favorite song and turns it into a musical canon by playing the song against a varying, time-offset copy of itself. The best way to understand exactly what this does is to listen to this autocanonized version of Adele's [Someone like you](canonizer_go.html?id=4kflIGfjdZJW4ot2ioixTB). After that, go and [Pick a song](canonizer_search.html) to listen to other autocanonized songs. 17 | {% endcapture %}{{ x | markdownify }} 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /_web/canonizer_search.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Autocanonizer' 3 | layout: search 4 | app: canonizer 5 | --- 6 | -------------------------------------------------------------------------------- /_web/faq.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Eternal Jukebox' 3 | layout: default 4 | type: faq 5 | app: jukebox 6 | --- 7 | 8 |
9 |
10 | {% capture x %} 11 | # The ~~Infinite~~ Eternal Jukebox FAQ 12 | #### For when your favorite song just isn't long enough 13 | {% endcapture %}{{ x | markdownify }} 14 |
15 |
16 | {% for item in site.data.faq.faq %} 17 | {{ item.question }} 18 |
19 | {% capture x %} 20 | {{ item.answer | liquify }} 21 | {% endcapture %}{{ x | markdownify }} 22 |
23 |
24 | {% endfor %} 25 |
26 |
27 | -------------------------------------------------------------------------------- /_web/files/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/apple-touch-icon.png -------------------------------------------------------------------------------- /_web/files/canonizer_styles.css: -------------------------------------------------------------------------------- 1 | li a, .dropbtn { 2 | display: inline-block; 3 | color: white; 4 | text-align: center; 5 | padding: 14px 16px; 6 | text-decoration: none; 7 | } 8 | 9 | li.dropdown { 10 | display: inline-block; 11 | } 12 | 13 | .dropdown-content { 14 | display: none; 15 | position: absolute; 16 | background-color: #f9f9f9; 17 | min-width: 160px; 18 | box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); 19 | z-index: 1; 20 | } 21 | 22 | .dropdown-content a { 23 | color: #7a8288; 24 | padding: 12px 16px; 25 | text-decoration: none; 26 | display: block; 27 | text-align: left; 28 | background-image: linear-gradient(280deg, #202328, #1c1f23) 29 | } 30 | 31 | .dropdown-content a:hover {background-image: linear-gradient(280deg, #202328, #272b30)} 32 | 33 | .dropdown:hover .dropdown-content { 34 | display: block; 35 | } 36 | 37 | #tiles { 38 | padding-left:20px; 39 | } 40 | 41 | #footer { 42 | margin-top:60px; 43 | } 44 | 45 | 46 | #file { 47 | margin-top:10px; 48 | width:auto; 49 | } 50 | 51 | #select-track { 52 | margin-top:20px; 53 | margin-bottom:20px; 54 | } 55 | 56 | #numbers { 57 | margin-left:30px; 58 | width:300px; 59 | height:20px; 60 | } 61 | 62 | #stats { 63 | margin-top:30px; 64 | margin-left:40px; 65 | } 66 | 67 | #play { 68 | width:80px; 69 | } 70 | 71 | 72 | #info-div { 73 | margin-left:40px; 74 | width: 90%; 75 | margin-bottom:10px; 76 | } 77 | 78 | #info { 79 | margin-right: 20px; 80 | font-size:28px; 81 | line-height:38px; 82 | width: 90%; 83 | } 84 | 85 | #error { 86 | margin-left:10px; 87 | margin-bottom:8px; 88 | margin-top: 10px; 89 | 90 | font-size:22px; 91 | height:60px; 92 | margin-bottom:10px; 93 | color:red; 94 | } 95 | 96 | 97 | 98 | .nval { 99 | margin-right:20px; 100 | width:60px; 101 | } 102 | 103 | 104 | #tweet-span { 105 | position:relative; 106 | top:2px; 107 | margin-left:20px; 108 | float:right; 109 | } 110 | 111 | .song-link.song-link { /* also overwrites hover, focus, active */ 112 | color: inherit; 113 | text-decoration: inherit; 114 | font-weight: inherit; 115 | display: list-item; 116 | list-style-position: inside; 117 | width: fit-content; 118 | } 119 | 120 | .ui-dialog { 121 | top: 75px; 122 | left: 20px; 123 | } 124 | -------------------------------------------------------------------------------- /_web/files/css.css: -------------------------------------------------------------------------------- 1 | /* latin */ 2 | @font-face { 3 | font-family: 'Questrial'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('Questrial'), local('Questrial-Regular'), url(https://fonts.gstatic.com/s/questrial/v6/MYWJ4lYm5dbZ1UBuYox79JBw1xU1rKptJj_0jans920.woff2) format('woff2'); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 8 | } 9 | -------------------------------------------------------------------------------- /_web/files/eternal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/eternal.png -------------------------------------------------------------------------------- /_web/files/eternal_circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/eternal_circle.png -------------------------------------------------------------------------------- /_web/files/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/favicon.png -------------------------------------------------------------------------------- /_web/files/jukebox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/jukebox.png -------------------------------------------------------------------------------- /_web/files/ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/_web/files/ss.png -------------------------------------------------------------------------------- /_web/files/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #594f4f; 3 | background: #000000; 4 | width:900px; 5 | color: #45ada8; 6 | font-family: 'Questrial', sans-serif; 7 | margin:0 auto; 8 | } 9 | 10 | 11 | 12 | h2 { 13 | margin-left:auto; 14 | margin-right:auto; 15 | font-size:28px; 16 | width:900px; 17 | } 18 | 19 | #faq { 20 | margin-top:20px; 21 | margin-left:auto; 22 | margin-right:auto; 23 | width:600px; 24 | color:#aaa; 25 | } 26 | 27 | #song-title { 28 | font-size:20px; 29 | overflow:hidden; 30 | width:600px; 31 | height:12px; 32 | } 33 | 34 | #faq h1 { 35 | text-align:center; 36 | } 37 | 38 | hr { 39 | width: 90%; 40 | } 41 | 42 | #new { 43 | } 44 | 45 | #go { 46 | } 47 | 48 | #load { 49 | text-align:center; 50 | margin-left:32px; 51 | width:320px; 52 | height:30px; 53 |     -moz-border-radius:15px; 54 | -webkit-border-radius:10px; 55 | border: 1px solid #ccc; 56 | background: #45ada8; 57 | border:1px solid #2a7ecd; 58 | padding:3px 10px; 59 | font-size: 18px; 60 | margin-bottom: 10px; 61 | color: #594f4f; 62 | } 63 | 64 | #details { 65 | margin-bottom:10px; 66 | margin-right:10px; 67 | margin-left:10px; 68 | } 69 | 70 | #stats { 71 | width: 900px; 72 | margin-bottom:4px; 73 | } 74 | 75 | #sbeats { 76 | position:relative; 77 | wdith: 150px; 78 | left:10px; 79 | } 80 | 81 | 82 | #stime { 83 | position:relative; 84 | float:right; 85 | 86 | } 87 | 88 | #file { 89 | width:200px; 90 | } 91 | 92 | 93 | 94 | #info { 95 | margin-top: 10px; 96 | margin-bottom:10px; 97 | min-height:28px; 98 | font-size:24px; 99 | } 100 | 101 | #info2 { 102 | margin-left: 20px; 103 | margin-top: 10px; 104 | margin-bottom:10px; 105 | height:20px; 106 | font-size:24px; 107 | } 108 | 109 | #error { 110 | margin-left: auto; 111 | margin-right: auto; 112 | margin-top: 10px; 113 | 114 | font-size:24px; 115 | height:60px; 116 | margin-bottom:10px; 117 | width: 700px; 118 | text-align:center; 119 | color:red; 120 | } 121 | 122 | 123 | 124 | #title { 125 | width:900px; 126 | margin-top:20px; 127 | margin-left: auto; 128 | margin-right: auto; 129 | text-align:center; 130 | margin-bottom: 10px; 131 | font-size: 32px; 132 | font-weight: bold; 133 | } 134 | 135 | #title a {} 136 | 137 | #select-track { 138 | margin-top:20px; 139 | font-size:20px; 140 | } 141 | 142 | #file { 143 | margin-left:20px; 144 | } 145 | 146 | #main { 147 | width:900px; 148 | margin-left: auto; 149 | margin-right: auto; 150 | margin-bottom: 10px; 151 | text-align:center; 152 | } 153 | 154 | #song-div { 155 | width:500px; 156 | text-align:center; 157 | margin-left: auto; 158 | margin-right: auto; 159 | } 160 | 161 | #song-list { 162 | width:500px; 163 | text-align:left; 164 | } 165 | 166 | .song-link.song-link { /* also overwrites hover, focus, active */ 167 | color: inherit; 168 | text-decoration: inherit; 169 | font-weight: inherit; 170 | display: list-item; 171 | width: fit-content; 172 | } 173 | 174 | a { 175 | color: #9de0ad; 176 | font-weight:bold; 177 | } 178 | 179 | a:link {text-decoration: none; } 180 | 181 | #footer { 182 | width:900px; 183 | margin-top:6px; 184 | margin-left:10px; 185 | margin-bottom:20px; 186 | text-align:center; 187 | font-size:12px; 188 | border-top:ridge; 189 | border-color: #45ada8; 190 | padding-top:6px; 191 | } 192 | 193 | #footer-list { 194 | text-align:left; 195 | margin-left:auto; 196 | margin-right:auto; 197 | width:450px; 198 | } 199 | 200 | .cbut { 201 | background:-webkit-gradient( linear, left top, left bottom, color-stop(0.05, #ededed), color-stop(1, #dfdfdf) ); 202 | background-color:#ededed; 203 | -webkit-border-radius:6px; 204 | border-radius:6px; 205 | border:1px solid #dcdcdc; 206 | display:inline-block; 207 | font-size:10px; 208 | font-weight:bold; 209 | padding:3px 13px; 210 | text-decoration:none; 211 | } 212 | 213 | .cbut:hover { 214 | background-color:#dfdfdf; 215 | } 216 | 217 | .cbut:active { 218 | position:relative; 219 | top:1px; 220 | } 221 | 222 | #button-panel { 223 | margin-left:10px; 224 | top:6px; 225 | float:right; 226 | } 227 | 228 | #control-instructions { 229 | font-style:italic; 230 | font-size:12px; 231 | margin:20px; 232 | text-align:left; 233 | } 234 | 235 | #tweet-span { 236 | top:6px; 237 | float:right; 238 | } 239 | 240 | #open-img-left { 241 | width:200px; 242 | float:left; 243 | margin-bottom:20px; 244 | } 245 | 246 | #open-img-right { 247 | width:200px; 248 | float:right; 249 | margin-bottom:20px; 250 | 251 | -webkit-animation-name: rotate; 252 | -webkit-animation-duration: 0.5s; 253 | -webkit-animation-iteration-count: infinite; 254 | -webkit-animation-timing-function: linear; 255 | } 256 | 257 | 258 | /* gratuitous eye-candy */ 259 | .rotate{ 260 | -webkit-transition-duration: 0.8s; 261 | -moz-transition-duration: 0.8s; 262 | -o-transition-duration: 0.8s; 263 | transition-duration: 0.8s; 264 | 265 | -webkit-transition-property: -webkit-transform; 266 | -moz-transition-property: -moz-transform; 267 | -o-transition-property: -o-transform; 268 | transition-property: transform; 269 | overflow:hidden; 270 | } 271 | 272 | .sel-list { 273 | font-weight:bold; 274 | color: #9de0ad; 275 | } 276 | 277 | #sel-text { 278 | text-align:left; 279 | margin-left:20px; 280 | } 281 | 282 | .sel-list:hover { 283 | cursor:pointer; 284 | } 285 | 286 | .activated { 287 | text-decoration:underline; 288 | } 289 | 290 | #controls { 291 | display:none; 292 | font-size:12px; 293 | text-align:center; 294 | } 295 | 296 | #l-counts { 297 | margin-left:auto; 298 | margin-right:auto; 299 | height:18px; 300 | } 301 | 302 | #tune-info { 303 | margin-top:10px; 304 | 305 | width: 170px; 306 | 307 | margin-left:auto; 308 | margin-right:auto; 309 | text-align:left; 310 | } 311 | 312 | 313 | #l-last-branch { 314 | margin-top:10px; 315 | font-weight: bold; 316 | text-align: center; 317 | } 318 | 319 | #l-reverse-branch { 320 | font-weight: bold; 321 | text-align: center; 322 | } 323 | 324 | #l-long-branch { 325 | font-weight: bold; 326 | text-align: center; 327 | } 328 | 329 | #l-sequential-branch { 330 | font-weight: bold; 331 | text-align: center; 332 | margin-bottom:10px; 333 | } 334 | 335 | .ti-val { 336 | font-weight:bold; 337 | float:right; 338 | } 339 | 340 | #sthreshold { 341 | text-align:left; 342 | font-weight:bold; 343 | margin-bottom:10px; 344 | } 345 | 346 | #svolume { 347 | text-align:left; 348 | font-weight:bold; 349 | margin-bottom:10px; 350 | } 351 | 352 | #probability-div { 353 | margin-top: 20px; 354 | } 355 | 356 | #sprobability { 357 | text-align:left; 358 | font-weight:bold; 359 | margin-bottom:10px; 360 | } 361 | 362 | #audio-offset-div { 363 | margin-top: 20px; 364 | } 365 | 366 | #saudio-offset { 367 | text-align:left; 368 | font-weight:bold; 369 | margin-bottom:10px; 370 | width: 300px; 371 | display: flex; 372 | align-items: center; 373 | box-sizing: border-box; 374 | } 375 | 376 | #saudio-offset > span { 377 | flex: 1; 378 | white-space: nowrap; 379 | } 380 | 381 | #saudio-offset > input { 382 | flex: 0 1 auto; 383 | min-width: 0; 384 | } 385 | 386 | 387 | .slider { 388 | width:300px; 389 | float:left; 390 | margin-right:20px; 391 | margin-bottom:4px; 392 | } 393 | 394 | #slider-labels { 395 | font-size:12px; 396 | width:300px; 397 | margin-left:0px; 398 | margin-right:0px; 399 | margin-bottom:25px; 400 | } 401 | 402 | #reset-edges { 403 | margin-left:auto; 404 | margin-right:auto; 405 | width:80%; 406 | text-align:center; 407 | margin-bottom:20px; 408 | } 409 | 410 | #submit-edges { 411 | margin-top:18px; 412 | margin-left:auto; 413 | margin-right:auto; 414 | width:80%; 415 | text-align:center; 416 | margin-bottom:2px; 417 | } 418 | 419 | .left-label { 420 | position:relative; 421 | float:left; 422 | font-size:10px; 423 | } 424 | 425 | .right-label { 426 | position:relative; 427 | float:right; 428 | margin-right:0px; 429 | font-size:10px; 430 | text-align:right; 431 | } 432 | 433 | #faq ul li { 434 | margin-top:20px; 435 | } 436 | 437 | #vote { 438 | } 439 | 440 | #search-form { 441 | margin-bottom:30px; 442 | } 443 | 444 | /* 445 | .rotate:hover 446 | { 447 | -webkit-transform:rotate(360deg); 448 | -moz-transform:rotate(360deg); 449 | -o-transform:rotate(360deg); 450 | } 451 | */ 452 | 453 | .ui-dialog { 454 | top: 75px; 455 | left: 20px; 456 | } 457 | -------------------------------------------------------------------------------- /_web/jukebox_go.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Eternal Jukebox' 3 | layout: go 4 | app: jukebox 5 | --- 6 | 7 |
8 | 9 | Total Beats: 0 10 | 11 | Listen Time: 00:00:00 12 | -------------------------------------------------------------------------------- /_web/jukebox_index.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Eternal Jukebox' 3 | layout: default 4 | app: jukebox 5 | --- 6 | 7 |
8 |
9 | {% capture x %} 10 | # The ~~Infinite~~ Eternal Jukebox 11 | #### For when your favorite song just isn't long enough 12 | 13 |
14 | 15 | [![eternal](files/eternal.png)](jukebox_go.html?id=03UrZgTINDqvnUMbbIMhql) 16 |
17 | *For when your favorite song just isn't long enough.* 18 |
19 | This web app lets you search a song on Spotify and will then generate a never-ending and ever changing version of the song. 20 | It does what Infinite Gangnam Style did but for any song. 21 | {% endcapture %}{{ x | markdownify }} 22 |
23 |
24 |
25 |

26 | -------------------------------------------------------------------------------- /_web/jukebox_search.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Eternal Jukebox' 3 | layout: search 4 | app: jukebox 5 | --- 6 | -------------------------------------------------------------------------------- /_web/retro_faq.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'The Eternal Jukebox' 3 | layout: retro 4 | type: faq 5 | app: jukebox 6 | --- 7 | 8 |

Frequently Asked Questions

9 |
10 |
11 |
12 |
13 | {% for item in site.data.faq.faq %} 14 |
  • {{ item.question }} 15 | {% capture x %} 16 | {{ item.answer | liquify }} 17 | {% endcapture %}{{ x | markdownify }} 18 |
  • 19 |
    20 | {% endfor %} 21 |
    22 | -------------------------------------------------------------------------------- /_web/worker.js: -------------------------------------------------------------------------------- 1 | var timeouts = {}; 2 | 3 | onmessage = function(event) { 4 | var data = event.data; 5 | var command = data.command; 6 | var id = data.id; 7 | var delay = data.delay; 8 | 9 | if (command === 'setTimeout') { 10 | var timeoutId = setTimeout(function() { 11 | postMessage({ id: id }); 12 | delete timeouts[id]; 13 | }, delay); 14 | timeouts[id] = timeoutId; 15 | } else if (command === 'clearTimeout') { 16 | if (timeouts.hasOwnProperty(id)) { 17 | clearTimeout(timeouts[id]); 18 | delete timeouts[id]; 19 | } 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | // KOTLIN NOTE 4 | // Kotlin is locked to 1.9.25 as the last version before K2 and Kotlin 2.0, 5 | // which is a larger change and we should wait until we're in a more stable position to upgrade. 6 | 7 | plugins { 8 | id 'application' 9 | id 'org.jetbrains.kotlin.jvm' version '1.9.25' 10 | id 'com.github.johnrengelman.shadow' version '8.1.1' 11 | } 12 | 13 | mainClassName = 'org.abimon.eternalJukebox.EternalJukebox' 14 | 15 | sourceCompatibility = 11 16 | targetCompatibility = 11 17 | 18 | repositories { 19 | mavenCentral() 20 | maven { url 'https://jitpack.io' } 21 | } 22 | 23 | ext { 24 | // See above for Kotlin being locked 25 | kotlin_version = '1.9.25' 26 | 27 | // Vert.x 4 changes things quite significantly; locking 28 | vertx_version = '3.9.16' 29 | 30 | jackson_version = '2.18.2' 31 | 32 | // NewPipe should be pinned to master to ensure we always have the latest YT extractor. 33 | new_pipe_extractor_version = 'master-SNAPSHOT' 34 | } 35 | 36 | dependencies { 37 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 38 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" 39 | // Last version of kotlinx.coroutines on Kotlin 1.9 40 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" 41 | 42 | implementation "io.vertx:vertx-web:$vertx_version" 43 | implementation "io.vertx:vertx-web-client:$vertx_version" 44 | implementation "io.vertx:vertx-lang-kotlin:$vertx_version" 45 | implementation "io.vertx:vertx-lang-kotlin-coroutines:$vertx_version" 46 | 47 | // IMPORTANT: Upgrading H2 completely breaks existing databases and can corrupt them. 48 | // Do NOT upgrade H2 unless a proper migration path is implemented 49 | implementation "com.h2database:h2:1.4.196" 50 | implementation 'com.mysql:mysql-connector-j:9.0.0' 51 | implementation 'com.zaxxer:HikariCP:5.1.0' 52 | implementation 'com.google.cloud.sql:mysql-socket-factory-connector-j-8:1.20.0' 53 | 54 | implementation 'com.auth0:java-jwt:4.4.0' 55 | implementation 'com.github.kittinunf.fuel:fuel:2.3.1' 56 | implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.3.1' 57 | 58 | implementation 'ch.qos.logback:logback-classic:1.5.7' 59 | 60 | implementation "com.fasterxml.jackson.core:jackson-core:$jackson_version" 61 | implementation "com.fasterxml.jackson.core:jackson-annotations:$jackson_version" 62 | implementation "com.fasterxml.jackson.core:jackson-databind:$jackson_version" 63 | implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jackson_version" 64 | implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jackson_version" 65 | implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version" 66 | implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version" 67 | implementation "com.fasterxml.jackson.module:jackson-module-parameter-names:$jackson_version" 68 | 69 | implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' 70 | 71 | implementation 'com.jakewharton.fliptables:fliptables:1.1.1' 72 | 73 | implementation "com.github.teamnewpipe.NewPipeExtractor:extractor:$new_pipe_extractor_version" 74 | implementation "com.github.teamnewpipe.NewPipeExtractor:timeago-parser:$new_pipe_extractor_version" 75 | } 76 | 77 | tasks.withType(KotlinCompile).configureEach { 78 | kotlinOptions { 79 | jvmTarget = '11' 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # template for the changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | striptags | trim | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 26 | {% if commit.breaking %}[**breaking**] {% endif %}\ 27 | {{ commit.message | upper_first }}\ 28 | {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif %}\ 29 | {% if commit.remote.pr_number %} in #{{ commit.remote.pr_number }}{%- endif %} 30 | {% endfor %} 31 | {% endfor %}\n 32 | """ 33 | # template for the changelog footer 34 | footer = """ 35 | 36 | """ 37 | # remove the leading and trailing s 38 | trim = true 39 | # postprocessors 40 | postprocessors = [ 41 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 42 | ] 43 | # render body even when there are no releases to process 44 | # render_always = true 45 | # output file path 46 | # output = "test.md" 47 | 48 | [git] 49 | # parse the commits based on https://www.conventionalcommits.org 50 | conventional_commits = true 51 | # filter out the commits that are not conventional 52 | filter_unconventional = true 53 | # process each line of a commit as an individual commit 54 | split_commits = false 55 | # regex for preprocessing the commit messages 56 | commit_preprocessors = [ 57 | # Replace issue numbers 58 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 59 | # Check spelling of the commit with https://github.com/crate-ci/typos 60 | # If the spelling is incorrect, it will be automatically fixed. 61 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 62 | ] 63 | # regex for parsing and grouping commits 64 | commit_parsers = [ 65 | { message = "^feat", group = "🚀 Features" }, 66 | { message = "^fix", group = "🐛 Bug Fixes" }, 67 | { message = "^doc", group = "📚 Documentation" }, 68 | { message = "^perf", group = "⚡ Performance" }, 69 | { message = "^refactor", group = "🚜 Refactor" }, 70 | { message = "^style", group = "🎨 Styling" }, 71 | { message = "^test", group = "🧪 Testing" }, 72 | { message = "^chore\\(release\\): prepare for", skip = true }, 73 | { message = "^chore\\(deps.*\\)", skip = true }, 74 | { message = "^chore\\(pr\\)", skip = true }, 75 | { message = "^chore\\(pull\\)", skip = true }, 76 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 77 | { body = ".*security", group = "🛡️ Security" }, 78 | { message = "^revert", group = "◀️ Revert" }, 79 | { message = ".*", group = "💼 Other" }, 80 | ] 81 | # filter out the commits that are not matched by commit parsers 82 | filter_commits = false 83 | # sort the tags topologically 84 | topo_order = false 85 | # sort the commits inside sections by oldest/newest order 86 | sort_commits = "oldest" 87 | -------------------------------------------------------------------------------- /config_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "portComment": "What port we're running on", 3 | "port": "", 4 | "spotifyClientComment": "Spotify Client / Secret; make an application over here: https://developer.spotify.com/my-applications/", 5 | "spotifyClient": "", 6 | "spotifySecret": "", 7 | "storageComment": "These can be any folders you want", 8 | "storageType": "LOCAL", 9 | "storageOptions": { 10 | "ANALYSIS_FOLDER": "data/analysis", 11 | "AUDIO_FOLDER": "data/audio", 12 | "EXTERNAL_AUDIO_FOLDER": "data/external_audio", 13 | "UPLOADED_AUDIO_FOLDER": "data/uploaded_audio", 14 | "UPLOADED_ANALYSIS_FOLDER": "data/uploaded_analysis", 15 | "PROFILE_FOLDER": "data/profile", 16 | "LOG_FOLDER": "data/log" 17 | }, 18 | "audioSourceComment": "If not provided, local audio should work", 19 | "audioSourceType": "YOUTUBE", 20 | "audioSourceOptions": { 21 | "apiKeyComment": "This can be obtained from here: https://developers.google.com/youtube/v3/getting-started", 22 | "apiKey": "" 23 | }, 24 | "workerExecuteTimeComment": "How long blocking workers can go for (in ns)", 25 | "workerExecuteTime": 1200000000000, 26 | "databaseTypeComment": "Can either be JDBC or H2", 27 | "databaseTypeComment2": "If H2, you'll need to provide 'databaseName' under options, or leave it blank to default to 'eternal_jukebox'", 28 | "databaseTypeComment3": "The values below are only needed if you're connecting to something like a MySQL database", 29 | "databaseType": "JDBC", 30 | "databaseOptions": { 31 | "username": "", 32 | "password": "", 33 | "jdbcUrl": "jdbc:://:/?useSSL=false" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config_template.yaml: -------------------------------------------------------------------------------- 1 | # What port we're running on 2 | port: 3 | # Spotify Client / Secret; make an application over here: https://developer.spotify.com/my-applications/ 4 | spotifyClient: 5 | spotifySecret: 6 | 7 | # These can be any folders you want 8 | storageType: LOCAL 9 | storageOptions: 10 | ANALYSIS_FOLDER: data/analysis 11 | AUDIO_FOLDER: data/audio 12 | EXTERNAL_AUDIO_FOLDER: data/external_audio 13 | UPLOADED_AUDIO_FOLDER: data/uploaded_audio 14 | UPLOADED_ANALYSIS_FOLDER: data/uploaded_analysis 15 | PROFILE_FOLDER: data/profile 16 | LOG_FOLDER: data/log 17 | 18 | # If not provided, local audio should work 19 | audioSourceType: YOUTUBE 20 | audioSourceOptions: 21 | # This can be obtained from here: https://developers.google.com/youtube/v3/getting-started 22 | API_KEY: 23 | 24 | # How long blocking workers can go for (in ns) 25 | workerExecuteTime: 1200000000000 26 | 27 | # Can either be JDBC or H2 28 | # If H2, you'll need to provide 'databaseName' under options, or leave it blank to default to 'eternal_jukebox' 29 | # The values below are only needed if you're connecting to something like a MySQL database 30 | databaseType: JDBC 31 | databaseOptions: 32 | username: 33 | password: 34 | jdbcUrl: jdbc:://:/?useSSL=false 35 | -------------------------------------------------------------------------------- /database/eternal_jukebox.mv.db.init: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/database/eternal_jukebox.mv.db.init -------------------------------------------------------------------------------- /docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | jukebox-dev: 5 | build: 6 | context: . 7 | dockerfile: ./Dockerfile.dev 8 | ports: 9 | - 5005:5005 10 | - 8080:8080 11 | env_file: 12 | - .env 13 | environment: 14 | - JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005" 15 | volumes: 16 | - './envvar_config.yaml:/EternalJukebox/envvar_config.yaml' 17 | - './data:/EternalJukebox/data' 18 | - './build/libs/EternalJukebox-all.jar:/EternalJukebox/EternalJukebox-all.jar' 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | main: 4 | build: . 5 | image: eternaljukebox 6 | restart: always 7 | #networks: 8 | # - internal 9 | ports: 10 | - 8080:8080 11 | env_file: 12 | - .env 13 | volumes: 14 | - './envvar_config.yaml:/EternalJukebox/envvar_config.yaml' 15 | - './data:/EternalJukebox/data' 16 | - './database/eternal_jukebox.mv.db:/EternalJukebox/eternal_jukebox.mv.db' #used for H2 db. Needs to copied from /database/eternal_jukebox.mv.db.init 17 | # depends_on: 18 | # - db 19 | 20 | #db: 21 | # image: mysql 22 | # #restart: always 23 | # volumes: 24 | # - ./database:/var/lib/mysql 25 | # networks: 26 | # - internal 27 | # command: --default-authentication-plugin=mysql_native_password 28 | # environment: 29 | # MYSQL_ROOT_PASSWORD: ${SQL_PASSWORD} 30 | # MYSQL_DATABASE: ${SQL_DATABASE_NAME} 31 | # MYSQL_USER: ${SQL_USERNAME} 32 | # MYSQL_PASSWORD: ${SQL_PASSWORD} 33 | 34 | #networks: 35 | # internal: 36 | -------------------------------------------------------------------------------- /envvar_config.yaml: -------------------------------------------------------------------------------- 1 | # What port we're running on 2 | port: ${PORT} 3 | # Spotify Client / Secret; make an application over here: https://developer.spotify.com/my-applications/ 4 | spotifyClient: ${SPOTIFYCLIENT} 5 | spotifySecret: ${SPOTIFYSECRET} 6 | 7 | disable: 8 | # Would only create empty analytics files if not disabled because analyticsProviders is not set 9 | analytics: true 10 | # We are not a node instance and SiteAPI also provides /healthy 11 | nodeAPI: true 12 | 13 | 14 | # These can be any folders you want 15 | storageType: LOCAL 16 | storageOptions: 17 | ANALYSIS_FOLDER: data/analysis 18 | AUDIO_FOLDER: data/audio 19 | EXTERNAL_AUDIO_FOLDER: data/external_audio 20 | UPLOADED_AUDIO_FOLDER: data/uploaded_audio 21 | UPLOADED_ANALYSIS_FOLDER: data/uploaded_analysis 22 | PROFILE_FOLDER: data/profile 23 | LOG_FOLDER: data/log 24 | 25 | # If not provided, local audio should work 26 | audioSourceType: YOUTUBE 27 | audioSourceOptions: 28 | # This can be obtained from here: https://developers.google.com/youtube/v3/getting-started 29 | API_KEY: ${YOUTUBE_API_KEY} 30 | 31 | # How long blocking workers can go for (in ns) 32 | workerExecuteTime: 1200000000000 33 | # Can either be JDBC or H2 34 | # If H2, you'll need to provide 'databaseName' under options, or leave it blank to default to 'eternal_jukebox' 35 | # The values below are only needed if you're connecting to something like a MySQL database 36 | # databaseType: JDBC 37 | # databaseOptions: 38 | # username: ${SQL_USERNAME} 39 | # password: ${SQL_PASSWORD} 40 | # jdbcUrl: jdbc:${SQL_TYPE}://${SQL_HOST}:${SQL_PORT}/${SQL_DATABASE_NAME}?useSSL=false 41 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | # Collect all arguments for the java command; 201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 202 | # shell script including quotes and variable substitutions, so put them in 203 | # double quotes to make sure that they get re-expanded; and 204 | # * put everything else in single quotes, so that it's not re-expanded. 205 | 206 | set -- \ 207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 208 | -classpath "$CLASSPATH" \ 209 | org.gradle.wrapper.GradleWrapperMain \ 210 | "$@" 211 | 212 | # Stop when "xargs" is not available. 213 | if ! command -v xargs >/dev/null 2>&1 214 | then 215 | die "xargs is not available" 216 | fi 217 | 218 | # Use "xargs" to parse quoted args. 219 | # 220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 221 | # 222 | # In Bash we could simply go: 223 | # 224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 225 | # set -- "${ARGS[@]}" "$@" 226 | # 227 | # but POSIX shell has neither arrays nor command substitution, so instead we 228 | # post-process each arg (as a line of input to sed) to backslash-escape any 229 | # character that might be a shell metacharacter, then use eval to reverse 230 | # that process (while maintaining the separation between arguments), and wrap 231 | # the whole thing up as a single "set" statement. 232 | # 233 | # This will of course break if any of these variables contains a newline or 234 | # an unmatched quote. 235 | # 236 | 237 | eval "set -- $( 238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 239 | xargs -n1 | 240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 241 | tr '\n' ' ' 242 | )" '"$@"' 243 | 244 | exec "$JAVACMD" "$@" 245 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /release.ps1: -------------------------------------------------------------------------------- 1 | # Adapted from https://github.com/orhun/git-cliff/blob/main/release.sh 2 | 3 | param( 4 | [Parameter(Mandatory = $True)] 5 | $VersionTag 6 | ) 7 | 8 | Write-Host "Preparing $VersionTag" 9 | 10 | try 11 | { 12 | git cliff --config cliff.toml --tag "$VersionTag" -o CHANGELOG.md 13 | git add CHANGELOG.md 14 | git commit -m "chore(release): prepare for $VersionTag" 15 | 16 | $changelog = (git cliff --config release_tag.toml --unreleased --strip all) -join "`n" 17 | 18 | git tag -a "$VersionTag" -m "Release $VersionTag" -m "$changelog" 19 | git tag -v "$VersionTag" 20 | 21 | Write-Host "Done!" 22 | Write-Host "Now push the commit (git push) and the tag (git push --tags)." 23 | } 24 | catch 25 | { 26 | Write-Error "Failed to generate CHANGELOG.md; is `git cliff` installed? (cargo binstall git-cliff)" 27 | } -------------------------------------------------------------------------------- /release_tag.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [changelog] 5 | # template for the changelog header 6 | header = """ 7 | # Changelog\n 8 | All notable changes to this project will be documented in this file.\n 9 | """ 10 | # template for the changelog body 11 | # https://keats.github.io/tera/docs/#introduction 12 | body = """ 13 | {% for group, commits in commits | group_by(attribute="group") %} 14 | {{ group | upper_first }} 15 | {% for commit in commits %} 16 | - {% if commit.breaking %}(breaking) {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end=\"\") }})\ 17 | {% endfor %} 18 | {% endfor %}\n 19 | """ 20 | # template for the changelog footer 21 | footer = """ 22 | 23 | """ 24 | # remove the leading and trailing whitespace from the templates 25 | trim = true 26 | 27 | [git] 28 | # parse the commits based on https://www.conventionalcommits.org 29 | conventional_commits = true 30 | # filter out the commits that are not conventional 31 | filter_unconventional = false 32 | # regex for parsing and grouping commits 33 | commit_parsers = [ 34 | { message = "^feat", group = "Features" }, 35 | { message = "^fix", group = "Bug Fixes" }, 36 | { message = "^doc", group = "Documentation" }, 37 | { message = "^perf", group = "Performance" }, 38 | { message = "^refactor", group = "Refactor" }, 39 | { message = "^style", group = "Styling" }, 40 | { message = "^test", group = "Testing" }, 41 | { message = "^chore\\(deps.*\\)", skip = true }, 42 | { message = "^chore\\(pr\\)", skip = true }, 43 | { message = "^chore\\(pull\\)", skip = true }, 44 | { message = "^chore\\(release\\): prepare for", skip = true }, 45 | { message = "^chore|^ci", group = "Miscellaneous Tasks" }, 46 | { body = ".*security", group = "Security" }, 47 | ] 48 | # filter out the commits that are not matched by commit parsers 49 | filter_commits = false 50 | # sort the tags topologically 51 | topo_order = false 52 | # sort the commits inside sections by oldest/newest order 53 | sort_commits = "oldest" 54 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/settings.gradle -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/BufferDataSource.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import io.vertx.core.buffer.Buffer 4 | import org.abimon.visi.io.DataSource 5 | import java.io.InputStream 6 | import kotlin.math.min 7 | 8 | data class BufferDataSource(val buffer: Buffer): DataSource { 9 | override val data: ByteArray 10 | get() = buffer.bytes 11 | override val inputStream: InputStream 12 | get() = BufferInputStream(buffer) 13 | override val size: Long 14 | get() = buffer.length().toLong() 15 | 16 | } 17 | 18 | class BufferInputStream( 19 | private val buffer: Buffer, 20 | private var pos: Int = 0, 21 | private var size: Int = buffer.length(), 22 | private var mark: Int = 0 23 | ) : InputStream() { 24 | 25 | override fun read(): Int = if (pos < size) buffer.getByte(pos++).toInt() and 0xFF else -1 26 | override fun read(b: ByteArray): Int = read(b, 0, b.size) 27 | override fun read(b: ByteArray, off: Int, len: Int): Int { 28 | if (len < 0 || off < 0 || len > b.size - off) 29 | throw IndexOutOfBoundsException() 30 | 31 | if (pos >= size) 32 | return -1 33 | 34 | val avail = size - pos 35 | 36 | @Suppress("NAME_SHADOWING") 37 | val len: Int = if (len > avail) avail else len 38 | if (len <= 0) 39 | return 0 40 | 41 | buffer.getBytes(pos, pos + len, b, off) 42 | pos += len 43 | return len 44 | } 45 | 46 | override fun skip(n: Long): Long { 47 | val k = min((size - pos).toLong(), n) 48 | pos += k.toInt() 49 | return k 50 | } 51 | 52 | override fun available(): Int = size - pos 53 | 54 | override fun reset() { 55 | pos = mark 56 | } 57 | 58 | override fun mark(readlimit: Int) { 59 | mark = pos 60 | } 61 | 62 | override fun markSupported(): Boolean { 63 | return true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/CoroutineUtils.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import kotlinx.coroutines.CoroutineExceptionHandler 4 | import org.slf4j.Logger 5 | import kotlin.coroutines.AbstractCoroutineContextElement 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | /** 9 | * Creates a named [CoroutineExceptionHandler] instance. 10 | * @param handler a function which handles exception thrown by a coroutine 11 | */ 12 | @Suppress("FunctionName") 13 | public inline fun NamedCoroutineExceptionHandler(name: String, crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler = 14 | object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { 15 | override fun handleException(context: CoroutineContext, exception: Throwable) = 16 | handler.invoke(context, exception) 17 | 18 | override fun toString(): String = name 19 | } 20 | 21 | data class LogCoroutineExceptionHandler(val logger: Logger) : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { 22 | override fun handleException(context: CoroutineContext, exception: Throwable) { 23 | logger.error("[$context] An unhandled exception occurred", exception) 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/EternalJukebox.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import ch.qos.logback.classic.Level 4 | import com.fasterxml.jackson.annotation.JsonInclude 5 | import com.fasterxml.jackson.databind.DeserializationFeature 6 | import com.fasterxml.jackson.databind.ObjectMapper 7 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 8 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module 9 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 10 | import com.fasterxml.jackson.module.kotlin.KotlinModule 11 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule 12 | import io.vertx.core.Vertx 13 | import io.vertx.core.VertxOptions 14 | import io.vertx.core.http.HttpServer 15 | import io.vertx.ext.web.Router 16 | import kotlinx.coroutines.CoroutineName 17 | import kotlinx.coroutines.CoroutineScope 18 | import kotlinx.coroutines.SupervisorJob 19 | import org.abimon.eternalJukebox.data.analysis.IAnalyser 20 | import org.abimon.eternalJukebox.data.analysis.SpotifyAnalyser 21 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsProvider 22 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsStorage 23 | import org.abimon.eternalJukebox.data.audio.IAudioSource 24 | import org.abimon.eternalJukebox.data.database.IDatabase 25 | import org.abimon.eternalJukebox.data.storage.IStorage 26 | import org.abimon.eternalJukebox.handlers.PopularHandler 27 | import org.abimon.eternalJukebox.handlers.StaticResources 28 | import org.abimon.eternalJukebox.handlers.api.* 29 | import org.abimon.eternalJukebox.objects.ConstantValues 30 | import org.abimon.eternalJukebox.objects.EmptyDataAPI 31 | import org.abimon.eternalJukebox.objects.JukeboxConfig 32 | import org.slf4j.Logger 33 | import org.slf4j.LoggerFactory 34 | import java.io.File 35 | import java.io.OutputStream 36 | import java.io.PrintStream 37 | import java.util.* 38 | import java.util.concurrent.ConcurrentSkipListSet 39 | import java.util.concurrent.Executors 40 | import java.util.concurrent.ScheduledExecutorService 41 | import java.util.concurrent.TimeUnit 42 | import kotlin.reflect.jvm.jvmName 43 | 44 | object EternalJukebox : CoroutineScope { 45 | private val logger: Logger = LoggerFactory.getLogger("EternalBox") 46 | 47 | // `SupervisorJob` means this won't be canceled 48 | override val coroutineContext = SupervisorJob() + CoroutineName("EternalJukebox") + LogCoroutineExceptionHandler(logger) 49 | 50 | val jsonMapper: ObjectMapper = ObjectMapper() 51 | .registerModules(Jdk8Module(), KotlinModule.Builder().build(), JavaTimeModule(), ParameterNamesModule()) 52 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 53 | .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) 54 | .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) 55 | 56 | private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()) 57 | .registerModules(Jdk8Module(), KotlinModule.Builder().build(), JavaTimeModule(), ParameterNamesModule()) 58 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 59 | .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) 60 | .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) 61 | 62 | private val jsonConfig: File = File("config.json") 63 | private val yamlConfig: File = File("config.yaml") 64 | 65 | val BASE_64_URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".toCharArray() 66 | 67 | @Suppress("JoinDeclarationAndAssignment") 68 | val config: JukeboxConfig 69 | val vertx: Vertx 70 | private val webserver: HttpServer 71 | 72 | val storage: IStorage 73 | val audio: IAudioSource? 74 | 75 | val spotify: IAnalyser 76 | 77 | val analytics: IAnalyticsStorage 78 | val analyticsProviders: List 79 | 80 | val database: IDatabase 81 | 82 | private val schedule: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() 83 | private val apis = ArrayList() 84 | 85 | private val logStreams: Map 86 | private val emptyPrintStream = PrintStream(object: OutputStream() { 87 | override fun write(b: Int) {} 88 | override fun write(b: ByteArray) {} 89 | override fun write(b: ByteArray, off: Int, len: Int) {} 90 | }) 91 | 92 | private val hourlyVisitorsAddress: ConcurrentSkipListSet = ConcurrentSkipListSet() 93 | private val referrers: ConcurrentSkipListSet = ConcurrentSkipListSet() 94 | private val referrersFile = File("referrers.txt") 95 | 96 | private fun start() { 97 | webserver.listen(config.port) 98 | logger.info("Now listening on port {}", config.port) 99 | } 100 | 101 | @JvmStatic 102 | fun main(args: Array) { 103 | val hikariLogger = LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger 104 | hikariLogger.level = Level.INFO 105 | 106 | start() 107 | } 108 | 109 | init { 110 | config = if (jsonConfig.exists()) 111 | jsonMapper.readValue(jsonConfig, JukeboxConfig::class.java) 112 | else if (yamlConfig.exists()) 113 | yamlMapper.readValue(yamlConfig, JukeboxConfig::class.java) 114 | else 115 | JukeboxConfig() 116 | 117 | logStreams = config.logFiles.mapValues { (_, filename) -> if(filename != null) PrintStream(File(filename)) else emptyPrintStream } 118 | 119 | if(config.printConfig) 120 | logger.trace("Loaded config: {}", config) 121 | else 122 | logger.trace("Loaded config") 123 | 124 | if (referrersFile.exists()) 125 | referrers.addAll(referrersFile.readLines()) 126 | 127 | // Config Handling 128 | 129 | vertx = Vertx.vertx(VertxOptions().setMaxWorkerExecuteTime(config.workerExecuteTime).setWarningExceptionTime(1).setWarningExceptionTimeUnit(TimeUnit.SECONDS)) 130 | webserver = vertx.createHttpServer() 131 | 132 | storage = config.storageType.storage 133 | 134 | val mainRouter = Router.router(vertx) 135 | 136 | //Something, something check for cookies 137 | mainRouter.route().handler { 138 | val ip = it.request().remoteAddress().host() 139 | if (ip !in hourlyVisitorsAddress) { 140 | it.data()[ConstantValues.HOURLY_UNIQUE_VISITOR] = true 141 | hourlyVisitorsAddress.add(ip) 142 | } 143 | 144 | it.request().getHeader("Referer")?.let(referrers::add) 145 | 146 | it.data()[ConstantValues.USER_UID] = UUID.randomUUID().toString() 147 | 148 | it.next() 149 | } 150 | 151 | config.redirects.forEach { (route, path) -> mainRouter.route(route).handler { context -> context.response().redirect(path) } } 152 | 153 | val runSiteAPI = isEnabled("siteAPI") 154 | 155 | if (runSiteAPI) { 156 | mainRouter.route().handler { 157 | it.next() 158 | } 159 | } 160 | 161 | val apiRouter = Router.router(vertx) 162 | 163 | if (isEnabled("analysisAPI")) 164 | apis.add(AnalysisAPI) 165 | 166 | if (runSiteAPI) { 167 | apis.add(SiteAPI) 168 | 169 | if(isEnabled("analytics")) { 170 | analytics = config.analyticsStorageType.analytics 171 | analyticsProviders = config.analyticsProviders.map { (type) -> type.provider } 172 | } else { 173 | analytics = EmptyDataAPI 174 | analyticsProviders = emptyList() 175 | } 176 | } else { 177 | analytics = EmptyDataAPI 178 | analyticsProviders = emptyList() 179 | } 180 | 181 | database = if (isEnabled("database")) 182 | requireNotNull(config.databaseType.db.objectInstance) { "No class of name ${config.databaseType.db.jvmName}"} 183 | else 184 | EmptyDataAPI 185 | 186 | if (isEnabled("audioAPI")) { 187 | apis.add(AudioAPI) 188 | 189 | audio = requireNotNull(config.audioSourceType.audio.objectInstance) { "No class of name ${config.audioSourceType.audio.jvmName}" } 190 | } else { 191 | audio = EmptyDataAPI 192 | } 193 | 194 | spotify = if (isEnabled("audioAPI") || isEnabled("analysisAPI")) 195 | SpotifyAnalyser 196 | else 197 | EmptyDataAPI 198 | 199 | if (isEnabled("nodeAPI")) 200 | apis.add(NodeAPI) 201 | 202 | analyticsProviders.forEach { provider -> provider.setupWebAnalytics(mainRouter) } 203 | 204 | apis.forEach { api -> 205 | val sub = Router.router(vertx) 206 | api.setup(sub) 207 | apiRouter.mountSubRouter(api.mountPath, sub) 208 | } 209 | mainRouter.mountSubRouter("/api", apiRouter) 210 | 211 | if (isEnabled("popular")) 212 | PopularHandler.setup(mainRouter) 213 | if (isEnabled("staticResources")) 214 | StaticResources.setup(mainRouter) 215 | 216 | webserver.requestHandler(mainRouter) 217 | 218 | schedule.scheduleAtFixedRate(0, 1, TimeUnit.HOURS) { referrersFile.writeText(referrers.joinToString("\n")) } 219 | } 220 | 221 | private fun isEnabled(function: String): Boolean = config.disable[function] != true 222 | } 223 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/FuelExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import com.github.kittinunf.fuel.core.Request 4 | 5 | fun Request.bearer(token: String): Request = header("Authorization" to "Bearer $token") 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/GeneralUtils.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import io.vertx.core.json.JsonObject 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.isActive 6 | import kotlinx.coroutines.launch 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | import java.io.File 10 | import java.util.* 11 | import java.util.concurrent.ScheduledExecutorService 12 | import java.util.concurrent.ScheduledFuture 13 | import java.util.concurrent.TimeUnit 14 | import kotlin.math.min 15 | import kotlin.math.pow 16 | import kotlin.reflect.KClass 17 | import kotlin.reflect.jvm.jvmName 18 | 19 | /** 20 | * @param task return true if we need to back off 21 | */ 22 | suspend fun exponentiallyBackoff(maximumBackoff: Long, maximumTries: Long, task: suspend (Long) -> Boolean): Boolean { 23 | if (!task(0)) 24 | return true 25 | 26 | for (i in 0 until maximumTries) { 27 | if (!task(i)) 28 | return true 29 | 30 | delay(min((2.0.pow(i.toDouble()) + kotlin.random.Random.nextInt(1000)).toLong(), maximumBackoff)) 31 | } 32 | 33 | return false 34 | } 35 | 36 | fun Any.toJsonObject(): JsonObject = JsonObject(EternalJukebox.jsonMapper.writeValueAsString(this)) 37 | 38 | fun jsonObjectOf(vararg pairs: Pair): JsonObject = JsonObject(pairs.toMap()) 39 | 40 | /** 41 | * Perform an action with this file if it exists, and then delete it. 42 | * Returns null if the file does not exist 43 | */ 44 | inline fun File.useThenDelete(action: (File) -> T): T? { 45 | try { 46 | return if (exists()) 47 | action(this) 48 | else 49 | null 50 | } finally { 51 | guaranteeDelete() 52 | } 53 | } 54 | 55 | val logger: Logger = LoggerFactory.getLogger("Miscellaneous") 56 | 57 | fun File.guaranteeDelete() { 58 | delete() 59 | if (exists()) { 60 | logger.trace("{} was not deleted successfully; deleting on exit and starting coroutine", this) 61 | deleteOnExit() 62 | 63 | EternalJukebox.launch { 64 | val rng = Random() 65 | var i = 0 66 | while (isActive && exists()) { 67 | delete() 68 | if (!exists()) { 69 | logger.trace("Finally deleted {} after {} attempts", this, i) 70 | } 71 | 72 | delay(min((2.0.pow((i++).toDouble()) + rng.nextInt(1000)).toLong(), 64000)) 73 | } 74 | } 75 | } 76 | } 77 | 78 | val KClass<*>.simpleClassName: String 79 | get() = simpleName ?: jvmName.substringAfterLast('.') 80 | 81 | fun ScheduledExecutorService.scheduleAtFixedRate( 82 | initialDelay: Long, 83 | every: Long, 84 | unit: TimeUnit = TimeUnit.MILLISECONDS, 85 | op: () -> Unit 86 | ): ScheduledFuture<*> = this.scheduleAtFixedRate(op, initialDelay, every, unit) 87 | 88 | fun ScheduledExecutorService.schedule(delay: Long, unit: TimeUnit = TimeUnit.MILLISECONDS, op: () -> Unit): ScheduledFuture<*> = this.schedule(op, delay, unit) 89 | 90 | fun String.toBase64LongOrNull(): Long? { 91 | var i = 0 92 | val len: Int = length 93 | val limit: Long = -Long.Companion.MAX_VALUE 94 | 95 | return if (len > 0) { 96 | val multmin: Long = limit / 64 97 | var result: Long = 0 98 | while (i < len) { 99 | // Accumulating negatively avoids surprises near MAX_VALUE 100 | val digit: Int = EternalJukebox.BASE_64_URL.indexOf(get(i++)) 101 | if (digit < 0 || result < multmin) { 102 | return null 103 | } 104 | result *= 64 105 | if (result < limit + digit) { 106 | return null 107 | } 108 | result -= digit.toLong() 109 | } 110 | -result 111 | } else { 112 | return null 113 | } 114 | } 115 | fun String.toBase64Long(): Long { 116 | var i = 0 117 | val len: Int = length 118 | val limit: Long = -Long.Companion.MAX_VALUE 119 | 120 | return if (len > 0) { 121 | val multmin: Long = limit / 64 122 | var result: Long = 0 123 | while (i < len) { 124 | // Accumulating negatively avoids surprises near MAX_VALUE 125 | val digit: Int = EternalJukebox.BASE_64_URL.indexOf(get(i++)) 126 | if (digit < 0 || result < multmin) { 127 | throw NumberFormatException() 128 | } 129 | result *= 64 130 | if (result < limit + digit) { 131 | throw NumberFormatException() 132 | } 133 | result -= digit.toLong() 134 | } 135 | -result 136 | } else { 137 | throw NumberFormatException() 138 | } 139 | } 140 | 141 | fun Long.toBase64(): String { 142 | val buf = StringBuffer() 143 | var i = -this 144 | while (i <= -64) { 145 | buf.append(EternalJukebox.BASE_64_URL[-(i % 64).toInt()]) 146 | i /= 64 147 | } 148 | buf.append(EternalJukebox.BASE_64_URL[-i.toInt()]) 149 | return buf.reverse().toString() 150 | } 151 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/LocalisedSnowstorm.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import kotlinx.coroutines.delay 4 | import java.net.InetAddress 5 | import java.net.NetworkInterface 6 | import java.net.SocketException 7 | import java.net.UnknownHostException 8 | import kotlin.random.Random 9 | 10 | /** 11 | * Snowflake 12 | 13 | * @author Maxim Khodanovich 14 | * * 15 | * @version 21.01.13 17:16 16 | * * 17 | * 18 | * 19 | * * id is composed of: 20 | * * time - 41 bits (millisecond precision w/ a custom epoch gives us 69 years) 21 | * * configured machine id - 10 bits - gives us up to 1024 machines 22 | * * sequence number - 12 bits - rolls over every 4096 per machine (with protection to avoid rollover in the same ms) 23 | */ 24 | class LocalisedSnowstorm(private val twepoch: Long) { 25 | 26 | // id format => 27 | // timestamp |datacenter | sequence 28 | // 41 |10 | 12 29 | private val sequenceBits = 12 30 | private val datacenterIdBits = 10 31 | 32 | private val datacenterIdShift = sequenceBits 33 | private val timestampLeftShift = sequenceBits + datacenterIdBits 34 | private val datacenterId: Long = getDatacenterId() 35 | private val sequenceMax = 4096 36 | 37 | @Volatile private var lastTimestamp = -1L 38 | @Volatile private var sequence = 0 39 | 40 | suspend fun generateLongId(): Long { 41 | var timestamp = System.currentTimeMillis() 42 | 43 | if (lastTimestamp == timestamp) { 44 | sequence = (sequence + 1) % sequenceMax 45 | if (sequence == 0) { 46 | timestamp = tilNextMillis(lastTimestamp) 47 | } 48 | } else { 49 | sequence = 0 50 | } 51 | lastTimestamp = timestamp 52 | val id = timestamp - twepoch shl timestampLeftShift or 53 | (datacenterId shl datacenterIdShift) or 54 | sequence.toLong() 55 | return id 56 | } 57 | 58 | private suspend fun tilNextMillis(lastTimestamp: Long): Long { 59 | var timestamp = System.currentTimeMillis() 60 | while (timestamp <= lastTimestamp) { 61 | delay(lastTimestamp - timestamp - 1) 62 | timestamp = System.currentTimeMillis() 63 | } 64 | return timestamp 65 | } 66 | 67 | private fun getDatacenterId(): Long { 68 | try { 69 | val addr = NetworkInterface.getNetworkInterfaces() 70 | .asSequence() 71 | .flatMap { network -> network.inetAddresses.asSequence() } 72 | .firstOrNull(InetAddress::isSiteLocalAddress) 73 | 74 | val id: Int 75 | when (addr) { 76 | null -> id = Random.nextInt(0, 1023) 77 | else -> { 78 | val addrData = addr.address 79 | 80 | id = (addrData[addrData.size - 2].toInt() and 0x3 shl 8) or (addrData[addrData.size - 1].toInt() and 0xFF) 81 | } 82 | } 83 | 84 | println("Snowstorm Datacenter ID: $id") 85 | 86 | return id.toLong() 87 | } catch (e: SocketException) { 88 | e.printStackTrace() 89 | return 0 90 | } catch (e: UnknownHostException) { 91 | e.printStackTrace() 92 | return 0 93 | } 94 | 95 | } 96 | 97 | suspend fun get(): Long { 98 | return generateLongId() 99 | } 100 | 101 | companion object WeatherMap { 102 | private val snowstorms = HashMap() 103 | 104 | fun getInstance(epoch: Long): LocalisedSnowstorm { 105 | if (!snowstorms.containsKey(epoch)) 106 | snowstorms[epoch] = LocalisedSnowstorm(epoch) 107 | return snowstorms.getValue(epoch) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/MediaWrapper.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import java.io.File 4 | import java.io.IOException 5 | import java.util.concurrent.TimeUnit 6 | 7 | object MediaWrapper { 8 | @Suppress("ClassName") 9 | object ffmpeg { 10 | val installed: Boolean 11 | get() { 12 | val process: Process = try { 13 | ProcessBuilder().command("ffmpeg", "-version").start() 14 | } catch (e: IOException) { 15 | return false 16 | } 17 | 18 | process.waitFor(5, TimeUnit.SECONDS) 19 | 20 | return String(process.inputStream.readBytes(), Charsets.UTF_8).startsWith("ffmpeg version") 21 | } 22 | 23 | fun convert(input: File, output: File, error: File): Boolean { 24 | val ffmpegProcess = ProcessBuilder().command("ffmpeg", "-i", input.absolutePath, output.absolutePath).redirectErrorStream(true).redirectOutput(error).start() 25 | 26 | if (ffmpegProcess.waitFor(60, TimeUnit.SECONDS)) { 27 | return ffmpegProcess.exitValue() == 0 28 | } 29 | 30 | ffmpegProcess.destroyForcibly().waitFor() 31 | return false 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/VertxExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox 2 | 3 | import io.vertx.core.http.HttpServerResponse 4 | import io.vertx.core.json.JsonArray 5 | import io.vertx.core.json.JsonObject 6 | import io.vertx.ext.web.Route 7 | import io.vertx.ext.web.RoutingContext 8 | import io.vertx.ext.web.handler.BodyHandler 9 | import io.vertx.kotlin.coroutines.dispatcher 10 | import kotlinx.coroutines.launch 11 | import org.abimon.eternalJukebox.objects.ClientInfo 12 | import org.abimon.eternalJukebox.objects.ConstantValues 13 | import org.abimon.eternalJukebox.objects.CoroutineClientInfo 14 | import org.slf4j.LoggerFactory 15 | 16 | fun HttpServerResponse.end(json: JsonArray) = putHeader("Content-Type", "application/json").end(json.toString()) 17 | fun HttpServerResponse.end(json: JsonObject) = putHeader("Content-Type", "application/json").end(json.toString()) 18 | 19 | fun RoutingContext.endWithStatusCode(statusCode: Int, init: JsonObject.() -> Unit) { 20 | val json = JsonObject() 21 | json.init() 22 | 23 | this.response().setStatusCode(statusCode) 24 | .putHeader("Content-Type", "application/json") 25 | .putHeader("X-Client-UID", clientInfo.userUID) 26 | .end(json.toString()) 27 | } 28 | 29 | fun HttpServerResponse.redirect(url: String): Unit = putHeader("Location", url).setStatusCode(307).end() 30 | 31 | val RoutingContext.clientInfo: ClientInfo 32 | get() { 33 | if (ConstantValues.CLIENT_INFO in data() && data()[ConstantValues.CLIENT_INFO] is ClientInfo) 34 | return data()[ConstantValues.CLIENT_INFO] as ClientInfo 35 | 36 | val info = ClientInfo(this) 37 | response().putHeader("X-Client-UID", info.userUID) 38 | 39 | data()[ConstantValues.CLIENT_INFO] = info 40 | 41 | return info 42 | } 43 | 44 | operator fun JsonObject.set(key: String, value: Any): JsonObject = put(key, value) 45 | 46 | fun Route.suspendingBodyHandler(handler: suspend (RoutingContext) -> Unit, maxMb: Long): Route = 47 | handler(BodyHandler.create().setBodyLimit(maxMb * 1000 * 1000).setDeleteUploadedFilesOnEnd(true)) 48 | .suspendingHandler(handler) 49 | 50 | val SUSPENDING_HANDLER_LOGGER = LoggerFactory.getLogger("SuspendingHandler") 51 | 52 | fun Route.suspendingHandler(handler: suspend (RoutingContext) -> Unit): Route = 53 | handler { ctx -> 54 | EternalJukebox.launch(ctx.vertx().dispatcher() + CoroutineClientInfo(ctx)) { 55 | try { 56 | handler(ctx) 57 | } catch (th: Throwable) { 58 | ctx.fail(th) 59 | 60 | SUSPENDING_HANDLER_LOGGER.error("[{}] An exception occurred whilst handling a request", coroutineContext, th) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/NodeSource.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import io.vertx.ext.web.RoutingContext 5 | import org.abimon.eternalJukebox.redirect 6 | import java.util.* 7 | 8 | abstract class NodeSource { 9 | abstract val nodeHosts: Array 10 | 11 | private val rng: Random = Random() 12 | 13 | fun provide(path: String, context: RoutingContext): Boolean { 14 | val starting = rng.nextInt(nodeHosts.size) 15 | 16 | for (i in nodeHosts.indices) { 17 | val host = nodeHosts[(starting + i) % nodeHosts.size] 18 | val (_, healthy) = Fuel.get("$host/api/node/healthy").timeout(5 * 1000).response() 19 | if (healthy.statusCode == 200) { 20 | context.response().redirect("$host/api/node/$path") 21 | return true 22 | } 23 | } 24 | 25 | return false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analysis/IAnalyser.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analysis 2 | 3 | import org.abimon.eternalJukebox.objects.ClientInfo 4 | import org.abimon.eternalJukebox.objects.JukeboxInfo 5 | 6 | interface IAnalyser { 7 | /** 8 | * Search for tracks based on the provided query. 9 | * @return An array of track information that matches the query 10 | */ 11 | suspend fun search(query: String, clientInfo: ClientInfo?): Array 12 | 13 | /** 14 | * Get track information from an ID 15 | */ 16 | suspend fun getInfo(id: String, clientInfo: ClientInfo?): JukeboxInfo? 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/HTTPAnalyticsProvider.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import io.vertx.ext.web.Router 4 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 5 | import org.abimon.eternalJukebox.schedule 6 | import java.util.concurrent.Executors 7 | import java.util.concurrent.ScheduledExecutorService 8 | import java.util.concurrent.TimeUnit 9 | import java.util.concurrent.atomic.AtomicInteger 10 | 11 | object HTTPAnalyticsProvider: IAnalyticsProvider { 12 | private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() 13 | 14 | private val requests: AtomicInteger = AtomicInteger(0) 15 | private val hourlyRequests: AtomicInteger = AtomicInteger(0) 16 | 17 | private val PROVIDING = arrayOf( 18 | EnumAnalyticType.SESSION_REQUESTS, EnumAnalyticType.HOURLY_REQUESTS 19 | ) 20 | 21 | override fun shouldProvide(type: EnumAnalyticType<*>): Boolean = type in PROVIDING 22 | 23 | @Suppress("UNCHECKED_CAST") 24 | override fun provide(now: Long, type: EnumAnalyticType): T? { 25 | return when (type) { 26 | is EnumAnalyticType.SESSION_REQUESTS -> requests.get() 27 | is EnumAnalyticType.HOURLY_REQUESTS -> hourlyRequests.get() 28 | 29 | else -> return null 30 | } as? T 31 | } 32 | 33 | override fun setupWebAnalytics(router: Router) { 34 | router.route().handler { context -> 35 | requests.incrementAndGet() 36 | hourlyRequests.incrementAndGet() 37 | 38 | scheduler.schedule(1, TimeUnit.HOURS) { hourlyRequests.decrementAndGet() } 39 | 40 | context.next() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/IAnalyticsProvider.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import io.vertx.ext.web.Router 4 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 5 | 6 | interface IAnalyticsProvider { 7 | /** 8 | * Should we store this type of analytics data? 9 | */ 10 | fun shouldProvide(type: EnumAnalyticType<*>): Boolean 11 | 12 | /** 13 | * Provides the requested data for this timeframe 14 | */ 15 | fun provide(now: Long, type: EnumAnalyticType): T? 16 | 17 | /** 18 | * Provides the requested data for this timeframe 19 | */ 20 | fun provideMultiple(now: Long, vararg types: EnumAnalyticType<*>): Map, Any> { 21 | val map: MutableMap, Any> = HashMap() 22 | 23 | for (type in types) 24 | map[type] = provide(now, type) ?: continue 25 | 26 | return map 27 | } 28 | 29 | fun setupWebAnalytics(router: Router) 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/IAnalyticsStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 4 | 5 | interface IAnalyticsStorage { 6 | 7 | /** 8 | * Store [data] as type [type] 9 | * Returns true if successfully stored; false otherwise 10 | */ 11 | fun store(now: Long, data: T, type: EnumAnalyticType): Boolean 12 | 13 | @Suppress("UNCHECKED_CAST") 14 | fun storeMultiple(now: Long, data: List, Any>>) = data.forEach { (type, data) -> store(now, data, type as EnumAnalyticType) } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/InfluxAnalyticsStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import com.github.kittinunf.fuel.core.Request 5 | import com.github.kittinunf.fuel.core.extensions.authentication 6 | import org.abimon.eternalJukebox.EternalJukebox 7 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 8 | import org.abimon.eternalJukebox.simpleClassName 9 | import java.net.URLEncoder 10 | import java.util.* 11 | import java.util.concurrent.TimeUnit 12 | 13 | object InfluxAnalyticsStorage : IAnalyticsStorage { 14 | private val ip: String = EternalJukebox.config.analyticsStorageOptions["ip"] as? String ?: throw IllegalStateException() 15 | private val db: String = URLEncoder.encode(EternalJukebox.config.analyticsStorageOptions["db"] as? String ?: throw IllegalStateException(), "UTF-8") 16 | private val user: String? = EternalJukebox.config.analyticsStorageOptions["user"] as? String 17 | private val pass: String? = EternalJukebox.config.analyticsStorageOptions["pass"] as? String 18 | 19 | override fun store(now: Long, data: T, type: EnumAnalyticType): Boolean { 20 | val ns = TimeUnit.NANOSECONDS.convert(now, TimeUnit.MILLISECONDS) 21 | 22 | val (_, response) = createPostRequest().body("eternal_jukebox ${type::class.simpleClassName.lowercase(Locale.getDefault())}=$data $ns") 23 | .response() 24 | return response.statusCode == 204 25 | } 26 | 27 | override fun storeMultiple(now: Long, data: List, Any>>) { 28 | val ns = TimeUnit.NANOSECONDS.convert(now, TimeUnit.MILLISECONDS) 29 | 30 | createPostRequest().body("eternal_jukebox ${data.joinToString(",") { (type, value) -> 31 | "${type::class.simpleClassName.lowercase(Locale.getDefault())}=$value" } 32 | } $ns").response { _, _, _ -> } 33 | } 34 | 35 | private fun createPostRequest(): Request { 36 | val postRequest = Fuel.post("${if (ip.indexOf("://") == -1) "http://" else ""}$ip/write?db=$db") 37 | if (user != null && pass != null) 38 | postRequest.authentication().basic(user, pass) 39 | return postRequest 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/LocalAnalyticStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import kotlinx.coroutines.* 4 | import org.abimon.eternalJukebox.EternalJukebox 5 | import org.abimon.eternalJukebox.guaranteeDelete 6 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 7 | import org.abimon.eternalJukebox.objects.EnumStorageType 8 | import org.abimon.eternalJukebox.simpleClassName 9 | import org.abimon.eternalJukebox.useThenDelete 10 | import org.abimon.visi.io.FileDataSource 11 | import java.io.File 12 | import java.io.FileOutputStream 13 | import java.io.PrintStream 14 | import java.util.* 15 | import kotlin.coroutines.CoroutineContext 16 | 17 | object LocalAnalyticStorage : IAnalyticsStorage, CoroutineScope { 18 | override val coroutineContext: CoroutineContext = SupervisorJob(EternalJukebox.coroutineContext[Job]) + CoroutineName("LocalAnalyticStorage") 19 | 20 | private val storageLocations: Map, File> = EnumAnalyticType.VALUES.associateWith { type -> 21 | File( 22 | EternalJukebox.config.analyticsStorageOptions["${type::class.simpleClassName.uppercase(Locale.getDefault())}_FILE"] as? String 23 | ?: "analytics-${type::class.simpleClassName.lowercase(Locale.getDefault())}.log" 24 | ) 25 | } 26 | private val storageStreams: MutableMap, PrintStream> = HashMap() 27 | 28 | override fun store(now: Long, data: T, type: EnumAnalyticType): Boolean { 29 | if(!storageStreams.containsKey(type)) 30 | storageStreams[type] = PrintStream(FileOutputStream(storageLocations[type] ?: return false), true) 31 | storageStreams[type]?.println("$now|$data") ?: return false 32 | return true 33 | } 34 | 35 | init { 36 | launch { 37 | storageLocations.forEach { (type, log) -> 38 | if (log.exists()) { 39 | if (EternalJukebox.storage.shouldStore(EnumStorageType.LOG)) { 40 | log.useThenDelete { file -> 41 | EternalJukebox.storage.store( 42 | "Analysis-${type::class.simpleClassName}-${UUID.randomUUID()}.log", 43 | EnumStorageType.LOG, 44 | FileDataSource(file), 45 | "text/plain", 46 | null 47 | ) 48 | } 49 | } else { 50 | log.guaranteeDelete() 51 | } 52 | } 53 | 54 | log.createNewFile() 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/analytics/SystemAnalyticsProvider.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.analytics 2 | 3 | import com.sun.management.OperatingSystemMXBean 4 | import io.vertx.ext.web.Router 5 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 6 | import org.abimon.visi.lang.usedMemory 7 | import java.lang.management.ManagementFactory 8 | 9 | object SystemAnalyticsProvider: IAnalyticsProvider { 10 | private val startup = System.currentTimeMillis() 11 | private val osBean = ManagementFactory.getOperatingSystemMXBean() as OperatingSystemMXBean 12 | private val PROVIDING = arrayOf( 13 | EnumAnalyticType.UPTIME, 14 | EnumAnalyticType.TOTAL_MEMORY, EnumAnalyticType.FREE_MEMORY, EnumAnalyticType.USED_MEMORY, 15 | EnumAnalyticType.FREE_MEMORY_PERCENT, EnumAnalyticType.USED_MEMORY_PERCENT, 16 | EnumAnalyticType.PROCESS_CPU_LOAD, EnumAnalyticType.SYSTEM_CPU_LOAD 17 | ) 18 | 19 | override fun shouldProvide(type: EnumAnalyticType<*>): Boolean = type in PROVIDING 20 | 21 | @Suppress("UNCHECKED_CAST") 22 | override fun provide(now: Long, type: EnumAnalyticType): T? { 23 | return when (type) { 24 | is EnumAnalyticType.UPTIME -> now - startup 25 | 26 | is EnumAnalyticType.TOTAL_MEMORY -> Runtime.getRuntime().totalMemory() 27 | is EnumAnalyticType.FREE_MEMORY -> Runtime.getRuntime().freeMemory() 28 | is EnumAnalyticType.USED_MEMORY -> Runtime.getRuntime().usedMemory() 29 | 30 | is EnumAnalyticType.FREE_MEMORY_PERCENT -> Runtime.getRuntime().freeMemory() * 100.0f / Runtime.getRuntime().totalMemory() 31 | is EnumAnalyticType.USED_MEMORY_PERCENT -> Runtime.getRuntime().usedMemory() * 100.0f / Runtime.getRuntime().totalMemory() 32 | 33 | is EnumAnalyticType.PROCESS_CPU_LOAD -> osBean.processCpuLoad 34 | is EnumAnalyticType.SYSTEM_CPU_LOAD -> osBean.systemCpuLoad 35 | 36 | else -> return null 37 | } as? T 38 | } 39 | 40 | override fun setupWebAnalytics(router: Router) {} 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/audio/DownloaderImpl.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.audio 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | import com.github.kittinunf.fuel.core.Method 5 | import com.github.kittinunf.result.Result 6 | import org.schabi.newpipe.extractor.downloader.Downloader 7 | import org.schabi.newpipe.extractor.downloader.Request 8 | import org.schabi.newpipe.extractor.downloader.Response 9 | import org.schabi.newpipe.extractor.exceptions.ReCaptchaException 10 | import java.io.IOException 11 | import java.util.concurrent.TimeUnit 12 | 13 | class DownloaderImpl private constructor() : Downloader() { 14 | 15 | @Throws(IOException::class, ReCaptchaException::class) 16 | override fun execute(request: Request): Response { 17 | val httpMethod = request.httpMethod() 18 | val url = request.url() 19 | val headers = request.headers() 20 | val dataToSend = request.dataToSend() 21 | 22 | var fuelRequest = Fuel.request(Method.valueOf(httpMethod.uppercase()), url) 23 | .timeoutRead(TimeUnit.SECONDS.toMillis(30).toInt()) 24 | .header("User-Agent" to USER_AGENT) 25 | .header(headers) 26 | if (dataToSend != null) { 27 | fuelRequest = fuelRequest.body(dataToSend) 28 | } 29 | 30 | val response = fuelRequest.responseString() 31 | 32 | if (response.second.statusCode == 429) { 33 | throw ReCaptchaException("reCaptcha Challenge requested", url) 34 | } 35 | 36 | var responseBodyToReturn: String? = null 37 | 38 | if (response.third is Result.Success) { 39 | responseBodyToReturn = response.third.get() 40 | } 41 | 42 | val latestUrl: String = response.first.url.toString() 43 | return Response( 44 | response.second.statusCode, 45 | response.second.responseMessage, 46 | response.second.headers.map { it.key to it.value.toList() }.toMap(), 47 | responseBodyToReturn, latestUrl 48 | ) 49 | } 50 | 51 | companion object { 52 | const val USER_AGENT: String = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" 53 | 54 | private var instance: DownloaderImpl? = null 55 | 56 | /** 57 | * It's recommended to call exactly once in the entire lifetime of the application. 58 | * 59 | * @return a new instance of [DownloaderImpl] 60 | */ 61 | fun init(): DownloaderImpl? { 62 | instance = DownloaderImpl() 63 | return instance 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/audio/IAudioSource.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.audio 2 | 3 | import io.vertx.ext.web.RoutingContext 4 | import org.abimon.eternalJukebox.EternalJukebox 5 | import org.abimon.eternalJukebox.objects.ClientInfo 6 | import org.abimon.eternalJukebox.objects.JukeboxInfo 7 | import java.net.URL 8 | 9 | @FunctionalInterface 10 | interface IAudioSource { 11 | val audioSourceOptions 12 | get() = EternalJukebox.config.audioSourceOptions 13 | /** 14 | * Provide the audio data for a required song to the routing context. 15 | * Returns true if handled; false otherwise. 16 | */ 17 | suspend fun provide(info: JukeboxInfo, context: RoutingContext): Boolean 18 | 19 | /** 20 | * Provide a location for a required song 21 | * The provided location may not be a direct download link, and may not contain valid audio data. 22 | * The provided location, however, should be a link to said song where possible, or return null if nothing could be found. 23 | */ 24 | suspend fun provideLocation(info: JukeboxInfo, clientInfo: ClientInfo?): URL? = null 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/audio/NodeAudioSource.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.audio 2 | 3 | import io.vertx.ext.web.RoutingContext 4 | import org.abimon.eternalJukebox.clientInfo 5 | import org.abimon.eternalJukebox.data.NodeSource 6 | import org.abimon.eternalJukebox.objects.JukeboxInfo 7 | 8 | @Suppress("UNCHECKED_CAST") 9 | object NodeAudioSource: NodeSource(), IAudioSource { 10 | @Suppress("JoinDeclarationAndAssignment") 11 | override val nodeHosts: Array 12 | 13 | override suspend fun provide(info: JukeboxInfo, context: RoutingContext): Boolean = provide("audio/${info.id}?user_uid=${context.clientInfo.userUID}", context) 14 | 15 | init { 16 | nodeHosts = if (audioSourceOptions.containsKey("NODE_HOST")) 17 | arrayOf(audioSourceOptions["NODE_HOST"] as? String ?: throw IllegalArgumentException("${audioSourceOptions["NODE_HOST"]} is not of type 'String' (is ${audioSourceOptions["NODE_HOST"]?.javaClass}")) 18 | else if (audioSourceOptions.containsKey("NODE_HOSTS")) { 19 | (audioSourceOptions["NODE_HOSTS"] as? List)?.toTypedArray() ?: throw throw IllegalArgumentException("${audioSourceOptions["NODE_HOSTS"]} is not of type 'List' (is ${audioSourceOptions["NODE_HOSTS"]?.javaClass}") 20 | } else 21 | throw IllegalArgumentException("No hosts assigned for NodeAudioSource") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/database/H2Database.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.database 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import kotlinx.coroutines.DelicateCoroutinesApi 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.launch 8 | import org.abimon.eternalJukebox.objects.ClientInfo 9 | import org.abimon.eternalJukebox.objects.JukeboxInfo 10 | import java.sql.Connection 11 | 12 | @OptIn(DelicateCoroutinesApi::class) 13 | object H2Database : HikariDatabase() { 14 | override val ds: HikariDataSource 15 | 16 | override fun updatePopular(connection: Connection, updates: Map) { 17 | val select = 18 | connection.prepareStatement("SELECT id FROM popular WHERE service=? AND song_id=? LIMIT 1;") 19 | val update = connection.prepareStatement("UPDATE popular SET hits=hits + ? WHERE id=?;") 20 | val insert = 21 | connection.prepareStatement("INSERT INTO popular (song_id, service, hits) VALUES (?, ?, ?);") 22 | 23 | updates.entries.chunked(100) { chunk -> 24 | insert.clearBatch() 25 | update.clearBatch() 26 | update.clearParameters() 27 | 28 | chunk.forEach { (key, amount) -> 29 | select.setString(1, key.substringBefore(':')) 30 | select.setString(2, key.substringAfter(':')) 31 | select.execute() 32 | 33 | select.resultSet.use { rs -> 34 | if (rs.next()) { 35 | update.setInt(1, amount) 36 | update.setLong(2, rs.getLong("id")) 37 | update.addBatch() 38 | } else { 39 | insert.setString(1, key.substringAfter(':')) 40 | insert.setString(2, key.substringBefore(':')) 41 | insert.setInt(3, amount) 42 | insert.addBatch() 43 | } 44 | } 45 | } 46 | 47 | insert.executeBatch() 48 | update.executeBatch() 49 | } 50 | } 51 | 52 | override fun updateLocation(connection: Connection, updates: Map) { 53 | val select = 54 | connection.prepareStatement("SELECT id FROM audio_locations WHERE id=?;") 55 | val update = connection.prepareStatement("UPDATE audio_locations SET location=? WHERE id=?;") 56 | val insert = 57 | connection.prepareStatement("INSERT INTO audio_locations (id,location) VALUES (?, ?);") 58 | 59 | updates.entries.chunked(100) { chunk -> 60 | insert.clearBatch() 61 | update.clearBatch() 62 | update.clearParameters() 63 | 64 | chunk.forEach { (songID, location) -> 65 | select.setString(1, songID) 66 | select.execute() 67 | 68 | select.resultSet.use { rs -> 69 | if (rs.next()) { 70 | update.setString(1, location) 71 | update.setString(2, songID) 72 | update.addBatch() 73 | } else { 74 | insert.setString(1, songID) 75 | insert.setString(2, location) 76 | insert.addBatch() 77 | } 78 | } 79 | } 80 | 81 | insert.executeBatch() 82 | update.executeBatch() 83 | } 84 | } 85 | 86 | override fun updateInfo(connection: Connection, updates: Collection) { 87 | val select = 88 | connection.prepareStatement("SELECT id FROM info_cache WHERE id=?;") 89 | val update = connection.prepareStatement("UPDATE info_cache SET song_name=?,song_title=?,song_artist=?,song_url=?,song_duration=? WHERE id=?;") 90 | val insert = 91 | connection.prepareStatement("INSERT INTO info_cache(id, song_name, song_title, song_artist, song_url, song_duration) VALUES (?, ?, ?, ?, ?, ?);") 92 | 93 | updates.chunked(100) { chunk -> 94 | insert.clearBatch() 95 | update.clearBatch() 96 | update.clearParameters() 97 | 98 | chunk.forEach { info -> 99 | select.setString(1, info.id) 100 | select.execute() 101 | 102 | select.resultSet.use { rs -> 103 | if (rs.next()) { 104 | update.setString(1, info.name) 105 | update.setString(2, info.title) 106 | update.setString(3, info.artist) 107 | update.setString(4, info.url) 108 | update.setInt(5, info.duration) 109 | update.setString(6, info.id) 110 | update.addBatch() 111 | } else { 112 | insert.setString(1, info.id) 113 | insert.setString(2, info.name) 114 | insert.setString(3, info.title) 115 | insert.setString(4, info.artist) 116 | insert.setString(5, info.url) 117 | insert.setInt(6, info.duration) 118 | insert.addBatch() 119 | } 120 | } 121 | } 122 | 123 | insert.executeBatch() 124 | update.executeBatch() 125 | } 126 | } 127 | 128 | override fun makeSongPopular(service: String, id: String, clientInfo: ClientInfo?) { 129 | // TODO: This will be suspend soon 130 | GlobalScope.launch(dispatcher) { popularUpdates[service]?.send(id) } 131 | } 132 | 133 | init { 134 | val config = HikariConfig() 135 | config.jdbcUrl = "jdbc:h2:./$databaseName;mode=MySQL" 136 | 137 | config.addDataSourceProperty("cachePrepStmts", "true") 138 | config.addDataSourceProperty("prepStmtCacheSize", "250") 139 | config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048") 140 | 141 | ds = HikariDataSource(config) 142 | 143 | initialise() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/database/IDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.database 2 | 3 | import org.abimon.eternalJukebox.EternalJukebox 4 | import org.abimon.eternalJukebox.objects.ClientInfo 5 | import org.abimon.eternalJukebox.objects.JukeboxInfo 6 | 7 | interface IDatabase { 8 | val databaseOptions 9 | get() = EternalJukebox.config.databaseOptions 10 | val databaseName 11 | get() = databaseOptions["databaseName"] ?: "eternal_jukebox" 12 | 13 | suspend fun provideAudioTrackOverride(id: String, clientInfo: ClientInfo?): String? 14 | 15 | suspend fun providePopularSongs(service: String, count: Int, clientInfo: ClientInfo?): List 16 | fun makeSongPopular(service: String, id: String, clientInfo: ClientInfo?) 17 | 18 | suspend fun provideShortURL(params: Array, clientInfo: ClientInfo?): String 19 | suspend fun expandShortURL(id: String, clientInfo: ClientInfo?): Array? 20 | 21 | suspend fun provideAudioLocation(id: String, clientInfo: ClientInfo?): String? 22 | suspend fun storeAudioLocation(id: String, location: String, clientInfo: ClientInfo?) 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/database/JDBCDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.database 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | 6 | object JDBCDatabase: HikariDatabase() { 7 | override val ds: HikariDataSource 8 | 9 | init { 10 | Class.forName("com.mysql.jdbc.Driver") 11 | .getDeclaredConstructor() 12 | .newInstance() 13 | 14 | val config = HikariConfig("hikari.properties") 15 | config.jdbcUrl = databaseOptions["jdbcUrl"]?.toString() ?: throw IllegalStateException("jdbcUrl was not provided!") 16 | 17 | config.username = databaseOptions["username"]?.toString() 18 | config.password = databaseOptions["password"]?.toString() 19 | config.initializationFailTimeout = 0 20 | 21 | val cloudSqlInstance = databaseOptions["cloudSqlInstance"]?.toString() 22 | 23 | if(cloudSqlInstance != null) { 24 | config.addDataSourceProperty("socketFactory", "com.google.cloud.sql.mysql.SocketFactory") 25 | config.addDataSourceProperty("cloudSqlInstance", cloudSqlInstance) 26 | } 27 | 28 | // config.addDataSourceProperty("useServerPrepStmts", databaseOptions["userServerPrepStmts"]?.toString() ?: "true") 29 | // config.addDataSourceProperty("cachePrepStmts", databaseOptions["cachePrepStmts"]?.toString() ?: "true") 30 | // config.addDataSourceProperty("prepStmtCacheSize", databaseOptions["prepStmtCacheSize"]?.toString() ?: "250") 31 | // config.addDataSourceProperty("prepStmtCacheSqlLimit", databaseOptions["prepStmtCacheSqlLimit"]?.toString() ?: "2048") 32 | // config.addDataSourceProperty("useLocalSessionState", "true") 33 | // config.addDataSourceProperty("rewriteBatchedStatements", "true") 34 | // config.addDataSourceProperty("cacheResultSetMetadata", "true") 35 | // config.addDataSourceProperty("cacheServerConfiguration", "true") 36 | // config.addDataSourceProperty("elideSetAutoCommits", "true") 37 | // config.addDataSourceProperty("maintainTimeStats", "false") 38 | 39 | ds = HikariDataSource(config) 40 | 41 | initialise() 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/storage/IStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.storage 2 | 3 | import io.vertx.ext.web.RoutingContext 4 | import org.abimon.eternalJukebox.EternalJukebox 5 | import org.abimon.eternalJukebox.clientInfo 6 | import org.abimon.eternalJukebox.objects.ClientInfo 7 | import org.abimon.eternalJukebox.objects.EnumStorageType 8 | import org.abimon.visi.io.DataSource 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import java.util.* 12 | 13 | interface IStorage { 14 | val storageOptions 15 | get() = EternalJukebox.config.storageOptions 16 | 17 | val disabledStorageTypes: List 18 | get() = storageOptions.let { storageOptionMap -> 19 | EnumStorageType.entries.filter { enumStorageType -> storageOptionMap["${enumStorageType.name.uppercase(Locale.getDefault())}_IS_DISABLED"]?.toString()?.toBoolean() ?: false } 20 | } 21 | 22 | val logger: Logger 23 | get() = LoggerFactory.getLogger(this::class.java) 24 | 25 | /** 26 | * Should we store this type of storage? 27 | */ 28 | fun shouldStore(type: EnumStorageType): Boolean 29 | 30 | /** 31 | * Store [data] under [name], as type [type] 32 | * Returns true if successfully stored; false otherwise 33 | */ 34 | suspend fun store(name: String, type: EnumStorageType, data: DataSource, mimeType: String, clientInfo: ClientInfo?): Boolean 35 | 36 | /** 37 | * Provide previously stored data of name [name] and type [type] to the routing context. 38 | * Returns true if handled; false otherwise. 39 | */ 40 | suspend fun provide(name: String, type: EnumStorageType, context: RoutingContext): Boolean 41 | 42 | suspend fun isStored(name: String, type: EnumStorageType): Boolean 43 | 44 | suspend fun safeProvide(name: String, type: EnumStorageType, context: RoutingContext): Boolean { 45 | if (context.response().closed()) { 46 | logger.debug( 47 | "[{}] User closed connection before the response could be sent for {} of type {}", 48 | context.clientInfo.userUID, 49 | name, 50 | type 51 | ) 52 | return true 53 | } 54 | return provide(name, type, context) 55 | } 56 | 57 | suspend fun provideIfStored(name: String, type: EnumStorageType, context: RoutingContext): Boolean { 58 | if(isStored(name, type)) { 59 | if (safeProvide(name, type, context)) { 60 | return true 61 | } else { 62 | logger.warn("Failed to provide stored data $name of type $type") 63 | } 64 | } 65 | return false 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/data/storage/LocalStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.data.storage 2 | 3 | import io.vertx.ext.web.RoutingContext 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import org.abimon.eternalJukebox.EternalJukebox 7 | import org.abimon.eternalJukebox.clientInfo 8 | import org.abimon.eternalJukebox.objects.ClientInfo 9 | import org.abimon.eternalJukebox.objects.EnumStorageType 10 | import org.abimon.visi.io.DataSource 11 | import java.io.File 12 | import java.io.FileOutputStream 13 | import java.util.* 14 | 15 | object LocalStorage : IStorage { 16 | private val storageLocations: Map = EnumStorageType.entries.associateWith { type -> 17 | File( 18 | EternalJukebox.config.storageOptions["${type.name}_FOLDER"] as? String 19 | ?: type.name.lowercase(Locale.getDefault()) 20 | ) 21 | } 22 | 23 | override fun shouldStore(type: EnumStorageType): Boolean = !disabledStorageTypes.contains(type) 24 | 25 | override suspend fun store(name: String, type: EnumStorageType, data: DataSource, mimeType: String, clientInfo: ClientInfo?): Boolean { 26 | return withContext(Dispatchers.IO) { 27 | val file = File(storageLocations[type]!!, name) 28 | FileOutputStream(file).use { fos -> 29 | data.use { inputStream -> 30 | inputStream.copyTo(fos) 31 | } 32 | } 33 | true 34 | } 35 | } 36 | 37 | override suspend fun provide(name: String, type: EnumStorageType, context: RoutingContext): Boolean { 38 | val file = File(storageLocations[type]!!, name) 39 | if(file.exists()) { 40 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID).sendFile(file.absolutePath) 41 | return true 42 | } 43 | 44 | return false 45 | } 46 | 47 | override suspend fun isStored(name: String, type: EnumStorageType): Boolean = File(storageLocations[type]!!, name).exists() 48 | 49 | init { 50 | storageLocations.values.forEach { if(!it.exists()) it.mkdirs() } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/PopularHandler.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers 2 | 3 | import io.vertx.ext.web.Router 4 | import io.vertx.ext.web.RoutingContext 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import org.abimon.eternalJukebox.EternalJukebox 8 | import org.abimon.eternalJukebox.clientInfo 9 | import org.abimon.eternalJukebox.suspendingHandler 10 | 11 | object PopularHandler { 12 | private val SPOTIFY_REGEX = "[0-9a-zA-Z]{22}".toRegex() 13 | 14 | fun setup(router: Router) { 15 | router.get("/jukebox_go.html").suspendingHandler(this::makeJukeboxPopular) 16 | router.get("/canonizer_go.html").suspendingHandler(this::makeCanonizerPopular) 17 | } 18 | 19 | private suspend fun makeJukeboxPopular(context: RoutingContext) { 20 | val clientInfo = context.clientInfo 21 | val id = context.request().getParam("id") ?: return context.next() 22 | if (SPOTIFY_REGEX.matches(id)) 23 | withContext(Dispatchers.IO) { EternalJukebox.database.makeSongPopular("jukebox", id, clientInfo) } 24 | 25 | context.next() 26 | } 27 | 28 | private suspend fun makeCanonizerPopular(context: RoutingContext) { 29 | val clientInfo = context.clientInfo 30 | val id = context.request().getParam("id") ?: return context.next() 31 | if (SPOTIFY_REGEX.matches(id)) 32 | withContext(Dispatchers.IO) { EternalJukebox.database.makeSongPopular("canonizer", id, clientInfo) } 33 | 34 | context.next() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/StaticResources.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers 2 | 3 | import io.vertx.ext.web.Router 4 | import io.vertx.ext.web.handler.StaticHandler 5 | import org.abimon.eternalJukebox.EternalJukebox 6 | 7 | object StaticResources { 8 | fun setup(router: Router) { 9 | router.get().handler(StaticHandler.create(EternalJukebox.config.webRoot)) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/api/AnalysisAPI.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers.api 2 | 3 | import io.vertx.core.json.JsonArray 4 | import io.vertx.ext.web.Router 5 | import io.vertx.ext.web.RoutingContext 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import org.abimon.eternalJukebox.* 9 | import org.abimon.eternalJukebox.objects.* 10 | import org.abimon.visi.io.ByteArrayDataSource 11 | import org.slf4j.Logger 12 | import org.slf4j.LoggerFactory 13 | import java.io.File 14 | import java.util.* 15 | 16 | object AnalysisAPI : IAPI { 17 | override val mountPath: String = "/analysis" 18 | private val logger: Logger = LoggerFactory.getLogger("AnalysisApi") 19 | 20 | override fun setup(router: Router) { 21 | router.get("/analyse/:id").suspendingHandler(this::analyseSpotify) 22 | router.get("/search").suspendingHandler(AnalysisAPI::searchSpotify) 23 | router.post("/upload/:id").suspendingBodyHandler(this::upload, maxMb = 10) 24 | } 25 | 26 | private suspend fun analyseSpotify(context: RoutingContext) { 27 | if (EternalJukebox.storage.shouldStore(EnumStorageType.ANALYSIS)) { 28 | val id = context.pathParam("id") 29 | if (EternalJukebox.storage.provideIfStored("$id.json", EnumStorageType.ANALYSIS, context)) 30 | return 31 | 32 | if (EternalJukebox.storage.shouldStore(EnumStorageType.UPLOADED_ANALYSIS)) { 33 | if (EternalJukebox.storage.provideIfStored("$id.json", EnumStorageType.UPLOADED_ANALYSIS, context)) 34 | return 35 | 36 | return context.response().putHeader("X-Client-UID", context.clientInfo.userUID).setStatusCode(400).end( 37 | jsonObjectOf( 38 | "error" to "This track currently has no analysis data. Below is a tutorial on how to manually provide analysis data on Desktop.", 39 | "show_manual_analysis_info" to true, 40 | "client_uid" to context.clientInfo.userUID 41 | ) 42 | ) 43 | } 44 | 45 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID).setStatusCode(400).end( 46 | jsonObjectOf( 47 | "error" to "It is not possible to get new analysis data from Spotify. Please check the subreddit linked under 'Social' in the navigation bar for more information.", 48 | "client_uid" to context.clientInfo.userUID 49 | ) 50 | ) 51 | } else { 52 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID).setStatusCode(501).end( 53 | jsonObjectOf( 54 | "error" to "Configured storage method does not support storing ANALYSIS", 55 | "client_uid" to context.clientInfo.userUID 56 | ) 57 | ) 58 | } 59 | } 60 | 61 | private suspend fun searchSpotify(context: RoutingContext) { 62 | val query = context.request().getParam("query") ?: "Never Gonna Give You Up" 63 | val results = EternalJukebox.spotify.search(query, context.clientInfo) 64 | 65 | context.response().end(JsonArray(results.map(JukeboxInfo::toJsonObject))) 66 | } 67 | 68 | private suspend fun upload(context: RoutingContext) { 69 | val id = context.pathParam("id") 70 | 71 | if (!EternalJukebox.storage.shouldStore(EnumStorageType.UPLOADED_ANALYSIS)) { 72 | return context.endWithStatusCode(502) { 73 | this["error"] = "This server does not support uploaded analysis" 74 | } 75 | } 76 | if (context.fileUploads().isEmpty()) { 77 | return context.endWithStatusCode(400) { 78 | this["error"] = "No file uploads" 79 | } 80 | } 81 | 82 | val uploadedFile = File(context.fileUploads().first().uploadedFileName()) 83 | 84 | val info = EternalJukebox.spotify.getInfo(id, context.clientInfo) ?: run { 85 | logger.warn("[{}] Failed to get track info for {}", context.clientInfo.userUID, id) 86 | return context.endWithStatusCode(400) { 87 | this["error"] = "Failed to get track info" 88 | } 89 | } 90 | 91 | val track: JukeboxTrack? = try { 92 | val analysisObject = withContext(Dispatchers.IO) { 93 | EternalJukebox.jsonMapper.readValue(uploadedFile.readBytes(), Map::class.java) 94 | }?.toJsonObject() 95 | 96 | analysisObject?.let { 97 | JukeboxTrack( 98 | info, 99 | withContext(Dispatchers.IO) { 100 | JukeboxAnalysis( 101 | EternalJukebox.jsonMapper.readValue( 102 | it.getJsonArray("sections").toString(), 103 | Array::class.java 104 | ), 105 | EternalJukebox.jsonMapper.readValue( 106 | it.getJsonArray("bars").toString(), 107 | Array::class.java 108 | ), 109 | EternalJukebox.jsonMapper.readValue( 110 | it.getJsonArray("beats").toString(), 111 | Array::class.java 112 | ), 113 | EternalJukebox.jsonMapper.readValue( 114 | it.getJsonArray("tatums").toString(), 115 | Array::class.java 116 | ), 117 | EternalJukebox.jsonMapper.readValue( 118 | it.getJsonArray("segments").toString(), 119 | Array::class.java 120 | ) 121 | ) 122 | }, 123 | JukeboxSummary(it.getJsonObject("track").getDouble("duration")) 124 | ) 125 | } 126 | } catch (e: Exception) { 127 | logger.warn("[{}] Failed to parse analysis file for {}", context.clientInfo.userUID, id, e) 128 | null 129 | } 130 | 131 | if (track == null) { 132 | return context.endWithStatusCode(400) { 133 | this["error"] = "Analysis file could not be parsed" 134 | } 135 | } 136 | if (track.info.duration != (track.audio_summary.duration * 1000).toInt()) { 137 | return context.endWithStatusCode(400) { 138 | this["error"] = "Track duration does not match analysis duration. This is likely due to an incorrect " + 139 | "analysis file. Make sure it is for the song ${info.name} by ${info.artist}" 140 | } 141 | } 142 | 143 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID) 144 | .end(track.toJsonObject()) 145 | 146 | logger.info("[{}] Uploaded analysis for {}", context.clientInfo.userUID, id) 147 | withContext(Dispatchers.IO) { 148 | EternalJukebox.storage.store( 149 | "$id.json", 150 | EnumStorageType.UPLOADED_ANALYSIS, 151 | ByteArrayDataSource(track.toJsonObject().toString().toByteArray(Charsets.UTF_8)), 152 | "application/json", 153 | context.clientInfo 154 | ) 155 | } 156 | } 157 | 158 | 159 | init { 160 | logger.info("Initialised Analysis Api") 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/api/IAPI.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers.api 2 | 3 | import io.vertx.ext.web.Router 4 | 5 | interface IAPI { 6 | /** 7 | * Path to mount this API on. Must start with `/` 8 | */ 9 | val mountPath: String 10 | 11 | fun setup(router: Router) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/api/NodeAPI.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers.api 2 | 3 | import io.vertx.ext.web.Router 4 | 5 | object NodeAPI: IAPI { 6 | override val mountPath: String = "/node" 7 | 8 | override fun setup(router: Router) { 9 | router.route("/healthy").handler { context -> context.response().end() } 10 | router.route("/audio/:id").blockingHandler { context -> context.reroute("/api/audio/jukebox/${context.pathParam("id")}") } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/handlers/api/SiteAPI.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.handlers.api 2 | 3 | import com.jakewharton.fliptables.FlipTable 4 | import com.sun.management.OperatingSystemMXBean 5 | import io.vertx.core.json.JsonArray 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.ext.web.Router 8 | import io.vertx.ext.web.RoutingContext 9 | import kotlinx.coroutines.* 10 | import org.abimon.eternalJukebox.* 11 | import org.abimon.eternalJukebox.objects.ClientInfo 12 | import org.abimon.eternalJukebox.objects.EnumAnalyticType 13 | import org.abimon.visi.lang.usedMemory 14 | import org.abimon.visi.time.timeDifference 15 | import java.lang.management.ManagementFactory 16 | import java.text.DecimalFormat 17 | import java.time.LocalDateTime 18 | import java.util.* 19 | 20 | @OptIn(DelicateCoroutinesApi::class) 21 | object SiteAPI: IAPI { 22 | override val mountPath: String = "/site" 23 | private val startupTime: LocalDateTime = LocalDateTime.now() 24 | 25 | private val memoryFormat = DecimalFormat("####.##") 26 | private val cpuFormat = DecimalFormat("#.####") 27 | 28 | private val osBean = ManagementFactory.getOperatingSystemMXBean() as OperatingSystemMXBean 29 | 30 | override fun setup(router: Router) { 31 | router.get("/healthy").handler { it.response().end("Up for ${startupTime.timeDifference()}") } 32 | router.get("/usage").handler(SiteAPI::usage) 33 | 34 | router.get("/expand/:id").suspendingHandler(SiteAPI::expand) 35 | router.get("/expand/:id/redirect").suspendingHandler(SiteAPI::expandAndRedirect) 36 | router.post("/shrink").suspendingBodyHandler(SiteAPI::shrink, maxMb = 1) 37 | 38 | router.get("/popular/:service").suspendingHandler(this::popular) 39 | } 40 | 41 | private suspend fun popular(context: RoutingContext) { 42 | val service = context.pathParam("service") 43 | val count = context.request().getParam("count")?.toIntOrNull() ?: context.request().getParam("limit")?.toIntOrNull() ?: 30 44 | 45 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID).end(JsonArray(withContext(Dispatchers.IO) { EternalJukebox.database.providePopularSongs(service, count, context.clientInfo) })) 46 | } 47 | 48 | private fun usage(context: RoutingContext) { 49 | val rows = arrayOf( 50 | "Uptime" to startupTime.timeDifference(), 51 | "Total Memory" to "${memoryFormat.format(Runtime.getRuntime().totalMemory() / 1000000.0)} MB", 52 | "Free Memory" to "${memoryFormat.format(Runtime.getRuntime().freeMemory() / 1000000.0)} MB", 53 | "Used Memory" to "${memoryFormat.format(Runtime.getRuntime().usedMemory() / 1000000.0)} MB", 54 | "CPU Load (Process)" to "${cpuFormat.format(osBean.processCpuLoad * 100)}%", 55 | "CPU Load (System)" to "${cpuFormat.format(osBean.systemCpuLoad * 100)}%" 56 | ) 57 | 58 | context.response() 59 | .putHeader("X-Client-UID", context.clientInfo.userUID) 60 | .putHeader("Content-Type", "text/plain; charset=UTF-8") 61 | .end(FlipTable.of(arrayOf("Key", "Value"), rows.map { (one, two) -> arrayOf(one, two) }.toTypedArray())) 62 | } 63 | 64 | private suspend fun expand(context: RoutingContext) { 65 | val id = context.pathParam("id") 66 | val clientInfo = context.clientInfo 67 | val expanded = expand(id, clientInfo) ?: return context.response().putHeader("X-Client-UID", clientInfo.userUID).setStatusCode(400).end(jsonObjectOf("error" to "No short ID stored", "id" to id)) 68 | context.response().end(expanded) 69 | } 70 | 71 | private suspend fun expandAndRedirect(context: RoutingContext) { 72 | val id = context.pathParam("id") 73 | val clientInfo = context.clientInfo 74 | val expanded = expand(id, clientInfo) ?: return context.response().putHeader("X-Client-UID", clientInfo.userUID).setStatusCode(400).end(jsonObjectOf("error" to "No short ID stored", "id" to id)) 75 | 76 | context.response().redirect(expanded.getString("url")) 77 | } 78 | 79 | private suspend fun expand(id: String, clientInfo: ClientInfo): JsonObject? { 80 | val params = 81 | withContext(Dispatchers.IO) { EternalJukebox.database.expandShortURL(id, clientInfo) } ?: return null 82 | val paramsMap = params.map { pair -> pair.split('=', limit = 2) }.filter { pair -> pair.size == 2 } 83 | .associateTo(HashMap()) { pair -> Pair(pair[0], pair[1]) } 84 | 85 | val service = paramsMap.remove("service") ?: "jukebox" 86 | val response = JsonObject() 87 | 88 | when (service.lowercase(Locale.getDefault())) { 89 | "jukebox" -> response["url"] = 90 | "/jukebox_go.html?${paramsMap.entries.joinToString("&") { (key, value) -> "$key=$value" }}" 91 | 92 | "canonizer" -> response["url"] = 93 | "/canonizer_go.html?${paramsMap.entries.joinToString("&") { (key, value) -> "$key=$value" }}" 94 | 95 | else -> response["url"] = "/jukebox_index.html" 96 | } 97 | 98 | val trackInfo = EternalJukebox.spotify.getInfo(paramsMap["id"] ?: "4uLU6hMCjMI75M1A2tKUQC", clientInfo) 99 | 100 | response["song"] = EternalJukebox.jsonMapper.convertValue(trackInfo, Map::class.java) 101 | response["params"] = paramsMap 102 | 103 | return response 104 | } 105 | 106 | private suspend fun shrink(context: RoutingContext) { 107 | val params = context.bodyAsString.split('&').toTypedArray() 108 | val id = withContext(Dispatchers.IO) { EternalJukebox.database.provideShortURL(params, context.clientInfo) } 109 | context.response().putHeader("X-Client-UID", context.clientInfo.userUID).end(jsonObjectOf("id" to id, "params" to params)) 110 | } 111 | 112 | init { 113 | EternalJukebox.launch { 114 | while (isActive) { 115 | val time = System.currentTimeMillis() 116 | 117 | try { 118 | EternalJukebox.analyticsProviders.forEach { provider -> 119 | EternalJukebox.analytics.storeMultiple(time, provider.provideMultiple(time, *EnumAnalyticType.VALUES.filter { type -> provider.shouldProvide(type) }.toTypedArray()).map { (type, data) -> type to data }) 120 | } 121 | } catch (th: Throwable) { 122 | th.printStackTrace() 123 | } 124 | 125 | delay(EternalJukebox.config.usageWritePeriod) 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/ClientInfo.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import io.vertx.core.http.HttpHeaders 4 | import io.vertx.core.net.SocketAddress 5 | import io.vertx.ext.web.RoutingContext 6 | import java.util.* 7 | 8 | data class ClientInfo( 9 | val userUID: String, 10 | val authToken: String?, 11 | val isNewHourly: Boolean = false, 12 | val remoteAddress: SocketAddress 13 | ) { 14 | constructor(context: RoutingContext) : this( 15 | (context.data()[ConstantValues.USER_UID] as? String) ?: UUID.randomUUID().toString(), 16 | context.request().getHeader(HttpHeaders.AUTHORIZATION) ?: context.getCookie(ConstantValues.AUTH_COOKIE_NAME)?.value, 17 | (context.data()[ConstantValues.HOURLY_UNIQUE_VISITOR] as? Boolean ?: false), 18 | context.request().connection().remoteAddress() 19 | ) 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/ConstantValues.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | object ConstantValues { 4 | const val HOURLY_UNIQUE_VISITOR = "Hourly-Unique-Visitor" 5 | 6 | const val USER_UID = "User-UID" 7 | const val CLIENT_INFO = "Jukebox-Client-Info" 8 | 9 | const val AUTH_COOKIE_NAME = "Eternal-Jukebox-Auth-Cookie" 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/CoroutineClientInfo.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import io.vertx.ext.web.RoutingContext 4 | import org.abimon.eternalJukebox.clientInfo 5 | import kotlin.coroutines.AbstractCoroutineContextElement 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | data class CoroutineClientInfo(val userUID: String, val path: String) : 9 | AbstractCoroutineContextElement(CoroutineClientInfo) { 10 | constructor(ctx: RoutingContext) : this(ctx.clientInfo.userUID, ctx.normalisedPath()) 11 | 12 | public companion object Key : CoroutineContext.Key 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EmptyDataAPI.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import io.vertx.ext.web.Router 4 | import io.vertx.ext.web.RoutingContext 5 | import org.abimon.eternalJukebox.data.analysis.IAnalyser 6 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsProvider 7 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsStorage 8 | import org.abimon.eternalJukebox.data.audio.IAudioSource 9 | import org.abimon.eternalJukebox.data.database.IDatabase 10 | import org.abimon.eternalJukebox.data.storage.IStorage 11 | import org.abimon.visi.io.DataSource 12 | 13 | object EmptyDataAPI: IAnalyser, IAudioSource, IDatabase, IStorage, IAnalyticsStorage, IAnalyticsProvider { 14 | override fun shouldStore(type: EnumStorageType): Boolean = false 15 | override suspend fun search(query: String, clientInfo: ClientInfo?): Array = emptyArray() 16 | override suspend fun provide(info: JukeboxInfo, context: RoutingContext): Boolean = false 17 | override suspend fun store(name: String, type: EnumStorageType, data: DataSource, mimeType: String, clientInfo: ClientInfo?): Boolean = false 18 | override suspend fun getInfo(id: String, clientInfo: ClientInfo?): JukeboxInfo? = null 19 | override suspend fun provide(name: String, type: EnumStorageType, context: RoutingContext): Boolean = false 20 | override suspend fun isStored(name: String, type: EnumStorageType): Boolean = false 21 | override fun store(now: Long, data: T, type: EnumAnalyticType): Boolean = false 22 | override suspend fun provideAudioTrackOverride(id: String, clientInfo: ClientInfo?): String? = null 23 | override suspend fun providePopularSongs(service: String, count: Int, clientInfo: ClientInfo?): List = emptyList() 24 | override suspend fun provideShortURL(params: Array, clientInfo: ClientInfo?): String = "" 25 | override fun shouldProvide(type: EnumAnalyticType<*>): Boolean = false 26 | override fun provide(now: Long, type: EnumAnalyticType): T? = null 27 | override fun makeSongPopular(service: String, id: String, clientInfo: ClientInfo?) {} 28 | override suspend fun expandShortURL(id: String, clientInfo: ClientInfo?): Array? = null 29 | override fun setupWebAnalytics(router: Router) {} 30 | override suspend fun provideAudioLocation(id: String, clientInfo: ClientInfo?): String? = null 31 | override suspend fun storeAudioLocation(id: String, location: String, clientInfo: ClientInfo?) {} 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumAnalyticType.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | @Suppress("ClassName") 4 | sealed class EnumAnalyticType { 5 | companion object { 6 | val VALUES: Array> by lazy { 7 | arrayOf( 8 | UPTIME, 9 | TOTAL_MEMORY, FREE_MEMORY, USED_MEMORY, 10 | FREE_MEMORY_PERCENT, USED_MEMORY_PERCENT, 11 | PROCESS_CPU_LOAD, SYSTEM_CPU_LOAD, 12 | SESSION_REQUESTS, HOURLY_REQUESTS 13 | ) 14 | } 15 | } 16 | 17 | object UPTIME : EnumAnalyticType() 18 | object TOTAL_MEMORY : EnumAnalyticType() 19 | object FREE_MEMORY : EnumAnalyticType() 20 | object USED_MEMORY : EnumAnalyticType() 21 | object FREE_MEMORY_PERCENT : EnumAnalyticType() 22 | object USED_MEMORY_PERCENT : EnumAnalyticType() 23 | object PROCESS_CPU_LOAD : EnumAnalyticType() 24 | object SYSTEM_CPU_LOAD : EnumAnalyticType() 25 | object SESSION_REQUESTS : EnumAnalyticType() 26 | object HOURLY_REQUESTS : EnumAnalyticType() 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumAnalyticsProvider.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import org.abimon.eternalJukebox.data.analytics.HTTPAnalyticsProvider 4 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsProvider 5 | import org.abimon.eternalJukebox.data.analytics.SystemAnalyticsProvider 6 | import kotlin.reflect.KClass 7 | 8 | enum class EnumAnalyticsProvider(val klass: KClass) { 9 | SYSTEM(SystemAnalyticsProvider::class), 10 | HTTP(HTTPAnalyticsProvider::class); 11 | 12 | val provider: IAnalyticsProvider 13 | get() = klass.objectInstance!! 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumAnalyticsStorage.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import org.abimon.eternalJukebox.data.analytics.IAnalyticsStorage 4 | import org.abimon.eternalJukebox.data.analytics.InfluxAnalyticsStorage 5 | import org.abimon.eternalJukebox.data.analytics.LocalAnalyticStorage 6 | import kotlin.reflect.KClass 7 | 8 | enum class EnumAnalyticsStorage(val klass: KClass) { 9 | LOCAL(LocalAnalyticStorage::class), 10 | INFLUX(InfluxAnalyticsStorage::class); 11 | 12 | val analytics: IAnalyticsStorage 13 | get() = klass.objectInstance!! 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumAudioSystem.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import org.abimon.eternalJukebox.data.audio.IAudioSource 4 | import org.abimon.eternalJukebox.data.audio.NodeAudioSource 5 | import org.abimon.eternalJukebox.data.audio.YoutubeAudioSource 6 | import kotlin.reflect.KClass 7 | 8 | enum class EnumAudioSystem(val audio: KClass) { 9 | YOUTUBE(YoutubeAudioSource::class), 10 | NODE(NodeAudioSource::class) 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumDatabaseType.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import org.abimon.eternalJukebox.data.database.H2Database 4 | import org.abimon.eternalJukebox.data.database.IDatabase 5 | import org.abimon.eternalJukebox.data.database.JDBCDatabase 6 | import kotlin.reflect.KClass 7 | 8 | enum class EnumDatabaseType(val db: KClass) { 9 | H2(H2Database::class), 10 | JDBC(JDBCDatabase::class) 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumStorageSystem.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import org.abimon.eternalJukebox.data.storage.GoogleStorage 4 | import org.abimon.eternalJukebox.data.storage.IStorage 5 | import org.abimon.eternalJukebox.data.storage.LocalStorage 6 | import kotlin.reflect.KClass 7 | 8 | enum class EnumStorageSystem(private val klass: KClass) { 9 | LOCAL(LocalStorage::class), 10 | GOOGLE(GoogleStorage::class); 11 | 12 | val storage: IStorage 13 | get() = klass.objectInstance!! 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/EnumStorageType.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | enum class EnumStorageType { 4 | ANALYSIS, 5 | UPLOADED_ANALYSIS, 6 | AUDIO, 7 | EXTERNAL_AUDIO, 8 | UPLOADED_AUDIO, 9 | LOG 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/JukeboxConfig.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | data class JukeboxConfig( 4 | val port: Int = 8080, 5 | 6 | val webRoot: String = "web", 7 | 8 | val redirects: Map = mapOf("/" to "/jukebox_index.html"), 9 | 10 | val spotifyClient: String? = null, 11 | val spotifySecret: String? = null, 12 | 13 | val disable: Map = emptyMap(), 14 | 15 | val storageType: EnumStorageSystem = EnumStorageSystem.LOCAL, 16 | val storageOptions: Map = emptyMap(), 17 | 18 | val audioSourceType: EnumAudioSystem = EnumAudioSystem.YOUTUBE, 19 | val audioSourceOptions: Map = emptyMap(), 20 | 21 | val analyticsStorageType: EnumAnalyticsStorage = EnumAnalyticsStorage.LOCAL, 22 | val analyticsStorageOptions: Map = emptyMap(), 23 | 24 | val analyticsProviders: Map> = emptyMap(), 25 | 26 | val databaseType: EnumDatabaseType = EnumDatabaseType.H2, 27 | val databaseOptions: Map = emptyMap(), 28 | 29 | val usageWritePeriod: Long = 1000 * 60, 30 | 31 | val workerExecuteTime: Long = 90L * 1000 * 1000 * 1000, 32 | 33 | val printConfig: Boolean = false, 34 | 35 | val logFiles: Map = emptyMap(), 36 | 37 | val hikariBatchTimeBetweenUpdatesMs: Long = 5 * 60_000, 38 | val hikariBatchShortIDUpdateTimeMs: Long = 5_000, 39 | 40 | val maximumShortIDCacheSize: Long = 10_000, 41 | val shortIDCacheStayDurationMinutes: Int = 10, 42 | val maximumOverridesCacheSize: Long = 1_000, 43 | val overridesCacheStayDurationMinutes: Int = 10, 44 | val maximumJukeboxInfoCacheSize: Long = 1_000, 45 | val jukeboxInfoCacheStayDurationMinutes: Int = 10, 46 | val locationsCacheStayDurationMinutes: Int = 5, 47 | val maximumLocationCacheSize: Long = 1_000 48 | ) 49 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/JukeboxInfo.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | @Suppress("PropertyName") 4 | data class JukeboxTrack( 5 | val info: JukeboxInfo, 6 | val analysis: JukeboxAnalysis, 7 | val audio_summary: JukeboxSummary 8 | ) 9 | 10 | data class JukeboxInfo( 11 | val service: String, 12 | val id: String, 13 | val name: String, 14 | val title: String, 15 | val artist: String, 16 | val url: String, 17 | val duration: Int 18 | ) 19 | 20 | @Suppress("ArrayInDataClass") 21 | data class JukeboxAnalysis( 22 | val sections: Array, 23 | val bars: Array, 24 | val beats: Array, 25 | val tatums: Array, 26 | val segments: Array 27 | ) 28 | 29 | data class JukeboxSummary( 30 | val duration: Double 31 | ) 32 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/SpotifyError.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | enum class SpotifyError { 4 | NO_AUTH_DETAILS, 5 | INVALID_AUTH_DETAILS, 6 | 7 | INVALID_SEARCH_DATA 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/SpotifyInfo.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | data class SpotifyAudioBar( 4 | val start: Double, 5 | val duration: Double, 6 | val confidence: Double 7 | ) 8 | 9 | data class SpotifyAudioBeat( 10 | val start: Double, 11 | val duration: Double, 12 | val confidence: Double 13 | ) 14 | 15 | data class SpotifyAudioTatum( 16 | val start: Double, 17 | val duration: Double, 18 | val confidence: Double 19 | ) 20 | 21 | @Suppress("PropertyName") 22 | data class SpotifyAudioSection( 23 | val start: Double, 24 | val duration: Double, 25 | val confidence: Double, 26 | val loudness: Double, 27 | val tempo: Double, 28 | val tempo_confidence: Double, 29 | val key: Int, 30 | val key_confidence: Double, 31 | val mode: Int, 32 | val mode_confidence: Double, 33 | val time_signature: Int, 34 | val time_signature_confidence: Double 35 | ) 36 | 37 | @Suppress("ArrayInDataClass", "PropertyName") 38 | data class SpotifyAudioSegment( 39 | val start: Double, 40 | var duration: Double, 41 | val confidence: Double, 42 | val loudness_start: Int, 43 | val loudness_max_time: Int, 44 | val loudness_max: Int, 45 | val pitches: DoubleArray, 46 | val timbre: DoubleArray 47 | ) 48 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/eternalJukebox/objects/YoutubeVideos.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.eternalJukebox.objects 2 | 3 | import java.time.Duration 4 | import java.time.ZonedDateTime 5 | 6 | data class YoutubeSearchResults( 7 | val kind: String, 8 | val etag: String, 9 | val nextPageToken: String?, 10 | val regionCode: String, 11 | val pageInfo: YoutubePageInfo, 12 | val items: List 13 | ) 14 | 15 | data class YoutubePageInfo( 16 | val totalResults: Long, 17 | val resultsPerPage: Int 18 | ) 19 | 20 | data class YoutubeID( 21 | val kind: String, 22 | val videoId: String? = null 23 | ) 24 | 25 | data class YoutubeVideoThumbnail( 26 | val url: String, 27 | val width: Int, 28 | val height: Int 29 | ) 30 | 31 | data class YoutubeVideo( 32 | val publishedAt: ZonedDateTime, 33 | val channelId: String, 34 | val title: String, 35 | val description: String, 36 | val thumbnails: Map, 37 | val channelTitle: String, 38 | val liveBroadcastContent: String 39 | ) 40 | 41 | data class YoutubeSearchItem( 42 | val kind: String, 43 | val etag: String, 44 | val id: YoutubeID, 45 | val snippet: YoutubeVideo 46 | ) 47 | 48 | data class YoutubeContentResults( 49 | val kind: String, 50 | val etag: String, 51 | val pageInfo: YoutubePageInfo, 52 | val items: List 53 | ) 54 | 55 | data class YoutubeContentItem( 56 | val kind: String, 57 | val etag: String, 58 | val id: String, 59 | val contentDetails: YoutubeContentDetails, 60 | val snippet: YoutubeVideo 61 | ) 62 | 63 | data class YoutubeContentDetails( 64 | val duration: Duration, 65 | val dimension: String, 66 | val definition: String, 67 | val caption: String, 68 | val licensedContent: Boolean, 69 | val projection: String 70 | ) 71 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/visi/io/DataSource.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.visi.io 2 | 3 | import java.io.ByteArrayInputStream 4 | import java.io.File 5 | import java.io.FileInputStream 6 | import java.io.InputStream 7 | 8 | interface DataSource { 9 | /** 10 | * Get an input stream associated with this data source. 11 | */ 12 | val inputStream: InputStream 13 | val data: ByteArray 14 | val size: Long 15 | 16 | fun use(action: (InputStream) -> T): T = inputStream.use(action) 17 | } 18 | 19 | class FileDataSource(val file: File) : DataSource { 20 | 21 | override val data: ByteArray 22 | get() = file.readBytes() 23 | 24 | override val inputStream: InputStream 25 | get() = FileInputStream(file) 26 | 27 | override val size: Long 28 | get() = file.length() 29 | } 30 | 31 | class ByteArrayDataSource(override val data: ByteArray): DataSource { 32 | override val inputStream: InputStream 33 | get() = ByteArrayInputStream(data) 34 | override val size: Long = data.size.toLong() 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/visi/io/VIO.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.visi.io 2 | 3 | import java.io.* 4 | 5 | fun InputStream.readChunked(processChunk: (ByteArray) -> Unit): Int { 6 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 7 | var total = 0 8 | var emptyReadCounter = 0 9 | 10 | while (true) { 11 | val bytesRead = read(buffer) 12 | if (bytesRead == -1) break 13 | 14 | if (bytesRead == 0) { 15 | emptyReadCounter++ 16 | if (emptyReadCounter > 3) break 17 | } 18 | 19 | processChunk(buffer.copyOfRange(0, bytesRead)) 20 | total += bytesRead 21 | } 22 | 23 | close() 24 | return total 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/visi/lang/VLang.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.visi.lang 2 | 3 | fun Runtime.usedMemory(): Long = (totalMemory() - freeMemory()) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/visi/security/VSecurity.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.visi.security 2 | 3 | import org.abimon.visi.io.readChunked 4 | import java.io.InputStream 5 | import java.math.BigInteger 6 | import java.security.MessageDigest 7 | 8 | /** ***Do not use for things like passwords*** */ 9 | fun ByteArray.md5Hash(): String { 10 | val md = MessageDigest.getInstance("MD5") 11 | val hashBytes = md.digest(this) 12 | return String.format("%032x", BigInteger(1, hashBytes)) 13 | } 14 | 15 | /** **Do not use for things like passwords, or situations where the data needs to be blanked out** */ 16 | fun String.md5Hash(): String = toByteArray(Charsets.UTF_8).md5Hash() 17 | 18 | /** ***Do not use for things like passwords*** */ 19 | fun InputStream.sha512Hash(): String { 20 | val md = MessageDigest.getInstance("SHA-512") 21 | readChunked { md.update(it) } 22 | val hashBytes = md.digest() 23 | return String.format("%032x", BigInteger(1, hashBytes)) 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/org/abimon/visi/time/VTime.kt: -------------------------------------------------------------------------------- 1 | package org.abimon.visi.time 2 | 3 | import java.time.* 4 | 5 | fun LocalDateTime.timeDifference(): String { 6 | val period = Period.between(this.toLocalDate(), LocalDate.now()) 7 | val duration = Duration.between(this.toLocalTime(), LocalTime.now()) 8 | 9 | val components = listOf( 10 | period.years.toLong() to "year", 11 | period.months.toLong() to "month", 12 | period.days.toLong() to "day", 13 | duration.toHours() % 24 to "hour", 14 | duration.toMinutes() % 60 to "minute", 15 | duration.seconds % 60 to "second" 16 | ).mapNotNull { (value, singular) -> 17 | value.takeIf { it > 0 }?.let { if (it == 1L) "$it $singular" else "$it ${singular}s" } 18 | } 19 | 20 | return when (components.size) { 21 | 0 -> "" 22 | 1 -> components[0] 23 | else -> components.dropLast(1).joinToString(", ") + " and ${components.last()}" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/MANIFEST.MF: -------------------------------------------------------------------------------- 1 | Manifest-Version: 1.0 2 | Main-Class: org.abimon.eternalJukebox.EternalJukeboxKt 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/hikari.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EternalBox/EternalJukebox/4ff72a0f782fa11eeb3ce38ec196f43690d9a910/src/main/resources/hikari.properties -------------------------------------------------------------------------------- /yt.bat: -------------------------------------------------------------------------------- 1 | IF "%~4"=="" GOTO download 2 | SET %2="%1=%2" 3 | SHIFT 4 | :download 5 | yt-dlp --audio-format %3 -x -o %2 --max-filesize 100m --no-playlist --max-downloads 1 --playlist-end 1 --exec "echo Video ID: %%(id)s" -- "%1" 6 | -------------------------------------------------------------------------------- /yt.sh: -------------------------------------------------------------------------------- 1 | yt-dlp --audio-format $3 -x -o $2 --max-filesize 100m --no-playlist --max-downloads 1 --playlist-end 1 --exec "echo Video ID: %(id)s" -- "$1" 2 | --------------------------------------------------------------------------------