diff --git a/build.gradle.kts b/build.gradle.kts index 585c95e..dd0313c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ repositories { } dependencies { + implementation("io.github.crackthecodeabhi:kreds:0.9.1") + // OpenAPI docs implementation("io.ktor:ktor-server-openapi:$ktor_version") diff --git a/src/main/kotlin/io/musicorum/api/Application.kt b/src/main/kotlin/io/musicorum/api/Application.kt index d954ae8..74c8d25 100644 --- a/src/main/kotlin/io/musicorum/api/Application.kt +++ b/src/main/kotlin/io/musicorum/api/Application.kt @@ -8,8 +8,10 @@ import io.musicorum.api.plugins.installStatusPages import io.musicorum.api.koin.installKoin import io.musicorum.api.plugins.configureHTTP import io.musicorum.api.realms.auth.createAuthRoutes +import io.musicorum.api.realms.charts.routes.createChartRoutes import io.musicorum.api.realms.collages.routes.createCollagesRoutes import io.musicorum.api.realms.docs.createDocsRoute +import io.musicorum.api.realms.party.routes.createPartyRoutes import io.musicorum.api.realms.resources.createResourcesRoutes import io.musicorum.api.security.configureSecurity @@ -31,4 +33,7 @@ fun Application.module() { createAuthRoutes() createCollagesRoutes() createDocsRoute() + createChartRoutes() + + createPartyRoutes() } diff --git a/src/main/kotlin/io/musicorum/api/enums/EnvironmentVariable.kt b/src/main/kotlin/io/musicorum/api/enums/EnvironmentVariable.kt index d5c7931..252ff52 100644 --- a/src/main/kotlin/io/musicorum/api/enums/EnvironmentVariable.kt +++ b/src/main/kotlin/io/musicorum/api/enums/EnvironmentVariable.kt @@ -9,4 +9,5 @@ object EnvironmentVariable { const val DatabaseUri = "DATABASE_URI" const val DatabaseUser = "DATABASE_USER" const val DatabasePassword = "DATABASE_PASS" + const val PartiesUrl = "PARTIES_URL" } \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/koin/KoinPlugin.kt b/src/main/kotlin/io/musicorum/api/koin/KoinPlugin.kt index b476f64..11b72d8 100644 --- a/src/main/kotlin/io/musicorum/api/koin/KoinPlugin.kt +++ b/src/main/kotlin/io/musicorum/api/koin/KoinPlugin.kt @@ -3,6 +3,7 @@ package io.musicorum.api.koin import io.ktor.server.application.* import io.musicorum.api.koin.modules.mainModule import io.musicorum.api.realms.auth.authModule +import io.musicorum.api.realms.charts.chartModules import io.musicorum.api.realms.collages.collagesModule import io.musicorum.api.realms.resources.resourcesModule import org.koin.ktor.plugin.Koin @@ -16,5 +17,7 @@ fun Application.installKoin() { modules(resourcesModule) modules(collagesModule) + + modules(chartModules) } } \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/KoinModules.kt b/src/main/kotlin/io/musicorum/api/realms/charts/KoinModules.kt new file mode 100644 index 0000000..1fe0a03 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/KoinModules.kt @@ -0,0 +1,11 @@ +package io.musicorum.api.realms.charts + +import io.musicorum.api.realms.charts.repositories.ChartSnapshotRepository +import io.musicorum.api.realms.charts.repositories.ChartTrackRepository +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val chartModules = module(createdAtStart = true) { + singleOf(::ChartTrackRepository) + singleOf(::ChartSnapshotRepository) +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartSnapshotRepository.kt b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartSnapshotRepository.kt new file mode 100644 index 0000000..42a26fb --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartSnapshotRepository.kt @@ -0,0 +1,48 @@ +package io.musicorum.api.realms.charts.repositories + +import io.musicorum.api.realms.charts.schemas.ChartSnapshot +import io.musicorum.api.services.database +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.javatime.CurrentDateTime +import org.jetbrains.exposed.sql.javatime.datetime +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import java.time.ZoneOffset + +class ChartSnapshotRepository { + object ChartSnapshot : Table("chart_snapshots") { + val id = integer("id").autoIncrement() + val updatedAt = datetime("updated_at") + + override val primaryKey = PrimaryKey(id) + } + + init { + transaction(database) { + SchemaUtils.create(ChartSnapshot) + } + } + + suspend fun createSnapshot(): Int { + return newSuspendedTransaction(Dispatchers.IO) { + return@newSuspendedTransaction ChartSnapshot.insert { + it[updatedAt] = CurrentDateTime + }[ChartSnapshot.id] + } + } + + suspend fun getSnapshot(id: Int): io.musicorum.api.realms.charts.schemas.ChartSnapshot { + return newSuspendedTransaction { + return@newSuspendedTransaction ChartSnapshot.select { ChartSnapshot.id eq id }.map { + ChartSnapshot( + it[ChartSnapshot.id], + it[ChartSnapshot.updatedAt].toEpochSecond(ZoneOffset.UTC) + ) + }.first() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartTrackRepository.kt b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartTrackRepository.kt new file mode 100644 index 0000000..e49e7f9 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/ChartTrackRepository.kt @@ -0,0 +1,57 @@ +package io.musicorum.api.realms.charts.repositories + +import io.musicorum.api.realms.charts.schemas.ChartTrack +import io.musicorum.api.services.database +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction + +class ChartTrackRepository { + object ChartTracks : Table("chart_tracks") { + val id = integer("id").autoIncrement() + val name = text("name") + val playCount = long("play_count") + val snapshotId = integer("snapshot_id").references(ChartSnapshotRepository.ChartSnapshot.id) + val listeners = long("listeners") + val artist = text("artist") + val position = integer("positions") + + override val primaryKey = PrimaryKey(id) + } + + init { + transaction(database) { + SchemaUtils.createMissingTablesAndColumns(ChartTracks) + } + } + + suspend fun insert(track: ChartTrack): Int { + return newSuspendedTransaction { + return@newSuspendedTransaction ChartTracks.insert { + it[name] = track.name + it[playCount] = track.playCount + it[snapshotId] = track.snapshotId + it[listeners] = track.listeners + it[position] = track.position + it[artist] = track.artist + }[ChartTracks.id] + } + } + + suspend fun getAll(snapshotId: Int): List { + return newSuspendedTransaction { + return@newSuspendedTransaction ChartTracks.select { + ChartTracks.snapshotId eq snapshotId + }.map { + ChartTrack( + it[ChartTracks.playCount], + it[ChartTracks.snapshotId], + it[ChartTracks.listeners], + it[ChartTracks.artist], + it[ChartTracks.position], + it[ChartTracks.name] + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/repositories/lastfm/LastFmChartRepository.kt b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/lastfm/LastFmChartRepository.kt new file mode 100644 index 0000000..7b0b9a7 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/repositories/lastfm/LastFmChartRepository.kt @@ -0,0 +1,56 @@ +package io.musicorum.api.realms.charts.repositories.lastfm + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.musicorum.api.enums.EnvironmentVariable +import io.musicorum.api.realms.charts.schemas.lastfm.BaseChartArtistResponse +import io.musicorum.api.realms.charts.schemas.lastfm.BaseChartTrackResponse +import io.musicorum.api.realms.charts.schemas.lastfm.ChartArtist +import io.musicorum.api.realms.charts.schemas.lastfm.ChartTrack +import io.musicorum.api.utils.getRequiredEnv +import kotlinx.serialization.json.Json + +class LastFmChartRepository { + companion object { + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + + defaultRequest { + url("https", "ws.audioscrobbler.com") { + path("2.0") + parameters.append("api_key", getRequiredEnv(EnvironmentVariable.LastfmApiKey)) + parameters.append("format", "json") + + } + } + } + + suspend fun getTopTracks(): List { + val res = client.get { + url { + parameter("method", "chart.gettoptracks") + } + }.body() + + return res.tracks.tracks + } + + suspend fun getTopArtists(): List { + val res = client.get { + url { + parameter("method", "chart.gettopartists") + } + }.body() + + return res.artists.artists + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/routes/Routes.kt b/src/main/kotlin/io/musicorum/api/realms/charts/routes/Routes.kt new file mode 100644 index 0000000..61b3610 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/routes/Routes.kt @@ -0,0 +1,42 @@ +package io.musicorum.api.realms.charts.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.musicorum.api.realms.charts.repositories.ChartSnapshotRepository +import io.musicorum.api.realms.charts.repositories.ChartTrackRepository +import io.musicorum.api.realms.charts.repositories.lastfm.LastFmChartRepository +import io.musicorum.api.realms.charts.schemas.ChartTrack +import kotlinx.serialization.Serializable +import org.koin.ktor.ext.inject + +fun Application.createChartRoutes() { + val chartSnapshotRepository = inject() + val trackRepository = inject() + + routing { + route("/charts") { + route("/positions") { + createUpdatePositionsRoute() + + get { + val snapshotId = call.request.queryParameters["snapshotId"]?.toIntOrNull() + if (snapshotId == null) { + call.respond(HttpStatusCode.BadRequest) + } else { + val tracks = trackRepository.value.getAll(snapshotId) + val lastUpdated = chartSnapshotRepository.value.getSnapshot(snapshotId).updatedAt + call.respond(PositionResponse(lastUpdated, tracks)) + } + } + } + } + } +} + +@Serializable +private data class PositionResponse( + val updatedAt: Long, + val trackEntries: List +) diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/routes/UpdatePositions.kt b/src/main/kotlin/io/musicorum/api/realms/charts/routes/UpdatePositions.kt new file mode 100644 index 0000000..07aff63 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/routes/UpdatePositions.kt @@ -0,0 +1,40 @@ +package io.musicorum.api.realms.charts.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.musicorum.api.realms.charts.repositories.ChartSnapshotRepository +import io.musicorum.api.realms.charts.repositories.ChartTrackRepository +import io.musicorum.api.realms.charts.repositories.lastfm.LastFmChartRepository +import org.koin.ktor.ext.inject + +fun Route.createUpdatePositionsRoute() { + val chartTrackRepository = inject() + val chartSnapshotRepository = inject() + + route("/update") { + post { + val topTracks = LastFmChartRepository.getTopTracks() + val topArtists = LastFmChartRepository.getTopArtists() + + val snapshotId = chartSnapshotRepository.value.createSnapshot() + + var trackPos = 1 + for (track in topTracks) { + chartTrackRepository.value.insert( + io.musicorum.api.realms.charts.schemas.ChartTrack( + name = track.name, + playCount = track.playCount.toLong(), + listeners = track.listeners.toLong(), + position = trackPos++, + artist = track.artist.name, + snapshotId = snapshotId + ) + ) + } + + call.respond(HttpStatusCode.Created) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartSnapshot.kt b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartSnapshot.kt new file mode 100644 index 0000000..ffa0e6a --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartSnapshot.kt @@ -0,0 +1,9 @@ +package io.musicorum.api.realms.charts.schemas + +import kotlinx.serialization.Serializable + +@Serializable +data class ChartSnapshot( + val id: Int, + val updatedAt: Long +) diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartTrack.kt b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartTrack.kt new file mode 100644 index 0000000..0946dfe --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/ChartTrack.kt @@ -0,0 +1,14 @@ +package io.musicorum.api.realms.charts.schemas + +import kotlinx.serialization.Serializable +import java.math.BigInteger + +@Serializable +data class ChartTrack( + val playCount: Long, + val snapshotId: Int, + val listeners: Long, + val artist: String, + val position: Int, + val name: String +) \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartArtist.kt b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartArtist.kt new file mode 100644 index 0000000..26aeaf3 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartArtist.kt @@ -0,0 +1,24 @@ +package io.musicorum.api.realms.charts.schemas.lastfm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseChartArtistResponse( + val artists: ChartArtistResponse +) + +@Serializable +data class ChartArtistResponse( + @SerialName("artist") + val artists: List +) + + +@Serializable +data class ChartArtist( + val name: String, + @SerialName("playcount") + val playCount: String, + val listeners: String +) \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartTrack.kt b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartTrack.kt new file mode 100644 index 0000000..46879a4 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/charts/schemas/lastfm/ChartTrack.kt @@ -0,0 +1,29 @@ +package io.musicorum.api.realms.charts.schemas.lastfm + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseChartTrackResponse( + val tracks: ChartTrackResponse +) + +@Serializable +data class ChartTrackResponse( + @SerialName("track") + val tracks: List +) + +@Serializable +data class ChartTrack( + val name: String, + @SerialName("playcount") + val playCount: String, + val listeners: String, + val artist: Artist +) + +@Serializable +data class Artist( + val name: String +) diff --git a/src/main/kotlin/io/musicorum/api/realms/party/routes/Create.kt b/src/main/kotlin/io/musicorum/api/realms/party/routes/Create.kt new file mode 100644 index 0000000..255dc94 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/routes/Create.kt @@ -0,0 +1,24 @@ +package io.musicorum.api.realms.party.routes + +import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.github.crackthecodeabhi.kreds.connection.newClient +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.musicorum.api.enums.EnvironmentVariable +import io.musicorum.api.realms.party.schemas.PartyResponse +import io.musicorum.api.utils.generateNanoId +import io.musicorum.api.utils.getRequiredEnv + +internal fun Route.createCreateRoute() { + val redisEndpoint = Endpoint.from(getRequiredEnv(EnvironmentVariable.PartiesUrl)) + post { + newClient(redisEndpoint).use { + val roomId = generateNanoId(15) + val userId = generateNanoId(10) + it.sadd("users:$roomId", userId) + it.set("owner:$roomId", userId) + call.respond(PartyResponse(roomId, userId)) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/party/routes/GetStats.kt b/src/main/kotlin/io/musicorum/api/realms/party/routes/GetStats.kt new file mode 100644 index 0000000..993f5c4 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/routes/GetStats.kt @@ -0,0 +1,38 @@ +package io.musicorum.api.realms.party.routes + +import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.github.crackthecodeabhi.kreds.connection.newClient +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.musicorum.api.enums.EnvironmentVariable +import io.musicorum.api.realms.party.schemas.PartyStatus +import io.musicorum.api.realms.party.schemas.PartyUser +import io.musicorum.api.security.AuthenticationMethod +import io.musicorum.api.utils.getRequiredEnv + +internal fun Route.createStatsRoute() { + val redisEndpoint = Endpoint.from(getRequiredEnv(EnvironmentVariable.PartiesUrl)) + authenticate(AuthenticationMethod.Super, AuthenticationMethod.Client) { + get { + val roomId = call.parameters["id"] + val partyUsers = mutableListOf() + newClient(redisEndpoint).use { + val users = it.smembers("users:$roomId") + if (users.isEmpty()) { + call.respond(HttpStatusCode.NotFound) + return@use + } + val owner = it.get("owner:$roomId") ?: "" + val ownerName = it.hget("sessions:$owner", "username") ?: "" + users.forEach { uid -> + val username = it.hget("sessions:$uid", "username") ?: "" + partyUsers.add(PartyUser(id = uid, username = username)) + } + call.respond(PartyStatus(owner = PartyUser(owner, ownerName), users = partyUsers)) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/party/routes/Join.kt b/src/main/kotlin/io/musicorum/api/realms/party/routes/Join.kt new file mode 100644 index 0000000..c058c20 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/routes/Join.kt @@ -0,0 +1,29 @@ +package io.musicorum.api.realms.party.routes + +import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.github.crackthecodeabhi.kreds.connection.newClient +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.musicorum.api.enums.EnvironmentVariable +import io.musicorum.api.realms.party.schemas.PartyResponse +import io.musicorum.api.utils.generateNanoId +import io.musicorum.api.utils.getRequiredEnv + +internal fun Route.createJoinRoute() { + val redisEndpoint = Endpoint.from(getRequiredEnv(EnvironmentVariable.PartiesUrl)) + + post { + val roomId = call.parameters["id"] + newClient(redisEndpoint).use { + if (it.keys("users:$roomId").isEmpty()) { + call.respond(HttpStatusCode.NotFound) + return@use + } + val clientId = generateNanoId(10) + it.sadd("users:$roomId", clientId) + call.respond(PartyResponse(null, clientId)) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/party/routes/Routes.kt b/src/main/kotlin/io/musicorum/api/realms/party/routes/Routes.kt new file mode 100644 index 0000000..e5e9dab --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/routes/Routes.kt @@ -0,0 +1,25 @@ +package io.musicorum.api.realms.party.routes + +import io.github.crackthecodeabhi.kreds.connection.Endpoint +import io.ktor.server.application.* +import io.ktor.server.routing.* +import io.musicorum.api.enums.EnvironmentVariable +import io.musicorum.api.utils.getRequiredEnv + +fun Application.createPartyRoutes() { + val redisEndpoint = Endpoint.from(getRequiredEnv(EnvironmentVariable.PartiesUrl)) + routing { + route("party") { + route("{id}") { + createStatsRoute() + + route("/join") { + createJoinRoute() + } + } + route("create") { + createCreateRoute() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyResponse.kt b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyResponse.kt new file mode 100644 index 0000000..b31ab93 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyResponse.kt @@ -0,0 +1,9 @@ +package io.musicorum.api.realms.party.schemas + +import kotlinx.serialization.Serializable + +@Serializable +data class PartyResponse( + val roomId: String?, + val userSessionId: String, +) diff --git a/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyStatus.kt b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyStatus.kt new file mode 100644 index 0000000..f34ab64 --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyStatus.kt @@ -0,0 +1,9 @@ +package io.musicorum.api.realms.party.schemas + +import kotlinx.serialization.Serializable + +@Serializable +data class PartyStatus( + val owner: PartyUser, + val users: List, +) diff --git a/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyUser.kt b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyUser.kt new file mode 100644 index 0000000..cdb789a --- /dev/null +++ b/src/main/kotlin/io/musicorum/api/realms/party/schemas/PartyUser.kt @@ -0,0 +1,9 @@ +package io.musicorum.api.realms.party.schemas + +import kotlinx.serialization.Serializable + +@Serializable +data class PartyUser( + val id: String, + val username: String +)