├── .devcontainer ├── library-scripts │ ├── zsh.sh │ └── maven.sh ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── src └── main │ ├── java │ └── de │ │ └── biosphere │ │ └── spoticord │ │ ├── database │ │ ├── model │ │ │ ├── SpotifyListen.java │ │ │ └── SpotifyTrack.java │ │ ├── Database.java │ │ ├── dao │ │ │ ├── AlbumDao.java │ │ │ ├── ArtistDao.java │ │ │ ├── TrackDao.java │ │ │ └── UserDao.java │ │ └── impl │ │ │ └── mysql │ │ │ ├── ArtistImplMySql.java │ │ │ ├── AlbumImplMySql.java │ │ │ ├── MySqlDatabase.java │ │ │ ├── UserImplMySql.java │ │ │ └── TrackImplMySql.java │ │ ├── commands │ │ ├── DeleteCommand.java │ │ ├── AlbumCommand.java │ │ ├── ArtistsCommand.java │ │ ├── DatabaseCommand.java │ │ ├── HistoryCommand.java │ │ ├── Command.java │ │ ├── SongsCommand.java │ │ ├── UsersCommand.java │ │ ├── ClockCommand.java │ │ ├── HelpCommand.java │ │ ├── StatsCommand.java │ │ ├── TimeCommand.java │ │ └── CommandManager.java │ │ ├── enums │ │ ├── TimeFilter.java │ │ └── TimeShortcut.java │ │ ├── utils │ │ ├── Metrics.java │ │ ├── DiscordUtils.java │ │ └── DayParser.java │ │ ├── handler │ │ ├── StatisticsHandlerCollector.java │ │ ├── MetricsCollector.java │ │ └── DiscordUserUpdateGameListener.java │ │ ├── Configuration.java │ │ └── Spoticord.java │ └── resources │ ├── schema │ ├── 0002_add_missing_timestamp_index.sql │ ├── 0001_add_timestamp_index.sql │ ├── db.changelog-main.xml │ └── 0000_initial_database_setup.sql │ └── logback.xml ├── Dockerfile ├── .github ├── workflows │ ├── build.yml │ └── docker.yml └── dependabot.yml ├── docker-compose.yml ├── prometheus.yml ├── LICENSE ├── docker-compose.yml.promcord ├── README.md ├── pom.xml ├── .gitignore └── grafana.json /.devcontainer/library-scripts/zsh.sh: -------------------------------------------------------------------------------- 1 | apt-get install -y zsh 2 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vscjava.vscode-java-pack", 4 | "coenraads.bracket-pair-colorizer-2", 5 | "formulahendry.auto-rename-tag" 6 | ] 7 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/model/SpotifyListen.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.model; 2 | 3 | import java.sql.Date; 4 | 5 | public record SpotifyListen(String id, Date timestamp, String guildId, String trackId, String userId) { 6 | } -------------------------------------------------------------------------------- /src/main/resources/schema/0002_add_missing_timestamp_index.sql: -------------------------------------------------------------------------------- 1 | -- liquibase formatted sql 2 | -- changeset nimarion:1605963946-1 3 | ALTER TABLE `Listens` ADD INDEX `listens_idx_guildid_timestamp` (`GuildId`, `Timestamp`); 4 | 5 | -- rollback DROP INDEX listens_idx_guildid_timestamp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "java.dependency.packagePresentation": "hierarchical", 3 | "java.dependency.syncWithFolderExplorer": false, 4 | "java.configuration.updateBuildConfiguration": "automatic", 5 | "editor.formatOnSave": true, 6 | "editor.fontLigatures": true 7 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/model/SpotifyTrack.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.model; 2 | 3 | public record SpotifyTrack( 4 | 5 | String id, String artists, String albumTitle, String trackTitle, String albumImageUrl, long duration 6 | 7 | ) { 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.6.3-openjdk-15 AS build 2 | COPY src /usr/src/app/src 3 | COPY pom.xml /usr/src/app 4 | RUN mvn -f /usr/src/app/pom.xml clean package 5 | 6 | FROM openjdk:15-jdk-slim 7 | COPY --from=build /usr/src/app/target/spoticord-*-SNAPSHOT-shaded.jar spoticord.jar 8 | 9 | EXPOSE 8080 10 | ENTRYPOINT ["java", "-jar", "--enable-preview", "spoticord.jar" ] 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up JDK 15 11 | uses: actions/setup-java@v1 12 | with: 13 | java-version: 15 14 | - name: Build with Maven 15 | run: mvn -B package --file pom.xml 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | bot: 4 | image: "biospheere/spoticord" 5 | restart: always 6 | env_file: .env 7 | depends_on: 8 | - db 9 | db: 10 | image: mysql:8 11 | restart: always 12 | container_name: mysql 13 | env_file: .env 14 | volumes: 15 | - mysql-volume:/var/lib/mysql 16 | 17 | 18 | volumes: 19 | mysql-volume: -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:15-jdk-slim 2 | 3 | ENV SDKMAN_DIR="/usr/local/sdkman" 4 | ENV PATH=${SDKMAN_DIR}/bin:${SDKMAN_DIR}/candidates/maven/current/bin:${PATH} 5 | COPY library-scripts/maven.sh /tmp/library-scripts/ 6 | RUN apt-get update && bash /tmp/library-scripts/maven.sh "latest" "${SDKMAN_DIR}" 7 | 8 | RUN apt-get install gpg openssh-client git -y 9 | 10 | COPY library-scripts/zsh.sh /tmp/library-scripts/ 11 | RUN bash /tmp/library-scripts/zsh.sh 12 | 13 | EXPOSE 8080 -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy on push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Build Docker Image 14 | uses: docker/build-push-action@v1 15 | with: 16 | username: ${{ secrets.DOCKER_USERNAME }} 17 | password: ${{ secrets.DOCKER_PASSWORD }} 18 | repository: biospheere/spoticord 19 | tags: latest 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "java", 9 | "name": "Run spoticord", 10 | "request": "launch", 11 | "mainClass": "de.biosphere.spoticord.Spoticord", 12 | "projectName": "spoticord" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/Database.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database; 2 | 3 | import de.biosphere.spoticord.database.dao.AlbumDao; 4 | import de.biosphere.spoticord.database.dao.ArtistDao; 5 | import de.biosphere.spoticord.database.dao.TrackDao; 6 | import de.biosphere.spoticord.database.dao.UserDao; 7 | 8 | public interface Database extends AutoCloseable { 9 | 10 | AlbumDao getAlbumDao(); 11 | 12 | ArtistDao getArtistDao(); 13 | 14 | TrackDao getTrackDao(); 15 | 16 | UserDao getUserDao(); 17 | 18 | } -------------------------------------------------------------------------------- /src/main/resources/schema/0001_add_timestamp_index.sql: -------------------------------------------------------------------------------- 1 | -- liquibase formatted sql 2 | -- changeset nimarion:1605901236-1 3 | ALTER TABLE 4 | `Listens` 5 | ADD 6 | INDEX `listens_idx_guildid_userid_timestamp` (`GuildId`, `UserId`, `Timestamp`); 7 | 8 | -- rollback DROP INDEX listens_idx_guildid_userid_timestamp 9 | 10 | -- changeset nimarion:1605901236-2 11 | ALTER TABLE 12 | `Listens` 13 | ADD 14 | INDEX `listens_idx_guildid_userid_trackid` (`GuildId`, `UserId`, `TrackId`); 15 | 16 | -- rollback DROP INDEX listens_idx_guildid_userid_trackid -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spoticord", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "settings": { 7 | "terminal.integrated.shell.linux": "/bin/zsh", 8 | "java.home": "/usr/local/openjdk-15", 9 | "maven.executable.path": "/usr/local/sdkman/candidates/maven/current/bin/mvn" 10 | }, 11 | "extensions": [ 12 | "vscjava.vscode-java-pack" 13 | ], 14 | "forwardPorts": [ 15 | 3306, 16 | 8080 17 | ] 18 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "maven" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | target-branch: "development" 11 | schedule: 12 | interval: "weekly" 13 | commit-message: 14 | prefix: "chore" 15 | -------------------------------------------------------------------------------- /src/main/resources/schema/db.changelog-main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 11 | 12 | 13 | 14 | 15 | 16 | WARN 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - ..:/workspace:cached 9 | environment: 10 | - DATABASE_PASSWORD=123456 11 | - DATABASE_USER=root 12 | - DATABASE_HOST=mysql 13 | - PROMETHEUS_PORT=8080 14 | 15 | container_name: spoticord 16 | # Overrides default command so things don't shut down after the process ends. 17 | command: sleep infinity 18 | 19 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 20 | network_mode: service:db 21 | 22 | db: 23 | image: mysql:latest 24 | restart: unless-stopped 25 | container_name: mysql 26 | environment: 27 | - MYSQL_ROOT_PASSWORD=123456 28 | - MYSQL_DATABASE=Tracks 29 | volumes: 30 | - mysql-volume:/var/lib/mysql 31 | 32 | volumes: 33 | mysql-volume: 34 | -------------------------------------------------------------------------------- /src/main/resources/schema/0000_initial_database_setup.sql: -------------------------------------------------------------------------------- 1 | -- liquibase formatted sql 2 | 3 | -- changeset markusk:1596819373758-1 4 | create table if not exists Listens 5 | ( 6 | Id int auto_increment primary key, 7 | Timestamp timestamp default CURRENT_TIMESTAMP not null, 8 | TrackId varchar(22) not null, 9 | GuildId varchar(100) not null, 10 | UserId varchar(100) not null, 11 | INDEX Id (Id), 12 | INDEX listens_idx_guildid_userid (GuildId, UserId) 13 | ); 14 | -- rollback DROP TABLE Listens 15 | 16 | 17 | -- changeset markusk:1596819373758-2 18 | create table if not exists Tracks 19 | ( 20 | Id varchar(22) not null primary key, 21 | Artists varchar(200) not null, 22 | AlbumImageUrl varchar(2083) not null, 23 | AlbumTitle varchar(200) not null, 24 | TrackTitle varchar(200) not null, 25 | Duration bigint unsigned not null 26 | ); 27 | -- rollback DROP TABLE Tracks 28 | -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | # my global config 2 | global: 3 | scrape_interval: 1s # Set the scrape interval to every 15 seconds. Default is every 1 minute. 4 | evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute. 5 | # scrape_timeout is set to the global default (10s). 6 | 7 | # Alertmanager configuration 8 | alerting: 9 | alertmanagers: 10 | - static_configs: 11 | - targets: 12 | # - alertmanager:9093 13 | 14 | # Load rules once and periodically evaluate them according to the global 'evaluation_interval'. 15 | rule_files: 16 | # - "first_rules.yml" 17 | # - "second_rules.yml" 18 | 19 | # A scrape configuration containing exactly one endpoint to scrape: 20 | # Here it's Prometheus itself. 21 | scrape_configs: 22 | # The job name is added as a label `job=` to any timeseries scraped from this config. 23 | - job_name: 'prometheus' 24 | 25 | # metrics_path defaults to '/metrics' 26 | # scheme defaults to 'http'. 27 | # promcord 28 | static_configs: 29 | - targets: ['spoticord:8080'] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 niklas. 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 | -------------------------------------------------------------------------------- /docker-compose.yml.promcord: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | spoticord: 4 | image: "biospheere/spoticord" 5 | restart: always 6 | env_file: .env 7 | depends_on: 8 | - db 9 | db: 10 | image: mysql:8 11 | restart: always 12 | container_name: mysql 13 | env_file: .env 14 | volumes: 15 | - mysql-volume:/var/lib/mysql 16 | prometheus: 17 | image: "prom/prometheus" 18 | restart: always 19 | volumes: 20 | - './prometheus.yml:/etc/prometheus/prometheus.yml' 21 | - prometheus:/prometheus 22 | command: 23 | - '--config.file=/etc/prometheus/prometheus.yml' 24 | - '--storage.tsdb.path=/prometheus' 25 | - '--web.console.libraries=/usr/share/prometheus/console_libraries' 26 | - '--web.console.templates=/usr/share/prometheus/consoles' 27 | - '--storage.tsdb.retention.time=200h' 28 | grafana: 29 | image: "grafana/grafana" 30 | restart: always 31 | volumes: 32 | - grafana_data:/var/lib/grafana 33 | ports: 34 | - 80:3000 35 | depends_on: 36 | - prometheus 37 | 38 | volumes: 39 | mysql-volume: 40 | prometheus: 41 | grafana_data: -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/dao/AlbumDao.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.dao; 2 | 3 | import java.util.Map; 4 | 5 | public interface AlbumDao { 6 | 7 | /** 8 | * Returns a map of the albums with the most database entry on this guild. The 9 | * count argument specifies the length of the map. Map contains 10 | * {@link String} as name of the album and {@link Integer} as the amount of 11 | * entries in the database. 12 | * 13 | * @param guildId the Snowflake id of the guild that the user is part of 14 | * @param userId the Snowflake id of the user 15 | * @param count the length of the map 16 | * @param lastDays the last days for data collection when 0 all data 17 | * @return A sorted map with count entries 18 | */ 19 | Map getTopAlbum(final String guildId, final String userId, final Integer count, 20 | final Integer lastDays); 21 | 22 | Map getTopAlbum(final String guildId, final Integer count, final Integer lastDays); 23 | 24 | /** 25 | * @return the amount of albums database entries 26 | */ 27 | Integer getAlbumAmount(); 28 | 29 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/dao/ArtistDao.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.dao; 2 | 3 | import java.util.Map; 4 | 5 | public interface ArtistDao { 6 | 7 | /** 8 | * Returns a map of the artists with the most database entry on this guild. The 9 | * count argument specifies the length of the map. Map contains 10 | * {@link String} as name of the artists and {@link Integer} as the amount of 11 | * entries in the database. 12 | * 13 | * @param guildId the Snowflake id of the guild that the user is part of 14 | * @param userId the Snowflake id of the user 15 | * @param count the length of the map 16 | * @param lastDays the last days for data collection when 0 all data 17 | * @return A sorted map with count entries 18 | */ 19 | Map getTopArtists(final String guildId, final String userId, final Integer count, 20 | final Integer lastDays); 21 | 22 | Map getTopArtists(final String guildId, final Integer count, final Integer lastDays); 23 | 24 | /** 25 | * @return the amount of artist database entries 26 | */ 27 | Integer getArtistAmount(); 28 | 29 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/DeleteCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DiscordUtils; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Member; 6 | import net.dv8tion.jda.api.entities.Message; 7 | 8 | public class DeleteCommand extends Command { 9 | 10 | public DeleteCommand() { 11 | super("delete", "Delete your stored data"); 12 | } 13 | 14 | @Override 15 | public void execute(String[] args, Message message) { 16 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 17 | final Member requiredMember = DiscordUtils.getRequiredMember(message, 0); 18 | if (requiredMember != null && requiredMember.getId().equals(message.getAuthor().getId())) { 19 | getBot().getDatabase().getUserDao().deleteUser(message.getGuild().getId(), message.getAuthor().getId()); 20 | embedBuilder.setDescription("Deleted your data"); 21 | } else { 22 | embedBuilder.setDescription( 23 | "-delete " + message.getAuthor().getAsMention() + "\n :warning: All your data will be deleted"); 24 | } 25 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/enums/TimeFilter.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.enums; 2 | 3 | import java.util.List; 4 | 5 | public enum TimeFilter { 6 | 7 | TODAY(1, List.of("day", "today", "1", "heute", "tag")), 8 | SEVEN_DAYS(7, List.of("week", "7", "seven", "sieben", "woche")), 9 | THIRTY_DAYS(30, List.of("month", "30", "thirty", "dreißig", "monat")), 10 | CUSTOM(-1, null); 11 | 12 | private final int dayValue; 13 | private final List alias; 14 | 15 | TimeFilter(final int dayValue, final List alias) { 16 | this.dayValue = dayValue; 17 | this.alias = alias; 18 | } 19 | 20 | public int getDayValue() { 21 | return dayValue; 22 | } 23 | 24 | public List getAlias() { 25 | return alias; 26 | } 27 | 28 | public static TimeFilter getFilter(final String value) { 29 | final String trimmed = value.trim(); 30 | for (final TimeFilter timeFilter : values()) { 31 | if (timeFilter.name().equalsIgnoreCase(trimmed)) return timeFilter; 32 | if(timeFilter.getAlias() == null) continue; 33 | for (final String alias : timeFilter.getAlias()) { 34 | if (alias.equalsIgnoreCase(trimmed)) return timeFilter; 35 | } 36 | } 37 | return CUSTOM; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/utils/Metrics.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.utils; 2 | 3 | import io.prometheus.client.Counter; 4 | import io.prometheus.client.Gauge; 5 | 6 | public class Metrics { 7 | 8 | public static final Gauge TOTAL_TRACK_AMOUNT; 9 | public static final Gauge TOTAL_LISTEN_AMOUNT; 10 | public static final Gauge TOTAL_ARTIST_AMOUNT; 11 | public static final Gauge TOTAL_ALBUM_AMOUNT; 12 | public static final Gauge CURRENT_LISTEN_MEMBERS; 13 | public static final Gauge CURRENT_PEAK_TIME; 14 | 15 | public static final Counter TRACKS_PER_MINUTE; 16 | 17 | static { 18 | TOTAL_TRACK_AMOUNT = Gauge.build().name("total_track_amount").help("Amount of tracks").register(); 19 | TOTAL_ARTIST_AMOUNT = Gauge.build().name("total_artist_amount").help("Amount of artists").register(); 20 | TOTAL_ALBUM_AMOUNT = Gauge.build().name("total_album_amount").help("Amount of albums").register(); 21 | TOTAL_LISTEN_AMOUNT = Gauge.build().name("total_listen_amount").help("Amount of listen tracks") 22 | .labelNames("guild").register(); 23 | CURRENT_LISTEN_MEMBERS = Gauge.build().name("current_listen_members").help("Amount of listen members") 24 | .labelNames("guild").register(); 25 | CURRENT_PEAK_TIME = Gauge.build().name("current_peak_time").help("Current peak time") 26 | .labelNames("guild").register(); 27 | 28 | TRACKS_PER_MINUTE = Counter.build().name("tracks_per_minute").help("Tracks per minute") 29 | .labelNames("guild").register(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/AlbumCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DayParser; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Message; 6 | 7 | import java.util.Map; 8 | 9 | public class AlbumCommand extends Command { 10 | 11 | public AlbumCommand() { 12 | super("album", "View the top 10 album in this guild"); 13 | } 14 | 15 | @Override 16 | public void execute(String[] args, Message message) { 17 | final DayParser.Parsed parsed = DayParser.get(args, message); 18 | 19 | final EmbedBuilder embedBuilder = DayParser.getEmbed(message.getGuild(), message.getAuthor(), parsed.getDays(), 20 | parsed.isServerStats()); 21 | 22 | final Map topAlbum = getBot().getDatabase().getAlbumDao().getTopAlbum( 23 | message.getGuild().getId(), parsed.isServerStats() ? null : parsed.getMember().getId(), 10, 24 | parsed.getDays()); 25 | 26 | addListToEmbed(embedBuilder, topAlbum); 27 | message.getChannel().sendMessage(embedBuilder.build()).queue(); 28 | } 29 | 30 | private void addListToEmbed(final EmbedBuilder embedBuilder, final Map topMap) { 31 | embedBuilder.setTitle("Top 10 Spotify Album"); 32 | 33 | int count = 1; 34 | for (Map.Entry entry : topMap.entrySet()) { 35 | embedBuilder.appendDescription(String.format("%s. **%s** (%s)\n", count, entry.getKey(), entry.getValue())); 36 | count++; 37 | } 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/ArtistsCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DayParser; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Message; 6 | 7 | import java.util.Map; 8 | 9 | public class ArtistsCommand extends Command { 10 | 11 | public ArtistsCommand() { 12 | super("artists", "View the top 10 artists in this guild"); 13 | } 14 | 15 | @Override 16 | public void execute(String[] args, Message message) { 17 | final DayParser.Parsed parsed = DayParser.get(args, message); 18 | 19 | final EmbedBuilder embedBuilder = DayParser.getEmbed(message.getGuild(), message.getAuthor(), parsed.getDays(), 20 | parsed.isServerStats()); 21 | 22 | final Map topArtists = getBot().getDatabase().getArtistDao().getTopArtists( 23 | message.getGuild().getId(), parsed.isServerStats() ? null : parsed.getMember().getId(), 10, 24 | parsed.getDays()); 25 | 26 | addListToEmbed(embedBuilder, topArtists); 27 | message.getChannel().sendMessage(embedBuilder.build()).queue(); 28 | } 29 | 30 | private void addListToEmbed(final EmbedBuilder embedBuilder, final Map topMap) { 31 | embedBuilder.setTitle("Top 10 Spotify Artists"); 32 | 33 | int count = 1; 34 | for (Map.Entry entry : topMap.entrySet()) { 35 | embedBuilder.appendDescription(String.format("%s. **%s** (%s)\n", count, entry.getKey(), entry.getValue())); 36 | count++; 37 | } 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/handler/StatisticsHandlerCollector.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.handler; 2 | 3 | import de.biosphere.spoticord.Spoticord; 4 | import io.prometheus.client.Collector; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Arrays; 8 | import java.util.Collections; 9 | import java.util.List; 10 | 11 | public class StatisticsHandlerCollector extends Collector { 12 | 13 | private static final List EMPTY_LIST = new ArrayList<>(); 14 | private final Spoticord bot; 15 | 16 | public StatisticsHandlerCollector(final Spoticord bot) { 17 | this.bot = bot; 18 | } 19 | 20 | @Override 21 | public List collect() { 22 | final long restPing = this.bot.getJDA().getRestPing().complete(); 23 | final long gatewayPing = this.bot.getJDA().getGatewayPing(); 24 | 25 | return Arrays.asList( 26 | buildGauge("discord_ping_websocket", 27 | "Time in milliseconds between heartbeat and the heartbeat ack response", gatewayPing), 28 | buildGauge("discord_ping_rest", 29 | "The time in milliseconds that discord took to respond to a REST request.", restPing), 30 | buildGauge("discord_guilds", 31 | "Amount of all Guilds that the bot is connected to. ", this.bot.getJDA().getGuilds().size())); 32 | } 33 | 34 | private MetricFamilySamples buildGauge(String name, String help, double value) { 35 | return new MetricFamilySamples(name, Type.GAUGE, help, 36 | Collections.singletonList(new MetricFamilySamples.Sample(name, EMPTY_LIST, EMPTY_LIST, value))); 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/DatabaseCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.database.impl.mysql.MySqlDatabase; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Message; 6 | 7 | public class DatabaseCommand extends Command { 8 | 9 | public DatabaseCommand() { 10 | super("database", "Show some informations about the database"); 11 | } 12 | 13 | @Override 14 | public void execute(String[] args, Message message) { 15 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 16 | 17 | final Integer trackAmount = getBot().getDatabase().getTrackDao().getTrackAmount(); 18 | final Integer listensAmountGlobal = getBot().getDatabase().getTrackDao().getListensAmount(); 19 | final Integer listensAmount = getBot().getDatabase().getTrackDao().getListensAmount(message.getGuild().getId()); 20 | 21 | embedBuilder.addField("Track Datapoints", String.valueOf(trackAmount), false); 22 | embedBuilder.addField("Total Listens Datapoints", String.valueOf(listensAmountGlobal), false); 23 | embedBuilder.addField("Listens Datapoints by this Guild", String.valueOf(listensAmount), false); 24 | 25 | if (getBot().getDatabase() instanceof MySqlDatabase) { 26 | MySqlDatabase database = (MySqlDatabase) getBot().getDatabase(); 27 | embedBuilder.addField("Track Size", database.getSizeOfTable("Tracks") + " MB", false); 28 | embedBuilder.addField("Listens Size", database.getSizeOfTable("Listens") + " MB", false); 29 | } 30 | 31 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 32 | 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/HistoryCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.database.model.SpotifyTrack; 4 | import de.biosphere.spoticord.utils.DiscordUtils; 5 | import net.dv8tion.jda.api.EmbedBuilder; 6 | import net.dv8tion.jda.api.entities.Member; 7 | import net.dv8tion.jda.api.entities.Message; 8 | 9 | import java.util.List; 10 | 11 | public class HistoryCommand extends Command { 12 | 13 | public HistoryCommand() { 14 | super("history", ""); 15 | } 16 | 17 | @Override 18 | public void execute(String[] args, Message message) { 19 | final EmbedBuilder embedBuilder = getEmbed(message.getMember()); 20 | final Member member = DiscordUtils.getAddressedMember(message); 21 | final List historyTracks = getBot().getDatabase().getTrackDao() 22 | .getLastTracks(member.getGuild().getId(), member.getId()); 23 | addListToEmbed(embedBuilder, historyTracks); 24 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 25 | } 26 | 27 | private void addListToEmbed(final EmbedBuilder embedBuilder, final List historyTracks) { 28 | embedBuilder.setTitle("Listening History"); 29 | if (!historyTracks.isEmpty()) { 30 | embedBuilder.setThumbnail(historyTracks.get(0).albumImageUrl()); 31 | } 32 | int count = 1; 33 | for (SpotifyTrack spotifyTrack : historyTracks) { 34 | embedBuilder.appendDescription(String.format("%s. **[%s](https://open.spotify.com/track/%s)** by %s\n", 35 | count, spotifyTrack.trackTitle(), spotifyTrack.id(), spotifyTrack.artists())); 36 | count++; 37 | } 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/Command.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.Spoticord; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Guild; 6 | import net.dv8tion.jda.api.entities.Member; 7 | import net.dv8tion.jda.api.entities.Message; 8 | import net.dv8tion.jda.api.entities.User; 9 | 10 | public abstract class Command { 11 | 12 | private final String command; 13 | private final String description; 14 | private Spoticord bot; 15 | 16 | public Command(final String command, final String description) { 17 | this.command = command; 18 | this.description = description; 19 | } 20 | 21 | public abstract void execute(final String[] args, final Message message); 22 | 23 | protected EmbedBuilder getEmbed(final Guild guild, final User requester) { 24 | return new EmbedBuilder().setFooter("@" + requester.getName() + "#" + requester.getDiscriminator(), 25 | requester.getEffectiveAvatarUrl()).setColor(guild.getSelfMember().getColor()); 26 | } 27 | 28 | protected EmbedBuilder getEmbed(final Member member) { 29 | return new EmbedBuilder() 30 | .setFooter("@" + member.getUser().getName() + "#" + member.getUser().getDiscriminator(), 31 | member.getUser().getEffectiveAvatarUrl()) 32 | .setColor(member.getGuild().getSelfMember().getColor()); 33 | } 34 | 35 | public void setInstance(final Spoticord instance) { 36 | if (bot != null) { 37 | throw new IllegalStateException("Can only initialize once!"); 38 | } 39 | bot = instance; 40 | } 41 | 42 | public String getCommand() { 43 | return command; 44 | } 45 | 46 | public String getDescription() { 47 | return description; 48 | } 49 | 50 | public Spoticord getBot() { 51 | return bot; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/utils/DiscordUtils.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.utils; 2 | 3 | import net.dv8tion.jda.api.entities.Member; 4 | import net.dv8tion.jda.api.entities.Message; 5 | 6 | import java.util.LinkedList; 7 | import java.util.List; 8 | 9 | public class DiscordUtils { 10 | 11 | public static Member getAddressedMember(final Message message) { 12 | if (message.getMentionedMembers().isEmpty()) { 13 | return message.getMember(); 14 | } 15 | if (message.getMentionedMembers().get(0).getUser().isBot() && message.getMentionedMembers().size() > 1) { 16 | return message.getMentionedMembers().get(1); 17 | } 18 | return message.getMentionedMembers().get(0).getUser().isBot() ? message.getMember() 19 | : message.getMentionedMembers().get(0); 20 | } 21 | 22 | /** 23 | * @param message a {@link Message} 24 | * @param index the index of a mentioned member without the bot-mention at the 25 | * start 26 | * @return a {@link Member}, when not found null 27 | */ 28 | public static Member getRequiredMember(final Message message, final int index) { 29 | final List mentionedMembers = new LinkedList<>(message.getMentionedMembers()); 30 | if (mentionedMembers.isEmpty()) 31 | return null; 32 | if (index < 0) 33 | return null; 34 | final Member botMember = message.getGuild().getSelfMember(); 35 | mentionedMembers.remove(botMember); 36 | if (mentionedMembers.size() <= index) 37 | return null; 38 | return mentionedMembers.get(index); 39 | } 40 | 41 | public static int getIntFromString(final String input, int defaultValue) { 42 | try { 43 | return input != null ? Integer.parseInt(input) : defaultValue; 44 | } catch (NumberFormatException e) { 45 | return defaultValue; 46 | } 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/SongsCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.database.model.SpotifyTrack; 4 | import de.biosphere.spoticord.utils.DayParser; 5 | import net.dv8tion.jda.api.EmbedBuilder; 6 | import net.dv8tion.jda.api.entities.Message; 7 | 8 | import java.util.Map; 9 | 10 | public class SongsCommand extends Command { 11 | 12 | public SongsCommand() { 13 | super("songs", "View the top 10 tracks in this guild"); 14 | } 15 | 16 | @Override 17 | public void execute(String[] args, Message message) { 18 | final DayParser.Parsed parsed = DayParser.get(args, message); 19 | 20 | final EmbedBuilder embedBuilder = DayParser.getEmbed(message.getGuild(), message.getAuthor(), parsed.getDays(), 21 | parsed.isServerStats()); 22 | 23 | final Map topTracks = getBot().getDatabase().getTrackDao().getTopTracks( 24 | message.getGuild().getId(), parsed.isServerStats() ? null : parsed.getMember().getId(), 10, 25 | parsed.getDays()); 26 | 27 | addListToEmbed(embedBuilder, topTracks); 28 | message.getChannel().sendMessage(embedBuilder.build()).queue(); 29 | } 30 | 31 | private void addListToEmbed(final EmbedBuilder embedBuilder, final Map topMap) { 32 | embedBuilder.setTitle("Top 10 Spotify Tracks"); 33 | if (!topMap.isEmpty()) { 34 | embedBuilder.setThumbnail(topMap.keySet().iterator().next().albumImageUrl()); 35 | } 36 | int count = 1; 37 | for (SpotifyTrack spotifyTrack : topMap.keySet()) { 38 | embedBuilder.appendDescription(String.format( 39 | "%s. **[%s](https://open.spotify.com/track/%s)** by %s (%s) \n", count, spotifyTrack.trackTitle(), 40 | spotifyTrack.id(), spotifyTrack.artists(), topMap.get(spotifyTrack))); 41 | count++; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/biospheere/spoticord/workflows/Build/badge.svg?branch=master)](https://github.com/biospheere/spoticord/actions) 2 | [![GitHub contributors](https://img.shields.io/github/contributors/biospheere/spoticord.svg)](https://github.com/Biospheere/spoticord/graphs/contributors/) 3 | [![GitHub Repo stars](https://img.shields.io/github/stars/Biospheere/spoticord?style=social)](https://github.com/Biospheere/spoticord/stargazers) 4 | 5 | # spoticord 6 | 7 | View your guild's Spotify listening activity. 8 | 9 | ## ⚡Commands 10 | 11 | - `songs` - View the top 10 tracks from the current guild. 12 | - `album` - View the top 10 album from the current guild. 13 | - `artists` - View the top 10 artists from the current guild. 14 | - `time` - See how much time you listened to music on Spotify 15 | - `users` - View the top 10 users in this guild 16 | - `clock` - Shows when you listen to music the most 17 | - `delete` - Delete your stored data from the database 18 | - `history` - View the last 10 tracks you have listened to. 19 | 20 | ## 🔰 Prerequisites 21 | 22 | - [Docker](https://docs.docker.com/get-docker/) 23 | - [Docker Compose](https://docs.docker.com/compose/install/) 24 | 25 | ## 🛠 Installation 26 | 27 | 1. Follow the [Docker CE install guide](https://docs.docker.com/install/) and the [Docker Compose install guide](https://docs.docker.com/compose/install/), which illustrates multiple installation options for each OS. 28 | 2. Set up your environment variables/secrets in `.env` file 29 | 30 | ``` 31 | MYSQL_ROOT_PASSWORD=??? 32 | MYSQL_DATABASE=Tracks 33 | DATABASE_PASSWORD=??? 34 | DATABASE_USER=root 35 | DATABASE_HOST=mysql 36 | DISCORD_TOKEN=??? 37 | DISCORD_PREFIX=+ 38 | ``` 39 | 40 | 3. Run the Docker App with `docker-compose up -d` 41 | 4. That's it! 🎉 42 | 43 | ## 📷 Screenshots 44 | 45 | ![Songs](https://i.imgur.com/zS72bjy.png) 46 | ![History](https://i.imgur.com/6PDekHm.png) 47 | ![Artists](https://i.imgur.com/nuJ0MM8.png) 48 | 49 | 50 | ## ⚖ [License](LICENSE) 51 | 52 | MIT © [Niklas](https://github.com/Biospheere/) 53 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/dao/TrackDao.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.dao; 2 | 3 | import de.biosphere.spoticord.database.model.SpotifyTrack; 4 | 5 | import java.util.List; 6 | import java.util.Map; 7 | 8 | public interface TrackDao { 9 | 10 | /** 11 | * @return the amount of track database entries 12 | */ 13 | Integer getTrackAmount(); 14 | 15 | /** 16 | * @return the amount of listen database entries 17 | */ 18 | Integer getListensAmount(); 19 | 20 | /** 21 | * 22 | * @param guildId the Snowflake id of the guild 23 | * @return the amount of listen database entries 24 | */ 25 | Integer getListensAmount(final String guildId); 26 | 27 | /** 28 | * 29 | * @param guildId the Snowflake id of the guild 30 | * @param userId the Snowflake id of the user 31 | * @return the amount of listen database entries 32 | */ 33 | Integer getListensAmount(final String guildId, final String userId); 34 | 35 | /** 36 | * Insert a new listen entry into the database 37 | * 38 | * @param spotifyTrack the {@link SpotifyTrack} 39 | * @param userId the Snowflake id of the user 40 | * @param guildId the Snowflake id of the guild that the user is part of 41 | */ 42 | void insertTrack(final SpotifyTrack spotifyTrack, final String userId, final String guildId); 43 | 44 | /** 45 | * 46 | * @param guildId the Snowflake id of the guild 47 | * @param userId the Snowflake id of the user 48 | * @param count the length of the map 49 | * @param lastDays the last days for data collection when 0 all data 50 | * @return A sorted map with count entries 51 | */ 52 | Map getTopTracks(final String guildId, final String userId, final Integer count, 53 | final Integer lastDays); 54 | 55 | List getLastTracks(final String guildId); 56 | 57 | List getLastTracks(final String guildId, final String userId); 58 | 59 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/UsersCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DayParser; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Guild; 6 | import net.dv8tion.jda.api.entities.Member; 7 | import net.dv8tion.jda.api.entities.Message; 8 | import net.dv8tion.jda.api.entities.User; 9 | 10 | import java.util.Map; 11 | 12 | public class UsersCommand extends Command { 13 | 14 | private static final String FOOTER_FORMAT = "%s#%s | %s day%s | %s"; 15 | 16 | public UsersCommand() { 17 | super("users", "View the top 10 users in this guild"); 18 | } 19 | 20 | @Override 21 | public void execute(String[] args, Message message) { 22 | final int lastDays = args.length == 0 ? 0 : DayParser.getDays(args[0]); 23 | 24 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor(), lastDays); 25 | final Map topMap = getBot().getDatabase().getUserDao().getTopUsers(message.getGuild().getId(), 26 | 10, lastDays); 27 | 28 | topMap.forEach((k, v) -> { 29 | final Member member = message.getGuild().getMemberById(k); 30 | if (member != null) { 31 | embedBuilder.appendDescription(String.format("%s#%s (%s) \n", member.getEffectiveName(), 32 | member.getUser().getDiscriminator(), v)); 33 | } 34 | }); 35 | message.getChannel().sendMessage(embedBuilder.build()).queue(); 36 | } 37 | 38 | public static EmbedBuilder getEmbed(final Guild guild, final User requester, final int days) { 39 | return new EmbedBuilder() 40 | .setFooter( 41 | FOOTER_FORMAT.formatted(requester.getName(), requester.getDiscriminator(), 42 | days == 0 ? "all" : days, days == 1 ? "" : "s", "Server"), 43 | requester.getEffectiveAvatarUrl()) 44 | .setColor(guild.getSelfMember().getColor()); 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/handler/MetricsCollector.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.handler; 2 | 3 | import de.biosphere.spoticord.Spoticord; 4 | import de.biosphere.spoticord.utils.Metrics; 5 | import net.dv8tion.jda.api.entities.Activity; 6 | import net.dv8tion.jda.api.entities.Guild; 7 | import net.dv8tion.jda.api.entities.Member; 8 | 9 | import java.util.List; 10 | import java.util.TimerTask; 11 | 12 | public class MetricsCollector extends TimerTask { 13 | 14 | private final Spoticord bot; 15 | 16 | public MetricsCollector(final Spoticord bot) { 17 | this.bot = bot; 18 | } 19 | 20 | @Override 21 | public void run() { 22 | final int trackAmount = this.bot.getDatabase().getTrackDao().getTrackAmount(); 23 | final int albumAmount = this.bot.getDatabase().getAlbumDao().getAlbumAmount(); 24 | final int artistAmount = this.bot.getDatabase().getArtistDao().getArtistAmount(); 25 | 26 | Metrics.TOTAL_TRACK_AMOUNT.set(trackAmount); 27 | Metrics.TOTAL_ALBUM_AMOUNT.set(albumAmount); 28 | Metrics.TOTAL_ARTIST_AMOUNT.set(artistAmount); 29 | 30 | for (final Guild guild : this.bot.getJDA().getGuilds()) { 31 | final String guildId = guild.getId(); 32 | final int listensAmount = this.bot.getDatabase().getTrackDao().getListensAmount(guildId); 33 | final Long mostListensTime = this.bot.getDatabase().getUserDao().getMostListensTime(guildId); 34 | 35 | Metrics.TOTAL_LISTEN_AMOUNT.labels(guildId).set(listensAmount); 36 | Metrics.CURRENT_PEAK_TIME.labels(guildId).set(mostListensTime); 37 | 38 | final long listenCount = 39 | guild.getMembers().stream().map(Member::getActivities).filter(this::checkActivities).count(); 40 | Metrics.CURRENT_LISTEN_MEMBERS.labels(guildId).set(listenCount); 41 | } 42 | } 43 | 44 | private boolean checkActivities(final List activities) { 45 | return activities.stream().anyMatch(this::checkActivity); 46 | } 47 | 48 | private boolean checkActivity(final Activity activity) { 49 | return activity != null && activity.isRich() && activity.getType() == Activity.ActivityType.LISTENING; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/Configuration.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord; 2 | 3 | import io.github.cdimascio.dotenv.Dotenv; 4 | import org.slf4j.LoggerFactory; 5 | 6 | import java.lang.reflect.Field; 7 | 8 | public class Configuration { 9 | 10 | public static final String DATABASE_HOST; 11 | public static final String DATABASE_USER; 12 | public static final String DATABASE_PASSWORD; 13 | public static final String DATABASE_NAME; 14 | public static final String DATABASE_PORT; 15 | 16 | public static final String PROMETHEUS_PORT; 17 | public static final String DISCORD_TOKEN; 18 | public static final String DISCORD_GAME; 19 | public static final String DISCORD_PREFIX; 20 | public static final String MAX_FETCH_DAYS; 21 | public static final String DEFAULT_DAYS; 22 | 23 | static { 24 | final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); 25 | 26 | DATABASE_HOST = getenv("DATABASE_HOST", dotenv); 27 | DATABASE_USER = getenv("DATABASE_USER", dotenv); 28 | DATABASE_PASSWORD = getenv("DATABASE_PASSWORD", dotenv); 29 | DATABASE_PORT = getenv("DATABASE_PORT", dotenv); 30 | DATABASE_NAME = getenv("DATABASE_NAME", dotenv); 31 | 32 | PROMETHEUS_PORT = getenv("PROMETHEUS_PORT", dotenv); 33 | DISCORD_TOKEN = getenv("DISCORD_TOKEN", dotenv); 34 | DISCORD_GAME = getenv("DISCORD_GAME", dotenv); 35 | DISCORD_PREFIX = getenv("DISCORD_PREFIX", dotenv); 36 | MAX_FETCH_DAYS = getenv("MAX_FETCH_DAYS", dotenv); 37 | DEFAULT_DAYS = getenv("DEFAULT_DAYS", dotenv); 38 | 39 | try { 40 | checkNull(); 41 | LoggerFactory.getLogger(Configuration.class).info("Configuration loaded!"); 42 | } catch (IllegalAccessException e) { 43 | e.printStackTrace(); 44 | } 45 | } 46 | 47 | private static String getenv(final String name, final Dotenv dotenv) { 48 | if (System.getenv(name) != null) { 49 | return System.getenv(name); 50 | } else if (dotenv.get(name) != null) { 51 | return dotenv.get(name); 52 | } 53 | return null; 54 | } 55 | 56 | private static void checkNull() throws IllegalAccessException { 57 | for (Field f : Configuration.class.getDeclaredFields()) { 58 | LoggerFactory.getLogger(Configuration.class).debug(f.getName() + " environment variable " 59 | + (f.get(Configuration.class) == null ? "is null" : "has been loaded")); 60 | } 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/enums/TimeShortcut.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.enums; 2 | 3 | import java.time.temporal.ChronoUnit; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | import java.util.concurrent.TimeUnit; 7 | 8 | public enum TimeShortcut { 9 | 10 | DAY(List.of('d'), toDays(ChronoUnit.DAYS)), 11 | WEEK(List.of('w'), toDays(ChronoUnit.WEEKS)), 12 | MONTH(List.of('m'), toDays(ChronoUnit.MONTHS)), 13 | YEAR(List.of('y'), toDays(ChronoUnit.YEARS)); 14 | 15 | private final List characters; 16 | private final int asDays; 17 | 18 | TimeShortcut(final List characters, final int days) { 19 | this.characters = characters; 20 | this.asDays = days; 21 | } 22 | 23 | public List getCharacters() { 24 | return characters; 25 | } 26 | 27 | public int asDays() { 28 | return asDays; 29 | } 30 | 31 | public int multiplyWith(final int value) { 32 | return this.asDays * value; 33 | } 34 | 35 | public static TimeShortcut getShortcut(final Character value) { 36 | return getShortcut(value, DAY); 37 | } 38 | 39 | public static TimeShortcut getShortcut(final Character value, final TimeShortcut defaultValue) { 40 | for (final TimeShortcut timeShortcut : values()) { 41 | if (timeShortcut.getCharacters() == null) continue; 42 | for (final Character character : timeShortcut.getCharacters()) { 43 | if (Character.toLowerCase(value) == Character.toLowerCase(character)) return timeShortcut; 44 | } 45 | } 46 | return defaultValue; 47 | } 48 | 49 | public static List getShortcuts() { 50 | final List characters = new ArrayList<>(); 51 | for (final TimeShortcut value : values()) { 52 | characters.addAll(value.getCharacters()); 53 | } 54 | return characters; 55 | } 56 | 57 | /** 58 | * @return a string with all shortcuts in a string. Like dwmy 59 | */ 60 | public static String getAsString() { 61 | final StringBuilder stringBuilder = new StringBuilder(); 62 | for (final Character shortcut : getShortcuts()) { 63 | stringBuilder.append(shortcut); 64 | } 65 | return stringBuilder.toString(); 66 | } 67 | 68 | private static int toDays(final ChronoUnit chronoUnit) { 69 | final long seconds = chronoUnit.getDuration().getSeconds(); 70 | return (int) TimeUnit.SECONDS.toDays(seconds); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/ClockCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DiscordUtils; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Member; 6 | import net.dv8tion.jda.api.entities.Message; 7 | 8 | import java.text.SimpleDateFormat; 9 | import java.util.Calendar; 10 | import java.util.Date; 11 | 12 | public class ClockCommand extends Command { 13 | 14 | public ClockCommand() { 15 | super("clock", ""); 16 | } 17 | 18 | @Override 19 | public void execute(String[] args, Message message) { 20 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 21 | final Member member = DiscordUtils.getAddressedMember(message); 22 | if (args.length > 0 && args[0].equalsIgnoreCase("server")) { 23 | addTimeToEmbed(embedBuilder, 24 | getBot().getDatabase().getUserDao().getMostListensTime(message.getGuild().getId()), null); 25 | } else { 26 | addTimeToEmbed(embedBuilder, 27 | getBot().getDatabase().getUserDao().getMostListensTime(member.getGuild().getId(), member.getId()), 28 | member); 29 | } 30 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 31 | } 32 | 33 | private void addTimeToEmbed(final EmbedBuilder embedBuilder, final Long date, final Member member) { 34 | final Date firstDateRange = new Date(date - 1800 * 1000); 35 | final Date secondDateRange = new Date(date + 1800 * 1000); 36 | final String firstRange = new SimpleDateFormat("HH.mm").format(roundToQuarter(firstDateRange)); 37 | final String secondRange = new SimpleDateFormat("HH.mm").format(roundToQuarter(secondDateRange)); 38 | if (member != null) { 39 | embedBuilder.setDescription(member.getUser().getName() + " hört am meisten Musik zwischen " + firstRange 40 | + " und " + secondRange + " Uhr"); 41 | } else { 42 | embedBuilder.setDescription( 43 | "Der Server hört am meisten Musik zwischen " + firstRange + " und " + secondRange + " Uhr"); 44 | } 45 | } 46 | 47 | private Date roundToQuarter(final Date date) { 48 | final Calendar calendar = Calendar.getInstance(); 49 | calendar.setTime(date); 50 | 51 | final int unroundedMinutes = calendar.get(Calendar.MINUTE); 52 | final int mod = unroundedMinutes % 15; 53 | calendar.add(Calendar.MINUTE, mod < 8 ? -mod : (15 - mod)); 54 | return calendar.getTime(); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/HelpCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import net.dv8tion.jda.api.EmbedBuilder; 4 | import net.dv8tion.jda.api.entities.Message; 5 | 6 | import java.util.Collection; 7 | import java.util.List; 8 | import java.util.Optional; 9 | import java.util.stream.Collectors; 10 | 11 | public class HelpCommand extends Command { 12 | 13 | public HelpCommand() { 14 | super("help", "View this help page"); 15 | } 16 | 17 | @Override 18 | public void execute(String[] args, Message message) { 19 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 20 | final Collection commandCollection = getBot().getCommandManager().getAvailableCommands(); 21 | 22 | if (args.length == 0) { 23 | embedBuilder.setTitle("Command Übersicht"); 24 | appendOverview(embedBuilder, commandCollection); 25 | } else { 26 | addCommandDescription(embedBuilder, args[0], commandCollection); 27 | } 28 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 29 | } 30 | 31 | public void appendOverview(final EmbedBuilder embedBuilder, final Collection commandCollection) { 32 | commandCollection.stream().collect(Collectors.groupingBy(Command::getCommand)).entrySet().stream() 33 | .sorted((entry1, entry2) -> { 34 | final int sizeComparison = entry2.getValue().size() - entry1.getValue().size(); 35 | return sizeComparison != 0 ? sizeComparison : entry1.getKey().compareTo(entry2.getKey()); 36 | }).forEach(entry -> { 37 | final List commandList = entry.getValue(); 38 | final String categoryCommands = commandList.stream().map(Command::getDescription) 39 | .sorted(String::compareTo).map(string -> String.format("`%s`", string)) 40 | .collect(Collectors.joining(" ")); 41 | embedBuilder.addField(entry.getKey(), categoryCommands, false); 42 | }); 43 | } 44 | 45 | public void addCommandDescription(final EmbedBuilder embedBuilder, final String commandName, 46 | final Collection commandCollection) { 47 | final Optional optCommand = commandCollection.stream() 48 | .filter(command -> command.getCommand().equals(commandName)).findFirst(); 49 | if (optCommand.isPresent()) { 50 | final Command command = optCommand.get(); 51 | embedBuilder.addField(command.getCommand(), command.getDescription(), false); 52 | } else { 53 | embedBuilder.addField("Command wurde nicht gefunden", "", false); 54 | } 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/handler/DiscordUserUpdateGameListener.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.handler; 2 | 3 | import de.biosphere.spoticord.Spoticord; 4 | import de.biosphere.spoticord.database.model.SpotifyTrack; 5 | import de.biosphere.spoticord.utils.Metrics; 6 | import net.dv8tion.jda.api.entities.Activity; 7 | import net.dv8tion.jda.api.entities.RichPresence; 8 | import net.dv8tion.jda.api.events.user.UserActivityStartEvent; 9 | import net.dv8tion.jda.api.hooks.ListenerAdapter; 10 | import net.jodah.expiringmap.ExpirationPolicy; 11 | import net.jodah.expiringmap.ExpiringMap; 12 | 13 | import javax.annotation.Nonnull; 14 | import java.util.Map; 15 | import java.util.concurrent.TimeUnit; 16 | 17 | public class DiscordUserUpdateGameListener extends ListenerAdapter { 18 | 19 | private final Spoticord bot; 20 | private final Map lastActivitiesMap; 21 | 22 | public DiscordUserUpdateGameListener(final Spoticord instance) { 23 | this.bot = instance; 24 | this.lastActivitiesMap = ExpiringMap.builder().expiration(7, TimeUnit.MINUTES) 25 | .expirationPolicy(ExpirationPolicy.ACCESSED).build(); 26 | } 27 | 28 | @Override 29 | public void onUserActivityStart(@Nonnull final UserActivityStartEvent event) { 30 | if (checkActivity(event.getNewActivity())) { 31 | return; 32 | } 33 | final RichPresence richPresence = event.getNewActivity().asRichPresence(); 34 | if (checkRichPresence(richPresence)) { 35 | return; 36 | } 37 | if (checkCache(event.getMember().getId(), richPresence.getSyncId())) { 38 | return; 39 | } 40 | lastActivitiesMap.put(event.getMember().getId(), richPresence.getSyncId()); 41 | final SpotifyTrack spotifyTrack = new SpotifyTrack(richPresence.getSyncId(), richPresence.getState(), 42 | richPresence.getLargeImage().getText(), richPresence.getDetails(), 43 | richPresence.getLargeImage().getUrl(), 44 | richPresence.getTimestamps().getEnd() - richPresence.getTimestamps().getStart()); 45 | bot.getDatabase().getTrackDao().insertTrack(spotifyTrack, event.getMember().getId(), event.getGuild().getId()); 46 | Metrics.TRACKS_PER_MINUTE.labels(event.getGuild().getId()).inc(); 47 | } 48 | 49 | private boolean checkActivity(final Activity activity) { 50 | return activity == null || !activity.isRich() || activity.getType() != Activity.ActivityType.LISTENING; 51 | } 52 | 53 | private boolean checkRichPresence(final RichPresence richPresence) { 54 | return richPresence == null || richPresence.getDetails() == null || richPresence.getSyncId() == null; 55 | } 56 | 57 | private boolean checkCache(final String memberId, final String spotifyId) { 58 | return lastActivitiesMap.containsKey(memberId) && lastActivitiesMap.get(memberId).equalsIgnoreCase(spotifyId); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.devcontainer/library-scripts/maven.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #------------------------------------------------------------------------------------------------------------- 3 | # Copyright (c) Microsoft Corporation. All rights reserved. 4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 5 | #------------------------------------------------------------------------------------------------------------- 6 | # 7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/master/script-library/docs/maven.md 8 | # 9 | # Syntax: ./maven-debian.sh [maven version] [SDKMAN_DIR] [non-root user] [Update rc files flag] 10 | 11 | MAVEN_VERSION=${1:-"latest"} 12 | export SDKMAN_DIR=${2:-"/usr/local/sdkman"} 13 | USERNAME=${3:-"automatic"} 14 | UPDATE_RC=${4:-"false"} 15 | 16 | set -e 17 | 18 | # Blank will install latest maven version 19 | if [ "${MAVEN_VERSION}" = "lts" ] || [ "${MAVEN_VERSION}" = "current" ] || [ "${MAVEN_VERSION}" = "latest" ]; then 20 | MAVEN_VERSION="" 21 | fi 22 | 23 | if [ "$(id -u)" -ne 0 ]; then 24 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' 25 | exit 1 26 | fi 27 | 28 | # Determine the appropriate non-root user 29 | if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then 30 | USERNAME="" 31 | POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") 32 | for CURRENT_USER in ${POSSIBLE_USERS[@]}; do 33 | if id -u ${CURRENT_USER} > /dev/null 2>&1; then 34 | USERNAME=${CURRENT_USER} 35 | break 36 | fi 37 | done 38 | if [ "${USERNAME}" = "" ]; then 39 | USERNAME=root 40 | fi 41 | elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then 42 | USERNAME=root 43 | fi 44 | 45 | function updaterc() { 46 | if [ "${UPDATE_RC}" = "true" ]; then 47 | echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." 48 | echo -e "$1" | tee -a /etc/bash.bashrc >> /etc/zsh/zshrc 49 | fi 50 | } 51 | 52 | export DEBIAN_FRONTEND=noninteractive 53 | 54 | # Install curl, zip, unzip if missing 55 | if ! dpkg -s curl ca-certificates zip unzip sed > /dev/null 2>&1; then 56 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then 57 | apt-get update 58 | fi 59 | apt-get -y install --no-install-recommends curl ca-certificates zip unzip sed 60 | fi 61 | 62 | # Install sdkman if not installed 63 | if [ ! -d "${SDKMAN_DIR}" ]; then 64 | curl -sSL "https://get.sdkman.io?rcupdate=false" | bash 65 | chown -R "${USERNAME}" "${SDKMAN_DIR}" 66 | # Add sourcing of sdkman into bashrc/zshrc files (unless disabled) 67 | updaterc "export SDKMAN_DIR=${SDKMAN_DIR}\nsource \${SDKMAN_DIR}/bin/sdkman-init.sh" 68 | fi 69 | 70 | # Install Maven 71 | su ${USERNAME} -c "source ${SDKMAN_DIR}/bin/sdkman-init.sh && sdk install maven ${MAVEN_VERSION} && sdk flush archives && sdk flush temp" 72 | updaterc "export M2=\$HOME/.m2" 73 | echo "Done!" -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/StatsCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import java.lang.management.ManagementFactory; 4 | import java.util.concurrent.TimeUnit; 5 | 6 | import net.dv8tion.jda.api.EmbedBuilder; 7 | import net.dv8tion.jda.api.JDA; 8 | import net.dv8tion.jda.api.JDAInfo; 9 | import net.dv8tion.jda.api.entities.Guild; 10 | import net.dv8tion.jda.api.entities.Message; 11 | 12 | public class StatsCommand extends Command { 13 | 14 | public StatsCommand() { 15 | super("info", "Show some statistics"); 16 | } 17 | 18 | @Override 19 | public void execute(String[] args, Message message) { 20 | final JDA jda = message.getJDA(); 21 | final long uptime = ManagementFactory.getRuntimeMXBean().getUptime(); 22 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 23 | 24 | embedBuilder.setTitle("spoticord", "https://github.com/Biospheere/spoticord"); 25 | embedBuilder.setThumbnail(getBot().getJDA().getSelfUser().getEffectiveAvatarUrl()); 26 | 27 | embedBuilder.addField("JDA Version", JDAInfo.VERSION, true); 28 | embedBuilder.addField("Ping", jda.getGatewayPing() + "ms", true); 29 | embedBuilder.addField("Uptime", 30 | String.valueOf(TimeUnit.MILLISECONDS.toDays(uptime) + "d " 31 | + TimeUnit.MILLISECONDS.toHours(uptime) % 24 + "h " 32 | + TimeUnit.MILLISECONDS.toMinutes(uptime) % 60 + "m " 33 | + TimeUnit.MILLISECONDS.toSeconds(uptime) % 60 + "s"), 34 | true); 35 | embedBuilder.addField("Commands", 36 | String.valueOf(getBot().getCommandManager().getAvailableCommands().size()), true); 37 | embedBuilder.addField("Members", 38 | String.valueOf(jda.getGuilds().stream().mapToInt(Guild::getMemberCount).sum()), true); 39 | embedBuilder.addField("Java Version", System.getProperty("java.runtime.version").replace("+", "_"), 40 | true); 41 | embedBuilder.addField("OS", ManagementFactory.getOperatingSystemMXBean().getName(), true); 42 | 43 | embedBuilder.addField("RAM Usage", (ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed() 44 | + ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getUsed()) / 1000000 45 | + " / " 46 | + (ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax() 47 | + ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getMax()) 48 | / 1000000 49 | + " MB", true); 50 | 51 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/impl/mysql/ArtistImplMySql.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.impl.mysql; 2 | 3 | import com.zaxxer.hikari.HikariDataSource; 4 | import de.biosphere.spoticord.database.dao.ArtistDao; 5 | 6 | import java.sql.Connection; 7 | import java.sql.PreparedStatement; 8 | import java.sql.ResultSet; 9 | import java.sql.SQLException; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | 13 | public class ArtistImplMySql implements ArtistDao { 14 | 15 | private final HikariDataSource hikariDataSource; 16 | 17 | public ArtistImplMySql(HikariDataSource hikariDataSource) { 18 | this.hikariDataSource = hikariDataSource; 19 | } 20 | 21 | @Override 22 | public Map getTopArtists(String guildId, String userId, Integer count, Integer lastDays) { 23 | final Map topMap = new LinkedHashMap<>(); 24 | try (final Connection connection = hikariDataSource.getConnection()) { 25 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 26 | ? "SELECT Tracks.Artists, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? " 27 | + MySqlDatabase.getTimestampQuery(lastDays) + " GROUP BY `Artists` ORDER BY COUNT(*) DESC LIMIT ?" 28 | : "SELECT Tracks.Artists, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? AND Listens.UserId=? " 29 | + MySqlDatabase.getTimestampQuery(lastDays) + "GROUP BY `Artists` ORDER BY COUNT(*) DESC LIMIT ?")) { 30 | preparedStatement.setString(1, guildId); 31 | if (userId != null) { 32 | preparedStatement.setString(2, userId); 33 | preparedStatement.setInt(3, count); 34 | } else { 35 | preparedStatement.setInt(2, count); 36 | } 37 | 38 | final ResultSet resultSet = preparedStatement.executeQuery(); 39 | while (resultSet.next()) { 40 | topMap.put(resultSet.getString("Artists"), resultSet.getInt("Listener")); 41 | } 42 | } 43 | } catch (final SQLException ex) { 44 | ex.printStackTrace(); 45 | } 46 | return topMap; 47 | } 48 | 49 | @Override 50 | public Map getTopArtists(String guildId, Integer count, Integer lastDays) { 51 | return getTopArtists(guildId, null, count, lastDays); 52 | } 53 | 54 | @Override 55 | public Integer getArtistAmount() { 56 | try (final Connection connection = hikariDataSource.getConnection()) { 57 | final PreparedStatement preparedStatement = connection 58 | .prepareStatement("SELECT COUNT(*) AS count FROM (SELECT DISTINCT Artists FROM Tracks) as T"); 59 | final ResultSet resultSet = preparedStatement.executeQuery(); 60 | if (resultSet.next()) { 61 | return resultSet.getInt("count"); 62 | } 63 | } catch (final SQLException ex) { 64 | ex.printStackTrace(); 65 | } 66 | return 0; 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/impl/mysql/AlbumImplMySql.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.impl.mysql; 2 | 3 | import com.zaxxer.hikari.HikariDataSource; 4 | import de.biosphere.spoticord.database.dao.AlbumDao; 5 | 6 | import java.sql.Connection; 7 | import java.sql.PreparedStatement; 8 | import java.sql.ResultSet; 9 | import java.sql.SQLException; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | 13 | public class AlbumImplMySql implements AlbumDao { 14 | 15 | private final HikariDataSource hikariDataSource; 16 | 17 | public AlbumImplMySql(HikariDataSource hikariDataSource) { 18 | this.hikariDataSource = hikariDataSource; 19 | } 20 | 21 | @Override 22 | public Map getTopAlbum(String guildId, String userId, Integer count, Integer lastDays) { 23 | final Map topMap = new LinkedHashMap<>(); 24 | try (final Connection connection = hikariDataSource.getConnection()) { 25 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 26 | ? "SELECT Tracks.AlbumTitle, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? " 27 | + MySqlDatabase.getTimestampQuery(lastDays) + " GROUP BY `AlbumTitle` ORDER BY COUNT(*) DESC LIMIT ?" 28 | : "SELECT Tracks.AlbumTitle, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? AND Listens.UserId=? " 29 | + MySqlDatabase.getTimestampQuery(lastDays) + " GROUP BY `AlbumTitle` ORDER BY COUNT(*) DESC LIMIT ?");) { 30 | preparedStatement.setString(1, guildId); 31 | if (userId != null) { 32 | preparedStatement.setString(2, userId); 33 | preparedStatement.setInt(3, count); 34 | } else { 35 | preparedStatement.setInt(2, count); 36 | } 37 | 38 | final ResultSet resultSet = preparedStatement.executeQuery(); 39 | while (resultSet.next()) { 40 | topMap.put(resultSet.getString("AlbumTitle"), resultSet.getInt("Listener")); 41 | } 42 | } 43 | } catch (final SQLException ex) { 44 | ex.printStackTrace(); 45 | } 46 | return topMap; 47 | } 48 | 49 | @Override 50 | public Map getTopAlbum(String guildId, Integer count, Integer lastDays) { 51 | return getTopAlbum(guildId, null, count, lastDays); 52 | } 53 | 54 | @Override 55 | public Integer getAlbumAmount() { 56 | try (final Connection connection = hikariDataSource.getConnection()) { 57 | try (final PreparedStatement preparedStatement = connection 58 | .prepareStatement("SELECT COUNT(*) AS count FROM (SELECT DISTINCT AlbumTitle FROM Tracks) as T")) { 59 | final ResultSet resultSet = preparedStatement.executeQuery(); 60 | if (resultSet.next()) { 61 | return resultSet.getInt("count"); 62 | } 63 | } 64 | } catch (final SQLException ex) { 65 | ex.printStackTrace(); 66 | } 67 | return 0; 68 | } 69 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/TimeCommand.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.utils.DayParser; 4 | import net.dv8tion.jda.api.EmbedBuilder; 5 | import net.dv8tion.jda.api.entities.Guild; 6 | import net.dv8tion.jda.api.entities.Member; 7 | import net.dv8tion.jda.api.entities.Message; 8 | 9 | import java.util.Map; 10 | import java.util.concurrent.TimeUnit; 11 | 12 | public class TimeCommand extends Command { 13 | 14 | public TimeCommand() { 15 | super("time", "See how much time you listened to music on Spotify"); 16 | } 17 | 18 | @Override 19 | public void execute(final String[] args, final Message message) { 20 | 21 | if (args.length == 0) { 22 | final EmbedBuilder embedBuilder = getEmbed(message.getGuild(), message.getAuthor()); 23 | final Long time = getBot().getDatabase().getUserDao().getListenTime(message.getGuild().getId(), null); 24 | final String timeFormat = formatDuration(time); 25 | embedBuilder.setDescription("Der ganze Server hat bereits " + timeFormat + " Musik gehört"); 26 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 27 | return; 28 | } 29 | 30 | final DayParser.Parsed parsed = DayParser.get(args, message); 31 | final EmbedBuilder embedBuilder = DayParser.getEmbed(message.getGuild(), message.getAuthor(), parsed.getDays(), 32 | parsed.isServerStats()); 33 | 34 | if (parsed.isServerStats()) { 35 | addListToEmbed(embedBuilder, message.getGuild(), 36 | getBot().getDatabase().getUserDao() 37 | .getTopListenersByTime(message.getGuild().getId(), 10, parsed.getDays())); 38 | } else { 39 | final Member targetMember = parsed.getMember(); 40 | final Long time = getBot().getDatabase().getUserDao().getListenTime(message.getGuild().getId(), 41 | targetMember.getId(), parsed.getDays()); 42 | embedBuilder.setDescription( 43 | targetMember.getAsMention() + " hat bereits " + formatDuration(time) + " Musik gehört"); 44 | } 45 | message.getTextChannel().sendMessage(embedBuilder.build()).queue(); 46 | } 47 | 48 | private void addListToEmbed(final EmbedBuilder embedBuilder, final Guild guild, final Map topMap) { 49 | topMap.keySet().stream().filter(userId -> guild.getMemberById(userId) != null) 50 | .map(guild::getMemberById).forEach(member -> { 51 | embedBuilder.appendDescription(String.format("%s#%s %s \n", member.getEffectiveName(), 52 | member.getUser().getDiscriminator(), formatDuration(topMap.get(member.getId())))); 53 | }); 54 | } 55 | 56 | private String formatDuration(long millis) { 57 | final long days = TimeUnit.MILLISECONDS.toDays(millis); 58 | millis -= TimeUnit.DAYS.toMillis(days); 59 | final long hours = TimeUnit.MILLISECONDS.toHours(millis); 60 | millis -= TimeUnit.HOURS.toMillis(hours); 61 | final long minutes = TimeUnit.MILLISECONDS.toMinutes(millis); 62 | millis -= TimeUnit.MINUTES.toMillis(minutes); 63 | final long seconds = TimeUnit.MILLISECONDS.toSeconds(millis); 64 | 65 | final StringBuilder sb = new StringBuilder(64); 66 | sb.append(days); 67 | sb.append(" Tage "); 68 | sb.append(hours); 69 | sb.append(" Stunden "); 70 | sb.append(minutes); 71 | sb.append(" Minuten "); 72 | sb.append(seconds); 73 | sb.append(" Sekunden"); 74 | 75 | return (sb.toString()); 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/commands/CommandManager.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.commands; 2 | 3 | import de.biosphere.spoticord.Configuration; 4 | import de.biosphere.spoticord.Spoticord; 5 | import net.dv8tion.jda.api.entities.Message.MentionType; 6 | import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; 7 | import net.dv8tion.jda.api.hooks.ListenerAdapter; 8 | import org.reflections.Reflections; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.util.*; 13 | import java.util.regex.Matcher; 14 | import java.util.regex.Pattern; 15 | 16 | public class CommandManager extends ListenerAdapter { 17 | 18 | private static final Logger logger = LoggerFactory.getLogger(CommandManager.class); 19 | private static final Pattern MENTION_PATTERN = MentionType.USER.getPattern(); 20 | 21 | private final Set availableCommands; 22 | 23 | public CommandManager(final Spoticord bot) { 24 | this.availableCommands = new HashSet<>(); 25 | final Set> classes = new Reflections("de.biosphere.spoticord.commands") 26 | .getSubTypesOf(Command.class); 27 | for (Class cmdClass : classes) { 28 | try { 29 | final Command command = cmdClass.getDeclaredConstructor().newInstance(); 30 | command.setInstance(bot); 31 | if (availableCommands.add(command)) { 32 | logger.info("Registered " + command.getCommand() + " Command"); 33 | } 34 | } catch (Exception exception) { 35 | logger.error("Error while registering Command!", exception); 36 | } 37 | } 38 | bot.getJDA().addEventListener(this); 39 | } 40 | 41 | @Override 42 | public void onGuildMessageReceived(final GuildMessageReceivedEvent event) { 43 | if (event.getAuthor().isBot()) { 44 | return; 45 | } 46 | final String content = event.getMessage().getContentRaw(); 47 | final PrefixType prefixType = checkPrefix(content, event.getGuild().getSelfMember().getId()); 48 | if (prefixType != PrefixType.NONE) { 49 | final Optional optional = availableCommands.stream() 50 | .filter(command -> command.getCommand().equalsIgnoreCase(getCommand(content, prefixType))) 51 | .findFirst(); 52 | optional.ifPresent(command -> command.execute(getArguments(content, prefixType), event.getMessage())); 53 | } 54 | } 55 | 56 | private PrefixType checkPrefix(final String content, final String botId) { 57 | if (content.split(" ").length > 1) { 58 | final Matcher matcher = MENTION_PATTERN.matcher(content.split(" ")[0]); 59 | if (matcher.matches() && matcher.group(1).equals(botId)) { 60 | return PrefixType.MENTION; 61 | } 62 | } 63 | if (Configuration.DISCORD_PREFIX != null && content.startsWith(Configuration.DISCORD_PREFIX)) { 64 | return PrefixType.PREFIX; 65 | } 66 | return PrefixType.NONE; 67 | } 68 | 69 | private String[] getArguments(final String content, final PrefixType prefixType) { 70 | final String[] arguments = content.split(" "); 71 | return Arrays.copyOfRange(arguments, prefixType == PrefixType.PREFIX ? 1 : 2, arguments.length); 72 | } 73 | 74 | private String getCommand(final String content, final PrefixType prefixType) { 75 | final String[] arguments = content.split(" "); 76 | if (prefixType == PrefixType.PREFIX) { 77 | return arguments[0].replaceFirst("\\" + Configuration.DISCORD_PREFIX, ""); 78 | } 79 | if (arguments.length >= 2) { 80 | return arguments[1]; 81 | } 82 | return null; 83 | } 84 | 85 | public Set getAvailableCommands() { 86 | return Collections.unmodifiableSet(availableCommands); 87 | } 88 | 89 | private enum PrefixType { 90 | MENTION, PREFIX, NONE 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/utils/DayParser.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.utils; 2 | 3 | import de.biosphere.spoticord.Configuration; 4 | import de.biosphere.spoticord.enums.TimeFilter; 5 | import de.biosphere.spoticord.enums.TimeShortcut; 6 | import net.dv8tion.jda.api.EmbedBuilder; 7 | import net.dv8tion.jda.api.entities.Guild; 8 | import net.dv8tion.jda.api.entities.Member; 9 | import net.dv8tion.jda.api.entities.Message; 10 | import net.dv8tion.jda.api.entities.User; 11 | 12 | import java.util.regex.Matcher; 13 | import java.util.regex.Pattern; 14 | 15 | public class DayParser { 16 | 17 | private static final Integer MAX_FETCH_DAYS; 18 | private static final Integer DEFAULT_DAYS; 19 | private static final TimeShortcut DEFAULT_SHORTCUT; 20 | private static final Integer MIN_DAYS; 21 | private static final String REGEX; 22 | private static final Pattern SELECT_PATTERN; 23 | private static final Pattern MENTION_PATTERN; 24 | 25 | private static final String FOOTER_FORMAT = "%s#%s | %d day%s | %s"; 26 | 27 | static { 28 | MAX_FETCH_DAYS = DiscordUtils.getIntFromString(Configuration.MAX_FETCH_DAYS, 30); 29 | DEFAULT_DAYS = DiscordUtils.getIntFromString(Configuration.DEFAULT_DAYS, 7); 30 | DEFAULT_SHORTCUT = TimeShortcut.DAY; 31 | MIN_DAYS = Math.min(DEFAULT_DAYS, MAX_FETCH_DAYS); 32 | REGEX = "(?i)^[+]?(\\d{1,%s})([%s])?$".formatted(MAX_FETCH_DAYS.toString().length(), TimeShortcut.getAsString()); 33 | SELECT_PATTERN = Pattern.compile(REGEX); 34 | MENTION_PATTERN = Message.MentionType.USER.getPattern(); 35 | } 36 | 37 | public static int getDays(final String input) { 38 | final TimeFilter filter = TimeFilter.getFilter(input); 39 | if (filter != TimeFilter.CUSTOM) 40 | return Math.min(filter.getDayValue(), MAX_FETCH_DAYS); 41 | final Matcher matcher = SELECT_PATTERN.matcher(input); 42 | if (!matcher.matches()) 43 | return MIN_DAYS; 44 | final TimeShortcut timeShortcut = 45 | matcher.group(2) != null ? TimeShortcut.getShortcut(matcher.group(2).charAt(0)) : DEFAULT_SHORTCUT; 46 | final int parseInt = Integer.parseInt(matcher.group(1)); 47 | final int days = timeShortcut.multiplyWith(parseInt); 48 | if (days <= 0) 49 | return MIN_DAYS; 50 | return Math.min(days, MAX_FETCH_DAYS); 51 | } 52 | 53 | public static EmbedBuilder getEmbed(final Guild guild, final User requester, final int days, 54 | final boolean serverStats) { 55 | return new EmbedBuilder() 56 | .setFooter( 57 | FOOTER_FORMAT.formatted(requester.getName(), requester.getDiscriminator(), days, 58 | days == 1 ? "" : "s", serverStats ? "Server" : "User"), 59 | requester.getEffectiveAvatarUrl()) 60 | .setColor(guild.getSelfMember().getColor()); 61 | } 62 | 63 | public static Parsed get(final String[] args, final Message message) { 64 | final Member member = DiscordUtils.getAddressedMember(message); 65 | final int days; 66 | final boolean serverStats; 67 | 68 | if (args.length >= 1) { 69 | String day = args[args.length == 1 ? 0 : 1]; 70 | days = getDays(day); 71 | serverStats = !MENTION_PATTERN.matcher(args[0]).matches(); 72 | } else { 73 | serverStats = true; 74 | days = MIN_DAYS; 75 | } 76 | return new Parsed(member, days, serverStats); 77 | } 78 | 79 | public static class Parsed { 80 | 81 | private final Member member; 82 | private final int days; 83 | private final boolean serverStats; 84 | 85 | public Parsed(final Member member, final int days, final boolean serverStats) { 86 | this.member = member; 87 | this.days = days; 88 | this.serverStats = serverStats; 89 | } 90 | 91 | public Member getMember() { 92 | return member; 93 | } 94 | 95 | public int getDays() { 96 | return days; 97 | } 98 | 99 | public boolean isServerStats() { 100 | return serverStats; 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/dao/UserDao.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.dao; 2 | 3 | import java.util.Map; 4 | 5 | public interface UserDao { 6 | 7 | /** 8 | * Returns the time a user has listened to music 9 | * 10 | * @param guildId the Snowflake id of the guild that the user is part of 11 | * @param userId the Snowflake id of the user 12 | * @return The time a user listened to music 13 | */ 14 | default Long getListenTime(final String guildId, final String userId) { 15 | return getListenTime(guildId, userId, 0); 16 | } 17 | 18 | /** 19 | * Returns the time a user has listened to music 20 | * 21 | * @param guildId the Snowflake id of the guild that the user is part of 22 | * @param userId the Snowflake id of the user 23 | * @param lastDays the last days for data collection when 0 all data 24 | * @return The time a user listened to music 25 | */ 26 | Long getListenTime(final String guildId, final String userId, final Integer lastDays); 27 | 28 | Long getListenTime(final String guildId); 29 | 30 | /** 31 | * Returns a map of the users with the most database entries on a guild. The 32 | * count argument specifies the length of the map. Map contains 33 | * {@link String} as Snowflake id of a user and {@link Integer} as the amount of 34 | * entries in the database. 35 | * 36 | * @param guildId the Snowflake id of the guild 37 | * @param count the length of the map 38 | * @return A sorted map with count entries 39 | */ 40 | default Map getTopUsers(final String guildId, final Integer count) { 41 | return getTopUsers(guildId, count, 0); 42 | } 43 | 44 | /** 45 | * Returns a map of the users with the most database entries on a guild. The 46 | * count argument specifies the length of the map. Map contains 47 | * {@link String} as Snowflake id of a user and {@link Integer} as the amount of 48 | * entries in the database. 49 | * 50 | * @param guildId the Snowflake id of the guild 51 | * @param count the length of the map 52 | * @param lastDays the last days for data collection when 0 all data 53 | * @return A sorted map with count entries 54 | */ 55 | Map getTopUsers(final String guildId, final Integer count, final Integer lastDays); 56 | 57 | /** 58 | * Returns a map of the users with the highest listening time on a guild The 59 | * count argument specifies the length of the map. Map contains 60 | * {@link String} as Snowflake id of a user and {@link Integer} as the amount of 61 | * entries to be returned. 62 | * 63 | * @param guildId the Snowflake id of the guild 64 | * @param count the length of the map 65 | * @return A sorted map with count entries 66 | */ 67 | default Map getTopListenersByTime(final String guildId, final Integer count) { 68 | return getTopListenersByTime(guildId, count, 0); 69 | } 70 | 71 | /** 72 | * Returns a map of the users with the highest listening time on a guild The 73 | * count argument specifies the length of the map. Map contains 74 | * {@link String} as Snowflake id of a user and {@link Integer} as the amount of 75 | * entrys to be returned. 76 | * 77 | * @param guildId the Snowflake id of the guild 78 | * @param count the length of the map 79 | * @param lastDays the last days for data collection when 0 all data 80 | * @return A sorted map with count entrys 81 | */ 82 | Map getTopListenersByTime(final String guildId, final Integer count, final Integer lastDays); 83 | 84 | /** 85 | * Deletes all database entries that are linked to a specific user 86 | * 87 | * @param guildId the Snowflake id of the guild that the user is part of 88 | * @param userId the Snowflake id of the user 89 | */ 90 | void deleteUser(String guildId, String userId); 91 | 92 | /** 93 | * Returns the time at which most users listen to music 94 | * 95 | * @return the time in milliseconds as {@link Long} 96 | */ 97 | Long getMostListensTime(final String guildId); 98 | 99 | Long getMostListensTime(final String guildId, final String userId); 100 | 101 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/Spoticord.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord; 2 | 3 | import de.biosphere.spoticord.commands.CommandManager; 4 | import de.biosphere.spoticord.database.Database; 5 | import de.biosphere.spoticord.database.impl.mysql.MySqlDatabase; 6 | import de.biosphere.spoticord.handler.DiscordUserUpdateGameListener; 7 | import de.biosphere.spoticord.handler.MetricsCollector; 8 | import de.biosphere.spoticord.handler.StatisticsHandlerCollector; 9 | import io.prometheus.client.exporter.HTTPServer; 10 | import io.sentry.Sentry; 11 | import net.dv8tion.jda.api.JDA; 12 | import net.dv8tion.jda.api.JDABuilder; 13 | import net.dv8tion.jda.api.entities.Activity; 14 | import net.dv8tion.jda.api.events.ReadyEvent; 15 | import net.dv8tion.jda.api.hooks.ListenerAdapter; 16 | import net.dv8tion.jda.api.requests.GatewayIntent; 17 | import net.dv8tion.jda.api.utils.MemberCachePolicy; 18 | import net.dv8tion.jda.api.utils.cache.CacheFlag; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import java.util.EnumSet; 23 | import java.util.Timer; 24 | import java.util.concurrent.TimeUnit; 25 | 26 | public class Spoticord { 27 | 28 | private static final Logger logger = LoggerFactory.getLogger(Spoticord.class); 29 | 30 | private final JDA jda; 31 | private final Database database; 32 | private final CommandManager commandManager; 33 | 34 | public Spoticord() throws Exception { 35 | final long startTime = System.currentTimeMillis(); 36 | logger.info("Starting spoticord"); 37 | 38 | database = new MySqlDatabase(Configuration.DATABASE_HOST, Configuration.DATABASE_USER, 39 | Configuration.DATABASE_PASSWORD, 40 | Configuration.DATABASE_NAME == null ? "Tracks" : Configuration.DATABASE_NAME, 41 | Configuration.DATABASE_PORT == null ? 3306 : Integer.valueOf(Configuration.DATABASE_PORT)); 42 | logger.info("Database-Connection set up!"); 43 | 44 | 45 | jda = initializeJDA(); 46 | logger.info("JDA set up!"); 47 | 48 | commandManager = new CommandManager(this); 49 | logger.info("Command-Manager set up!"); 50 | 51 | if (Configuration.PROMETHEUS_PORT != null) { 52 | final Timer timer = new Timer("Metrics Timer"); 53 | new HTTPServer(Integer.valueOf(Configuration.PROMETHEUS_PORT)); 54 | new StatisticsHandlerCollector(this).register(); 55 | timer.schedule(new MetricsCollector(this), 100, TimeUnit.SECONDS.toMillis(5)); 56 | logger.info("Prometheus set up!"); 57 | } 58 | 59 | Runtime.getRuntime().addShutdownHook(new Thread(() -> { 60 | jda.shutdown(); 61 | try { 62 | database.close(); 63 | } catch (final Exception e) { 64 | e.printStackTrace(); 65 | } 66 | })); 67 | 68 | logger.info(String.format("Startup finished in %dms!", System.currentTimeMillis() - startTime)); 69 | } 70 | 71 | /** 72 | * Connect to Discord 73 | * 74 | * @return The {@link JDA} instance fot the current session 75 | */ 76 | private JDA initializeJDA() throws Exception { 77 | try { 78 | final JDABuilder jdaBuilder = JDABuilder.create(GatewayIntent.GUILD_PRESENCES, GatewayIntent.GUILD_MEMBERS, 79 | GatewayIntent.GUILD_MESSAGES); 80 | jdaBuilder.setToken(Configuration.DISCORD_TOKEN); 81 | if (Configuration.DISCORD_GAME != null) { 82 | jdaBuilder.setActivity(Activity.playing(Configuration.DISCORD_GAME)); 83 | } else { 84 | jdaBuilder.setActivity(Activity.playing("🎶")); 85 | } 86 | jdaBuilder.setMemberCachePolicy(MemberCachePolicy.ONLINE); 87 | jdaBuilder.disableCache(EnumSet.of(CacheFlag.VOICE_STATE, CacheFlag.EMOTE)); 88 | jdaBuilder.addEventListeners(new ListenerAdapter() { 89 | @Override 90 | public void onReady(final ReadyEvent event) { 91 | logger.info(String.format("Logged in as %s#%s", event.getJDA().getSelfUser().getName(), 92 | event.getJDA().getSelfUser().getDiscriminator())); 93 | } 94 | }, new DiscordUserUpdateGameListener(this)); 95 | return jdaBuilder.build().awaitReady(); 96 | } catch (final Exception exception) { 97 | logger.error("Encountered exception while initializing ShardManager!"); 98 | throw exception; 99 | } 100 | } 101 | 102 | public JDA getJDA() { 103 | return jda; 104 | } 105 | 106 | public Database getDatabase() { 107 | return database; 108 | } 109 | 110 | public CommandManager getCommandManager() { 111 | return commandManager; 112 | } 113 | 114 | public static void main(String... args) { 115 | if (System.getenv("SENTRY_DSN") != null || System.getProperty("sentry.properties") != null) { 116 | Sentry.init(); 117 | } 118 | try { 119 | new Spoticord(); 120 | } catch (Exception exception) { 121 | logger.error("Encountered exception while initializing the bot!", exception); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/impl/mysql/MySqlDatabase.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.impl.mysql; 2 | 3 | import com.zaxxer.hikari.HikariConfig; 4 | import com.zaxxer.hikari.HikariDataSource; 5 | import com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory; 6 | import de.biosphere.spoticord.Configuration; 7 | import de.biosphere.spoticord.database.Database; 8 | import de.biosphere.spoticord.database.dao.AlbumDao; 9 | import de.biosphere.spoticord.database.dao.ArtistDao; 10 | import de.biosphere.spoticord.database.dao.TrackDao; 11 | import de.biosphere.spoticord.database.dao.UserDao; 12 | import liquibase.Liquibase; 13 | import liquibase.database.DatabaseFactory; 14 | import liquibase.database.jvm.JdbcConnection; 15 | import liquibase.exception.DatabaseException; 16 | import liquibase.resource.ClassLoaderResourceAccessor; 17 | 18 | import java.sql.Connection; 19 | import java.sql.PreparedStatement; 20 | import java.sql.ResultSet; 21 | import java.sql.SQLException; 22 | import java.sql.Timestamp; 23 | import java.util.Objects; 24 | import java.util.concurrent.TimeUnit; 25 | import java.util.logging.Level; 26 | import java.util.logging.Logger; 27 | 28 | public class MySqlDatabase implements Database { 29 | 30 | private final static String SCHEMA_FILE = "schema/db.changelog-main.xml"; 31 | 32 | private final HikariDataSource dataSource; 33 | 34 | // DAOS 35 | private final AlbumDao albumDao; 36 | private final ArtistDao artistDao; 37 | private final TrackDao trackDao; 38 | private final UserDao userDao; 39 | 40 | public MySqlDatabase(final String host, final String username, final String password, final String database, 41 | final int port) { 42 | 43 | final HikariConfig config = new HikariConfig(); 44 | config.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database); 45 | config.setUsername(username); 46 | config.setPassword(password); 47 | config.addDataSourceProperty("cachePrepStmts", "true"); 48 | config.addDataSourceProperty("prepStmtCacheSize", "250"); 49 | config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); 50 | config.addDataSourceProperty("useServerPrepStmts", "true"); 51 | if (Configuration.PROMETHEUS_PORT != null) { 52 | config.setMetricsTrackerFactory(new PrometheusMetricsTrackerFactory()); 53 | } 54 | 55 | dataSource = new HikariDataSource(config); 56 | 57 | setupLiquibaseLogger(); 58 | updateDatabase(); 59 | 60 | // InitDAOs 61 | albumDao = new AlbumImplMySql(dataSource); 62 | artistDao = new ArtistImplMySql(dataSource); 63 | trackDao = new TrackImplMySql(dataSource); 64 | userDao = new UserImplMySql(dataSource); 65 | } 66 | 67 | private void updateDatabase() { 68 | final liquibase.database.Database implementation; 69 | try (final Connection connection = dataSource.getConnection()) { 70 | implementation = DatabaseFactory.getInstance() 71 | .findCorrectDatabaseImplementation(new JdbcConnection(connection)); 72 | 73 | this.liquibaseUpdate(implementation); 74 | } catch (final SQLException | DatabaseException ex) { 75 | ex.printStackTrace(); 76 | } 77 | } 78 | 79 | private void liquibaseUpdate(final liquibase.database.Database implementation) { 80 | Objects.requireNonNull(implementation, "Implementation is null!"); 81 | 82 | try (final Liquibase liquibase = new Liquibase(SCHEMA_FILE, new ClassLoaderResourceAccessor(), 83 | implementation)) { 84 | liquibase.update(""); 85 | } catch (Exception ex) { 86 | ex.printStackTrace(); 87 | } 88 | } 89 | 90 | public String getSizeOfTable(final String table) { 91 | try (final Connection connection = dataSource.getConnection()) { 92 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 93 | "SELECT table_name AS `Table`, round(((data_length + index_length) / 1024 / 1024), 2) `Size in MB` FROM information_schema.TABLES WHERE table_schema = \"" 94 | + (Configuration.DATABASE_NAME == null ? "Tracks" : Configuration.DATABASE_NAME) 95 | + "\" AND table_name = \"" + table + "\";")) { 96 | final ResultSet resultSet = preparedStatement.executeQuery(); 97 | if (resultSet.next()) { 98 | return resultSet.getString("Size in MB"); 99 | } 100 | } 101 | } catch (final SQLException ex) { 102 | ex.printStackTrace(); 103 | } 104 | return null; 105 | } 106 | 107 | @Override 108 | public AlbumDao getAlbumDao() { 109 | return albumDao; 110 | } 111 | 112 | @Override 113 | public ArtistDao getArtistDao() { 114 | return artistDao; 115 | } 116 | 117 | @Override 118 | public TrackDao getTrackDao() { 119 | return trackDao; 120 | } 121 | 122 | @Override 123 | public UserDao getUserDao() { 124 | return userDao; 125 | } 126 | 127 | @Override 128 | public void close() { 129 | dataSource.close(); 130 | } 131 | 132 | private void setupLiquibaseLogger() { 133 | final Logger liquibase = Logger.getLogger("liquibase"); 134 | liquibase.setLevel(Level.SEVERE); 135 | } 136 | 137 | public static Timestamp getTimestamp(int daysBack) { 138 | return new Timestamp(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(daysBack)); 139 | } 140 | 141 | public static String getTimestampQuery(int daysBack) { 142 | return daysBack == 0 ? "" : String.format("AND Listens.Timestamp between '%s' and '%s'", getTimestamp(daysBack), getTimestamp(0)); 143 | } 144 | 145 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | de.biosphere 6 | spoticord 7 | 1.0-SNAPSHOT 8 | jar 9 | 10 | 11 | 12 | jcenter 13 | jcenter-bintray 14 | https://jcenter.bintray.com 15 | 16 | 17 | jitpack.io 18 | https://jitpack.io 19 | 20 | 21 | 22 | 23 | 24 | com.zaxxer 25 | HikariCP 26 | 3.4.5 27 | 28 | 29 | 30 | net.dv8tion 31 | JDA 32 | 4.2.0_218 33 | 34 | 35 | club.minnced 36 | opus-java 37 | 38 | 39 | 40 | 41 | 42 | io.sentry 43 | sentry-logback 44 | 3.2.0 45 | 46 | 47 | 48 | org.reflections 49 | reflections 50 | 0.9.12 51 | 52 | 53 | 54 | io.prometheus 55 | simpleclient_httpserver 56 | 0.9.0 57 | 58 | 59 | 60 | mysql 61 | mysql-connector-java 62 | 8.0.22 63 | 64 | 65 | 66 | net.jodah 67 | expiringmap 68 | 0.5.9 69 | 70 | 71 | 72 | io.github.cdimascio 73 | java-dotenv 74 | 5.3.1 75 | 76 | 77 | 78 | org.liquibase 79 | liquibase-core 80 | 4.2.0 81 | 82 | 83 | 84 | 85 | 86 | 87 | src/main/resources 88 | true 89 | 90 | 91 | 92 | 93 | 94 | org.apache.maven.plugins 95 | maven-compiler-plugin 96 | 3.8.1 97 | 98 | 15 99 | 100 | --enable-preview 101 | 102 | 103 | 104 | 105 | org.apache.maven.plugins 106 | maven-shade-plugin 107 | 3.2.4 108 | 109 | 110 | package 111 | 112 | shade 113 | 114 | 115 | 116 | 117 | false 118 | true 119 | 120 | 121 | 122 | org.apache.maven.plugins 123 | maven-resources-plugin 124 | 3.2.0 125 | 126 | 127 | org.apache.maven.plugins 128 | maven-jar-plugin 129 | 3.2.0 130 | 131 | 132 | 133 | de.biosphere.spoticord.Spoticord 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/git,java,maven,dotenv,eclipse,intellij,code-java,visualstudiocode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=git,java,maven,dotenv,eclipse,intellij,code-java,visualstudiocode 4 | 5 | ### Code-Java ### 6 | # Language Support for Java(TM) by Red Hat extension for Visual Studio Code - https://marketplace.visualstudio.com/items?itemName=redhat.java 7 | 8 | .project 9 | .classpath 10 | factoryConfiguration.json 11 | 12 | ### dotenv ### 13 | .env 14 | 15 | ### Eclipse ### 16 | .metadata 17 | bin/ 18 | tmp/ 19 | *.tmp 20 | *.bak 21 | *.swp 22 | *~.nib 23 | local.properties 24 | .settings/ 25 | .loadpath 26 | .recommenders 27 | 28 | # External tool builders 29 | .externalToolBuilders/ 30 | 31 | # Locally stored "Eclipse launch configurations" 32 | *.launch 33 | 34 | # PyDev specific (Python IDE for Eclipse) 35 | *.pydevproject 36 | 37 | # CDT-specific (C/C++ Development Tooling) 38 | .cproject 39 | 40 | # CDT- autotools 41 | .autotools 42 | 43 | # Java annotation processor (APT) 44 | .factorypath 45 | 46 | # PDT-specific (PHP Development Tools) 47 | .buildpath 48 | 49 | # sbteclipse plugin 50 | .target 51 | 52 | # Tern plugin 53 | .tern-project 54 | 55 | # TeXlipse plugin 56 | .texlipse 57 | 58 | # STS (Spring Tool Suite) 59 | .springBeans 60 | 61 | # Code Recommenders 62 | .recommenders/ 63 | 64 | # Annotation Processing 65 | .apt_generated/ 66 | .apt_generated_test/ 67 | 68 | # Scala IDE specific (Scala & Java development for Eclipse) 69 | .cache-main 70 | .scala_dependencies 71 | .worksheet 72 | 73 | # Uncomment this line if you wish to ignore the project description file. 74 | # Typically, this file would be tracked if it contains build/dependency configurations: 75 | #.project 76 | 77 | ### Eclipse Patch ### 78 | # Spring Boot Tooling 79 | .sts4-cache/ 80 | 81 | ### Git ### 82 | # Created by git for backups. To disable backups in Git: 83 | # $ git config --global mergetool.keepBackup false 84 | *.orig 85 | 86 | # Created by git when using merge tools for conflicts 87 | *.BACKUP.* 88 | *.BASE.* 89 | *.LOCAL.* 90 | *.REMOTE.* 91 | *_BACKUP_*.txt 92 | *_BASE_*.txt 93 | *_LOCAL_*.txt 94 | *_REMOTE_*.txt 95 | 96 | ### Intellij ### 97 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 98 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 99 | 100 | # User-specific stuff 101 | .idea/**/workspace.xml 102 | .idea/**/tasks.xml 103 | .idea/**/usage.statistics.xml 104 | .idea/**/dictionaries 105 | .idea/**/shelf 106 | 107 | # Generated files 108 | .idea/**/contentModel.xml 109 | 110 | # Sensitive or high-churn files 111 | .idea/**/dataSources/ 112 | .idea/**/dataSources.ids 113 | .idea/**/dataSources.local.xml 114 | .idea/**/sqlDataSources.xml 115 | .idea/**/dynamic.xml 116 | .idea/**/uiDesigner.xml 117 | .idea/**/dbnavigator.xml 118 | 119 | # Gradle 120 | .idea/**/gradle.xml 121 | .idea/**/libraries 122 | 123 | # Gradle and Maven with auto-import 124 | # When using Gradle or Maven with auto-import, you should exclude module files, 125 | # since they will be recreated, and may cause churn. Uncomment if using 126 | # auto-import. 127 | # .idea/artifacts 128 | # .idea/compiler.xml 129 | # .idea/jarRepositories.xml 130 | # .idea/modules.xml 131 | # .idea/*.iml 132 | # .idea/modules 133 | # *.iml 134 | # *.ipr 135 | 136 | # CMake 137 | cmake-build-*/ 138 | 139 | # Mongo Explorer plugin 140 | .idea/**/mongoSettings.xml 141 | 142 | # File-based project format 143 | *.iws 144 | 145 | # IntelliJ 146 | out/ 147 | 148 | # mpeltonen/sbt-idea plugin 149 | .idea_modules/ 150 | 151 | # JIRA plugin 152 | atlassian-ide-plugin.xml 153 | 154 | # Cursive Clojure plugin 155 | .idea/replstate.xml 156 | 157 | # Crashlytics plugin (for Android Studio and IntelliJ) 158 | com_crashlytics_export_strings.xml 159 | crashlytics.properties 160 | crashlytics-build.properties 161 | fabric.properties 162 | 163 | # Editor-based Rest Client 164 | .idea/httpRequests 165 | 166 | # Android studio 3.1+ serialized cache file 167 | .idea/caches/build_file_checksums.ser 168 | 169 | ### Intellij Patch ### 170 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 171 | 172 | *.iml 173 | # modules.xml 174 | .idea/ 175 | # *.ipr 176 | 177 | # Sonarlint plugin 178 | .idea/**/sonarlint/ 179 | 180 | # SonarQube Plugin 181 | .idea/**/sonarIssues.xml 182 | 183 | # Markdown Navigator plugin 184 | .idea/**/markdown-navigator.xml 185 | .idea/**/markdown-navigator-enh.xml 186 | .idea/**/markdown-navigator/ 187 | 188 | # Cache file creation bug 189 | # See https://youtrack.jetbrains.com/issue/JBR-2257 190 | .idea/$CACHE_FILE$ 191 | 192 | ### Java ### 193 | # Compiled class file 194 | *.class 195 | 196 | # Log file 197 | *.log 198 | 199 | # BlueJ files 200 | *.ctxt 201 | 202 | # Mobile Tools for Java (J2ME) 203 | .mtj.tmp/ 204 | 205 | # Package Files # 206 | *.jar 207 | *.war 208 | *.nar 209 | *.ear 210 | *.zip 211 | *.tar.gz 212 | *.rar 213 | 214 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 215 | hs_err_pid* 216 | 217 | ### Maven ### 218 | target/ 219 | pom.xml.tag 220 | pom.xml.releaseBackup 221 | pom.xml.versionsBackup 222 | pom.xml.next 223 | release.properties 224 | dependency-reduced-pom.xml 225 | buildNumber.properties 226 | .mvn/timing.properties 227 | # https://github.com/takari/maven-wrapper#usage-without-binary-jar 228 | .mvn/wrapper/maven-wrapper.jar 229 | 230 | ### VisualStudioCode ### 231 | .vscode/* 232 | !.vscode/settings.json 233 | !.vscode/tasks.json 234 | !.vscode/launch.json 235 | !.vscode/extensions.json 236 | *.code-workspace 237 | 238 | ### VisualStudioCode Patch ### 239 | # Ignore all local history of files 240 | .history 241 | 242 | # End of https://www.toptal.com/developers/gitignore/api/git,java,maven,dotenv,eclipse,intellij,code-java,visualstudiocode -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/impl/mysql/UserImplMySql.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.impl.mysql; 2 | 3 | import com.zaxxer.hikari.HikariDataSource; 4 | import de.biosphere.spoticord.database.dao.UserDao; 5 | 6 | import java.sql.Connection; 7 | import java.sql.PreparedStatement; 8 | import java.sql.ResultSet; 9 | import java.sql.SQLException; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | 13 | public class UserImplMySql implements UserDao { 14 | 15 | private final HikariDataSource hikariDataSource; 16 | 17 | public UserImplMySql(HikariDataSource hikariDataSource) { 18 | this.hikariDataSource = hikariDataSource; 19 | } 20 | 21 | @Override 22 | public Long getListenTime(String guildId) { 23 | return getListenTime(guildId, null); 24 | } 25 | 26 | @Override 27 | public Long getListenTime(String guildId, String userId, Integer lastDays) { 28 | try (final Connection connection = hikariDataSource.getConnection()) { 29 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 30 | ? "SELECT SUM(Tracks.Duration) AS Duration FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=?" 31 | + MySqlDatabase.getTimestampQuery(lastDays) 32 | : "SELECT SUM(Tracks.Duration) AS Duration FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? AND Listens.UserId=?" 33 | + MySqlDatabase.getTimestampQuery(lastDays))) { 34 | preparedStatement.setString(1, guildId); 35 | if (userId != null) { 36 | preparedStatement.setString(2, userId); 37 | } 38 | final ResultSet resultSet = preparedStatement.executeQuery(); 39 | if (resultSet.next()) { 40 | return resultSet.getLong("Duration"); 41 | } 42 | } 43 | } catch (final SQLException ex) { 44 | ex.printStackTrace(); 45 | } 46 | return 0L; 47 | } 48 | 49 | @Override 50 | public Map getTopUsers(String guildId, Integer count, Integer lastDays) { 51 | final Map topMap = new LinkedHashMap<>(); 52 | try (final Connection connection = hikariDataSource.getConnection()) { 53 | try (final PreparedStatement preparedStatement = connection 54 | .prepareStatement("SELECT UserId, COUNT(*) AS Listener FROM `Listens` WHERE GuildId=? " 55 | + MySqlDatabase.getTimestampQuery(lastDays) + "GROUP BY `UserId` ORDER BY COUNT(*) DESC LIMIT ?")) { 56 | preparedStatement.setString(1, guildId); 57 | preparedStatement.setInt(2, count); 58 | 59 | final ResultSet resultSet = preparedStatement.executeQuery(); 60 | 61 | while (resultSet.next()) { 62 | topMap.put(resultSet.getString("UserId"), resultSet.getInt("Listener")); 63 | } 64 | } 65 | } catch (final SQLException ex) { 66 | ex.printStackTrace(); 67 | } 68 | return topMap; 69 | } 70 | 71 | @Override 72 | public Map getTopListenersByTime(String guildId, Integer count, final Integer lastDays) { 73 | final Map topMap = new LinkedHashMap<>(); 74 | try (final Connection connection = hikariDataSource.getConnection()) { 75 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 76 | "SELECT SUM(Tracks.Duration) AS Duration, Listens.UserId FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? " 77 | + MySqlDatabase.getTimestampQuery(lastDays) + " GROUP BY Listens.UserId ORDER BY Duration DESC LIMIT ?")) { 78 | preparedStatement.setString(1, guildId); 79 | preparedStatement.setInt(2, count); 80 | 81 | final ResultSet resultSet = preparedStatement.executeQuery(); 82 | while (resultSet.next()) { 83 | topMap.put(resultSet.getString("UserId"), resultSet.getLong("Duration")); 84 | } 85 | } 86 | } catch (final SQLException ex) { 87 | ex.printStackTrace(); 88 | } 89 | return topMap; 90 | } 91 | 92 | @Override 93 | public void deleteUser(String guildId, String userId) { 94 | try (final Connection connection = hikariDataSource.getConnection()) { 95 | try (final PreparedStatement preparedStatement = connection 96 | .prepareStatement("DELETE FROM Listens WHERE GuildId=? AND UserId=?")) { 97 | preparedStatement.setString(1, guildId); 98 | preparedStatement.setString(2, userId); 99 | preparedStatement.execute(); 100 | } 101 | } catch (SQLException ex) { 102 | ex.printStackTrace(); 103 | } 104 | 105 | } 106 | 107 | @Override 108 | public Long getMostListensTime(String guildId) { 109 | return getMostListensTime(guildId, null); 110 | } 111 | 112 | @Override 113 | public Long getMostListensTime(String guildId, String userId) { 114 | try (final Connection connection = hikariDataSource.getConnection()) { 115 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 116 | ? "SELECT SEC_TO_TIME(AVG(TIME_TO_SEC(cast(Timestamp as Time)))) AS result FROM Listens WHERE GuildId=?" 117 | : "SELECT SEC_TO_TIME(AVG(TIME_TO_SEC(cast(Timestamp as Time)))) AS result FROM Listens WHERE GuildId=? AND UserId=?")) { 118 | preparedStatement.setString(1, guildId); 119 | if (userId != null) { 120 | preparedStatement.setString(2, userId); 121 | } 122 | final ResultSet resultSet = preparedStatement.executeQuery(); 123 | if (resultSet.next() && resultSet.getString("result") != null) { 124 | return resultSet.getTime("result").getTime(); 125 | } 126 | } 127 | } catch (SQLException ex) { 128 | ex.printStackTrace(); 129 | } 130 | return 0L; 131 | } 132 | 133 | } -------------------------------------------------------------------------------- /src/main/java/de/biosphere/spoticord/database/impl/mysql/TrackImplMySql.java: -------------------------------------------------------------------------------- 1 | package de.biosphere.spoticord.database.impl.mysql; 2 | 3 | import com.zaxxer.hikari.HikariDataSource; 4 | import de.biosphere.spoticord.database.dao.TrackDao; 5 | import de.biosphere.spoticord.database.model.SpotifyTrack; 6 | 7 | import java.sql.Connection; 8 | import java.sql.PreparedStatement; 9 | import java.sql.ResultSet; 10 | import java.sql.SQLException; 11 | import java.util.LinkedHashMap; 12 | import java.util.LinkedList; 13 | import java.util.List; 14 | import java.util.Map; 15 | 16 | public class TrackImplMySql implements TrackDao { 17 | 18 | private final HikariDataSource hikariDataSource; 19 | 20 | public TrackImplMySql(HikariDataSource hikariDataSource) { 21 | this.hikariDataSource = hikariDataSource; 22 | } 23 | 24 | @Override 25 | public Integer getTrackAmount() { 26 | try (final Connection connection = hikariDataSource.getConnection()) { 27 | try (final PreparedStatement preparedStatement = connection 28 | .prepareStatement("SELECT COUNT(*) AS Count FROM Tracks")) { 29 | final ResultSet resultSet = preparedStatement.executeQuery(); 30 | if (resultSet.next()) { 31 | return resultSet.getInt("Count"); 32 | } 33 | } 34 | } catch (final SQLException ex) { 35 | ex.printStackTrace(); 36 | } 37 | return 0; 38 | } 39 | 40 | @Override 41 | public Integer getListensAmount(String guildId) { 42 | return getListensAmount(guildId, null); 43 | } 44 | 45 | @Override 46 | public Integer getListensAmount() { 47 | try (final Connection connection = hikariDataSource.getConnection()) { 48 | try (final PreparedStatement preparedStatement = connection 49 | .prepareStatement("SELECT COUNT(*) AS Count FROM `Listens`")) { 50 | final ResultSet resultSet = preparedStatement.executeQuery(); 51 | if (resultSet.next()) { 52 | return resultSet.getInt("Count"); 53 | } 54 | } 55 | } catch (final SQLException ex) { 56 | ex.printStackTrace(); 57 | } 58 | return 0; 59 | } 60 | 61 | @Override 62 | public Integer getListensAmount(String guildId, String userId) { 63 | try (final Connection connection = hikariDataSource.getConnection()) { 64 | try (final PreparedStatement preparedStatement = connection 65 | .prepareStatement(userId == null ? "SELECT COUNT(*) AS Count FROM `Listens` WHERE GuildId=?" 66 | : "SELECT COUNT(*) AS Count FROM `Listens` WHERE GuildId=? AND UserId=?")) { 67 | preparedStatement.setString(1, guildId); 68 | if (userId != null) { 69 | preparedStatement.setString(2, userId); 70 | } 71 | final ResultSet resultSet = preparedStatement.executeQuery(); 72 | if (resultSet.next()) { 73 | return resultSet.getInt("Count"); 74 | } 75 | } 76 | } catch (final SQLException ex) { 77 | ex.printStackTrace(); 78 | } 79 | return 0; 80 | } 81 | 82 | @Override 83 | public void insertTrack(SpotifyTrack spotifyTrack, String userId, String guildId) { 84 | try (final Connection connection = hikariDataSource.getConnection()) { 85 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 86 | "INSERT IGNORE INTO `Tracks` (`Id`, `Artists`, `AlbumImageUrl`, `AlbumTitle`, `TrackTitle`, `Duration`) VALUES (?, ?, ?, ?, ?, ?)")) { 87 | preparedStatement.setString(1, spotifyTrack.id()); 88 | preparedStatement.setString(2, spotifyTrack.artists()); 89 | preparedStatement.setString(3, spotifyTrack.albumImageUrl()); 90 | preparedStatement.setString(4, spotifyTrack.albumTitle()); 91 | preparedStatement.setString(5, spotifyTrack.trackTitle()); 92 | preparedStatement.setLong(6, spotifyTrack.duration()); 93 | preparedStatement.execute(); 94 | } 95 | 96 | try (final PreparedStatement preparedStatement2 = connection.prepareStatement( 97 | "INSERT INTO `Listens` (`Id`, `Timestamp`, `TrackId`, `GuildId`, `UserId`) VALUES (NULL, CURRENT_TIMESTAMP, ?, ?, ?)")) { 98 | preparedStatement2.setString(1, spotifyTrack.id()); 99 | preparedStatement2.setString(2, guildId); 100 | preparedStatement2.setString(3, userId); 101 | preparedStatement2.execute(); 102 | } 103 | } catch (final SQLException ex) { 104 | ex.printStackTrace(); 105 | } 106 | } 107 | 108 | @Override 109 | public Map getTopTracks(String guildId, String userId, Integer count, Integer lastDays) { 110 | final Map topMap = new LinkedHashMap<>(); 111 | try (final Connection connection = hikariDataSource.getConnection()) { 112 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 113 | ? "SELECT Tracks.*, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? " 114 | + MySqlDatabase.getTimestampQuery(lastDays) 115 | + "GROUP BY Listens.`TrackId` ORDER BY COUNT(*) DESC LIMIT ?" 116 | : "SELECT Tracks.*, COUNT(*) AS Listener FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE Listens.GuildId=? AND Listens.UserId=? " 117 | + MySqlDatabase.getTimestampQuery(lastDays) 118 | + "GROUP BY Listens.`TrackId` ORDER BY COUNT(*) DESC LIMIT ?")) { 119 | preparedStatement.setString(1, guildId); 120 | if (userId != null) { 121 | preparedStatement.setString(2, userId); 122 | preparedStatement.setInt(3, count); 123 | } else { 124 | preparedStatement.setInt(2, count); 125 | } 126 | 127 | final ResultSet resultSet = preparedStatement.executeQuery(); 128 | while (resultSet.next()) { 129 | final SpotifyTrack spotifyTrack = getTrackFromResultSet(resultSet); 130 | final Integer listener = resultSet.getInt("Listener"); 131 | topMap.put(spotifyTrack, listener); 132 | } 133 | } 134 | } catch (final SQLException ex) { 135 | ex.printStackTrace(); 136 | } 137 | return topMap; 138 | } 139 | 140 | private SpotifyTrack getTrackFromResultSet(final ResultSet resultSet) throws SQLException { 141 | return new SpotifyTrack(resultSet.getString("Id"), resultSet.getString("Artists"), 142 | resultSet.getString("AlbumTitle"), resultSet.getString("TrackTitle"), 143 | resultSet.getString("AlbumImageUrl"), resultSet.getLong("Duration")); 144 | } 145 | 146 | @Override 147 | public List getLastTracks(String guildId) { 148 | return getLastTracks(guildId, null); 149 | } 150 | 151 | @Override 152 | public List getLastTracks(String guildId, String userId) { 153 | final List tracks = new LinkedList<>(); 154 | try (final Connection connection = hikariDataSource.getConnection()) { 155 | try (final PreparedStatement preparedStatement = connection.prepareStatement(userId == null 156 | ? "SELECT Tracks.* FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE GuildId=? ORDER BY `Listens`.`Timestamp` DESC LIMIT 10" 157 | : "SELECT Tracks.* FROM `Listens` INNER JOIN Tracks ON Listens.TrackId=Tracks.Id WHERE GuildId=? AND UserId=? ORDER BY `Listens`.`Timestamp` DESC LIMIT 10")) { 158 | preparedStatement.setString(1, guildId); 159 | if (userId != null) { 160 | preparedStatement.setString(2, userId); 161 | } 162 | final ResultSet resultSet = preparedStatement.executeQuery(); 163 | while (resultSet.next()) { 164 | final SpotifyTrack spotifyTrack = getTrackFromResultSet(resultSet); 165 | tracks.add(spotifyTrack); 166 | } 167 | } 168 | } catch (SQLException ex) { 169 | ex.printStackTrace(); 170 | } 171 | return tracks; 172 | } 173 | 174 | } -------------------------------------------------------------------------------- /grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "editable": true, 16 | "gnetId": null, 17 | "graphTooltip": 0, 18 | "id": 1, 19 | "iteration": 1602267861528, 20 | "links": [], 21 | "panels": [ 22 | { 23 | "datasource": null, 24 | "description": "", 25 | "fieldConfig": { 26 | "defaults": { 27 | "custom": {}, 28 | "mappings": [], 29 | "noValue": "No value", 30 | "thresholds": { 31 | "mode": "absolute", 32 | "steps": [ 33 | { 34 | "color": "green", 35 | "value": null 36 | }, 37 | { 38 | "color": "yellow", 39 | "value": 150 40 | }, 41 | { 42 | "color": "red", 43 | "value": 200 44 | } 45 | ] 46 | }, 47 | "unit": "ms" 48 | }, 49 | "overrides": [] 50 | }, 51 | "gridPos": { 52 | "h": 7, 53 | "w": 4, 54 | "x": 0, 55 | "y": 0 56 | }, 57 | "id": 3, 58 | "options": { 59 | "colorMode": "value", 60 | "graphMode": "area", 61 | "justifyMode": "auto", 62 | "orientation": "auto", 63 | "reduceOptions": { 64 | "calcs": [ 65 | "last" 66 | ], 67 | "fields": "", 68 | "values": false 69 | }, 70 | "textMode": "auto" 71 | }, 72 | "pluginVersion": "7.2.0", 73 | "targets": [ 74 | { 75 | "expr": "discord_ping_websocket", 76 | "interval": "", 77 | "legendFormat": "", 78 | "refId": "A" 79 | } 80 | ], 81 | "timeFrom": null, 82 | "timeShift": null, 83 | "title": "Discord Gateway Ping", 84 | "type": "stat" 85 | }, 86 | { 87 | "datasource": null, 88 | "description": "", 89 | "fieldConfig": { 90 | "defaults": { 91 | "custom": { 92 | "align": null, 93 | "filterable": false 94 | }, 95 | "mappings": [], 96 | "thresholds": { 97 | "mode": "absolute", 98 | "steps": [ 99 | { 100 | "color": "green", 101 | "value": null 102 | } 103 | ] 104 | } 105 | }, 106 | "overrides": [] 107 | }, 108 | "gridPos": { 109 | "h": 7, 110 | "w": 5, 111 | "x": 4, 112 | "y": 0 113 | }, 114 | "id": 8, 115 | "options": { 116 | "colorMode": "value", 117 | "graphMode": "area", 118 | "justifyMode": "auto", 119 | "orientation": "horizontal", 120 | "reduceOptions": { 121 | "calcs": [ 122 | "lastNotNull" 123 | ], 124 | "fields": "", 125 | "values": false 126 | }, 127 | "textMode": "auto" 128 | }, 129 | "pluginVersion": "7.2.0", 130 | "targets": [ 131 | { 132 | "expr": "total_listen_amount {guild=~\"$guild\"}", 133 | "format": "time_series", 134 | "instant": false, 135 | "interval": "", 136 | "legendFormat": "Listencount: {{guild}}", 137 | "refId": "A" 138 | } 139 | ], 140 | "timeFrom": null, 141 | "timeShift": null, 142 | "title": "Total Listen", 143 | "type": "stat" 144 | }, 145 | { 146 | "datasource": null, 147 | "fieldConfig": { 148 | "defaults": { 149 | "custom": {}, 150 | "mappings": [], 151 | "thresholds": { 152 | "mode": "absolute", 153 | "steps": [ 154 | { 155 | "color": "green", 156 | "value": null 157 | } 158 | ] 159 | } 160 | }, 161 | "overrides": [] 162 | }, 163 | "gridPos": { 164 | "h": 7, 165 | "w": 5, 166 | "x": 9, 167 | "y": 0 168 | }, 169 | "id": 7, 170 | "options": { 171 | "colorMode": "value", 172 | "graphMode": "area", 173 | "justifyMode": "auto", 174 | "orientation": "auto", 175 | "reduceOptions": { 176 | "calcs": [ 177 | "lastNotNull" 178 | ], 179 | "fields": "/^Tracks$/", 180 | "values": false 181 | }, 182 | "textMode": "auto" 183 | }, 184 | "pluginVersion": "7.2.0", 185 | "targets": [ 186 | { 187 | "expr": "total_track_amount", 188 | "interval": "", 189 | "legendFormat": "Tracks", 190 | "refId": "A" 191 | } 192 | ], 193 | "timeFrom": null, 194 | "timeShift": null, 195 | "title": "Total Tracks", 196 | "type": "stat" 197 | }, 198 | { 199 | "datasource": null, 200 | "fieldConfig": { 201 | "defaults": { 202 | "custom": {}, 203 | "mappings": [], 204 | "thresholds": { 205 | "mode": "absolute", 206 | "steps": [ 207 | { 208 | "color": "green", 209 | "value": null 210 | } 211 | ] 212 | } 213 | }, 214 | "overrides": [] 215 | }, 216 | "gridPos": { 217 | "h": 7, 218 | "w": 5, 219 | "x": 14, 220 | "y": 0 221 | }, 222 | "id": 11, 223 | "options": { 224 | "colorMode": "value", 225 | "graphMode": "area", 226 | "justifyMode": "auto", 227 | "orientation": "auto", 228 | "reduceOptions": { 229 | "calcs": [ 230 | "lastNotNull" 231 | ], 232 | "fields": "/^Tracks$/", 233 | "values": false 234 | }, 235 | "textMode": "auto" 236 | }, 237 | "pluginVersion": "7.2.0", 238 | "targets": [ 239 | { 240 | "expr": "total_artist_amount", 241 | "interval": "", 242 | "legendFormat": "Tracks", 243 | "refId": "A" 244 | } 245 | ], 246 | "timeFrom": null, 247 | "timeShift": null, 248 | "title": "Artists", 249 | "type": "stat" 250 | }, 251 | { 252 | "datasource": null, 253 | "description": "", 254 | "fieldConfig": { 255 | "defaults": { 256 | "custom": {}, 257 | "mappings": [], 258 | "thresholds": { 259 | "mode": "absolute", 260 | "steps": [ 261 | { 262 | "color": "green", 263 | "value": null 264 | } 265 | ] 266 | } 267 | }, 268 | "overrides": [] 269 | }, 270 | "gridPos": { 271 | "h": 7, 272 | "w": 5, 273 | "x": 19, 274 | "y": 0 275 | }, 276 | "id": 12, 277 | "options": { 278 | "colorMode": "value", 279 | "graphMode": "area", 280 | "justifyMode": "auto", 281 | "orientation": "auto", 282 | "reduceOptions": { 283 | "calcs": [ 284 | "lastNotNull" 285 | ], 286 | "fields": "/^Tracks$/", 287 | "values": false 288 | }, 289 | "textMode": "auto" 290 | }, 291 | "pluginVersion": "7.2.0", 292 | "targets": [ 293 | { 294 | "expr": "total_album_amount", 295 | "hide": false, 296 | "interval": "", 297 | "legendFormat": "Tracks", 298 | "refId": "A" 299 | } 300 | ], 301 | "timeFrom": null, 302 | "timeShift": null, 303 | "title": "Albums", 304 | "type": "stat" 305 | }, 306 | { 307 | "datasource": null, 308 | "description": "", 309 | "fieldConfig": { 310 | "defaults": { 311 | "custom": {}, 312 | "mappings": [], 313 | "noValue": "No value", 314 | "thresholds": { 315 | "mode": "absolute", 316 | "steps": [ 317 | { 318 | "color": "green", 319 | "value": null 320 | }, 321 | { 322 | "color": "#EAB839", 323 | "value": 150 324 | }, 325 | { 326 | "color": "red", 327 | "value": 250 328 | } 329 | ] 330 | }, 331 | "unit": "ms" 332 | }, 333 | "overrides": [] 334 | }, 335 | "gridPos": { 336 | "h": 7, 337 | "w": 4, 338 | "x": 0, 339 | "y": 7 340 | }, 341 | "id": 2, 342 | "options": { 343 | "colorMode": "value", 344 | "graphMode": "area", 345 | "justifyMode": "auto", 346 | "orientation": "auto", 347 | "reduceOptions": { 348 | "calcs": [ 349 | "last" 350 | ], 351 | "fields": "", 352 | "values": false 353 | }, 354 | "textMode": "auto" 355 | }, 356 | "pluginVersion": "7.2.0", 357 | "repeat": null, 358 | "targets": [ 359 | { 360 | "expr": "discord_ping_rest", 361 | "interval": "", 362 | "legendFormat": "", 363 | "refId": "A" 364 | } 365 | ], 366 | "timeFrom": null, 367 | "timeShift": null, 368 | "title": "Discord Rest Ping", 369 | "type": "stat" 370 | }, 371 | { 372 | "aliasColors": {}, 373 | "bars": false, 374 | "dashLength": 10, 375 | "dashes": false, 376 | "datasource": null, 377 | "decimals": 0, 378 | "description": "", 379 | "fieldConfig": { 380 | "defaults": { 381 | "custom": {}, 382 | "mappings": [], 383 | "thresholds": { 384 | "mode": "absolute", 385 | "steps": [ 386 | { 387 | "color": "green", 388 | "value": null 389 | }, 390 | { 391 | "color": "red", 392 | "value": 80 393 | } 394 | ] 395 | } 396 | }, 397 | "overrides": [] 398 | }, 399 | "fill": 1, 400 | "fillGradient": 0, 401 | "gridPos": { 402 | "h": 10, 403 | "w": 20, 404 | "x": 4, 405 | "y": 7 406 | }, 407 | "hiddenSeries": false, 408 | "id": 10, 409 | "legend": { 410 | "avg": false, 411 | "current": true, 412 | "max": true, 413 | "min": false, 414 | "show": true, 415 | "total": false, 416 | "values": true 417 | }, 418 | "lines": true, 419 | "linewidth": 1, 420 | "nullPointMode": "null as zero", 421 | "options": { 422 | "alertThreshold": true 423 | }, 424 | "percentage": false, 425 | "pluginVersion": "7.2.0", 426 | "pointradius": 2, 427 | "points": false, 428 | "renderer": "flot", 429 | "seriesOverrides": [], 430 | "spaceLength": 10, 431 | "stack": false, 432 | "steppedLine": false, 433 | "targets": [ 434 | { 435 | "expr": "current_listen_members {guild=~\"$guild\"}", 436 | "interval": "", 437 | "legendFormat": "Current listen: {{guild}}", 438 | "refId": "A" 439 | } 440 | ], 441 | "thresholds": [], 442 | "timeFrom": null, 443 | "timeRegions": [], 444 | "timeShift": null, 445 | "title": "Current Listen Members", 446 | "tooltip": { 447 | "shared": true, 448 | "sort": 0, 449 | "value_type": "individual" 450 | }, 451 | "type": "graph", 452 | "xaxis": { 453 | "buckets": null, 454 | "mode": "time", 455 | "name": null, 456 | "show": true, 457 | "values": [] 458 | }, 459 | "yaxes": [ 460 | { 461 | "decimals": 0, 462 | "format": "short", 463 | "label": null, 464 | "logBase": 1, 465 | "max": null, 466 | "min": "0", 467 | "show": true 468 | }, 469 | { 470 | "format": "short", 471 | "label": null, 472 | "logBase": 1, 473 | "max": null, 474 | "min": null, 475 | "show": true 476 | } 477 | ], 478 | "yaxis": { 479 | "align": false, 480 | "alignLevel": null 481 | } 482 | }, 483 | { 484 | "datasource": null, 485 | "fieldConfig": { 486 | "defaults": { 487 | "custom": {}, 488 | "mappings": [], 489 | "thresholds": { 490 | "mode": "absolute", 491 | "steps": [ 492 | { 493 | "color": "green", 494 | "value": null 495 | } 496 | ] 497 | } 498 | }, 499 | "overrides": [] 500 | }, 501 | "gridPos": { 502 | "h": 6, 503 | "w": 4, 504 | "x": 0, 505 | "y": 14 506 | }, 507 | "id": 5, 508 | "options": { 509 | "colorMode": "value", 510 | "graphMode": "area", 511 | "justifyMode": "auto", 512 | "orientation": "auto", 513 | "reduceOptions": { 514 | "calcs": [ 515 | "lastNotNull" 516 | ], 517 | "fields": "", 518 | "values": false 519 | }, 520 | "textMode": "auto" 521 | }, 522 | "pluginVersion": "7.2.0", 523 | "targets": [ 524 | { 525 | "expr": "discord_guilds", 526 | "interval": "", 527 | "legendFormat": "", 528 | "refId": "A" 529 | } 530 | ], 531 | "timeFrom": null, 532 | "timeShift": null, 533 | "title": "Guilds", 534 | "type": "stat" 535 | }, 536 | { 537 | "aliasColors": {}, 538 | "bars": false, 539 | "dashLength": 10, 540 | "dashes": false, 541 | "datasource": null, 542 | "decimals": 0, 543 | "description": "", 544 | "fieldConfig": { 545 | "defaults": { 546 | "custom": { 547 | "align": null, 548 | "filterable": false 549 | }, 550 | "mappings": [], 551 | "thresholds": { 552 | "mode": "absolute", 553 | "steps": [ 554 | { 555 | "color": "green", 556 | "value": null 557 | }, 558 | { 559 | "color": "red", 560 | "value": 80 561 | } 562 | ] 563 | } 564 | }, 565 | "overrides": [] 566 | }, 567 | "fill": 1, 568 | "fillGradient": 0, 569 | "gridPos": { 570 | "h": 10, 571 | "w": 20, 572 | "x": 4, 573 | "y": 17 574 | }, 575 | "hiddenSeries": false, 576 | "id": 13, 577 | "interval": "$TPI", 578 | "legend": { 579 | "avg": true, 580 | "current": false, 581 | "max": true, 582 | "min": false, 583 | "show": true, 584 | "total": false, 585 | "values": true 586 | }, 587 | "lines": true, 588 | "linewidth": 1, 589 | "nullPointMode": "null as zero", 590 | "options": { 591 | "alertThreshold": true 592 | }, 593 | "percentage": false, 594 | "pluginVersion": "7.2.0", 595 | "pointradius": 2, 596 | "points": false, 597 | "renderer": "flot", 598 | "seriesOverrides": [], 599 | "spaceLength": 10, 600 | "stack": false, 601 | "steppedLine": false, 602 | "targets": [ 603 | { 604 | "expr": "increase(tracks_per_minute {guild=~\"$guild\"}[$TPI])", 605 | "format": "time_series", 606 | "instant": false, 607 | "interval": "", 608 | "legendFormat": "Tracks: {{guild}}", 609 | "refId": "A" 610 | } 611 | ], 612 | "thresholds": [], 613 | "timeFrom": null, 614 | "timeRegions": [], 615 | "timeShift": null, 616 | "title": "Tracks per Interval ($TPI)", 617 | "tooltip": { 618 | "shared": true, 619 | "sort": 0, 620 | "value_type": "individual" 621 | }, 622 | "transformations": [], 623 | "type": "graph", 624 | "xaxis": { 625 | "buckets": null, 626 | "mode": "time", 627 | "name": null, 628 | "show": true, 629 | "values": [] 630 | }, 631 | "yaxes": [ 632 | { 633 | "decimals": 0, 634 | "format": "short", 635 | "label": null, 636 | "logBase": 1, 637 | "max": null, 638 | "min": null, 639 | "show": true 640 | }, 641 | { 642 | "format": "short", 643 | "label": null, 644 | "logBase": 1, 645 | "max": null, 646 | "min": null, 647 | "show": true 648 | } 649 | ], 650 | "yaxis": { 651 | "align": false, 652 | "alignLevel": null 653 | } 654 | }, 655 | { 656 | "datasource": null, 657 | "fieldConfig": { 658 | "defaults": { 659 | "custom": { 660 | "align": null, 661 | "displayMode": "auto", 662 | "filterable": false 663 | }, 664 | "mappings": [], 665 | "thresholds": { 666 | "mode": "absolute", 667 | "steps": [ 668 | { 669 | "color": "green", 670 | "value": null 671 | } 672 | ] 673 | }, 674 | "unit": "dthms" 675 | }, 676 | "overrides": [] 677 | }, 678 | "gridPos": { 679 | "h": 7, 680 | "w": 4, 681 | "x": 0, 682 | "y": 20 683 | }, 684 | "id": 14, 685 | "options": { 686 | "colorMode": "value", 687 | "graphMode": "none", 688 | "justifyMode": "auto", 689 | "orientation": "horizontal", 690 | "reduceOptions": { 691 | "calcs": [ 692 | "lastNotNull" 693 | ], 694 | "fields": "", 695 | "values": false 696 | }, 697 | "textMode": "auto" 698 | }, 699 | "pluginVersion": "7.2.0", 700 | "targets": [ 701 | { 702 | "expr": "current_peak_time {guild=~\"$guild\"} / 1000", 703 | "interval": "", 704 | "legendFormat": "Guild: {{guild}}", 705 | "refId": "A" 706 | } 707 | ], 708 | "timeFrom": null, 709 | "timeShift": null, 710 | "title": "Peak Time", 711 | "type": "stat" 712 | } 713 | ], 714 | "refresh": "10s", 715 | "schemaVersion": 26, 716 | "style": "dark", 717 | "tags": [], 718 | "templating": { 719 | "list": [ 720 | { 721 | "allValue": ".*", 722 | "current": { 723 | "selected": true, 724 | "text": [ 725 | "All" 726 | ], 727 | "value": [ 728 | "$__all" 729 | ] 730 | }, 731 | "datasource": "Prometheus", 732 | "definition": "total_listen_amount", 733 | "hide": 0, 734 | "includeAll": true, 735 | "label": "Guild", 736 | "multi": true, 737 | "name": "guild", 738 | "options": [], 739 | "query": "total_listen_amount", 740 | "refresh": 2, 741 | "regex": ".*guild=\"([^\"]*)\".*", 742 | "skipUrlSync": false, 743 | "sort": 1, 744 | "tagValuesQuery": "", 745 | "tags": [], 746 | "tagsQuery": "", 747 | "type": "query", 748 | "useTags": false 749 | }, 750 | { 751 | "auto": false, 752 | "auto_count": 30, 753 | "auto_min": "10s", 754 | "current": { 755 | "selected": false, 756 | "text": "1m", 757 | "value": "1m" 758 | }, 759 | "hide": 0, 760 | "label": "Tracks per Interval", 761 | "name": "TPI", 762 | "options": [ 763 | { 764 | "selected": true, 765 | "text": "1m", 766 | "value": "1m" 767 | }, 768 | { 769 | "selected": false, 770 | "text": "5m", 771 | "value": "5m" 772 | }, 773 | { 774 | "selected": false, 775 | "text": "15m", 776 | "value": "15m" 777 | }, 778 | { 779 | "selected": false, 780 | "text": "30m", 781 | "value": "30m" 782 | }, 783 | { 784 | "selected": false, 785 | "text": "45m", 786 | "value": "45m" 787 | }, 788 | { 789 | "selected": false, 790 | "text": "1h", 791 | "value": "1h" 792 | }, 793 | { 794 | "selected": false, 795 | "text": "2h", 796 | "value": "2h" 797 | }, 798 | { 799 | "selected": false, 800 | "text": "5h", 801 | "value": "5h" 802 | }, 803 | { 804 | "selected": false, 805 | "text": "1d", 806 | "value": "1d" 807 | }, 808 | { 809 | "selected": false, 810 | "text": "1w", 811 | "value": "1w" 812 | } 813 | ], 814 | "query": "1m,5m,15m,30m,45m,1h,2h,5h,1d,1w", 815 | "queryValue": "", 816 | "refresh": 2, 817 | "skipUrlSync": false, 818 | "type": "interval" 819 | } 820 | ] 821 | }, 822 | "time": { 823 | "from": "now-15m", 824 | "to": "now" 825 | }, 826 | "timepicker": { 827 | "refresh_intervals": [ 828 | "5s", 829 | "10s", 830 | "30s", 831 | "1m", 832 | "5m", 833 | "15m", 834 | "30m", 835 | "1h", 836 | "2h", 837 | "1d" 838 | ] 839 | }, 840 | "timezone": "", 841 | "title": "Spoticord", 842 | "uid": "SObBh45Mz", 843 | "version": 57 844 | } --------------------------------------------------------------------------------