diff --git a/newm-server/src/main/kotlin/io/newm/server/config/repo/ConfigRepository.kt b/newm-server/src/main/kotlin/io/newm/server/config/repo/ConfigRepository.kt index 11237afa..5ee86d39 100644 --- a/newm-server/src/main/kotlin/io/newm/server/config/repo/ConfigRepository.kt +++ b/newm-server/src/main/kotlin/io/newm/server/config/repo/ConfigRepository.kt @@ -81,5 +81,6 @@ interface ConfigRepository { const val CONFIG_KEY_CLIENT_CONFIG_STUDIO = "clientConfig.studio" const val CONFIG_KEY_CLIENT_CONFIG_MARKETPLACE = "clientConfig.marketplace" const val CONFIG_KEY_CLIENT_CONFIG_MOBILE = "clientConfig.mobile" + const val CONFIG_KEY_SONG_SMART_LINKS_CACHE_TTL = "songSmartLinks.cacheTimeToLive" } } diff --git a/newm-server/src/main/kotlin/io/newm/server/database/migration/V72__CreateSongSmartLinks.kt b/newm-server/src/main/kotlin/io/newm/server/database/migration/V72__CreateSongSmartLinks.kt new file mode 100644 index 00000000..844941e0 --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/database/migration/V72__CreateSongSmartLinks.kt @@ -0,0 +1,29 @@ +package io.newm.server.database.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.jetbrains.exposed.sql.transactions.transaction + +@Suppress("unused") +class V72__CreateSongSmartLinks : BaseJavaMigration() { + override fun migrate(context: Context?) { + transaction { + execInBatch( + listOf( + """ + CREATE TABLE IF NOT EXISTS song_smart_links ( + id uuid PRIMARY KEY, + created_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + song_id uuid NOT NULL, + store_name TEXT NOT NULL, + url TEXT NOT NULL, + CONSTRAINT fk_song_smart_links_song_id__id FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE NO ACTION + ) + """.trimIndent(), + "CREATE INDEX IF NOT EXISTS song_smart_links_song_id_index ON song_smart_links(song_id)", + "INSERT INTO config VALUES ('songSmartLinks.cacheTimeToLive','172800') ON CONFLICT(id) DO NOTHING", + ), + ) + } + } +} diff --git a/newm-server/src/main/kotlin/io/newm/server/features/distribution/DistributionRepository.kt b/newm-server/src/main/kotlin/io/newm/server/features/distribution/DistributionRepository.kt index 0ef85e85..7ad7ea5e 100644 --- a/newm-server/src/main/kotlin/io/newm/server/features/distribution/DistributionRepository.kt +++ b/newm-server/src/main/kotlin/io/newm/server/features/distribution/DistributionRepository.kt @@ -1,14 +1,45 @@ package io.newm.server.features.distribution import io.newm.server.features.collaboration.model.Collaboration -import io.newm.server.features.distribution.model.* +import io.newm.server.features.distribution.model.AddAlbumResponse +import io.newm.server.features.distribution.model.AddArtistRequest +import io.newm.server.features.distribution.model.AddArtistResponse +import io.newm.server.features.distribution.model.AddParticipantResponse +import io.newm.server.features.distribution.model.AddTrackResponse +import io.newm.server.features.distribution.model.AddUserLabelResponse +import io.newm.server.features.distribution.model.AddUserResponse +import io.newm.server.features.distribution.model.AddUserSubscriptionResponse +import io.newm.server.features.distribution.model.DeleteUserLabelResponse +import io.newm.server.features.distribution.model.DistributeReleaseResponse +import io.newm.server.features.distribution.model.DistributionOutletReleaseStatusResponse +import io.newm.server.features.distribution.model.EvearaSimpleResponse +import io.newm.server.features.distribution.model.GetAlbumResponse +import io.newm.server.features.distribution.model.GetArtistResponse +import io.newm.server.features.distribution.model.GetCountriesResponse +import io.newm.server.features.distribution.model.GetGenresResponse +import io.newm.server.features.distribution.model.GetLanguagesResponse +import io.newm.server.features.distribution.model.GetOutletProfileNamesResponse +import io.newm.server.features.distribution.model.GetOutletsResponse +import io.newm.server.features.distribution.model.GetParticipantsResponse +import io.newm.server.features.distribution.model.GetPayoutBalanceResponse +import io.newm.server.features.distribution.model.GetPayoutHistoryResponse +import io.newm.server.features.distribution.model.GetRolesResponse +import io.newm.server.features.distribution.model.GetTracksResponse +import io.newm.server.features.distribution.model.GetUserLabelResponse +import io.newm.server.features.distribution.model.GetUserResponse +import io.newm.server.features.distribution.model.GetUserSubscriptionResponse +import io.newm.server.features.distribution.model.InitiatePayoutResponse +import io.newm.server.features.distribution.model.SmartLink +import io.newm.server.features.distribution.model.UpdateArtistRequest +import io.newm.server.features.distribution.model.UpdateArtistResponse +import io.newm.server.features.distribution.model.UpdateUserLabelResponse +import io.newm.server.features.distribution.model.ValidateAlbumResponse import io.newm.server.features.song.model.Release import io.newm.server.features.song.model.Song import io.newm.server.features.user.model.User import io.newm.server.typealiases.UserId import java.io.File import java.time.LocalDate -import java.util.* /** * Higher level api for working with a music distribution service @@ -162,4 +193,9 @@ interface DistributionRepository { suspend fun createDistributionUserIfNeeded(user: User) suspend fun createDistributionSubscription(user: User) + + suspend fun getSmartLinks( + distributionUserId: String, + distributionReleaseId: Long + ): List } diff --git a/newm-server/src/main/kotlin/io/newm/server/features/distribution/eveara/EvearaDistributionRepositoryImpl.kt b/newm-server/src/main/kotlin/io/newm/server/features/distribution/eveara/EvearaDistributionRepositoryImpl.kt index ffe9e2a0..9af71090 100644 --- a/newm-server/src/main/kotlin/io/newm/server/features/distribution/eveara/EvearaDistributionRepositoryImpl.kt +++ b/newm-server/src/main/kotlin/io/newm/server/features/distribution/eveara/EvearaDistributionRepositoryImpl.kt @@ -4,7 +4,6 @@ import com.github.benmanes.caffeine.cache.Caffeine import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ServerResponseException -import io.ktor.client.plugins.retry import io.ktor.client.plugins.timeout import io.ktor.client.request.accept import io.ktor.client.request.bearerAuth @@ -70,6 +69,7 @@ import io.newm.server.features.distribution.model.GetParticipantsResponse import io.newm.server.features.distribution.model.GetPayoutBalanceResponse import io.newm.server.features.distribution.model.GetPayoutHistoryResponse import io.newm.server.features.distribution.model.GetRolesResponse +import io.newm.server.features.distribution.model.GetSmartLinksResponse import io.newm.server.features.distribution.model.GetTrackStatusResponse import io.newm.server.features.distribution.model.GetTracksResponse import io.newm.server.features.distribution.model.GetUserLabelResponse @@ -81,6 +81,7 @@ import io.newm.server.features.distribution.model.OutletStatusCode import io.newm.server.features.distribution.model.OutletsDetail import io.newm.server.features.distribution.model.Participant import io.newm.server.features.distribution.model.Preview +import io.newm.server.features.distribution.model.SmartLink import io.newm.server.features.distribution.model.Subscription import io.newm.server.features.distribution.model.Track import io.newm.server.features.distribution.model.UpdateArtistRequest @@ -1761,6 +1762,26 @@ class EvearaDistributionRepositoryImpl( } } + override suspend fun getSmartLinks( + distributionUserId: String, + distributionReleaseId: Long + ): List { + val httpResponse = httpClient.get("$evearaApiBaseUrl/smartlinks/$distributionReleaseId") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + bearerAuth(getEvearaApiToken()) + parameter("uuid", distributionUserId) + } + if (!httpResponse.status.isSuccess()) { + throw ServerResponseException(httpResponse, "Error getting smart-links: ${httpResponse.bodyAsText()}") + } + val response: GetSmartLinksResponse = httpResponse.body() + if (!response.success) { + throw ServerResponseException(httpResponse, "Error getting smart-links: success==false") + } + return response.data.orEmpty() + } + private suspend fun createDistributionParticipants( user: User, collabs: List diff --git a/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/GetSmartLinksResponse.kt b/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/GetSmartLinksResponse.kt new file mode 100644 index 00000000..1ee64c6a --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/GetSmartLinksResponse.kt @@ -0,0 +1,14 @@ +package io.newm.server.features.distribution.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetSmartLinksResponse( + @SerialName("success") + val success: Boolean, + @SerialName("message") + val message: String? = null, + @SerialName("data") + val data: List? = null, +) diff --git a/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/SmartLink.kt b/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/SmartLink.kt new file mode 100644 index 00000000..90e8c8f9 --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/features/distribution/model/SmartLink.kt @@ -0,0 +1,12 @@ +package io.newm.server.features.distribution.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SmartLink( + @SerialName("store_name") + val storeName: String, + @SerialName("smart_link_url") + val url: String +) diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/SongRoutes.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/SongRoutes.kt index f0decbd5..68ad9222 100644 --- a/newm-server/src/main/kotlin/io/newm/server/features/song/SongRoutes.kt +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/SongRoutes.kt @@ -189,6 +189,9 @@ fun Routing.createSongRoutes() { ) respond(HttpStatusCode.NoContent) } + get("smartlinks") { + respond(songRepository.getSmartLinks(songId)) + } } } } diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkEntity.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkEntity.kt new file mode 100644 index 00000000..b0a72470 --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkEntity.kt @@ -0,0 +1,29 @@ +package io.newm.server.features.song.database + +import io.newm.server.features.song.model.SongSmartLink +import io.newm.server.typealiases.SongId +import org.jetbrains.exposed.dao.UUIDEntity +import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import java.time.LocalDateTime +import java.util.UUID + +class SongSmartLinkEntity( + id: EntityID +) : UUIDEntity(id) { + val createdAt: LocalDateTime by SongSmartLinkTable.createdAt + var songId: EntityID by SongSmartLinkTable.songId + var storeName: String by SongSmartLinkTable.storeName + var url: String by SongSmartLinkTable.url + + fun toModel(): SongSmartLink = SongSmartLink( + id = id.value, + storeName = storeName, + url = url + ) + + companion object : UUIDEntityClass(SongSmartLinkTable) { + fun findBySongId(songId: SongId): List = + find { SongSmartLinkTable.songId eq songId }.toList() + } +} diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkTable.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkTable.kt new file mode 100644 index 00000000..ac63067f --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/database/SongSmartLinkTable.kt @@ -0,0 +1,17 @@ +package io.newm.server.features.song.database + +import io.newm.server.typealiases.SongId +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.UUIDTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.javatime.CurrentDateTime +import org.jetbrains.exposed.sql.javatime.datetime +import java.time.LocalDateTime + +object SongSmartLinkTable : UUIDTable(name = "song_smart_links") { + val createdAt: Column = datetime("created_at").defaultExpression(CurrentDateTime) + val songId: Column> = reference("song_id", SongTable, onDelete = ReferenceOption.NO_ACTION).index() + val storeName: Column = text("store_name") + val url: Column = text("url") +} diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/model/SongSmartLink.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/model/SongSmartLink.kt new file mode 100644 index 00000000..ab514008 --- /dev/null +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/model/SongSmartLink.kt @@ -0,0 +1,13 @@ +package io.newm.server.features.song.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class SongSmartLink( + @Contextual + val id: UUID, + val storeName: String, + val url: String +) diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepository.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepository.kt index 2e74494f..608a8883 100644 --- a/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepository.kt +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepository.kt @@ -8,9 +8,10 @@ import io.newm.server.features.song.model.AudioUploadReport import io.newm.server.features.song.model.MintPaymentResponse import io.newm.server.features.song.model.MintingStatus import io.newm.server.features.song.model.RefundPaymentResponse +import io.newm.server.features.song.model.Release import io.newm.server.features.song.model.Song import io.newm.server.features.song.model.SongFilters -import io.newm.server.features.song.model.Release +import io.newm.server.features.song.model.SongSmartLink import io.newm.server.typealiases.ReleaseId import io.newm.server.typealiases.SongId import io.newm.server.typealiases.UserId @@ -116,4 +117,6 @@ interface SongRepository { songId: SongId, mintPaymentResponse: MintPaymentResponse ) + + suspend fun getSmartLinks(songId: SongId): List } diff --git a/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepositoryImpl.kt b/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepositoryImpl.kt index f9dfe151..513b5f2f 100644 --- a/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepositoryImpl.kt +++ b/newm-server/src/main/kotlin/io/newm/server/features/song/repo/SongRepositoryImpl.kt @@ -17,6 +17,7 @@ import io.newm.server.aws.s3.s3UrlStringOf import io.newm.server.config.repo.ConfigRepository import io.newm.server.config.repo.ConfigRepository.Companion.CONFIG_KEY_DISTRIBUTION_PRICE_USD import io.newm.server.config.repo.ConfigRepository.Companion.CONFIG_KEY_MINT_PRICE +import io.newm.server.config.repo.ConfigRepository.Companion.CONFIG_KEY_SONG_SMART_LINKS_CACHE_TTL import io.newm.server.features.cardano.database.KeyTable import io.newm.server.features.cardano.model.Key import io.newm.server.features.cardano.repo.CardanoRepository @@ -33,6 +34,7 @@ import io.newm.server.features.song.database.SongEntity import io.newm.server.features.song.database.SongErrorHistoryTable import io.newm.server.features.song.database.SongReceiptEntity import io.newm.server.features.song.database.SongReceiptTable +import io.newm.server.features.song.database.SongSmartLinkEntity import io.newm.server.features.song.database.SongTable import io.newm.server.features.song.model.AudioEncodingStatus import io.newm.server.features.song.model.AudioStreamData @@ -44,6 +46,7 @@ import io.newm.server.features.song.model.Release import io.newm.server.features.song.model.ReleaseType import io.newm.server.features.song.model.Song import io.newm.server.features.song.model.SongFilters +import io.newm.server.features.song.model.SongSmartLink import io.newm.server.features.user.database.UserEntity import io.newm.server.features.user.database.UserTable import io.newm.server.features.user.model.UserVerificationStatus @@ -856,6 +859,47 @@ internal class SongRepositoryImpl( } } + override suspend fun getSmartLinks(songId: SongId): List { + logger.debug { "getSmartLinks: songId = $songId" } + val minCreatedAt = LocalDateTime.now() + .minusSeconds(configRepository.getLong(CONFIG_KEY_SONG_SMART_LINKS_CACHE_TTL)) + val cachedSmartLinks = transaction { + with(SongSmartLinkEntity.findBySongId(songId)) { + takeIf { any { it.createdAt >= minCreatedAt } } ?: run { + forEach { it.delete() } + null + } + } + } + if (!cachedSmartLinks.isNullOrEmpty()) { + logger.debug { "Found ${cachedSmartLinks.size} cached smart-links" } + return cachedSmartLinks.map { it.toModel() } + } + val (distributionUserId, distributionReleaseId) = transaction { + SongEntity[songId].run { + UserEntity[ownerId].distributionUserId to releaseId?.let { ReleaseEntity[it].distributionReleaseId } + } + } + if (distributionUserId == null || distributionReleaseId == null) { + logger.debug { "No distribution: userId = $distributionUserId, releaseId = $distributionReleaseId" } + return emptyList() + } + + val networkSmartLinks = distributionRepository.getSmartLinks(distributionUserId, distributionReleaseId) + .filter { it.url.isNotEmpty() } + logger.debug { "Found ${networkSmartLinks.size} network smart-links" } + return transaction { + val songEntityId = EntityID(songId, SongTable) + networkSmartLinks.map { + SongSmartLinkEntity.new { + this.songId = songEntityId + this.storeName = it.storeName + this.url = it.url + } + } + }.map { it.toModel() } + } + private suspend fun sendMintingStartedNotification(songId: SongId) { collaborationRepository.invite(songId) sendMintingNotification("started", songId)