diff --git a/.idea/dictionaries/shabinder.xml b/.idea/dictionaries/shabinder.xml index 726356e5..6457524e 100755 --- a/.idea/dictionaries/shabinder.xml +++ b/.idea/dictionaries/shabinder.xml @@ -1,6 +1,7 @@ + cherrypick downloadrecord emoji ffmpeg diff --git a/app/build.gradle b/app/build.gradle index 36a5fdfe..236c0f2b 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -118,6 +118,7 @@ dependencies { implementation "com.squareup.retrofit2:converter-scalars:2.9.0" implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.beust:klaxon:5.4' + implementation 'me.xdrop:fuzzywuzzy:1.3.1' implementation 'com.mpatric:mp3agic:0.9.1' implementation 'com.shreyaspatil:EasyUpiPayment:3.0.0' diff --git a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt index 4868a89b..3b7745d6 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/MainActivity.kt @@ -35,10 +35,11 @@ import androidx.lifecycle.ViewModelProvider import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.enums.UpdateFrom import com.shabinder.spotiflyer.databinding.MainActivityBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.SpotifyService import com.shabinder.spotiflyer.utils.SpotifyServiceTokenRequest import com.shabinder.spotiflyer.utils.createDirectories +import com.shabinder.spotiflyer.utils.startService import com.squareup.moshi.Moshi import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -84,7 +85,7 @@ class MainActivity : AppCompatActivity(){ Log.i("Connection Status", isConnected.toString()) //starting Notification and Downloader Service! - SpotifyDownloadHelper.startService(this) + startService(this) handleIntentFromExternalActivity() } @@ -227,5 +228,6 @@ class MainActivity : AppCompatActivity(){ } init { instance = this + activity = this } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt index b783a9a5..8d8a903d 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/SpotifyDownloadHelper.kt @@ -17,25 +17,20 @@ package com.shabinder.spotiflyer.downloadHelper -import android.content.Context -import android.content.Intent +import android.annotation.SuppressLint import android.os.Environment +import android.os.Handler import android.util.Log import android.view.View import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.widget.TextView -import androidx.core.content.ContextCompat -import com.github.kiulian.downloader.YoutubeDownloader -import com.github.kiulian.downloader.model.formats.Format -import com.github.kiulian.downloader.model.quality.AudioQuality -import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track -import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel -import com.shabinder.spotiflyer.utils.YoutubeMusicApi -import com.shabinder.spotiflyer.utils.getEmojiByUnicode -import com.shabinder.spotiflyer.utils.makeJsonBody -import com.shabinder.spotiflyer.worker.ForegroundService +import android.widget.Toast +import com.shabinder.spotiflyer.SharedViewModel +import com.shabinder.spotiflyer.models.* +import com.shabinder.spotiflyer.utils.* +import com.shabinder.spotiflyer.utils.Provider.activity +import com.shabinder.spotiflyer.utils.Provider.defaultDir import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -45,13 +40,13 @@ import retrofit2.Response import java.io.File object SpotifyDownloadHelper { - var context : Context? = null + var statusBar:TextView? = null var youtubeMusicApi:YoutubeMusicApi? = null - val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator - var spotifyViewModel: SpotifyViewModel? = null - var total = 0 - var Processed = 0 + var sharedViewModel: SharedViewModel? = null + + private var total = 0 + private var processed = 0 var notFound = 0 /** @@ -60,16 +55,94 @@ object SpotifyDownloadHelper { suspend fun downloadAllTracks( type:String, subFolder: String?, - trackList: List, ytDownloader: YoutubeDownloader?) { + trackList: List) { + val downloadList = ArrayList() + withContext(Dispatchers.Main){ total += trackList.size // Adding New Download List Count to StatusBar - trackList.forEach { - if(it.downloaded == "Downloaded"){//Download Already Present!! - Processed++ + trackList.forEachIndexed { index, it -> + if(it.downloaded == DownloadStatus.Downloaded){//Download Already Present!! + processed++ + if(index == (trackList.size-1)){//LastElement + Handler().postDelayed({ + //Delay is Added ,if a request is in processing it may finish + Log.i("Spotify Helper","Download Request Sent") + sharedViewModel?.uiScope?.launch (Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + } + startService(activity,downloadList) + },5000) + } }else{ val artistsList = mutableListOf() it.artists?.forEach { artist -> artistsList.add(artist!!.name!!) } - searchYTMusic(type,subFolder,ytDownloader,"${it.name} - ${artistsList.joinToString(",")}", it) + val searchQuery = "${it.name} - ${artistsList.joinToString(",")}" + + val jsonBody = makeJsonBody(searchQuery.trim()) + youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( + object : Callback{ + override fun onResponse(call: Call, response: Response) { + sharedViewModel?.uiScope?.launch { + val videoId = sortByBestMatch( + getYTTracks(response.body().toString()), + trackName = it.name.toString(), + trackArtists = artistsList, + trackDurationSec = (it.duration_ms/1000).toInt() + ).keys.firstOrNull() + Log.i("Spotify Helper Video ID",videoId ?: "Not Found") + + if(videoId.isNullOrBlank()) {notFound++ ; updateStatusBar()} + else {//Found Youtube Video ID + val trackDetails = TrackDetails( + title = it.name.toString(), + artists = artistsList, + durationSec = (it.duration_ms/1000).toInt(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + (it.album?.images?.get(0)?.url.toString()).substringAfterLast('/') + ".jpeg"), + albumName = it.album?.name, + year = it.album?.release_date, + comment = "Genres:${it.album?.genres?.joinToString()}", + trackUrl = it.href, + source = Source.Spotify + ) + + val outputFile: String = + Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + + removeIllegalChars(type) + File.separator + + (if (subFolder == null) { "" } + else { removeIllegalChars(subFolder) + File.separator } + + removeIllegalChars(it.name!!) + ".m4a") + + val downloadObject = DownloadObject( + trackDetails = trackDetails, + ytVideoId = videoId, + outputFile = outputFile + ) + processed++ + sharedViewModel?.uiScope?.launch(Dispatchers.Main) { + updateStatusBar() + } + downloadList.add(downloadObject) + if(index == (trackList.size-1)){//LastElement + Handler().postDelayed({ + //Delay is Added ,if a request is in processing it may finish + Log.i("Spotify Helper","Download Request Sent") + sharedViewModel?.uiScope?.launch (Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + } + startService(activity,downloadList) + },5000) + } + } + } + } + override fun onFailure(call: Call, t: Throwable) { + Log.i("YT API Req. Fail",t.message.toString()) + } + } + ) } updateStatusBar() } @@ -77,140 +150,18 @@ object SpotifyDownloadHelper { } } - - suspend fun searchYTMusic(type:String, - subFolder:String?, - ytDownloader: YoutubeDownloader?, - searchQuery: String, - track: Track){ - val jsonBody = makeJsonBody(searchQuery.trim()) - youtubeMusicApi?.getYoutubeMusicResponse(jsonBody)?.enqueue( - object : Callback{ - override fun onResponse(call: Call, response: Response) { - spotifyViewModel?.uiScope?.launch { - Log.i("YT API BODY",response.body().toString()) - Log.i("YT Search Query",searchQuery) - getYTLink(type,subFolder,ytDownloader,response.body().toString(),track) - } - } - - override fun onFailure(call: Call, t: Throwable) { - Log.i("YT API Fail",t.message.toString()) - } - } - ) - - } - - - fun updateStatusBar() { - statusBar!!.visibility = View.VISIBLE - statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $Processed ${getEmojiByUnicode(0x274C)}: $notFound" - } - - - fun downloadFile(subFolder: String?, type: String, track:Track, ytDownloader: YoutubeDownloader?, id: String) { - spotifyViewModel!!.uiScope.launch { - withContext(Dispatchers.IO) { - try { - val video = ytDownloader?.getVideo(id) - val format: Format? = try { - video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format - } catch (e: java.lang.IndexOutOfBoundsException) { - Log.i("YTDownloader", e.toString()) - null - } - } - } - format?.let { - val url: String = format.url() - Log.i("DHelper Link Found", url) - val outputFile: String = - Environment.getExternalStorageDirectory().toString() + File.separator + - defaultDir + removeIllegalChars(type) + File.separator + (if (subFolder == null) { - "" - } else { - removeIllegalChars(subFolder) + File.separator - } + removeIllegalChars(track.name!!) + ".m4a") - - val downloadObject = DownloadObject( - track = track, - url = url, - outputDir = outputFile - ) - Log.i("DH", outputFile) - startService(context!!, downloadObject) - Processed++ - spotifyViewModel?.uiScope?.launch(Dispatchers.Main) { - updateStatusBar() - } - } - }catch (e: com.github.kiulian.downloader.YoutubeException){ - Log.i("DH", e.message) - } - } - } - } - - fun startService(context:Context,obj:DownloadObject? = null ) { - val serviceIntent = Intent(context, ForegroundService::class.java) - obj?.let { serviceIntent.putExtra("object",it) } - ContextCompat.startForegroundService(context, serviceIntent) - } - - /** - * 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 - } - private fun animateStatusBar() { - val anim: Animation = AlphaAnimation(0.0f, 0.9f) - anim.duration = 650 //You can manage the blinking time with this parameter + val anim: Animation = AlphaAnimation(0.3f, 0.9f) + anim.duration = 1500 //You can manage the blinking time with this parameter anim.startOffset = 20 anim.repeatMode = Animation.REVERSE anim.repeatCount = Animation.INFINITE statusBar?.animation = anim } + + @SuppressLint("SetTextI18n") + fun updateStatusBar() { + statusBar!!.visibility = View.VISIBLE + statusBar?.text = "Total: $total ${getEmojiByUnicode(0x2705)}: $processed ${getEmojiByUnicode(0x274C)}: $notFound" + } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt index 92856c54..02e17f8f 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YTDownloadHelper.kt @@ -17,49 +17,48 @@ package com.shabinder.spotiflyer.downloadHelper -import android.content.Context -import android.content.Intent import android.os.Environment import android.util.Log -import android.view.View -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.github.kiulian.downloader.model.formats.Format +import android.widget.Toast import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track -import com.shabinder.spotiflyer.worker.ForegroundService +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.Provider.activity +import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.utils.removeIllegalChars +import com.shabinder.spotiflyer.utils.startService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File object YTDownloadHelper { - var context : Context? = null - var statusBar: TextView? = null - - fun downloadFile(subFolder: String?, type: String,ytTrack: Track,format: Format?) { - format?.let { - val url:String = format.url() -// Log.i("DHelper Link Found", url) - val outputFile:String = Environment.getExternalStorageDirectory().toString() + File.separator + - SpotifyDownloadHelper.defaultDir + SpotifyDownloadHelper.removeIllegalChars(type) + File.separator + (if(subFolder == null){""}else{ SpotifyDownloadHelper.removeIllegalChars(subFolder) + File.separator} + SpotifyDownloadHelper.removeIllegalChars( - ytTrack.name!! - ) +".m4a") + suspend fun downloadYTTracks( + type:String, + subFolder: String?, + tracks:List, + ){ + val downloadList = ArrayList() + tracks.forEach { + val outputFile: String = + Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + + removeIllegalChars(type) + File.separator + + (if (subFolder == null) { "" } + else { removeIllegalChars(subFolder) + File.separator } + + removeIllegalChars(it.title) + ".m4a") val downloadObject = DownloadObject( - track = ytTrack, - url = url, - outputDir = outputFile + trackDetails = it, + ytVideoId = "https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + .substringBeforeLast(".")}/maxresdefault.jpg", + outputFile = outputFile ) - Log.i("DH",outputFile) - startService(context!!, downloadObject) - statusBar?.visibility= View.VISIBLE + + downloadList.add(downloadObject) + } + Log.i("YT Downloader Helper","Download Request Sent") + withContext(Dispatchers.Main){ + Toast.makeText(activity,"Download Started, Now You can leave the App!", Toast.LENGTH_SHORT).show() + startService(activity,downloadList) } } - - - - private fun startService(context:Context, obj: DownloadObject? = null ) { - val serviceIntent = Intent(context, ForegroundService::class.java) - serviceIntent.putExtra("object",obj) - ContextCompat.startForegroundService(context, serviceIntent) - } - } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt index daa90974..1ca9a5b5 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/downloadHelper/YoutubeProvider.kt @@ -17,34 +17,26 @@ package com.shabinder.spotiflyer.downloadHelper +import android.annotation.SuppressLint import android.util.Log import com.beust.klaxon.JsonArray import com.beust.klaxon.JsonObject import com.beust.klaxon.Parser -import com.github.kiulian.downloader.YoutubeDownloader -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadFile -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.notFound -import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.models.YoutubeTrack +import me.xdrop.fuzzywuzzy.FuzzySearch +import kotlin.math.absoluteValue /* -* Thanks and credits To https://github.com/spotDL/spotify-downloader +* Thanks To https://github.com/spotDL/spotify-downloader * */ -fun getYTLink(type:String, - subFolder:String?, - ytDownloader: YoutubeDownloader?, - response: String, - track: Track -){ - //TODO Download File +fun getYTTracks(response: String):List{ val youtubeTracks = mutableListOf() - val parser: Parser = Parser.default() + val stringBuilder: StringBuilder = StringBuilder(response) - val responseObj: JsonObject = parser.parse(stringBuilder) as JsonObject + val responseObj: JsonObject = Parser.default().parse(stringBuilder) as JsonObject val contentBlocks = responseObj.obj("contents")?.obj("sectionListRenderer")?.array("contents") val resultBlocks = mutableListOf>() if (contentBlocks != null) { - Log.i("Total Content Blocks:", contentBlocks.size.toString()) for (cBlock in contentBlocks){ /** *Ignore user-suggestion @@ -109,8 +101,6 @@ fun getYTLink(type:String, ! we do so only if their Type is 'Song' or 'Video */ - val simplifiedResults = mutableListOf() - for(result in resultBlocks){ // Blindly gather available details @@ -126,7 +116,7 @@ fun getYTLink(type:String, ! 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-2)){ + for(detail in result.subList(0,result.size-1)){ if(detail.obj("musicResponsiveListItemFlexColumnRenderer")?.size!! < 2) continue // if not a dummy, collect All Variables @@ -138,7 +128,7 @@ fun getYTLink(type:String, ) } } - + //Log.i("Text Api",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 @@ -146,14 +136,7 @@ fun getYTLink(type:String, if ( availableDetails.size > 1 && 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 TODO - - /* - ! grab position of result - ! This helps for those oddball cases where 2+ results are rated equally, - ! lower position --> better match - */ - val resultPosition = resultBlocks.indexOf(result) + if(availableDetails[4].split(':').size != 2) continue //Has Been Giving Issues /* ! grab Video ID @@ -168,23 +151,86 @@ fun getYTLink(type:String, name = availableDetails[0], type = availableDetails[1], artist = availableDetails[2], + duration = availableDetails[4], videoId = videoId ) youtubeTracks.add(ytTrack) } } } - //Songs First, Videos Later - youtubeTracks.sortWith { o1: YoutubeTrack, o2: YoutubeTrack -> o1.type.toString().compareTo(o2.type.toString()) } - if(youtubeTracks.firstOrNull()?.videoId.isNullOrBlank()) notFound++ - else downloadFile( - subFolder, - type, - track, - ytDownloader, - id = youtubeTracks[0].videoId.toString() - ) - Log.i("DHelper YT ID", youtubeTracks.firstOrNull()?.videoId ?: "Not Found") - SpotifyDownloadHelper.updateStatusBar() + 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.i("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.i("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.i("YT Api Result", "$trackName - $linksWithMatchValue") + return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap() } diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt index a630a217..033a6944 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/DownloadObject.kt @@ -19,11 +19,32 @@ package com.shabinder.spotiflyer.models import android.os.Parcelable import kotlinx.android.parcel.Parcelize +import java.io.File @Parcelize data class DownloadObject( - var ytVideo: YTTrack?=null, - var track: Track?=null, - var url:String, - var outputDir:String -):Parcelable \ No newline at end of file + var trackDetails: TrackDetails, + var ytVideoId:String, + var outputFile:String +):Parcelable + +@Parcelize +data class TrackDetails( + var title:String, + var artists:List, + var durationSec:Int, + var albumName:String?=null, + var year:String?=null, + var comment:String?=null, + var lyrics:String?=null, + var trackUrl:String?=null, + var albumArt: File, + var source:Source, + var downloaded:DownloadStatus = DownloadStatus.NotDownloaded +):Parcelable + +enum class DownloadStatus{ + Downloaded, + Downloading, + NotDownloaded +} \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt b/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt index 9ce8893c..01f8e984 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/Track.kt @@ -31,7 +31,6 @@ data class Track( var explicit: Boolean? = null, var external_urls: Map? = null, var href: String? = null, - var id: String? = null, var name: String? = null, var preview_url: String? = null, var track_number: Int = 0, @@ -40,5 +39,5 @@ data class Track( var album: Album? = null, var external_ids: Map? = null, var popularity: Int? = null, - var ytCoverUrl:String? = null, - var downloaded:String? = "notDownloaded"):Parcelable \ No newline at end of file + var downloaded:DownloadStatus? = DownloadStatus.NotDownloaded):Parcelable + diff --git a/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt index 4ea7de9d..03b588ba 100644 --- a/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/models/YoutubeTrack.kt @@ -25,5 +25,6 @@ data class YoutubeTrack( var name: String? = null, var type: String? = null, // Song / Video var artist: String? = null, + var duration:String? = null, var videoId: String? = null ):Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt index ec6af4e4..277ee1f4 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/SpotifyTrackListAdapter.kt @@ -17,6 +17,7 @@ package com.shabinder.spotiflyer.recyclerView +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -24,13 +25,14 @@ import android.widget.Toast import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.context import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper.downloadAllTracks +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.Track import com.shabinder.spotiflyer.ui.spotify.SpotifyViewModel +import com.shabinder.spotiflyer.utils.Provider.activity import com.shabinder.spotiflyer.utils.bindImage import com.shabinder.spotiflyer.utils.rotateAnim import kotlinx.coroutines.launch @@ -40,7 +42,6 @@ class SpotifyTrackListAdapter: ListAdapter { + DownloadStatus.Downloaded -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_tick) holder.binding.btnDownload.clearAnimation() } - "Downloading" -> { + DownloadStatus.Downloading -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(holder.binding.btnDownload) } - "notDownloaded" -> { + DownloadStatus.NotDownloaded -> { holder.binding.btnDownload.setImageResource(R.drawable.ic_arrow) holder.binding.btnDownload.clearAnimation() holder.binding.btnDownload.setOnClickListener{ - Toast.makeText(context,"Starting Download",Toast.LENGTH_SHORT).show() + Toast.makeText(activity,"Processing!",Toast.LENGTH_SHORT).show() holder.binding.btnDownload.setImageResource(R.drawable.ic_refresh) rotateAnim(it) - item.downloaded = "Downloading" + item.downloaded = DownloadStatus.Downloading spotifyViewModel!!.uiScope.launch { val itemList = mutableListOf() itemList.add(item) - downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList,ytDownloader) + downloadAllTracks(spotifyViewModel!!.folderType,spotifyViewModel!!.subFolder,itemList) } notifyItemChanged(position)//start showing anim! } diff --git a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt index d3e5e224..ae0b1a61 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/recyclerView/YoutubeTrackListAdapter.kt @@ -22,18 +22,16 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.github.kiulian.downloader.model.formats.Format import com.shabinder.spotiflyer.databinding.TrackListItemBinding -import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.utils.bindImage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class YoutubeTrackListAdapter: ListAdapter(YouTubeTrackDiffCallback()) { +class YoutubeTrackListAdapter: ListAdapter(YouTubeTrackDiffCallback()) { - var format:Format? = null private val adapterScope = CoroutineScope(Dispatchers.Default) override fun onCreateViewHolder( @@ -51,26 +49,31 @@ class YoutubeTrackListAdapter: ListAdapter(){ - override fun areItemsTheSame(oldItem: Track, newItem: Track): Boolean { - return oldItem.name == newItem.name +class YouTubeTrackDiffCallback: DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean { + return oldItem.title == newItem.title } - override fun areContentsTheSame(oldItem: Track, newItem: Track): Boolean { + override fun areContentsTheSame(oldItem: TrackDetails, newItem: TrackDetails): Boolean { return oldItem == newItem } } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt index b4a2d6e5..24c7d99c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyFragment.kt @@ -24,41 +24,33 @@ import android.content.Intent import android.content.IntentFilter import android.net.ConnectivityManager import android.os.Bundle -import android.os.Environment import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.core.net.toUri import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.SimpleItemAnimator -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.SpotifyFragmentBinding import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.recyclerView.SpotifyTrackListAdapter import com.shabinder.spotiflyer.utils.YoutubeMusicApi import com.shabinder.spotiflyer.utils.bindImage -import com.shabinder.spotiflyer.utils.copyTo +import com.shabinder.spotiflyer.utils.loadAllImages import com.shabinder.spotiflyer.utils.rotateAnim import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException import javax.inject.Inject @Suppress("DEPRECATION") @@ -69,7 +61,6 @@ class SpotifyFragment : Fragment() { private lateinit var spotifyViewModel: SpotifyViewModel private lateinit var sharedViewModel: SharedViewModel private lateinit var adapterSpotify:SpotifyTrackListAdapter - @Inject lateinit var ytDownloader:YoutubeDownloader @Inject lateinit var youtubeMusicApi: YoutubeMusicApi private var intentFilter:IntentFilter? = null private var updateUIReceiver: BroadcastReceiver? = null @@ -109,30 +100,34 @@ class SpotifyFragment : Fragment() { spotifyViewModel.spotifySearch(type,link) if(type=="album")adapterSpotify.isAlbum = true - binding.btnDownloadAllSpotify.setOnClickListener { - for (track in spotifyViewModel.trackList.value!!){ - if(track.downloaded != "Downloaded"){ - track.downloaded = "Downloading" - } - } - binding.btnDownloadAllSpotify.visibility = View.GONE - binding.downloadingFabSpotify.visibility = View.VISIBLE + binding.btnDownloadAll.setOnClickListener { + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE - rotateAnim(binding.downloadingFabSpotify) + rotateAnim(binding.downloadingFab) for (track in spotifyViewModel.trackList.value!!){ - if(track.downloaded != "Downloaded"){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading adapterSpotify.notifyItemChanged(spotifyViewModel.trackList.value!!.indexOf(track)) } } - showToast("Starting Download in Few Seconds") - spotifyViewModel.uiScope.launch(Dispatchers.Default){loadAllImages(spotifyViewModel.trackList.value!!)} + showToast("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + spotifyViewModel.trackList.value?.forEach { urlList.add(it.album?.images?.get(0)?.url.toString()) } + //Appending Source + urlList.add("spotify") + loadAllImages( + requireActivity(), + urlList + ) + } spotifyViewModel.uiScope.launch { SpotifyDownloadHelper.downloadAllTracks( spotifyViewModel.folderType, spotifyViewModel.subFolder, spotifyViewModel.trackList.value!!, - ytDownloader ) } } @@ -154,15 +149,18 @@ class SpotifyFragment : Fragment() { override fun onReceive(context: Context?, intent: Intent?) { //UI update here if (intent != null){ - val track = intent.getParcelableExtra("track") - track?.let { - val position: Int = spotifyViewModel.trackList.value?.indexOf(track)!! + val trackDetails = intent.getParcelableExtra("track") + trackDetails?.let { + val position: Int = spotifyViewModel.trackList.value?.map { it.name }?.indexOf(trackDetails.title) ?: -1 Log.i("Track","Download Completed Intent :$position") - track.downloaded = "Downloaded" if(position != -1) { - spotifyViewModel.trackList.value?.set(position, track) - adapterSpotify.notifyItemChanged(position) - checkIfAllDownloaded() + val track = spotifyViewModel.trackList.value?.get(position) + track?.let{ + it.downloaded = DownloadStatus.Downloaded + spotifyViewModel.trackList.value?.set(position, it) + adapterSpotify.notifyItemChanged(position) + checkIfAllDownloaded() + } } } } @@ -184,7 +182,7 @@ class SpotifyFragment : Fragment() { * CoverUrl Binding Observer! **/ spotifyViewModel.coverUrl.observe(viewLifecycleOwner, { - if(it!="Loading") bindImage(binding.spotifyCoverImage,it) + if(it!="Loading") bindImage(binding.coverImage,it,Source.Spotify) }) /** @@ -202,7 +200,7 @@ class SpotifyFragment : Fragment() { * Title Binding Observer! **/ spotifyViewModel.title.observe(viewLifecycleOwner, { - binding.titleViewSpotify.text = it + binding.titleView.text = it }) sharedViewModel.intentString.observe(viewLifecycleOwner,{ @@ -215,10 +213,10 @@ class SpotifyFragment : Fragment() { } private fun checkIfAllDownloaded() { - if(!spotifyViewModel.trackList.value!!.any { it.downloaded != "Downloaded" }){ + if(!spotifyViewModel.trackList.value!!.any { it.downloaded != DownloadStatus.Downloaded }){ //All Tracks Downloaded - binding.btnDownloadAllSpotify.visibility = View.GONE - binding.downloadingFabSpotify.apply{ + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.apply{ setImageResource(R.drawable.ic_tick) visibility = View.VISIBLE clearAnimation() @@ -236,69 +234,17 @@ class SpotifyFragment : Fragment() { sharedViewModel.spotifyService.observe(viewLifecycleOwner, Observer { spotifyViewModel.spotifyService = it }) - SpotifyDownloadHelper.context = requireContext() SpotifyDownloadHelper.youtubeMusicApi = youtubeMusicApi - SpotifyDownloadHelper.spotifyViewModel = spotifyViewModel - SpotifyDownloadHelper.statusBar = binding.StatusBarSpotify - binding.trackListSpotify.adapter = adapterSpotify - (binding.trackListSpotify.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - } - - /** - * Function to fetch all Images for using in mp3 tag. - **/ - private suspend fun loadAllImages(trackList: List) { - trackList.forEach { - val imgUrl = it.album?.images?.get(0)?.url - imgUrl?.let { - val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() - Glide - .with(requireContext()) - .asFile() - .load(imgUri) - .listener(object: RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - Log.i("Glide","LoadFailed") - return false - } - - override fun onResourceReady( - resource: File?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - sharedViewModel.uiScope.launch { - withContext(Dispatchers.IO){ - try { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/') + ".jpeg" - ) - resource?.copyTo(file) - } catch (e: IOException) { - e.printStackTrace() - } - } - } - return false - } - }).submit() - } - } + SpotifyDownloadHelper.sharedViewModel = sharedViewModel + SpotifyDownloadHelper.statusBar = binding.statusBar + binding.trackList.adapter = adapterSpotify + (binding.trackList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } /** * Configure Recycler View Adapter **/ private fun adapterConfig(trackList: List){ - adapterSpotify.ytDownloader = ytDownloader adapterSpotify.spotifyViewModel = spotifyViewModel adapterSpotify.submitList(trackList) } @@ -320,4 +266,5 @@ class SpotifyFragment : Fragment() { val netInfo = cm.activeNetworkInfo return netInfo != null && netInfo.isConnectedOrConnecting } + } \ No newline at end of file diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt index 6a3a9082..2ab721a6 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/spotify/SpotifyViewModel.kt @@ -52,7 +52,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO folderType = "Tracks" val tempTrackList = mutableListOf() if(File(finalOutputDir(trackObject?.name!!,folderType,subFolder)).exists()){//Download Already Present!! - trackObject.downloaded = "Downloaded" + trackObject.downloaded = DownloadStatus.Downloaded } tempTrackList.add(trackObject) trackList.value = tempTrackList @@ -65,7 +65,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO link = "https://open.spotify.com/$type/$link", coverUrl = coverUrl.value!!, totalFiles = tempTrackList.size, - downloaded = trackObject.downloaded =="Downloaded", + downloaded = trackObject.downloaded == DownloadStatus.Downloaded, directory = finalOutputDir(trackObject.name!!,folderType,subFolder) )) } @@ -80,7 +80,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO val tempTrackList = mutableListOf() albumObject?.tracks?.items?.forEach { if(File(finalOutputDir(it.name!!,folderType,subFolder)).exists()){//Download Already Present!! - it.downloaded = "Downloaded" + it.downloaded = DownloadStatus.Downloaded } it.album = Album(images = listOf(Image(url = albumObject.images?.get(0)?.url))) tempTrackList.add(it) @@ -112,7 +112,7 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO playlistObject?.tracks?.items?.forEach { it.track?.let { it1 -> if(File(finalOutputDir(it1.name!!,folderType,subFolder)).exists()){//Download Already Present!! - it1.downloaded = "Downloaded" + it1.downloaded = DownloadStatus.Downloaded } tempTrackList.add(it1) } @@ -130,13 +130,13 @@ class SpotifyViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO Log.i("Total Tracks Fetched",tempTrackList.size.toString()) trackList.value = tempTrackList title.value = playlistObject?.name - coverUrl.value = playlistObject?.images?.get(0)!!.url!! + coverUrl.value = playlistObject?.images?.get(0)?.url.toString() withContext(Dispatchers.IO){ databaseDAO.insert(DownloadRecord( type = "Playlist", - name = title.value!!, + name = title.value.toString(), link = "https://open.spotify.com/$type/$link", - coverUrl = coverUrl.value!!, + coverUrl = coverUrl.value.toString(), totalFiles = tempTrackList.size, downloaded = File(finalOutputDir(type = folderType,subFolder = subFolder)).listFiles()?.size == tempTrackList.size, directory = finalOutputDir(type = folderType,subFolder = subFolder) diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt index cc3678ad..9801997a 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeFragment.kt @@ -24,17 +24,22 @@ import android.view.ViewGroup import android.widget.Toast import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.R import com.shabinder.spotiflyer.SharedViewModel import com.shabinder.spotiflyer.databinding.YoutubeFragmentBinding import com.shabinder.spotiflyer.downloadHelper.YTDownloadHelper -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails import com.shabinder.spotiflyer.recyclerView.YoutubeTrackListAdapter import com.shabinder.spotiflyer.utils.bindImage +import com.shabinder.spotiflyer.utils.loadAllImages +import com.shabinder.spotiflyer.utils.rotateAnim import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -43,10 +48,10 @@ class YoutubeFragment : Fragment() { private lateinit var binding:YoutubeFragmentBinding private lateinit var youtubeViewModel: YoutubeViewModel private lateinit var sharedViewModel: SharedViewModel + @Inject lateinit var ytDownloader: YoutubeDownloader private lateinit var adapter : YoutubeTrackListAdapter private val sampleDomain1 = "youtube.com" private val sampleDomain2 = "youtu.be" - @Inject lateinit var ytDownloader: YoutubeDownloader override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -56,9 +61,7 @@ class YoutubeFragment : Fragment() { youtubeViewModel = ViewModelProvider(this).get(YoutubeViewModel::class.java) sharedViewModel = ViewModelProvider(this.requireActivity()).get(SharedViewModel::class.java) adapter = YoutubeTrackListAdapter() - YTDownloadHelper.context = requireContext() - YTDownloadHelper.statusBar = binding.StatusBarYoutube - binding.trackListYoutube.adapter = adapter + binding.trackList.adapter = adapter initializeLiveDataObservers() @@ -70,7 +73,11 @@ class YoutubeFragment : Fragment() { private fun youtubeSearch(linkSearch:String) { val link = linkSearch.removePrefix("https://").removePrefix("http://") - if(!link.contains("playlist",true)){ + if(link.contains("playlist",true) || link.contains("list",true)){ + // Given Link is of a Playlist + val playlistId = link.substringAfter("?list=").substringAfter("&list=").substringBefore("&") + youtubeViewModel.getYTPlaylist(playlistId,ytDownloader) + }else{//Given Link is of a Video var searchId = "error" if(link.contains(sampleDomain1,true) ){ searchId = link.substringAfterLast("=","error") @@ -79,41 +86,63 @@ class YoutubeFragment : Fragment() { searchId = link.substringAfterLast("/","error") } if(searchId != "error") { - youtubeViewModel.getYTTrack(searchId,ytDownloader) - binding.btnDownloadAllYoutube.setOnClickListener { - YTDownloadHelper.downloadFile(null,"YT_Downloads", - youtubeViewModel.ytTrack.value!!,youtubeViewModel.format.value) - } + youtubeViewModel.getYTTrack(searchId,ytDownloader) }else{showToast("Your Youtube Link is not of a Video!!")} - }else(showToast("Your Youtube Link is not of a Video!!")) + } + binding.btnDownloadAll.setOnClickListener { + binding.btnDownloadAll.visibility = View.GONE + binding.downloadingFab.visibility = View.VISIBLE + + rotateAnim(binding.downloadingFab) + + for (track in youtubeViewModel.ytTrackList.value?: listOf()){ + if(track.downloaded != DownloadStatus.Downloaded){ + track.downloaded = DownloadStatus.Downloading + adapter.notifyItemChanged(youtubeViewModel.ytTrackList.value!!.indexOf(track)) + } + } + showToast("Processing!") + sharedViewModel.uiScope.launch(Dispatchers.Default){ + val urlList = arrayListOf() + youtubeViewModel.ytTrackList.value?.forEach { urlList.add("https://i.ytimg.com/vi/${it.albumArt.absolutePath.substringAfterLast("/") + .substringBeforeLast(".")}/maxresdefault.jpg")} + //Appending Source + urlList.add("youtube") + loadAllImages( + requireActivity(), + urlList + ) + } + youtubeViewModel.uiScope.launch { + YTDownloadHelper.downloadYTTracks( + type = youtubeViewModel.folderType, + subFolder = youtubeViewModel.subFolder, + tracks = youtubeViewModel.ytTrackList.value ?: listOf() + ) + } + } } private fun initializeLiveDataObservers() { /** * CoverUrl Binding Observer! **/ - youtubeViewModel.coverUrl.observe(viewLifecycleOwner, Observer { - if(it!="Loading") bindImage(binding.youtubeCoverImage,it) + youtubeViewModel.coverUrl.observe(viewLifecycleOwner, { + if(it!="Loading") bindImage(binding.coverImage,it,Source.YouTube) }) /** * TrackList Binding Observer! **/ - youtubeViewModel.ytTrack.observe(viewLifecycleOwner, Observer { - val list = mutableListOf() - list.add(it) - adapterConfig(list) - }) - - youtubeViewModel.format.observe(viewLifecycleOwner, Observer { - adapter.format = it + youtubeViewModel.ytTrackList.observe(viewLifecycleOwner, { + adapterConfig(it) }) /** * Title Binding Observer! **/ - youtubeViewModel.title.observe(viewLifecycleOwner, Observer { - binding.titleViewYoutube.text = it + youtubeViewModel.title.observe(viewLifecycleOwner, { + binding.titleView.text = it }) } @@ -121,7 +150,7 @@ class YoutubeFragment : Fragment() { /** * Configure Recycler View Adapter **/ - private fun adapterConfig(list:List){ + private fun adapterConfig(list:List){ adapter.submitList(list) } diff --git a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt index 48beedee..f9a7b15c 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/ui/youtube/YoutubeViewModel.kt @@ -17,78 +17,119 @@ package com.shabinder.spotiflyer.ui.youtube +import android.annotation.SuppressLint +import android.os.Environment import android.util.Log import androidx.hilt.lifecycle.ViewModelInject import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.model.formats.Format -import com.github.kiulian.downloader.model.quality.AudioQuality import com.shabinder.spotiflyer.database.DatabaseDAO import com.shabinder.spotiflyer.database.DownloadRecord -import com.shabinder.spotiflyer.models.Artist -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.DownloadStatus +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.Provider +import com.shabinder.spotiflyer.utils.Provider.defaultDir import com.shabinder.spotiflyer.utils.finalOutputDir +import com.shabinder.spotiflyer.utils.removeIllegalChars import kotlinx.coroutines.* +import java.io.File -class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : - ViewModel(){ +class YoutubeViewModel @ViewModelInject constructor(val databaseDAO: DatabaseDAO) : ViewModel(){ - val ytTrack = MutableLiveData() + /* + * YT Album Art Schema + * Normal Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + * */ + + val ytTrackList = MutableLiveData>() val format = MutableLiveData() private val loading = "Loading" var title = MutableLiveData().apply { value = "\"Loading!\"" } var coverUrl = MutableLiveData().apply { value = loading } - + val folderType = "YT_Downloads" + var subFolder = "" private var viewModelJob = Job() val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) - fun getYTTrack(searchId:String,ytDownloader:YoutubeDownloader) { - uiScope.launch { - withContext(Dispatchers.IO){ - Log.i("YT View Model",searchId) - val video = ytDownloader.getVideo(searchId) - val detail = video?.details() - val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() - Log.i("YT View Model",detail.toString()) - ytTrack.postValue( - Track( - id = searchId, - name = name, - artists = listOf(Artist(name = detail?.author())), - duration_ms = detail?.lengthSeconds()?.times(1000)?.toLong()?:0, - ytCoverUrl = "https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" - )) - coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") - title.postValue( - if(name?.length!! > 17){"${name.subSequence(0,16)}..."}else{name} + fun getYTPlaylist(searchId:String, ytDownloader:YoutubeDownloader){ + uiScope.launch(Dispatchers.IO) { + Log.i("YT Playlist",searchId) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details() + val name = playlistDetails.title() + subFolder = removeIllegalChars(name).toString() + val videos = playlist.videos() + coverUrl.postValue("https://i.ytimg.com/vi/${videos.firstOrNull()?.videoId()}/maxresdefault.jpg") + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{name} + ) + ytTrackList.postValue(videos.map { + TrackDetails( + title = it.title(), + artists = listOf(it.author().toString()), + durationSec = it.lengthSeconds(), + albumArt = File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + it.videoId() + ".jpeg" + ), + source = Source.YouTube, + downloaded = if(File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists()) + DownloadStatus.Downloaded + else DownloadStatus.NotDownloaded ) - format.postValue(try { - video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - try { - video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format - } catch (e: IndexOutOfBoundsException) { - Log.i("YTDownloader", e.toString()) - null - } - } - }) + }) + 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/maxresdefault.jpg", - totalFiles = 1, - downloaded = false, - directory = finalOutputDir(type = "YT_Downloads") - )) - } + 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()}/maxresdefault.jpg", + totalFiles = videos.size, + directory = finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder), + downloaded = File(finalOutputDir(itemName = removeIllegalChars(name),type = folderType,subFolder = subFolder)).exists() + )) + } + } + } + + @SuppressLint("DefaultLocale") + fun getYTTrack(searchId:String, ytDownloader:YoutubeDownloader) { + uiScope.launch(Dispatchers.IO) { + Log.i("YT Video",searchId) + val video = ytDownloader.getVideo(searchId) + coverUrl.postValue("https://i.ytimg.com/vi/$searchId/maxresdefault.jpg") + val detail = video?.details() + val name = detail?.title()?.replace(detail.author()!!.toUpperCase(),"",true) ?: detail?.title() ?: "" + Log.i("YT View Model",detail.toString()) + ytTrackList.postValue(listOf(TrackDetails( + title = name, + artists = listOf(detail?.author().toString()), + durationSec = detail?.lengthSeconds()?:0, + albumArt = File( + Environment.getExternalStorageDirectory(), + Provider.defaultDir +".Images/" + searchId + ".jpeg" + ), + source = Source.YouTube + ))) + title.postValue( + if(name.length > 17){"${name.subSequence(0,16)}..."}else{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/maxresdefault.jpg", + totalFiles = 1, + downloaded = false, + directory = finalOutputDir(type = "YT_Downloads") + )) } } } diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt deleted file mode 100755 index b18980a7..00000000 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/BindingAdapter.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (C) 2020 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.spotiflyer.utils - -import android.os.Environment -import android.util.Log -import android.view.View -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import android.widget.ImageView -import androidx.core.net.toUri -import androidx.databinding.BindingAdapter -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException - -fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,extension:String? = ".mp3"): String{ - return Environment.getExternalStorageDirectory().toString() + File.separator + - SpotifyDownloadHelper.defaultDir + SpotifyDownloadHelper.removeIllegalChars(type) + File.separator + - (if(subFolder == null){""}else{ SpotifyDownloadHelper.removeIllegalChars(subFolder) + File.separator} - + itemName?.let { SpotifyDownloadHelper.removeIllegalChars(it) + extension}) -} - -fun rotateAnim(view: View){ - val rotate = RotateAnimation( - 0F, 360F, - Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f - ) - rotate.duration = 1000 - rotate.repeatCount = Animation.INFINITE - rotate.repeatMode = Animation.INFINITE - rotate.interpolator = LinearInterpolator() - view.animation = rotate -} - - -@BindingAdapter("imageUrl") -fun bindImage(imgView: ImageView, imgUrl: String?) { - imgUrl?.let { - val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() - Glide - .with(imgView) - .asFile() - .load(imgUri) - .placeholder(R.drawable.ic_song_placeholder) - .error(R.drawable.ic_musicplaceholder) - .listener(object:RequestListener{ - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - Log.i("Glide","LoadFailed") - return false - } - - override fun onResourceReady( - resource: File?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - CoroutineScope(Dispatchers.Main).launch { - try { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" - ) // the File to save , append increasing numeric counter to prevent files from getting overwritten. - resource?.copyTo(file) - withContext(Dispatchers.Main){ - Glide.with(imgView) - .load(file) - .placeholder(R.drawable.ic_song_placeholder) - .into(imgView) -// Log.i("Glide","imageSaved") - } - } catch (e: IOException) { - e.printStackTrace() - } - } - return false - } - }).submit() - } - } - -/** - *Extension Function For Copying Files! - **/ -fun File.copyTo(file: File) { - inputStream().use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } -} -fun createDirectory(dir:String){ - val yourAppDir = File(Environment.getExternalStorageDirectory(), - dir) - - if(!yourAppDir.exists() && !yourAppDir.isDirectory) - { // create empty directory - if (yourAppDir.mkdirs()) - {Log.i("CreateDir","App dir created")} - else - {Log.w("CreateDir","Unable to create app dir!")} - } - else - {Log.i("CreateDir","App dir already exists")} -} diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt index 3638edf0..123a2fd2 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Provider.kt @@ -18,6 +18,7 @@ package com.shabinder.spotiflyer.utils import android.content.Context +import android.os.Environment import com.github.kiulian.downloader.YoutubeDownloader import com.shabinder.spotiflyer.App import com.shabinder.spotiflyer.MainActivity @@ -38,18 +39,28 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory +import java.io.File import javax.inject.Singleton @InstallIn(ApplicationComponent::class) @Module object Provider { + lateinit var activity: MainActivity + val defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator + + @Provides fun databaseDAO(@ApplicationContext appContext: Context):DatabaseDAO{ return DownloadRecordDatabase.getInstance(appContext).databaseDAO } + @Provides + @Singleton + fun getYTDownloader():YoutubeDownloader{ + return YoutubeDownloader() + } @Provides @Singleton @@ -72,12 +83,6 @@ object Provider { .build() } - @Provides - @Singleton - fun getYTDownloader():YoutubeDownloader{ - return YoutubeDownloader() - } - @Provides @Singleton fun getSpotifyTokenInterface():SpotifyServiceTokenRequest{ diff --git a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt old mode 100644 new mode 100755 index 79f44b5a..8435cb1e --- a/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/utils/Utils.kt @@ -17,15 +17,203 @@ package com.shabinder.spotiflyer.utils -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper +import android.content.Context +import android.content.Intent +import android.os.Environment +import android.util.Log +import android.view.View +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import android.view.animation.RotateAnimation +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.shabinder.spotiflyer.R +import com.shabinder.spotiflyer.models.DownloadObject +import com.shabinder.spotiflyer.models.Source +import com.shabinder.spotiflyer.utils.Provider.defaultDir +import com.shabinder.spotiflyer.worker.ForegroundService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException + +fun loadAllImages(context: Context?, images:ArrayList? = null ) { + val serviceIntent = Intent(context, ForegroundService::class.java) + images?.let { serviceIntent.putStringArrayListExtra("imagesList",it) } + context?.let { ContextCompat.startForegroundService(it, serviceIntent) } +} + +fun startService(context:Context?,objects:ArrayList? = null ) { + val serviceIntent = Intent(context, ForegroundService::class.java) + objects?.let { serviceIntent.putParcelableArrayListExtra("object",it) } + context?.let { ContextCompat.startForegroundService(it, serviceIntent) } +} + +fun finalOutputDir(itemName:String? = null,type:String, subFolder:String?=null,extension:String? = ".mp3"): String{ + return Environment.getExternalStorageDirectory().toString() + File.separator + + defaultDir + removeIllegalChars(type) + File.separator + + (if(subFolder == null){""}else{ removeIllegalChars(subFolder) + File.separator} + + itemName?.let { removeIllegalChars(it) + extension}) +} + +fun rotateAnim(view: View){ + val rotate = RotateAnimation( + 0F, 360F, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f + ) + rotate.duration = 1000 + rotate.repeatCount = Animation.INFINITE + rotate.repeatMode = Animation.INFINITE + rotate.interpolator = LinearInterpolator() + view.animation = rotate +} + +@BindingAdapter("imageUrl") +fun bindImage(imgView: ImageView, imgUrl: String?,source: Source) { + imgUrl?.let { + val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() + Glide + .with(imgView) + .asFile() + .load(imgUri) + .placeholder(R.drawable.ic_song_placeholder) + .error(R.drawable.ic_musicplaceholder) + .listener(object:RequestListener{ + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + Log.i("Glide","LoadFailed") + return false + } + + override fun onResourceReady( + resource: File?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + CoroutineScope(Dispatchers.Main).launch { + try { + val file = when(source){ + Source.Spotify->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir+".Images/" + imgUrl.substringAfterLast('/',imgUrl) + ".jpeg" + ) + } + Source.YouTube->{ + //Url Format: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" + // We Are Naming using "$searchId" + File( + Environment.getExternalStorageDirectory(), + defaultDir+".Images/" + imgUrl.substringBeforeLast('/',imgUrl).substringAfterLast('/',imgUrl) + ".jpeg" + ) + } + } + // the File to save , append increasing numeric counter to prevent files from getting overwritten. + resource?.copyTo(file) + withContext(Dispatchers.Main){ + Glide.with(imgView) + .load(file) + .placeholder(R.drawable.ic_song_placeholder) + .into(imgView) +// Log.i("Glide","imageSaved") + } + } catch (e: IOException) { + e.printStackTrace() + } + } + return false + } + }).submit() + } + } + +/** + *Extension Function For Copying Files! + **/ +fun File.copyTo(file: File) { + inputStream().use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } +} +fun createDirectory(dir:String){ + val yourAppDir = File(Environment.getExternalStorageDirectory(), + dir) + + if(!yourAppDir.exists() && !yourAppDir.isDirectory) + { // create empty directory + if (yourAppDir.mkdirs()) + {Log.i("CreateDir","App dir created")} + else + {Log.w("CreateDir","Unable to create app dir!")} + } + else + {Log.i("CreateDir","App dir already exists")} +} +/** + * 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 +} fun createDirectories() { - createDirectory(SpotifyDownloadHelper.defaultDir) - createDirectory(SpotifyDownloadHelper.defaultDir + ".Images/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Tracks/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Albums/") - createDirectory(SpotifyDownloadHelper.defaultDir + "Playlists/") - createDirectory(SpotifyDownloadHelper.defaultDir + "YT_Downloads/") + createDirectory(defaultDir) + createDirectory(defaultDir + ".Images/") + createDirectory(defaultDir + "Tracks/") + createDirectory(defaultDir + "Albums/") + createDirectory(defaultDir + "Playlists/") + createDirectory(defaultDir + "YT_Downloads/") } fun getEmojiByUnicode(unicode: Int): String? { return String(Character.toChars(unicode)) diff --git a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt index ba103032..9dcf145e 100755 --- a/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt +++ b/app/src/main/java/com/shabinder/spotiflyer/worker/ForegroundService.kt @@ -29,27 +29,36 @@ import android.os.* import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import com.arthenica.mobileffmpeg.Config import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS import com.arthenica.mobileffmpeg.FFmpeg +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.github.kiulian.downloader.YoutubeDownloader +import com.github.kiulian.downloader.model.formats.Format +import com.github.kiulian.downloader.model.quality.AudioQuality import com.mpatric.mp3agic.ID3v1Tag import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.Mp3File import com.shabinder.spotiflyer.MainActivity import com.shabinder.spotiflyer.R -import com.shabinder.spotiflyer.downloadHelper.SpotifyDownloadHelper import com.shabinder.spotiflyer.models.DownloadObject -import com.shabinder.spotiflyer.models.Track +import com.shabinder.spotiflyer.models.TrackDetails +import com.shabinder.spotiflyer.utils.copyTo import com.tonyodev.fetch2.* import com.tonyodev.fetch2core.DownloadBlock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.io.File import java.io.FileInputStream +import java.io.IOException +import java.util.* +@Suppress("DEPRECATION") class ForegroundService : Service(){ private val tag = "Foreground Service" private val channelId = "ForegroundDownloaderService" @@ -57,22 +66,21 @@ class ForegroundService : Service(){ private var total = 0 //Total Downloads Requested private var converted = 0//Total Files Converted private var downloaded = 0//Total Files downloaded - private var fetch:Fetch? = null - private var downloadManager : DownloadManager? = null - private var downloadList = mutableListOf() + private lateinit var fetch:Fetch + private lateinit var ytDownloader: YoutubeDownloader + private lateinit var downloadManager : DownloadManager private var serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) - private val requestMap = mutableMapOf() - private val downloadMap = mutableMapOf() + private val requestMap = mutableMapOf() private var speed :Long = 0 - private var defaultDirectory = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator + private var defaultDir = Environment.DIRECTORY_MUSIC + File.separator + "SpotiFlyer" + File.separator private val parentDirectory = File(Environment.getExternalStorageDirectory(), - defaultDirectory+File.separator + defaultDir +File.separator ) private var wakeLock: PowerManager.WakeLock? = null private var isServiceStarted = false var notificationLine = 0 - val messageList = mutableListOf("","","","") + val messageList = mutableListOf("","","","") private var pendingIntent:PendingIntent? = null @@ -89,7 +97,7 @@ class ForegroundService : Service(){ 0, notificationIntent, 0 ) downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - + ytDownloader = YoutubeDownloader() val fetchConfiguration = FetchConfiguration.Builder(this) .setDownloadConcurrentLimit(4) @@ -98,86 +106,41 @@ class ForegroundService : Service(){ Fetch.setDefaultInstanceConfiguration(fetchConfiguration) fetch = Fetch.getDefaultInstance() -// fetch?.enableLogging(true) - fetch?.addListener(fetchListener) + fetch.addListener(fetchListener) //clearing all not completed Downloads //Starting fresh - fetch?.removeAll() + fetch.removeAll() startForeground() } - /** - *Starting Service with Notification as Foreground! - **/ - private fun startForeground() { - val channelId = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(channelId, "Downloader Service") - } else { - // If earlier version channel ID is not used - // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) - "" - } - - val notification = NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.down_arrowbw) - .setNotificationSilent() - .setSubText("Total: $total Completed:$converted") - .setStyle(NotificationCompat.InboxStyle() - .setBigContentTitle("Speed: $speed KB/s") - .addLine(messageList[0]) - .addLine(messageList[1]) - .addLine(messageList[2]) - .addLine(messageList[3])) - .setContentIntent(pendingIntent) - .build() - startForeground(notificationId, notification) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(channelId: String, channelName: String): String{ - val chan = NotificationChannel(channelId, - channelName, NotificationManager.IMPORTANCE_DEFAULT) - chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - service.createNotificationChannel(chan) - return channelId - } - @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { // Send a notification that service is started Log.i(tag,"Service Started.") startForeground() - val obj:DownloadObject? = intent.getParcelableExtra("object") ?: intent.extras?.getParcelable("object") - obj?.let { - total ++ - updateNotification() - serviceScope.launch { - val request= Request(obj.url, obj.outputDir) - request.priority = Priority.NORMAL - request.networkType = NetworkType.ALL + val downloadObjects: ArrayList? = (intent.getParcelableArrayListExtra("object") ?: intent.extras?.getParcelableArrayList("object")) + val imagesList: ArrayList? = (intent.getStringArrayListExtra("imagesList") ?: intent.extras?.getStringArrayList("imagesList")) - fetch!!.enqueue(request, - { - obj.track?.let { it1 -> requestMap.put(it, it1) } - downloadList.remove(obj) - Log.i(tag, "Enqueuing Download") - }, - { - Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} - ) + imagesList?.let{ + serviceScope.launch { + loadAllImages(it) } } + downloadObjects?.let { + total += downloadObjects.size + updateNotification() + downloadAllTracks(downloadObjects) + } + //Wake locks and misc tasks from here : return if (isServiceStarted){ + //Service Already Started START_STICKY } else{ Log.i(tag,"Starting the foreground service task") isServiceStarted = true - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { @@ -188,9 +151,53 @@ class ForegroundService : Service(){ } } + private fun downloadAllTracks(downloadObjects: List){ + serviceScope.launch(Dispatchers.IO) { + for(downloadObj in downloadObjects){ + try { + val video = ytDownloader.getVideo(downloadObj.ytVideoId) + val format: Format? = try { + video?.findAudioWithQuality(AudioQuality.medium)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + try { + video?.findAudioWithQuality(AudioQuality.high)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + try { + video?.findAudioWithQuality(AudioQuality.low)?.get(0) as Format + } catch (e: java.lang.IndexOutOfBoundsException) { + Log.i("YTDownloader", e.toString()) + null + } + } + } + format?.let { + val url: String = format.url() + Log.i("DHelper Link Found", url) + serviceScope.launch { + val request= Request(url, downloadObj.outputFile) + request.priority = Priority.NORMAL + request.networkType = NetworkType.ALL + + fetch.enqueue(request, + { + requestMap[it] = downloadObj.trackDetails + Log.i(tag, "Enqueuing Download") + }, + { + Log.i(tag, "Enqueuing Error:${it.throwable.toString()}")} + ) + } + } + }catch (e: com.github.kiulian.downloader.YoutubeException){ + Log.i("Service YT Error", e.message.toString()) + } + } + } + } + override fun onDestroy() { super.onDestroy() - if(downloadMap.isEmpty() && converted == total){ + if(converted == total){ Handler().postDelayed({ Log.i(tag,"Service destroyed.") deleteFile(parentDirectory) @@ -200,25 +207,11 @@ class ForegroundService : Service(){ } } - private fun releaseWakeLock() { - Log.i(tag,"Releasing Wake Lock") - try { - wakeLock?.let { - if (it.isHeld) { - it.release() - } - } - } catch (e: Exception) { - Log.i(tag,"Service stopped without being started: ${e.message}") - } - isServiceStarted = false - } - - override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - if(downloadMap.isEmpty() && converted == total ){ + if(converted == total ){ Log.i(tag,"Service Removed.") + deleteFile(parentDirectory) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { stopForeground(true) } else { @@ -227,25 +220,6 @@ class ForegroundService : Service(){ } } - /** - * Deleting All Residual Files except Mp3 Files - **/ - private fun deleteFile(dir:File) { - Log.i(tag,"Starting Deletions in ${dir.path} ") - val fList = dir.listFiles() - fList?.let { - for (file in fList) { - if (file.isDirectory) { - deleteFile(file) - } else if(file.isFile) { - if(file.path.toString().substringAfterLast(".") != "mp3"){ -// Log.i(tag,"deleting ${file.path}") - file.delete() - } - } - } - } - } /** * Fetch Listener/ Responsible for Fetch Behaviour @@ -274,23 +248,23 @@ class ForegroundService : Service(){ val track = requestMap[download.request] when(notificationLine){ 0 -> { - messageList[0] = "Downloading ${track?.name}" + messageList[0] = "Downloading ${track?.title}" notificationLine = 1 } 1 -> { - messageList[1] = "Downloading ${track?.name}" + messageList[1] = "Downloading ${track?.title}" notificationLine = 2 } 2-> { - messageList[2] = "Downloading ${track?.name}" + messageList[2] = "Downloading ${track?.title}" notificationLine = 3 } 3 -> { - messageList[3] = "Downloading ${track?.name}" + messageList[3] = "Downloading ${track?.title}" notificationLine = 0 } } - Log.i(tag,"${track?.name} Download Started") + Log.i(tag,"${track?.title} Download Started") updateNotification() } @@ -309,19 +283,20 @@ class ForegroundService : Service(){ override fun onCompleted(download: Download) { val track = requestMap[download.request] for (message in messageList){ - if( message == "Downloading ${track?.name}"){ + if( message == "Downloading ${track?.title}"){ + //Remove Downloading Status from Notification messageList[messageList.indexOf(message)] = "" } } serviceScope.launch { try{ - convertToMp3(download.file, track!!) - Log.i(tag,"${track.name} Download Completed") + track?.let { convertToMp3(download.file, it) } + Log.i(tag,"${track?.title} Download Completed") }catch (e:KotlinNullPointerException ){ - Log.i(tag,"${track?.name} Download Failed! Error:Fetch!!!!") - Log.i(tag,"${track?.name} Requesting Download thru Android DM") + Log.i(tag,"${track?.title} Download Failed! Error:Fetch!!!!") + Log.i(tag,"${track?.title} Requesting Download thru Android DM") downloadUsingDM(download.request.url,download.request.file, track!!) downloaded++ requestMap.remove(download.request) @@ -348,7 +323,7 @@ class ForegroundService : Service(){ val track = requestMap[download.request] downloaded++ Log.i(tag,download.error.throwable.toString()) - Log.i(tag,"${track?.name} Requesting Download thru Android DM") + Log.i(tag,"${track?.title} Requesting Download thru Android DM") downloadUsingDM(download.request.url,download.request.file, track!!) requestMap.remove(download.request) } @@ -365,7 +340,7 @@ class ForegroundService : Service(){ downloadedBytesPerSecond: Long ) { val track = requestMap[download.request] - Log.i(tag,"${track?.name} ETA: ${etaInMilliSeconds/1000} sec") + Log.i(tag,"${track?.title} ETA: ${etaInMilliSeconds/1000} sec") speed = (downloadedBytesPerSecond/1000) updateNotification() } @@ -375,7 +350,7 @@ class ForegroundService : Service(){ /** * If fetch Fails , Android Download Manager To RESCUE!! **/ - fun downloadUsingDM(url:String, outputDir:String, track: Track){ + fun downloadUsingDM(url:String, outputDir:String, track: TrackDetails){ val uri = Uri.parse(url) val request = DownloadManager.Request(uri) .setAllowedNetworkTypes( @@ -383,14 +358,14 @@ class ForegroundService : Service(){ DownloadManager.Request.NETWORK_MOBILE ) .setAllowedOverRoaming(false) - .setTitle(track.name) + .setTitle(track.title) .setDescription("Spotify Downloader Working Up here...") .setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, outputDir.removePrefix( Environment.getExternalStorageDirectory().toString() + Environment.DIRECTORY_MUSIC + File.separator )) .setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) //Start Download - val downloadID = downloadManager?.enqueue(request) + val downloadID = downloadManager.enqueue(request) Log.i("DownloadManager", "Download Request Sent") val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { @@ -412,7 +387,7 @@ class ForegroundService : Service(){ /** *Converting Downloaded Audio (m4a) to Mp3.( Also Applying Metadata) **/ - fun convertToMp3(filePath: String, track: Track){ + fun convertToMp3(filePath: String, track: TrackDetails){ val m4aFile = File(filePath) FFmpeg.executeAsync( @@ -435,7 +410,7 @@ class ForegroundService : Service(){ } } - private fun writeMp3Tags(filePath:String, track: Track){ + private fun writeMp3Tags(filePath:String, track: TrackDetails){ var mp3File = Mp3File(filePath) mp3File = removeAllTags(mp3File) mp3File = setId3v1Tags(mp3File,track) @@ -485,62 +460,40 @@ class ForegroundService : Service(){ /** *Modifying Mp3 Tags with MetaData! **/ - private fun setId3v1Tags(mp3File: Mp3File, track: Track): Mp3File { - val id3v1Tag = ID3v1Tag() - id3v1Tag.track = track.disc_number.toString() - val artistsList = mutableListOf() - track.artists?.forEach { artistsList.add(it!!.name!!) } - id3v1Tag.artist = artistsList.joinToString() - id3v1Tag.title = track.name - id3v1Tag.album = track.album?.name - id3v1Tag.year = track.album?.release_date - id3v1Tag.comment = "Genres:${track.album?.genres?.joinToString()}" + private fun setId3v1Tags(mp3File: Mp3File, track: TrackDetails): Mp3File { + val id3v1Tag = ID3v1Tag().apply { + artist = track.artists.joinToString(",") + title = track.title + album = track.albumName + year = track.year + comment = "Genres:${track.comment}" + } mp3File.id3v1Tag = id3v1Tag return mp3File } - private fun setId3v2Tags(mp3file: Mp3File, track: Track): Mp3File { - val id3v2Tag = ID3v24Tag() - id3v2Tag.track = track.disc_number.toString() - val artistsList = mutableListOf() - track.artists?.forEach { artistsList.add(it!!.name!!) } - id3v2Tag.artist = artistsList.joinToString() - id3v2Tag.title = track.name - id3v2Tag.album = track.album?.name - id3v2Tag.year = track.album?.release_date - id3v2Tag.comment = "Genres:${track.album?.genres?.joinToString()}" - id3v2Tag.lyrics = "Gonna Implement Soon" - val copyrights = mutableListOf() - track.album?.copyrights?.forEach { copyrights.add(it!!.type!!) } - id3v2Tag.copyright = copyrights.joinToString() - id3v2Tag.url = track.href - track.ytCoverUrl?.let { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir +".Images/" + it.substringAfterLast('/',it) + ".jpeg") - Log.i("Mp3Tags editing Tags",file.path) - //init array with file length - val bytesArray = ByteArray(file.length().toInt()) - val fis = FileInputStream(file) + private fun setId3v2Tags(mp3file: Mp3File, track: TrackDetails): Mp3File { + val id3v2Tag = ID3v24Tag().apply { + artist = track.artists.joinToString(",") + title = track.title + album = track.albumName + year = track.year + comment = "Genres:${track.comment}" + lyrics = "Gonna Implement Soon" + url = track.trackUrl + } + val bytesArray = ByteArray(track.albumArt.length().toInt()) + try{ + val fis = FileInputStream(track.albumArt) fis.read(bytesArray) //read file into bytes[] fis.close() id3v2Tag.setAlbumImage(bytesArray,"image/jpeg") + }catch (e:java.io.FileNotFoundException){ + Log.i("Error","Couldn't Write Mp3 Album Art") } - track.album?.let { - val file = File( - Environment.getExternalStorageDirectory(), - SpotifyDownloadHelper.defaultDir +".Images/" + (it.images?.get(0)?.url!!).substringAfterLast('/') + ".jpeg") - Log.i("Mp3Tags editing Tags",file.path) - //init array with file length - val bytesArray = ByteArray(file.length().toInt()) - val fis = FileInputStream(file) - fis.read(bytesArray) //read file into bytes[] - fis.close() - id3v2Tag.setAlbumImage(bytesArray,"image/jpeg") - } - id3v2Tag.albumImage mp3file.id3v2Tag = id3v2Tag return mp3file } + private fun removeAllTags(mp3file: Mp3File): Mp3File { if (mp3file.hasId3v1Tag()) { mp3file.removeId3v1Tag() @@ -554,4 +507,141 @@ class ForegroundService : Service(){ return mp3file } + private fun releaseWakeLock() { + Log.i(tag,"Releasing Wake Lock") + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + } catch (e: Exception) { + Log.i(tag,"Service stopped without being started: ${e.message}") + } + isServiceStarted = false + } + + /** + *Starting Service with Notification as Foreground! + **/ + private fun startForeground() { + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, "Downloader Service") + } else { + // If earlier version channel ID is not used + // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + "" + } + + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.down_arrowbw) + .setNotificationSilent() + .setSubText("Total: $total Completed:$converted") + .setStyle(NotificationCompat.InboxStyle() + .setBigContentTitle("Speed: $speed KB/s") + .addLine(messageList[0]) + .addLine(messageList[1]) + .addLine(messageList[2]) + .addLine(messageList[3])) + .setContentIntent(pendingIntent) + .build() + startForeground(notificationId, notification) + } + + @Suppress("SameParameterValue") + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String{ + val chan = NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_DEFAULT) + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + /** + * Deleting All Residual Files except Mp3 Files + **/ + private fun deleteFile(dir:File) { + Log.i(tag,"Starting Deletions in ${dir.path} ") + val fList = dir.listFiles() + fList?.let { + for (file in fList) { + if (file.isDirectory) { + deleteFile(file) + } else if(file.isFile) { + if(file.path.toString().substringAfterLast(".") != "mp3"){ + Log.i(tag,"deleting ${file.path}") + file.delete() + } + } + } + } + } + + /** + * Function to fetch all Images for use in mp3 tags. + **/ + private suspend fun loadAllImages(urlList: ArrayList) { + /* + * Last Element of this List defines Its Source + * */ + val source = urlList.last() + for (url in urlList.subList(0,urlList.size-2)) { + val imgUri = url.toUri().buildUpon().scheme("https").build() + Glide + .with(this) + .asFile() + .load(imgUri) + .listener(object: RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + Log.i("Glide","LoadFailed") + return false + } + + override fun onResourceReady( + resource: File?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + serviceScope.launch { + withContext(Dispatchers.IO){ + try { + val file = when(source){ + "spotify" ->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringAfterLast('/') + ".jpeg" + ) + } + "youtube" ->{ + File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringBeforeLast('/',url).substringAfterLast('/',url) + ".jpeg" + ) + } + else -> File( + Environment.getExternalStorageDirectory(), + defaultDir +".Images/" + url.substringAfterLast('/') + ".jpeg") + } + resource?.copyTo(file) + } catch (e: IOException) { + e.printStackTrace() + } + } + } + return false + } + }).submit() + } + } + } \ No newline at end of file diff --git a/app/src/main/res/layout/spotify_fragment.xml b/app/src/main/res/layout/spotify_fragment.xml index 6b363de5..d8d584c9 100755 --- a/app/src/main/res/layout/spotify_fragment.xml +++ b/app/src/main/res/layout/spotify_fragment.xml @@ -20,7 +20,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> @@ -72,14 +72,14 @@ app:toolbarId="@+id/toolbar"> + app:layout_constraintTop_toBottomOf="@id/appbar" /> diff --git a/app/src/main/res/layout/youtube_fragment.xml b/app/src/main/res/layout/youtube_fragment.xml index 90c375e5..985b4029 100755 --- a/app/src/main/res/layout/youtube_fragment.xml +++ b/app/src/main/res/layout/youtube_fragment.xml @@ -19,14 +19,14 @@ @@ -69,14 +69,14 @@ app:toolbarId="@+id/toolbar"> + app:layout_constraintTop_toBottomOf="@id/appbar" />