/* * OfflineMusicService.kt * Copyright (C) 2009-2021 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ package org.moire.ultrasonic.service import android.graphics.Bitmap import android.media.MediaMetadataRetriever import java.io.BufferedReader import java.io.BufferedWriter import java.io.File import java.io.FileReader import java.io.FileWriter import java.io.InputStream import java.io.Reader import java.util.ArrayList import java.util.HashSet import java.util.LinkedList import java.util.Locale import java.util.Random import java.util.concurrent.TimeUnit import java.util.regex.Pattern import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.ChatMessage import org.moire.ultrasonic.domain.Genre import org.moire.ultrasonic.domain.Indexes import org.moire.ultrasonic.domain.JukeboxStatus import org.moire.ultrasonic.domain.Lyrics import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicFolder import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.domain.PodcastsChannel import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.domain.UserInfo import org.moire.ultrasonic.imageloader.BitmapUtils import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Util import timber.log.Timber // TODO: There are quite a number of deeply nested and complicated functions in this class.. // Simplify them :) @Suppress("TooManyFunctions") class OfflineMusicService : MusicService, KoinComponent { private val activeServerProvider: ActiveServerProvider by inject() override fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes { val artists: MutableList = ArrayList() val root = FileUtil.getMusicDirectory() for (file in FileUtil.listFiles(root)) { if (file.isDirectory) { val artist = Artist() artist.id = file.path artist.index = file.name.substring(0, 1) artist.name = file.name artists.add(artist) } } val ignoredArticlesString = "The El La Los Las Le Les" val ignoredArticles = COMPILE.split(ignoredArticlesString) artists.sortWith { lhsArtist, rhsArtist -> var lhs = lhsArtist.name!!.lowercase(Locale.ROOT) var rhs = rhsArtist.name!!.lowercase(Locale.ROOT) val lhs1 = lhs[0] val rhs1 = rhs[0] if (Character.isDigit(lhs1) && !Character.isDigit(rhs1)) { return@sortWith 1 } if (Character.isDigit(rhs1) && !Character.isDigit(lhs1)) { return@sortWith -1 } for (article in ignoredArticles) { var index = lhs.indexOf( String.format(Locale.ROOT, "%s ", article.lowercase(Locale.ROOT)) ) if (index == 0) { lhs = lhs.substring(article.length + 1) } index = rhs.indexOf( String.format(Locale.ROOT, "%s ", article.lowercase(Locale.ROOT)) ) if (index == 0) { rhs = rhs.substring(article.length + 1) } } lhs.compareTo(rhs) } return Indexes(0L, ignoredArticlesString, artists = artists) } override fun getMusicDirectory( id: String, name: String?, refresh: Boolean ): MusicDirectory { val dir = File(id) val result = MusicDirectory() result.name = dir.name val seen: MutableCollection = HashSet() for (file in FileUtil.listMediaFiles(dir)) { val filename = getName(file) if (filename != null && !seen.contains(filename)) { seen.add(filename) result.addChild(createEntry(file, filename)) } } return result } override fun getAvatar( username: String?, size: Int, saveToFile: Boolean ): Bitmap? { return try { val bitmap = BitmapUtils.getAvatarBitmapFromDisk(username, size) Util.scaleBitmap(bitmap, size) } catch (ignored: Exception) { null } } override fun getCoverArt( entry: MusicDirectory.Entry, size: Int, saveToFile: Boolean ): Bitmap? { return try { val bitmap = BitmapUtils.getAlbumArtBitmapFromDisk(entry, size) Util.scaleBitmap(bitmap, size) } catch (ignored: Exception) { null } } override fun search(criteria: SearchCriteria): SearchResult { val artists: MutableList = ArrayList() val albums: MutableList = ArrayList() val songs: MutableList = ArrayList() val root = FileUtil.getMusicDirectory() var closeness: Int for (artistFile in FileUtil.listFiles(root)) { val artistName = artistFile.name if (artistFile.isDirectory) { if (matchCriteria(criteria, artistName).also { closeness = it } > 0) { val artist = Artist() artist.id = artistFile.path artist.index = artistFile.name.substring(0, 1) artist.name = artistName artist.closeness = closeness artists.add(artist) } recursiveAlbumSearch(artistName, artistFile, criteria, albums, songs) } } artists.sort() albums.sort() songs.sort() return SearchResult(artists, albums, songs) } @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") override fun getPlaylists(refresh: Boolean): List { val playlists: MutableList = ArrayList() val root = FileUtil.getPlaylistDirectory() var lastServer: String? = null var removeServer = true for (folder in FileUtil.listFiles(root)) { if (folder.isDirectory) { val server = folder.name val fileList = FileUtil.listFiles(folder) for (file in fileList) { if (FileUtil.isPlaylistFile(file)) { val id = file.name val filename = server + ": " + FileUtil.getBaseName(id) val playlist = Playlist(server, filename) playlists.add(playlist) } } if (server != lastServer && !fileList.isEmpty()) { if (lastServer != null) { removeServer = false } lastServer = server } } else { // Delete legacy playlist files try { if (!folder.delete()) { Timber.w("Failed to delete old playlist file: %s", folder.name) } } catch (e: Exception) { Timber.w(e, "Failed to delete old playlist file: %s", folder.name) } } } if (removeServer) { for (playlist in playlists) { playlist.name = playlist.name.substring(playlist.id.length + 2) } } return playlists } @Throws(Exception::class) override fun getPlaylist(id: String, name: String): MusicDirectory { var playlistName = name var reader: Reader? = null var buffer: BufferedReader? = null return try { val firstIndex = playlistName.indexOf(id) if (firstIndex != -1) { playlistName = playlistName.substring(id.length + 2) } val playlistFile = FileUtil.getPlaylistFile(id, playlistName) reader = FileReader(playlistFile) buffer = BufferedReader(reader) val playlist = MusicDirectory() var line = buffer.readLine() if ("#EXTM3U" != line) return playlist while (buffer.readLine().also { line = it } != null) { val entryFile = File(line) val entryName = getName(entryFile) if (entryFile.exists() && entryName != null) { playlist.addChild(createEntry(entryFile, entryName)) } } playlist } finally { Util.close(buffer) Util.close(reader) } } @Suppress("TooGenericExceptionCaught") @Throws(Exception::class) override fun createPlaylist(id: String, name: String, entries: List) { val playlistFile = FileUtil.getPlaylistFile(activeServerProvider.getActiveServer().name, name) val fw = FileWriter(playlistFile) val bw = BufferedWriter(fw) try { fw.write("#EXTM3U\n") for (e in entries) { var filePath = FileUtil.getSongFile(e).absolutePath if (!File(filePath).exists()) { val ext = FileUtil.getExtension(filePath) val base = FileUtil.getBaseName(filePath) filePath = "$base.complete.$ext" } fw.write( """ $filePath """.trimIndent() ) } } catch (ignored: Exception) { Timber.w("Failed to save playlist: %s", name) } finally { bw.close() fw.close() } } override fun getRandomSongs(size: Int): MusicDirectory { val root = FileUtil.getMusicDirectory() val children: MutableList = LinkedList() listFilesRecursively(root, children) val result = MusicDirectory() if (children.isEmpty()) { return result } val random = Random() for (i in 0 until size) { val file = children[random.nextInt(children.size)] result.addChild(createEntry(file, getName(file))) } return result } @Throws(Exception::class) override fun deletePlaylist(id: String) { throw OfflineException("Playlists not available in offline mode") } @Throws(Exception::class) override fun updatePlaylist(id: String, name: String?, comment: String?, pub: Boolean) { throw OfflineException("Updating playlist not available in offline mode") } @Throws(Exception::class) override fun getLyrics(artist: String, title: String): Lyrics? { throw OfflineException("Lyrics not available in offline mode") } @Throws(Exception::class) override fun scrobble(id: String, submission: Boolean) { throw OfflineException("Scrobbling not available in offline mode") } @Throws(Exception::class) override fun getAlbumList( type: String, size: Int, offset: Int, musicFolderId: String? ): MusicDirectory { throw OfflineException("Album lists not available in offline mode") } @Throws(Exception::class) override fun updateJukeboxPlaylist(ids: List?): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun skipJukebox(index: Int, offsetSeconds: Int): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun stopJukebox(): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun startJukebox(): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun getJukeboxStatus(): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun setJukeboxGain(gain: Float): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @Throws(Exception::class) override fun getStarred(): SearchResult { throw OfflineException("Starred not available in offline mode") } @Throws(Exception::class) override fun getSongsByGenre(genre: String, count: Int, offset: Int): MusicDirectory { throw OfflineException("Getting Songs By Genre not available in offline mode") } @Throws(Exception::class) override fun getGenres(refresh: Boolean): List? { throw OfflineException("Getting Genres not available in offline mode") } @Throws(Exception::class) override fun getUser(username: String): UserInfo { throw OfflineException("Getting user info not available in offline mode") } @Throws(Exception::class) override fun createShare( ids: List, description: String?, expires: Long? ): List { throw OfflineException("Creating shares not available in offline mode") } @Throws(Exception::class) override fun getShares(refresh: Boolean): List { throw OfflineException("Getting shares not available in offline mode") } @Throws(Exception::class) override fun deleteShare(id: String) { throw OfflineException("Deleting shares not available in offline mode") } @Throws(Exception::class) override fun updateShare(id: String, description: String?, expires: Long?) { throw OfflineException("Updating shares not available in offline mode") } @Throws(Exception::class) override fun star(id: String?, albumId: String?, artistId: String?) { throw OfflineException("Star not available in offline mode") } @Throws(Exception::class) override fun unstar(id: String?, albumId: String?, artistId: String?) { throw OfflineException("UnStar not available in offline mode") } @Throws(Exception::class) override fun getMusicFolders(refresh: Boolean): List { throw OfflineException("Music folders not available in offline mode") } @Throws(OfflineException::class) override fun getAlbumList2( type: String, size: Int, offset: Int, musicFolderId: String? ): MusicDirectory { throw OfflineException("getAlbumList2 isn't available in offline mode") } @Throws(OfflineException::class) override fun getVideoUrl(id: String, useFlash: Boolean): String? { throw OfflineException("getVideoUrl isn't available in offline mode") } @Throws(OfflineException::class) override fun getChatMessages(since: Long?): List? { throw OfflineException("getChatMessages isn't available in offline mode") } @Throws(OfflineException::class) override fun addChatMessage(message: String) { throw OfflineException("addChatMessage isn't available in offline mode") } @Throws(OfflineException::class) override fun getBookmarks(): List? { throw OfflineException("getBookmarks isn't available in offline mode") } @Throws(OfflineException::class) override fun deleteBookmark(id: String) { throw OfflineException("deleteBookmark isn't available in offline mode") } @Throws(OfflineException::class) override fun createBookmark(id: String, position: Int) { throw OfflineException("createBookmark isn't available in offline mode") } @Throws(OfflineException::class) override fun getVideos(refresh: Boolean): MusicDirectory? { throw OfflineException("getVideos isn't available in offline mode") } @Throws(OfflineException::class) override fun getStarred2(): SearchResult { throw OfflineException("getStarred2 isn't available in offline mode") } override fun ping() { // Void } override fun isLicenseValid(): Boolean = true @Throws(OfflineException::class) override fun getArtists(refresh: Boolean): Indexes { throw OfflineException("getArtists isn't available in offline mode") } @Throws(OfflineException::class) override fun getArtist(id: String, name: String?, refresh: Boolean): MusicDirectory { throw OfflineException("getArtist isn't available in offline mode") } @Throws(OfflineException::class) override fun getAlbum(id: String, name: String?, refresh: Boolean): MusicDirectory { throw OfflineException("getAlbum isn't available in offline mode") } @Throws(OfflineException::class) override fun getPodcastEpisodes(podcastChannelId: String?): MusicDirectory? { throw OfflineException("getPodcastEpisodes isn't available in offline mode") } @Throws(OfflineException::class) override fun getDownloadInputStream( song: MusicDirectory.Entry, offset: Long, maxBitrate: Int ): Pair { throw OfflineException("getDownloadInputStream isn't available in offline mode") } @Throws(OfflineException::class) override fun setRating(id: String, rating: Int) { throw OfflineException("setRating isn't available in offline mode") } @Throws(OfflineException::class) override fun getPodcastsChannels(refresh: Boolean): List { throw OfflineException("getPodcastsChannels isn't available in offline mode") } companion object { private val COMPILE = Pattern.compile(" ") private fun getName(file: File): String? { var name = file.name if (file.isDirectory) { return name } if (name.endsWith(".partial") || name.contains(".partial.") || name == Constants.ALBUM_ART_FILE ) { return null } name = name.replace(".complete", "") return FileUtil.getBaseName(name) } @Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth") private fun createEntry(file: File, name: String?): MusicDirectory.Entry { val entry = MusicDirectory.Entry(file.path) entry.isDirectory = file.isDirectory entry.parent = file.parent entry.size = file.length() val root = FileUtil.getMusicDirectory().path entry.path = file.path.replaceFirst( String.format(Locale.ROOT, "^%s/", root).toRegex(), "" ) entry.title = name if (file.isFile) { var artist: String? = null var album: String? = null var title: String? = null var track: String? = null var disc: String? = null var year: String? = null var genre: String? = null var duration: String? = null var hasVideo: String? = null try { val mmr = MediaMetadataRetriever() mmr.setDataSource(file.path) artist = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) album = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) title = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) track = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER) disc = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER) year = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR) genre = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE) duration = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) hasVideo = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) mmr.release() } catch (ignored: Exception) { } entry.artist = artist ?: file.parentFile!!.parentFile!!.name entry.album = album ?: file.parentFile!!.name if (title != null) { entry.title = title } entry.isVideo = hasVideo != null Timber.i("Offline Stuff: %s", track) if (track != null) { var trackValue = 0 try { val slashIndex = track.indexOf('/') if (slashIndex > 0) { track = track.substring(0, slashIndex) } trackValue = track.toInt() } catch (ex: Exception) { Timber.e(ex, "Offline Stuff") } Timber.i("Offline Stuff: Setting Track: %d", trackValue) entry.track = trackValue } if (disc != null) { var discValue = 0 try { val slashIndex = disc.indexOf('/') if (slashIndex > 0) { disc = disc.substring(0, slashIndex) } discValue = disc.toInt() } catch (ignored: Exception) { } entry.discNumber = discValue } if (year != null) { var yearValue = 0 try { yearValue = year.toInt() } catch (ignored: Exception) { } entry.year = yearValue } if (genre != null) { entry.genre = genre } if (duration != null) { var durationValue: Long = 0 try { durationValue = duration.toLong() durationValue = TimeUnit.MILLISECONDS.toSeconds(durationValue) } catch (ignored: Exception) { } entry.setDuration(durationValue) } } entry.suffix = FileUtil.getExtension(file.name.replace(".complete", "")) val albumArt = FileUtil.getAlbumArtFile(entry) if (albumArt.exists()) { entry.coverArt = albumArt.path } return entry } @Suppress("NestedBlockDepth") private fun recursiveAlbumSearch( artistName: String, file: File, criteria: SearchCriteria, albums: MutableList, songs: MutableList ) { var closeness: Int for (albumFile in FileUtil.listMediaFiles(file)) { if (albumFile.isDirectory) { val albumName = getName(albumFile) if (matchCriteria(criteria, albumName).also { closeness = it } > 0) { val album = createEntry(albumFile, albumName) album.artist = artistName album.closeness = closeness albums.add(album) } for (songFile in FileUtil.listMediaFiles(albumFile)) { val songName = getName(songFile) if (songFile.isDirectory) { recursiveAlbumSearch(artistName, songFile, criteria, albums, songs) } else if (matchCriteria(criteria, songName).also { closeness = it } > 0) { val song = createEntry(albumFile, songName) song.artist = artistName song.album = albumName song.closeness = closeness songs.add(song) } } } else { val songName = getName(albumFile) if (matchCriteria(criteria, songName).also { closeness = it } > 0) { val song = createEntry(albumFile, songName) song.artist = artistName song.album = songName song.closeness = closeness songs.add(song) } } } } private fun matchCriteria(criteria: SearchCriteria, name: String?): Int { val query = criteria.query.lowercase(Locale.ROOT) val queryParts = COMPILE.split(query) val nameParts = COMPILE.split( name!!.lowercase(Locale.ROOT) ) var closeness = 0 for (queryPart in queryParts) { for (namePart in nameParts) { if (namePart == queryPart) { closeness++ } } } return closeness } private fun listFilesRecursively(parent: File, children: MutableList) { for (file in FileUtil.listMediaFiles(parent)) { if (file.isFile) { children.add(file) } else { listFilesRecursively(file, children) } } } } }