diff --git a/src/main/xerus/monstercat/api/APIConnection.kt b/src/main/xerus/monstercat/api/APIConnection.kt index 6a55140..db2eb81 100644 --- a/src/main/xerus/monstercat/api/APIConnection.kt +++ b/src/main/xerus/monstercat/api/APIConnection.kt @@ -11,6 +11,7 @@ import org.apache.http.client.config.CookieSpecs import org.apache.http.client.config.RequestConfig import org.apache.http.client.methods.CloseableHttpResponse import org.apache.http.client.methods.HttpGet +import org.apache.http.client.methods.HttpPatch import org.apache.http.client.methods.HttpPost import org.apache.http.client.methods.HttpUriRequest import org.apache.http.client.protocol.HttpClientContext @@ -26,10 +27,7 @@ import xerus.ktutil.javafx.properties.SimpleObservable import xerus.ktutil.javafx.properties.listen import xerus.monstercat.Settings import xerus.monstercat.Sheets -import xerus.monstercat.api.response.ReleaseResponse -import xerus.monstercat.api.response.Session -import xerus.monstercat.api.response.TrackResponse -import xerus.monstercat.api.response.declaredKeys +import xerus.monstercat.api.response.* import xerus.monstercat.downloader.CONNECTSID import xerus.monstercat.downloader.QUALITY import java.io.IOException @@ -80,6 +78,9 @@ class APIConnection(vararg path: String): HTTPQuery() { fun getTracks() = parseJSON(TrackResponse::class.java)?.results + fun getPlaylists() = + parseJSON(PlaylistResponse::class.java)?.results + private var httpRequest: HttpUriRequest? = null /** Aborts this connection and thus terminates the InputStream if active */ fun abort() { @@ -234,6 +235,37 @@ class APIConnection(vararg path: String): HTTPQuery() { CONNECTSID.clear() } + private fun convertTracklist(tracks: List) = tracks.map { HashMap().apply { this["trackId"] = it.id; this["releaseId"] = it.release.id } } + + fun editPlaylist(id: String, tracks: List? = null, name: String? = null, public: Boolean? = null, deleted: Boolean? = null) { + val json = HashMap() + tracks?.also { json["tracks"] = convertTracklist(it) } + name?.also { json["name"] = it } + public?.also { json["public"] = it } + deleted?.also { json["deleted"] = it } + val connection = APIConnection("v2", "playlist", id) + val request = HttpPatch(connection.uri).apply { + setHeader("Content-Type", "application/json") + val content = Sheets.JSON_FACTORY.toString(json) + entity = StringEntity(content) + } + connection.execute(request) + } + + fun createPlaylist(name: String, tracks: List, public: Boolean = false) { + val connection = APIConnection("v2", "self", "playlist") + val request = HttpPost(connection.uri).apply { + setHeader("Content-Type", "application/json") + val json = HashMap() + json["name"] = name + json["public"] = public + json["tracks"] = convertTracklist(tracks) + val content = Sheets.JSON_FACTORY.toString(json) + entity = StringEntity(content) + } + connection.execute(request) + } + data class ConnectResult(val connectsid: String, val validity: ConnectValidity, val session: Session?) } diff --git a/src/main/xerus/monstercat/api/Playlist.kt b/src/main/xerus/monstercat/api/Playlist.kt index a92e709..5bf4c1e 100644 --- a/src/main/xerus/monstercat/api/Playlist.kt +++ b/src/main/xerus/monstercat/api/Playlist.kt @@ -3,17 +3,32 @@ package xerus.monstercat.api import javafx.beans.property.SimpleBooleanProperty import javafx.collections.FXCollections import javafx.collections.ObservableList +import javafx.scene.control.* +import javafx.scene.input.MouseButton +import javafx.scene.layout.VBox +import javafx.scene.media.MediaPlayer +import javafx.stage.Modality +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import mu.KotlinLogging +import xerus.ktutil.ifNull +import xerus.ktutil.javafx.* import xerus.ktutil.javafx.properties.SimpleObservable import xerus.ktutil.javafx.properties.bindSoft +import xerus.ktutil.javafx.ui.App +import xerus.monstercat.api.response.ConnectPlaylist import xerus.monstercat.api.response.Track +import xerus.monstercat.monsterUtilities import java.util.* import kotlin.random.Random import kotlin.random.nextInt +private val logger = KotlinLogging.logger {} + object Playlist { - val logger = KotlinLogging.logger { } - val tracks: ObservableList = FXCollections.observableArrayList() val history = ArrayDeque() val currentIndex = SimpleObservable(null).apply { @@ -52,6 +67,8 @@ object Playlist { tracks.add(track) } + fun addAll(tracks: ArrayList) = this.tracks.addAll(tracks) + fun removeAt(index: Int?) { tracks.removeAt(index ?: tracks.size - 1) } @@ -67,13 +84,9 @@ object Playlist { } fun getNextTrackRandom(): Track { - return if(tracks.size <= 1) { - tracks[0] - } else { - var index = Random.nextInt(0..tracks.lastIndex) - if(index >= currentIndex.value!!) index++ - tracks[index] - } + return if (tracks.size <= 1) tracks[0] + else tracks[ + Random.nextInt(0..tracks.lastIndex).let { if(it >= currentIndex.value!!) it + 1 else it } ] } fun getNextTrack(): Track? { @@ -91,3 +104,205 @@ object Playlist { this.tracks.addAll(if(asNext) currentIndex.value?.plus(1) ?: 0 else this.tracks.lastIndex.coerceAtLeast(0), tracks) } } + +object PlaylistManager { + suspend fun loadPlaylist(apiConnection: APIConnection) { + val tracks = apiConnection.getTracks() + tracks?.forEachIndexed { index, track -> + val found = Cache.getTracks().find { it.id == track.id } + if(found != null) + tracks[index] = found + else { + tracks.removeAt(index) + logger.error("Skipped track ${track.artistsTitle} - ${track.title} with id ${track.id}: Not found in the cache.") + } + } + if(tracks != null && tracks.isNotEmpty()) { + if(Player.player?.status != MediaPlayer.Status.DISPOSED) + Player.reset() + Playlist.setTracks(tracks) + } + } + + /** Opens the playlist manager dialog + * Allows to load, save, and manage playlists stored on Monstercat.com + */ + fun playlistDialog() { + val connection = APIConnection("api", "playlist").fields(ConnectPlaylist::class) + + val parent = VBox() + val stage = App.stage.createStage("Monstercat.com Playlists", parent) + stage.initModality(Modality.WINDOW_MODAL) + val connectTable = TableView() + + // Common playlist functions + fun load() = GlobalScope.launch { loadPlaylist(APIConnection("api", "playlist", connectTable.selectionModel.selectedItem.id, "tracks")) } + + fun loadUrl(): Job { + val subParent = VBox() + val subStage = stage.createStage("Load from URL", subParent) + subStage.initModality(Modality.WINDOW_MODAL) + val textField = TextField().apply { promptText = "URL" } + subParent.add(textField) + + var playlistId: String? = null + val job = GlobalScope.launch(start = CoroutineStart.LAZY) { + try { + loadPlaylist(APIConnection("api", "playlist", playlistId!!, "tracks")) + onFx { subStage.close() } + } catch(e: Exception) { // FIXME : This breaks everything; if we get in the catch, the error message will show, but job.invokeOnComplete will still work and everything will be closed. + onFx { + monsterUtilities.showAlert(Alert.AlertType.WARNING, "No playlist found", content = "No playlists were found at ${textField.text}.") + } + this.cancel() + } + } + + subParent.addRow(createButton("Load") { + playlistId = textField.text.substringAfterLast("/") + if(playlistId!!.length == 24) { + job.start() + (it.source as Button).let { button -> + button.isDisable = true + button.text = "Loading..." + } + } else { + monsterUtilities.showAlert(Alert.AlertType.WARNING, "Playlist URL invalid", content = "${textField.text} is not a valid URL.") + } + }, createButton("Cancel") { + subStage.close() + job.cancel() + }) + subStage.show() + return job + } + + fun replace() = GlobalScope.launch { APIConnection.editPlaylist(connectTable.selectionModel.selectedItem.id, tracks = Playlist.tracks) } + fun delete() = GlobalScope.launch { APIConnection.editPlaylist(connectTable.selectionModel.selectedItem.id, deleted = true) } + fun new(): Job { + val subParent = VBox() + val subStage = stage.createStage("New Playlist", subParent) + subStage.initModality(Modality.WINDOW_MODAL) + val textField = TextField().apply { promptText = "Name" } + val publicTick = CheckBox("Public") + subParent.children.addAll(textField, publicTick) + + val job = GlobalScope.launch(start = CoroutineStart.LAZY) { + APIConnection.createPlaylist(textField.text.let { if(it.isBlank()) "New Playlist" else it }, Playlist.tracks, publicTick.isSelected) + onFx { subStage.close() } + } + + subParent.addRow(createButton("Create") { + job.start() + (it.source as Button).let { button -> + button.isDisable = true + button.text = "Loading..." + } + }, createButton("Cancel") { + subStage.close() + job.cancel() + }) + subStage.show() + return job + } + + fun rename(): Job { + val subParent = VBox() + val subStage = stage.createStage("Rename Playlist", subParent) + subStage.initModality(Modality.WINDOW_MODAL) + val textField = TextField().apply { promptText = "Name" } + subParent.add(textField) + + val job = GlobalScope.launch(start = CoroutineStart.LAZY) { + APIConnection.editPlaylist(connectTable.selectionModel.selectedItem.id, name = textField.text.let { if(it.isBlank()) "Unnamed" else it }) + onFx { subStage.close() } + } + + subParent.addRow(createButton("Rename") { + job.start() + (it.source as Button).let { button -> + button.isDisable = true + button.text = "Loading..." + } + }, createButton("Cancel") { + subStage.close() + job.cancel() + }) + subStage.show() + return job + } + + val playlists = FXCollections.observableArrayList() + fun updatePlaylists() { + playlists.clear() + if(APIConnection.connectValidity.value != ConnectValidity.NOGOLD && APIConnection.connectValidity.value != ConnectValidity.GOLD) { + connectTable.placeholder = Label("Please connect using connect.sid in the downloader tab.") + } else { + connectTable.placeholder = Label("Loading...") + GlobalScope.launch { + val results = connection.getPlaylists() + if(results != null && results.isNotEmpty()) + playlists.addAll(results) + else + onFx { + connectTable.placeholder = Label("No playlists were found on your account.") + } + } + } + } + + connectTable.apply { + columnResizePolicy = TableView.CONSTRAINED_RESIZE_POLICY + columns.addAll(TableColumn("Name") { it.value.name }, + TableColumn("Size") { it.value.tracks.size.toString() }) + items = playlists + updatePlaylists() + + selectionModel.selectionMode = SelectionMode.SINGLE + setOnMouseClicked { if(it.button == MouseButton.PRIMARY && it.clickCount == 2) load() } + + val publicMenuItem = CheckMenuItem("Public", { + if(connectTable.selectionModel.selectedItem != null) { + GlobalScope.launch { + APIConnection.editPlaylist(connectTable.selectionModel.selectedItem.id, public = it) + onFx { updatePlaylists() } + } + } + }) + contextMenu = ContextMenu( + publicMenuItem, + SeparatorMenuItem(), + MenuItem("Save into") { replace().invokeOnCompletion { onFx { updatePlaylists() } } }, + MenuItem("Rename playlist") { rename().invokeOnCompletion { it.ifNull { onFx { updatePlaylists() } } } }, + MenuItem("Delete playlist") { delete().invokeOnCompletion { onFx { updatePlaylists() } } }) + contextMenu.setOnShown { + contextMenu.items.forEach { it.isDisable = connectTable.selectionModel.selectedItem == null } + publicMenuItem.isSelected = selectionModel.selectedItem?.public ?: false + } + } + + parent.add(Label("Tip : You can right-click a playlist to edit it without the window closing each time !")) + parent.addRow( + createButton("Load") { + connectTable.selectionModel.selectedItem ?: return@createButton + load().invokeOnCompletion { onFx { stage.close() } } + (it.source as Button).let { button -> + button.parent.isDisable = true + button.text = "Loading..." + } + }, + createButton("From URL...") { loadUrl().invokeOnCompletion { it.ifNull { onFx { stage.close() } } } }, + createButton("Save into selected") { + connectTable.selectionModel.selectedItem ?: return@createButton + replace().invokeOnCompletion { onFx { stage.close() } } + (it.source as Button).let { button -> + button.parent.isDisable = true + button.text = "Loading..." + } + }, + createButton("Save as new...") { new().invokeOnCompletion { it.ifNull { onFx { stage.close() } } } }, + createButton("Cancel") { stage.close() }) + parent.fill(connectTable, 0) + stage.show() + } +} \ No newline at end of file diff --git a/src/main/xerus/monstercat/api/response/ConnectPlaylist.kt b/src/main/xerus/monstercat/api/response/ConnectPlaylist.kt new file mode 100644 index 0000000..0db252d --- /dev/null +++ b/src/main/xerus/monstercat/api/response/ConnectPlaylist.kt @@ -0,0 +1,12 @@ +package xerus.monstercat.api.response + +import com.google.api.client.util.Key + +data class ConnectPlaylist( + @Key("_id") var id: String = "", + @Key var name: String = "", + @Key var public: Boolean = false, + @Key var deleted: Boolean = false, + + @Key var tracks: List = arrayListOf() +) \ No newline at end of file diff --git a/src/main/xerus/monstercat/api/response/ListResponse.kt b/src/main/xerus/monstercat/api/response/ListResponse.kt index 3ae58d7..c149135 100644 --- a/src/main/xerus/monstercat/api/response/ListResponse.kt +++ b/src/main/xerus/monstercat/api/response/ListResponse.kt @@ -11,7 +11,8 @@ open class ListResponse { override fun toString() = "${this.javaClass.simpleName}($total elements): $results" } -class ReleaseResponse: ListResponse() -class TrackResponse: ListResponse() +class ReleaseResponse : ListResponse() +class TrackResponse : ListResponse() +class PlaylistResponse : ListResponse() class ReleaseList: ArrayList() \ No newline at end of file diff --git a/src/main/xerus/monstercat/tabs/TabPlaylist.kt b/src/main/xerus/monstercat/tabs/TabPlaylist.kt index 82e96cf..9f8d1af 100644 --- a/src/main/xerus/monstercat/tabs/TabPlaylist.kt +++ b/src/main/xerus/monstercat/tabs/TabPlaylist.kt @@ -12,10 +12,12 @@ import javafx.scene.input.MouseButton import javafx.scene.input.TransferMode import xerus.ktutil.javafx.MenuItem import xerus.ktutil.javafx.TableColumn +import xerus.ktutil.javafx.addButton import xerus.ktutil.javafx.fill import xerus.ktutil.javafx.properties.listen import xerus.monstercat.api.Player import xerus.monstercat.api.Playlist +import xerus.monstercat.api.PlaylistManager import xerus.monstercat.api.response.Track @@ -104,9 +106,10 @@ class TabPlaylist: VTab() { init { table.items = Playlist.tracks + addButton("Playlists from Monstercat.com..."){ PlaylistManager.playlistDialog() } fill(table) } - + private val selectedTrack: Track get() = table.selectionModel.selectedItem private val selectedIndex: Int