├── .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 | [](https://github.com/biospheere/spoticord/actions)
2 | [](https://github.com/Biospheere/spoticord/graphs/contributors/)
3 | [](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 | 
46 | 
47 | 
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 extends Command> 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 | }
--------------------------------------------------------------------------------