From be23c0e4c53e21ad10ca9f6fc913f8c9339ae014 Mon Sep 17 00:00:00 2001 From: shabinder Date: Wed, 27 Jan 2021 01:28:11 +0530 Subject: [PATCH] Refactoring(WIP) --- .../com/shabinder/android/MainActivity.kt | 7 +- .../src/main/kotlin/android-setup.gradle.kts | 4 + common/compose-ui/build.gradle.kts | 2 +- common/data-models/build.gradle.kts | 2 +- .../kotlin/com/shabinder/common/Utils.kt | 42 +++ common/dependency-injection/build.gradle.kts | 6 +- .../com/shabinder/common/PlatformImp.kt | 22 ++ .../com/shabinder/common/YoutubeProvider.kt | 226 ++++++++++++++++ .../kotlin/com/shabinder/common/DIModule.kt | 6 +- .../kotlin/com/shabinder/common/Expect.kt | 13 + .../common/providers/GaanaProvider.kt | 220 ++++++++++++++++ .../common/providers/SpotifyProvider.kt | 241 ++++++++++++++++++ .../common/providers/YoutubeFetcher.kt | 241 ++++++++++++++++++ .../common/spotify/SpotifyRequests.kt | 3 + .../com/shabinder/common/PlatformImpl.kt | 17 ++ .../com/shabinder/common/YoutubeProvider.kt | 227 +++++++++++++++++ 16 files changed, 1265 insertions(+), 14 deletions(-) create mode 100644 common/data-models/src/commonMain/kotlin/com/shabinder/common/Utils.kt create mode 100644 common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/PlatformImp.kt create mode 100644 common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/YoutubeProvider.kt create mode 100644 common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/Expect.kt create mode 100644 common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/GaanaProvider.kt create mode 100644 common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/SpotifyProvider.kt create mode 100644 common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/YoutubeFetcher.kt create mode 100644 common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/PlatformImpl.kt create mode 100644 common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/YoutubeProvider.kt diff --git a/android/src/main/java/com/shabinder/android/MainActivity.kt b/android/src/main/java/com/shabinder/android/MainActivity.kt index 5059287f..f07388b3 100644 --- a/android/src/main/java/com/shabinder/android/MainActivity.kt +++ b/android/src/main/java/com/shabinder/android/MainActivity.kt @@ -11,17 +11,14 @@ import com.shabinder.common.youtube.YoutubeMusic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class MainActivity : AppCompatActivity(),YoutubeMusic { +class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val scope = rememberCoroutineScope() SpotiFlyerMain() scope.launch(Dispatchers.IO) { - val token = authenticateSpotify() - val response = getYoutubeMusicResponse("filhaal") - Log.i("Spotify",token.toString()) - Log.i("Youtube",response) + } } } diff --git a/buildSrc/src/main/kotlin/android-setup.gradle.kts b/buildSrc/src/main/kotlin/android-setup.gradle.kts index 8d437967..b4d58c67 100644 --- a/buildSrc/src/main/kotlin/android-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/android-setup.gradle.kts @@ -11,10 +11,12 @@ android { minSdkVersion(Versions.minSdkVersion) targetSdkVersion(Versions.targetSdkVersion) } + composeOptions { kotlinCompilerExtensionVersion = Versions.composeVersion kotlinCompilerVersion = Versions.kotlinVersion } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -23,7 +25,9 @@ android { sourceSets { named("main") { manifest.srcFile("src/androidMain/AndroidManifest.xml") + java.srcDirs("src/androidMain/kotlin") res.srcDirs("src/androidMain/res") } } + } diff --git a/common/compose-ui/build.gradle.kts b/common/compose-ui/build.gradle.kts index 476cd3fd..660a114f 100644 --- a/common/compose-ui/build.gradle.kts +++ b/common/compose-ui/build.gradle.kts @@ -5,7 +5,7 @@ plugins { kotlin { sourceSets { - named("commonMain") { + commonMain { dependencies { implementation(Deps.ArkIvanov.Decompose.decompose) implementation(Deps.ArkIvanov.Decompose.extensionsCompose) diff --git a/common/data-models/build.gradle.kts b/common/data-models/build.gradle.kts index 741d3f10..ea079772 100644 --- a/common/data-models/build.gradle.kts +++ b/common/data-models/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin { sourceSets { - named("commonMain") { + commonMain { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1") } diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/Utils.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/Utils.kt new file mode 100644 index 00000000..2a6c2ad6 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/Utils.kt @@ -0,0 +1,42 @@ +package com.shabinder.common + +/** + * Removing Illegal Chars from File Name + * **/ +fun removeIllegalChars(fileName: String): String { + val illegalCharArray = charArrayOf( + '/', + '\n', + '\r', + '\t', + '\u0000', + '\u000C', + '`', + '?', + '*', + '\\', + '<', + '>', + '|', + '\"', + '.', + '-', + '\'' + ) + + var name = fileName + for (c in illegalCharArray) { + name = fileName.replace(c, '_') + } + name = name.replace("\\s".toRegex(), "_") + name = name.replace("\\)".toRegex(), "") + name = name.replace("\\(".toRegex(), "") + name = name.replace("\\[".toRegex(), "") + name = name.replace("]".toRegex(), "") + name = name.replace("\\.".toRegex(), "") + name = name.replace("\"".toRegex(), "") + name = name.replace("\'".toRegex(), "") + name = name.replace(":".toRegex(), "") + name = name.replace("\\|".toRegex(), "") + return name +} diff --git a/common/dependency-injection/build.gradle.kts b/common/dependency-injection/build.gradle.kts index 15404d60..91ddb986 100644 --- a/common/dependency-injection/build.gradle.kts +++ b/common/dependency-injection/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin { sourceSets { - named("commonMain") { + commonMain { dependencies { implementation(project(":common:data-models")) implementation(project(":common:database")) @@ -20,13 +20,13 @@ kotlin { implementation(Ktor.auth) } } - named("androidMain"){ + androidMain { dependencies{ implementation(Ktor.clientAndroid) } } - named("desktopMain"){ + desktopMain { dependencies{ //implementation(Ktor.clientDesktop) } diff --git a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/PlatformImp.kt b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/PlatformImp.kt new file mode 100644 index 00000000..c1adfbb3 --- /dev/null +++ b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/PlatformImp.kt @@ -0,0 +1,22 @@ +package com.shabinder.common + +import android.content.Context +import android.os.Environment +import java.io.File + +actual open class PlatformDir { + + actual fun fileSeparator(): String = File.separator + +// actual fun imageDir(): String = context.cacheDir.absolutePath + File.separator + actual fun imageDir(): String = defaultDir() + File.separator + ".images" + File.separator + + @Suppress("DEPRECATION") + actual fun defaultDir(): String = + Environment.getExternalStorageDirectory().toString() + File.separator + + Environment.DIRECTORY_MUSIC + File.separator + + "SpotiFlyer"+ File.separator + + + actual fun isPresent(path: String): Boolean = File(path).exists() +} \ No newline at end of file diff --git a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/YoutubeProvider.kt b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/YoutubeProvider.kt new file mode 100644 index 00000000..78baaeb9 --- /dev/null +++ b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/YoutubeProvider.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2021 Shabinder Singh + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.shabinder.common + +import android.annotation.SuppressLint +import com.github.kiulian.downloader.YoutubeDownloader +import com.shabinder.common.PlatformDir +import com.shabinder.spotiflyer.database.DownloadRecord +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.PlatformQueryResult +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.utils.log +import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.showDialog +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +class YoutubeProvider(ytDownloader: YoutubeDownloader): PlatformDir() { + + /* + * YT Album Art Schema + * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * Normal Url: https://i.ytimg.com/vi/$searchId/hqdefault.jpg" + * */ + private val sampleDomain1 = "music.youtube.com" + private val sampleDomain2 = "youtube.com" + private val sampleDomain3 = "youtu.be" + + suspend fun query(fullLink: String): PlatformQueryResult?{ + val link = fullLink.removePrefix("https://").removePrefix("http://") + if(link.contains("playlist",true) || link.contains("list",true)){ + // Given Link is of a Playlist + log("YT Play",link) + val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&").substringBefore("?") + return getYTPlaylist( + playlistId + ) + }else{//Given Link is of a Video + var searchId = "error" + when{ + link.contains(sampleDomain1,true) -> {//Youtube Music + searchId = link.substringAfterLast("/","error").substringBefore("&").substringAfterLast("=") + } + link.contains(sampleDomain2,true) -> {//Standard Youtube Link + searchId = link.substringAfterLast("=","error").substringBefore("&") + } + link.contains(sampleDomain3,true) -> {//Shortened Youtube Link + searchId = link.substringAfterLast("/","error").substringBefore("&") + } + } + return if(searchId != "error") { + getYTTrack( + searchId + ) + }else{ + showDialog("Your Youtube Link is not of a Video!!") + null + } + } + } + + private suspend fun getYTPlaylist( + searchId: String + ):PlatformQueryResult?{ + val result = PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.YouTube + ) + with(result) { + try { + log("YT Playlist", searchId) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details() + val name = playlistDetails.title() + subFolder = removeIllegalChars(name) + val videos = playlist.videos() + + coverUrl = "https://i.ytimg.com/vi/${ + videos.firstOrNull()?.videoId() + }/hqdefault.jpg" + title = name + + trackList = videos.map { + TrackDetails( + title = it.title(), + artists = listOf(it.author().toString()), + durationSec = it.lengthSeconds(), + albumArt = File(imageDir + it.videoId() + ".jpeg"), + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/${it.videoId()}/hqdefault.jpg", + downloaded = if (File( + finalOutputDir( + itemName = it.title(), + type = folderType, + subFolder = subFolder, + defaultDir + ) + ).exists() + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFile = finalOutputDir(it.title(), folderType, subFolder, defaultDir,".m4a"), + videoID = it.videoId() + ) + } + + withContext(Dispatchers.IO) { + databaseDAO.insert( + DownloadRecord( + type = "PlayList", + name = if (name.length > 17) { + "${name.subSequence(0, 16)}..." + } else { + name + }, + link = "https://www.youtube.com/playlist?list=$searchId", + coverUrl = "https://i.ytimg.com/vi/${ + videos.firstOrNull()?.videoId() + }/hqdefault.jpg", + totalFiles = videos.size, + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + showDialog("An Error Occurred While Processing!") + } + } + return if(result.title.isNotBlank()) result + else null + } + + @SuppressLint("DefaultLocale") + private suspend fun getYTTrack( + searchId:String, + ):PlatformQueryResult? { + val result = PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.YouTube + ) + with(result) { + try { + log("YT Video", searchId) + val video = ytDownloader.getVideo(searchId) + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" + val detail = video?.details() + val name = detail?.title()?.replace(detail.author()!!.toUpperCase(), "", true) + ?: detail?.title() ?: "" + log("YT View Model", detail.toString()) + trackList = listOf( + TrackDetails( + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds() ?: 0, + albumArt = File(imageDir, "$searchId.jpeg"), + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + downloaded = if (File( + finalOutputDir( + itemName = name, + type = folderType, + subFolder = subFolder, + defaultDir = defaultDir + ) + ).exists() + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFile = finalOutputDir(name, folderType, subFolder, defaultDir,".m4a"), + videoID = searchId + ) + ) + title = name + + withContext(Dispatchers.IO) { + databaseDAO.insert( + DownloadRecord( + type = "Track", + name = if (name.length > 17) { + "${name.subSequence(0, 16)}..." + } else { + name + }, + link = "https://www.youtube.com/watch?v=$searchId", + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + totalFiles = 1, + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + showDialog("An Error Occurred While Processing!,$searchId") + } + } + return if(result.title.isNotBlank()) result + else null + } +} \ No newline at end of file diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/DIModule.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/DIModule.kt index 679f7925..a04024b1 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/DIModule.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/DIModule.kt @@ -10,7 +10,5 @@ import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import org.kodein.di.DI - -val networking = DI.Module("Networking"){ - -} \ No newline at end of file +import org.kodein.di.bind +import org.kodein.di.singleton diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/Expect.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/Expect.kt new file mode 100644 index 00000000..8f6c0fa9 --- /dev/null +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/Expect.kt @@ -0,0 +1,13 @@ +package com.shabinder.common + +expect open class PlatformDir() { + fun isPresent(path:String):Boolean + fun fileSeparator(): String + fun defaultDir(): String + fun imageDir(): String +} + +fun PlatformDir.finalOutputDir(itemName:String ,type:String, subFolder:String,defaultDir:String,extension:String = ".mp3" ): String = + defaultDir + removeIllegalChars(type) + this.fileSeparator() + + if(subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator()} + + removeIllegalChars(itemName) + extension diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/GaanaProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/GaanaProvider.kt new file mode 100644 index 00000000..8353d862 --- /dev/null +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/GaanaProvider.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2021 Shabinder Singh + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.shabinder.common.providers + +import com.shabinder.common.* +import com.shabinder.common.gaana.GaanaRequests +import com.shabinder.common.gaana.GaanaTrack +import com.shabinder.common.spotify.Source +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GaanaProvider: PlatformDir(),GaanaRequests { + + private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg" + + suspend fun query(fullLink: String): PlatformQueryResult?{ + //Link Schema: https://gaana.com/type/link + val gaanaLink = fullLink.substringAfter("gaana.com/") + + val link = gaanaLink.substringAfterLast('/', "error") + val type = gaanaLink.substringBeforeLast('/', "error").substringAfterLast('/') + + //Error + if (type == "Error" || link == "Error"){ + return null + } + return gaanaSearch( + type, + link + ) + } + + private suspend fun gaanaSearch( + type:String, + link:String, + ): PlatformQueryResult { + val result = PlatformQueryResult( + folderType = "", + subFolder = link, + title = link, + coverUrl = gaanaPlaceholderImageUrl, + trackList = listOf(), + Source.Gaana + ) + with(result) { + when (type) { + "song" -> { + getGaanaSong(seokey = link).tracks.firstOrNull()?.also { + folderType = "Tracks" + subFolder = "" + if (isPresent( + finalOutputDir( + it.track_title, + folderType, + subFolder, + defaultDir() + ) + )) {//Download Already Present!! + it.downloaded = DownloadStatus.Downloaded + } + trackList = listOf(it).toTrackDetailsList(folderType, subFolder) + title = it.track_title + coverUrl = it.artworkLink + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Track", + name = title, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl, + totalFiles = 1, + ) + ) + } + } + } + "album" -> { + getGaanaAlbum(seokey = link).also { + folderType = "Albums" + subFolder = link + it.tracks.forEach { track -> + if (isPresent( + finalOutputDir( + track.track_title, + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList = it.tracks.toTrackDetailsList(folderType, subFolder) + title = link + coverUrl = it.custom_artworks.size_480p + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Album", + name = title, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl, + totalFiles = trackList.size, + ) + ) + } + } + } + "playlist" -> { + getGaanaPlaylist(seokey = link).also { + folderType = "Playlists" + subFolder = link + it.tracks.forEach { track -> + if (isPresent( + finalOutputDir( + track.track_title, + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList = it.tracks.toTrackDetailsList(folderType, subFolder) + title = link + //coverUrl.value = "TODO" + coverUrl = gaanaPlaceholderImageUrl + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Playlist", + name = title, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl, + totalFiles = it.tracks.size, + ) + ) + } + } + } + "artist" -> { + folderType = "Artist" + subFolder = link + coverUrl = gaanaPlaceholderImageUrl + val artistDetails = + getGaanaArtistDetails(seokey = link).artist?.firstOrNull() + ?.also { + title = it.name + coverUrl = it.artworkLink ?: gaanaPlaceholderImageUrl + } + getGaanaArtistTracks(seokey = link).also { + it.tracks.forEach { track -> + if (isPresent( + finalOutputDir( + track.track_title, + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + track.downloaded = DownloadStatus.Downloaded + } + } + trackList = it.tracks.toTrackDetailsList(folderType, subFolder) + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Artist", + name = artistDetails?.name ?: link, + link = "https://gaana.com/$type/$link", + coverUrl = coverUrl, + totalFiles = trackList.size, + ) + ) + } + } + } + else -> {//TODO Handle Error} + } + } + return result + } + } + + private fun List.toTrackDetailsList(type:String, subFolder:String) = this.map { + TrackDetails( + title = it.track_title, + artists = it.artist.map { artist -> artist?.name.toString() }, + durationSec = it.duration, +// albumArt = File( +// imageDir + (it.artworkLink.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg"), + albumName = it.album_title, + year = it.release_date, + comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}", + trackUrl = it.lyrics_url, + downloaded = it.downloaded ?: DownloadStatus.NotDownloaded, + source = Source.Gaana, + albumArtURL = it.artworkLink, + outputFile = finalOutputDir(it.track_title,type, subFolder,defaultDir(),".m4a") + ) + } +} diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/SpotifyProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/SpotifyProvider.kt new file mode 100644 index 00000000..89418315 --- /dev/null +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/SpotifyProvider.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2021 Shabinder Singh + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.shabinder.common.providers + +import com.shabinder.common.* +import com.shabinder.common.spotify.* +import com.shabinder.spotiflyer.database.DownloadRecord +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SpotifyProvider: PlatformDir(),SpotifyRequests { + + suspend fun query(fullLink: String): PlatformQueryResult?{ + var spotifyLink = + "https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim() + + if (!spotifyLink.contains("open.spotify")) { + //Very Rare instance + spotifyLink = resolveLink(spotifyLink) + } + + val link = spotifyLink.substringAfterLast('/', "Error").substringBefore('?') + val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') + + + if (type == "Error" || link == "Error") { + return null + } + + if (type == "episode" || type == "show") { + //TODO Implementation + return null + } + + return spotifySearch( + type, + link + ) + } + + private suspend fun spotifySearch( + type:String, + link: String + ): PlatformQueryResult { + val result = PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.Spotify + ) + with(result) { + when (type) { + "track" -> { + getTrack(link).also { + folderType = "Tracks" + subFolder = "" + if (isPresent( + finalOutputDir( + it.name.toString(), + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + it.downloaded = DownloadStatus.Downloaded + } + trackList = listOf(it).toTrackDetailsList(folderType, subFolder) + title = it.name.toString() + coverUrl = (it.album?.images?.elementAtOrNull(1)?.url + ?: it.album?.images?.elementAtOrNull(0)?.url).toString() + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Track", + name = title, + link = "https://open.spotify.com/$type/$link", + coverUrl = coverUrl, + totalFiles = 1, + ) + ) + } + } + } + + "album" -> { + val albumObject = getAlbum(link) + folderType = "Albums" + subFolder = albumObject?.name.toString() + albumObject?.tracks?.items?.forEach { + if (isPresent( + finalOutputDir( + it.name.toString(), + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + it.downloaded = DownloadStatus.Downloaded + } + it.album = Album( + images = listOf( + Image( + url = albumObject.images?.elementAtOrNull(1)?.url + ?: albumObject.images?.elementAtOrNull(0)?.url + ) + ) + ) + } + albumObject.tracks?.items?.toTrackDetailsList(folderType, subFolder).let {it -> + if (it.isNullOrEmpty()) { + //TODO Handle Error + } else { + trackList = it + title = albumObject.name.toString() + coverUrl = (albumObject.images?.elementAtOrNull(1)?.url + ?: albumObject.images?.elementAtOrNull(0)?.url).toString() + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Album", + name = title, + link = "https://open.spotify.com/$type/$link", + coverUrl = coverUrl, + totalFiles = trackList.size, + ) + ) + } + } + } + } + + "playlist" -> { + val playlistObject = getPlaylist(link) + folderType = "Playlists" + subFolder = playlistObject.name.toString() + val tempTrackList = mutableListOf() + //log("Tracks Fetched", playlistObject.tracks?.items?.size.toString()) + playlistObject?.tracks?.items?.forEach { + it.track?.let { it1 -> + if (isPresent( + finalOutputDir( + it1.name.toString(), + folderType, + subFolder, + defaultDir() + ) + ) + ) {//Download Already Present!! + it1.downloaded = DownloadStatus.Downloaded + } + tempTrackList.add(it1) + } + } + var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank() + + while (moreTracksAvailable) { + //Check For More Tracks If available + val moreTracks = + getPlaylistTracks(link, offset = tempTrackList.size) + moreTracks.items?.forEach { + it.track?.let { it1 -> tempTrackList.add(it1) } + } + moreTracksAvailable = !moreTracks.next.isNullOrBlank() + } + //log("Total Tracks Fetched", tempTrackList.size.toString()) + trackList = tempTrackList.toTrackDetailsList(folderType, subFolder) + title = playlistObject.name.toString() + coverUrl = playlistObject.images?.elementAtOrNull(1)?.url + ?: playlistObject.images?.firstOrNull()?.url.toString() + withContext(Dispatchers.Default) { + databaseDAO.insert( + DownloadRecord( + type = "Playlist", + name = title, + link = "https://open.spotify.com/$type/$link", + coverUrl = coverUrl, + totalFiles = tempTrackList.size, + ) + ) + } + } + "episode" -> {//TODO + } + "show" -> {//TODO + } + else -> { + //TODO Handle Error + } + } + } + return result + } + + /* + * New Link Schema: https://link.tospotify.com/kqTBblrjQbb, + * Fetching Standard Link: https://open.spotify.com/playlist/37i9dQZF1DX9RwfGbeGQwP?si=iWz7B1tETiunDntnDo3lSQ&_branch_match_id=862039436205270630 + * */ + private suspend fun resolveLink( + url:String + ):String { + val response = getResponse(url) + val regex = """https://open\.spotify\.com.+\w""".toRegex() + return regex.find(response)?.value.toString() + } + + private fun List.toTrackDetailsList(type:String, subFolder:String) = this.map { + TrackDetails( + title = it.name.toString(), + artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(), + durationSec = (it.duration_ms/1000).toInt(), +// albumArt = File( +// imageDir + (it.album?.images?.elementAtOrNull(1)?.url ?: it.album?.images?.firstOrNull()?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = it.album?.name, + year = it.album?.release_date, + comment = "Genres:${it.album?.genres?.joinToString()}", + trackUrl = it.href, + downloaded = it.downloaded, + source = Source.Spotify, + albumArtURL = it.album?.images?.elementAtOrNull(1)?.url ?: it.album?.images?.firstOrNull()?.url.toString(), + outputFile = finalOutputDir(it.name.toString(),type, subFolder,defaultDir(),".m4a") + ) + } +} \ No newline at end of file diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/YoutubeFetcher.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/YoutubeFetcher.kt new file mode 100644 index 00000000..eb52318a --- /dev/null +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/providers/YoutubeFetcher.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2021 Shabinder Singh + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.shabinder.common.providers + +import android.annotation.SuppressLint +import com.beust.klaxon.JsonArray +import com.beust.klaxon.JsonObject +import com.beust.klaxon.Parser +import com.shabinder.common.YoutubeTrack +import com.shabinder.spotiflyer.utils.log +import me.xdrop.fuzzywuzzy.FuzzySearch +import kotlin.math.absoluteValue + +/* +* Thanks To https://github.com/spotDL/spotify-downloader +* */ +fun getYTTracks(response: String):List{ + val youtubeTracks = mutableListOf() + + val stringBuilder: StringBuilder = StringBuilder(response) + val responseObj: JsonObject = Parser.default().parse(stringBuilder) as JsonObject + val contentBlocks = responseObj.obj("contents")?.obj("sectionListRenderer")?.array("contents") + val resultBlocks = mutableListOf>() + if (contentBlocks != null) { + for (cBlock in contentBlocks){ + /** + *Ignore user-suggestion + *The 'itemSectionRenderer' field is for user notices (stuff like - 'showing + *results for xyz, search for abc instead') we have no use for them, the for + *loop below if throw a keyError if we don't ignore them + */ + if(cBlock.containsKey("itemSectionRenderer")){ + continue + } + + for(contents in cBlock.obj("musicShelfRenderer")?.array("contents") ?: listOf()){ + /** + * apparently content Blocks without an 'overlay' field don't have linkBlocks + * I have no clue what they are and why there even exist + * + if(!contents.containsKey("overlay")){ + println(contents) + continue + TODO check and correct + }*/ + + val result = contents.obj("musicResponsiveListItemRenderer") + ?.array("flexColumns") + + //Add the linkBlock + val linkBlock = contents.obj("musicResponsiveListItemRenderer") + ?.obj("overlay") + ?.obj("musicItemThumbnailOverlayRenderer") + ?.obj("content") + ?.obj("musicPlayButtonRenderer") + ?.obj("playNavigationEndpoint") + + // detailsBlock is always a list, so we just append the linkBlock to it + // instead of carrying along all the other junk from "musicResponsiveListItemRenderer" + linkBlock?.let { result?.add(it) } + result?.let { resultBlocks.add(it) } + } + } + + /* We only need results that are Songs or Videos, so we filter out the rest, since + ! Songs and Videos are supplied with different details, extracting all details from + ! both is just carrying on redundant data, so we also have to selectively extract + ! relevant details. What you need to know to understand how we do that here: + ! + ! Songs details are ALWAYS in the following order: + ! 0 - Name + ! 1 - Type (Song) + ! 2 - com.shabinder.spotiflyer.models.gaana.Artist + ! 3 - Album + ! 4 - Duration (mm:ss) + ! + ! Video details are ALWAYS in the following order: + ! 0 - Name + ! 1 - Type (Video) + ! 2 - Channel + ! 3 - Viewers + ! 4 - Duration (hh:mm:ss) + ! + ! We blindly gather all the details we get our hands on, then + ! cherrypick the details we need based on their index numbers, + ! we do so only if their Type is 'Song' or 'Video + */ + + for(result in resultBlocks){ + + // Blindly gather available details + val availableDetails = mutableListOf() + + /* + Filter Out dummies here itself + ! 'musicResponsiveListItemFlexColumnRenderer' should have more that one + ! sub-block, if not its a dummy, why does the YTM response contain dummies? + ! I have no clue. We skip these. + + ! Remember that we appended the linkBlock to result, treating that like the + ! other constituents of a result block will lead to errors, hence the 'in + ! result[:-1] ,i.e., skip last element in array ' + */ + for(detail in result.subList(0,result.size-1)){ + if(detail.obj("musicResponsiveListItemFlexColumnRenderer")?.size!! < 2) continue + + // if not a dummy, collect All Variables + val details = detail.obj("musicResponsiveListItemFlexColumnRenderer") + ?.obj("text") + ?.array("runs") ?: listOf() + for (d in details){ + d["text"]?.let { + if(it.toString() != " • "){ + availableDetails.add( + it.toString() + ) + } + } + } + } +// log("YT Music details",availableDetails.toString()) + /* + ! Filter Out non-Song/Video results and incomplete results here itself + ! From what we know about detail order, note that [1] - indicate result type + */ + if ( availableDetails.size == 5 && availableDetails[1] in listOf("Song","Video") ){ + + // skip if result is in hours instead of minutes (no song is that long) + if(availableDetails[4].split(':').size != 2) continue + + /* + ! grab Video ID + ! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...] + ! so hardcoding the dict keys for data look up is an ardours process, since + ! the sub-block pattern is fixed even though the key isn't, we just + ! reference the dict keys by index + */ + + val videoId:String? = result.last().obj("watchEndpoint")?.get("videoId") as String? + val ytTrack = YoutubeTrack( + name = availableDetails[0], + type = availableDetails[1], + artist = availableDetails[2], + duration = availableDetails[4], + videoId = videoId + ) + youtubeTracks.add(ytTrack) + + } + } + } + log("YT Search",youtubeTracks.joinToString(" abc \n")) + return youtubeTracks +} + +@SuppressLint("DefaultLocale") +fun sortByBestMatch(ytTracks:List, + trackName:String, + trackArtists:List, + trackDurationSec:Int, + ):Map{ + /* + * "linksWithMatchValue" is map with Youtube VideoID and its rating/match with 100 as Max Value + **/ + val linksWithMatchValue = mutableMapOf() + + for (result in ytTracks){ + + // LoweCasing Name to match Properly + // most song results on youtube go by $artist - $songName or artist1/artist2 + var hasCommonWord = false + + val resultName = result.name?.toLowerCase()?.replace("-"," ")?.replace("/"," ") ?: "" + val trackNameWords = trackName.toLowerCase().split(" ") + + for (nameWord in trackNameWords){ + if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord,resultName) > 85) hasCommonWord = true + } + + // Skip this Result if No Word is Common in Name + if (!hasCommonWord) { + //log("YT Api Removing", result.toString()) + continue + } + + + // Find artist match + // Will Be Using Fuzzy Search Because YT Spelling might be mucked up + // match = (no of artist names in result) / (no. of artist names on spotify) * 100 + var artistMatchNumber = 0 + + if(result.type == "Song"){ + for (artist in trackArtists){ + if(FuzzySearch.ratio(artist.toLowerCase(),result.artist?.toLowerCase()) > 85) + artistMatchNumber++ + } + }else{//i.e. is a Video + for (artist in trackArtists) { + if(FuzzySearch.partialRatio(artist.toLowerCase(),result.name?.toLowerCase()) > 85) + artistMatchNumber++ + } + } + + if(artistMatchNumber == 0) { + //log("YT Api Removing", result.toString()) + continue + } + + val artistMatch = (artistMatchNumber / trackArtists.size ) * 100 + + // Duration Match + /*! time match = 100 - (delta(duration)**2 / original duration * 100) + ! difference in song duration (delta) is usually of the magnitude of a few + ! seconds, we need to amplify the delta if it is to have any meaningful impact + ! wen we calculate the avg match value*/ + val difference = result.duration?.split(":")?.get(0)?.toInt()?.times(60) + ?.plus(result.duration?.split(":")?.get(1)?.toInt()?:0) + ?.minus(trackDurationSec)?.absoluteValue ?: 0 + val nonMatchValue :Float= ((difference*difference).toFloat()/trackDurationSec.toFloat()) + val durationMatch = 100 - (nonMatchValue*100) + + val avgMatch = (artistMatch + durationMatch)/2 + linksWithMatchValue[result.videoId.toString()] = avgMatch.toInt() + } + //log("YT Api Result", "$trackName - $linksWithMatchValue") + return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap() +} diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/spotify/SpotifyRequests.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/spotify/SpotifyRequests.kt index 483a7768..c7a8f6ae 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/spotify/SpotifyRequests.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/spotify/SpotifyRequests.kt @@ -44,4 +44,7 @@ interface SpotifyRequests { return spotifyRequestsClient.get("$BASE_URL/albums/$id") } + suspend fun getResponse(url:String):String{ + return spotifyRequestsClient.get(url) + } } \ No newline at end of file diff --git a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/PlatformImpl.kt b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/PlatformImpl.kt new file mode 100644 index 00000000..dd74a2ca --- /dev/null +++ b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/PlatformImpl.kt @@ -0,0 +1,17 @@ +package com.shabinder.common + +import java.io.File + +actual open class PlatformDir{ + + actual fun fileSeparator(): String = File.separator + + actual fun imageDir(): String = System.getProperty("user.home") + ".images" + File.separator + + @Suppress("DEPRECATION") + actual fun defaultDir(): String = System.getProperty("user.home") + fileSeparator() + + "SpotiFlyer" + fileSeparator() + + actual fun isPresent(path: String): Boolean = File(path).exists() + +} \ No newline at end of file diff --git a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/YoutubeProvider.kt b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/YoutubeProvider.kt new file mode 100644 index 00000000..afdd1b57 --- /dev/null +++ b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/YoutubeProvider.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2021 Shabinder Singh + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.shabinder.common + +import android.annotation.SuppressLint +import com.github.kiulian.downloader.YoutubeDownloader +import com.shabinder.common.PlatformDir +import com.shabinder.common.providers.BaseProvider +import com.shabinder.spotiflyer.database.DownloadRecord +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.PlatformQueryResult +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.models.spotify.Source +import com.shabinder.spotiflyer.utils.log +import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.showDialog +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +class YoutubeProvider(ytDownloader: YoutubeDownloader): PlatformDir() { + + /* + * YT Album Art Schema + * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * Normal Url: https://i.ytimg.com/vi/$searchId/hqdefault.jpg" + * */ + private val sampleDomain1 = "music.youtube.com" + private val sampleDomain2 = "youtube.com" + private val sampleDomain3 = "youtu.be" + + override suspend fun query(fullLink: String): PlatformQueryResult?{ + val link = fullLink.removePrefix("https://").removePrefix("http://") + if(link.contains("playlist",true) || link.contains("list",true)){ + // Given Link is of a Playlist + log("YT Play",link) + val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&").substringBefore("?") + return getYTPlaylist( + playlistId + ) + }else{//Given Link is of a Video + var searchId = "error" + when{ + link.contains(sampleDomain1,true) -> {//Youtube Music + searchId = link.substringAfterLast("/","error").substringBefore("&").substringAfterLast("=") + } + link.contains(sampleDomain2,true) -> {//Standard Youtube Link + searchId = link.substringAfterLast("=","error").substringBefore("&") + } + link.contains(sampleDomain3,true) -> {//Shortened Youtube Link + searchId = link.substringAfterLast("/","error").substringBefore("&") + } + } + return if(searchId != "error") { + getYTTrack( + searchId + ) + }else{ + showDialog("Your Youtube Link is not of a Video!!") + null + } + } + } + + private suspend fun getYTPlaylist( + searchId: String + ):PlatformQueryResult?{ + val result = PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.YouTube + ) + with(result) { + try { + log("YT Playlist", searchId) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details() + val name = playlistDetails.title() + subFolder = removeIllegalChars(name) + val videos = playlist.videos() + + coverUrl = "https://i.ytimg.com/vi/${ + videos.firstOrNull()?.videoId() + }/hqdefault.jpg" + title = name + + trackList = videos.map { + TrackDetails( + title = it.title(), + artists = listOf(it.author().toString()), + durationSec = it.lengthSeconds(), + albumArt = File(imageDir + it.videoId() + ".jpeg"), + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/${it.videoId()}/hqdefault.jpg", + downloaded = if (File( + finalOutputDir( + itemName = it.title(), + type = folderType, + subFolder = subFolder, + defaultDir + ) + ).exists() + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFile = finalOutputDir(it.title(), folderType, subFolder, defaultDir,".m4a"), + videoID = it.videoId() + ) + } + + withContext(Dispatchers.IO) { + databaseDAO.insert( + DownloadRecord( + type = "PlayList", + name = if (name.length > 17) { + "${name.subSequence(0, 16)}..." + } else { + name + }, + link = "https://www.youtube.com/playlist?list=$searchId", + coverUrl = "https://i.ytimg.com/vi/${ + videos.firstOrNull()?.videoId() + }/hqdefault.jpg", + totalFiles = videos.size, + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + showDialog("An Error Occurred While Processing!") + } + } + return if(result.title.isNotBlank()) result + else null + } + + @SuppressLint("DefaultLocale") + private suspend fun getYTTrack( + searchId:String, + ):PlatformQueryResult? { + val result = PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.YouTube + ) + with(result) { + try { + log("YT Video", searchId) + val video = ytDownloader.getVideo(searchId) + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" + val detail = video?.details() + val name = detail?.title()?.replace(detail.author()!!.toUpperCase(), "", true) + ?: detail?.title() ?: "" + log("YT View Model", detail.toString()) + trackList = listOf( + TrackDetails( + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds() ?: 0, + albumArt = File(imageDir, "$searchId.jpeg"), + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + downloaded = if (File( + finalOutputDir( + itemName = name, + type = folderType, + subFolder = subFolder, + defaultDir = defaultDir + ) + ).exists() + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFile = finalOutputDir(name, folderType, subFolder, defaultDir,".m4a"), + videoID = searchId + ) + ) + title = name + + withContext(Dispatchers.IO) { + databaseDAO.insert( + DownloadRecord( + type = "Track", + name = if (name.length > 17) { + "${name.subSequence(0, 16)}..." + } else { + name + }, + link = "https://www.youtube.com/watch?v=$searchId", + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + totalFiles = 1, + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + showDialog("An Error Occurred While Processing!,$searchId") + } + } + return if(result.title.isNotBlank()) result + else null + } +} \ No newline at end of file