Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/java/com/theveloper/pixelplay/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,8 @@ class MainActivity : ComponentActivity() {
persistentListOf(
BottomNavItem("Home", R.string.nav_bar_home, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home),
BottomNavItem("Search", R.string.nav_bar_search, R.drawable.rounded_search_24, R.drawable.rounded_search_24, Screen.Search),
BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library)
BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library),
BottomNavItem("Smart", R.string.nav_bar_smart, R.drawable.rounded_all_inclusive_24, R.drawable.rounded_all_inclusive_24, Screen.NlpCommand)
)
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.theveloper.pixelplay.data.nlp

sealed class NlpCommandIntent {
data class CreatePlaylist(
val playlistName: String,
val targetQueries: List<String>
) : NlpCommandIntent()

data class DeleteArtist(
val targetQueries: List<String>
) : NlpCommandIntent()

data class CategorizeGenre(
val genreNames: List<String>
) : NlpCommandIntent()

object Unknown : NlpCommandIntent()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.theveloper.pixelplay.data.nlp

object NlpCommandParser {

private val CREATE_PLAYLIST_REGEX = Regex(
pattern = """(?:create|make|build|generate)\s+(?:a\s+)?playlist\s+(?:of|for|from|with|named|called)?\s*(.+)""",
option = RegexOption.IGNORE_CASE
)

private val DELETE_ARTIST_REGEX = Regex(
pattern = """(?:delete|remove|erase)\s+(?:artist|singer|band|musician)?\s*(.+)""",
option = RegexOption.IGNORE_CASE
)

private val CATEGORIZE_GENRE_REGEX = Regex(
pattern = """(?:categorize|group|organize|sort)\s+(?:songs?|music|tracks?)?\s*(?:by)?\s*(?:genre)?\s+(.+)""",
option = RegexOption.IGNORE_CASE
)

private val CLEAN_SUFFIX_REGEX = Regex(
pattern = """(?:'s)?\s+(?:songs?|tracks?|music|mix|albums?)$""",
option = RegexOption.IGNORE_CASE
)

private val CLEAN_PREFIX_REGEX = Regex(
pattern = """^(?:songs?|tracks?|music|mix|albums?)\s+(?:by|of|from|with)\s+""",
option = RegexOption.IGNORE_CASE
)

fun parse(rawInput: String): NlpCommandIntent {
val cleanedInput = rawInput.trim()
if (cleanedInput.isBlank()) return NlpCommandIntent.Unknown

CREATE_PLAYLIST_REGEX.find(cleanedInput)?.let { match ->
val rawTarget = match.groupValues[1].trim()
val targets = cleanAndSplit(rawTarget)
if (targets.isNotEmpty()) {
return NlpCommandIntent.CreatePlaylist(
playlistName = buildPlaylistName(targets),
targetQueries = targets
)
}
}

DELETE_ARTIST_REGEX.find(cleanedInput)?.let { match ->
val rawTarget = match.groupValues[1].trim()
val targets = cleanAndSplit(rawTarget)
if (targets.isNotEmpty()) {
return NlpCommandIntent.DeleteArtist(targetQueries = targets)
}
}

CATEGORIZE_GENRE_REGEX.find(cleanedInput)?.let { match ->
val rawGenre = match.groupValues[1].trim()
val genres = cleanAndSplit(rawGenre)
if (genres.isNotEmpty()) {
return NlpCommandIntent.CategorizeGenre(genreNames = genres)
}
}

return NlpCommandIntent.Unknown
}

private fun cleanAndSplit(rawTarget: String): List<String> {
val parts = rawTarget.split(Regex("""\s+and\s+|\s+or\s+|,\s*"""))
return parts.map { part ->
var cleaned = part.trim()
cleaned = CLEAN_PREFIX_REGEX.replace(cleaned, "")
cleaned = CLEAN_SUFFIX_REGEX.replace(cleaned, "")
cleaned.trim()
}.filter { it.isNotBlank() }
}

private fun buildPlaylistName(targets: List<String>): String {
val capitalizedTargets = targets.map { target ->
target.split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}
return when (capitalizedTargets.size) {
1 -> "${capitalizedTargets.first()} — Mix"
2 -> "${capitalizedTargets[0]} & ${capitalizedTargets[1]}"
else -> "${capitalizedTargets.take(2).joinToString(", ")} & More"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package com.theveloper.pixelplay.data.nlp

import android.content.Context
import android.util.Log
import com.theveloper.pixelplay.data.database.MusicDao
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository
import com.theveloper.pixelplay.data.repository.MusicRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NlpCommandRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository,
private val musicDao: MusicDao,
private val playlistPreferencesRepository: PlaylistPreferencesRepository
) {

companion object {
private const val TAG = "NlpCommandRepository"
}

suspend fun execute(intent: NlpCommandIntent): NlpCommandResult = withContext(Dispatchers.IO) {
when (intent) {
is NlpCommandIntent.CreatePlaylist -> createPlaylistByTarget(intent)
is NlpCommandIntent.DeleteArtist -> resolveDeleteArtist(intent)
is NlpCommandIntent.CategorizeGenre -> categorizeByGenre(intent)
is NlpCommandIntent.Unknown -> NlpCommandResult.Error(
"Sorry, I didn't understand that command. Try:\n" +
"• \"create playlist of [artist]\"\n" +
"• \"delete artist [name]\"\n" +
"• \"categorize songs by genre [genre]\""
)
}
}

private suspend fun createPlaylistByTarget(intent: NlpCommandIntent.CreatePlaylist): NlpCommandResult {
val allSongs = musicRepository.getAllSongsOnce()
if (allSongs.isEmpty()) {
return NlpCommandResult.Error("Your library is empty. Add some songs first.")
}

val allArtistNames = allSongs.map { it.artist }.distinct()
val allGenres = allSongs.mapNotNull { it.genre }.distinct()

val matchingSongs = mutableListOf<Song>()
val resolvedTargets = mutableListOf<String>()

for (query in intent.targetQueries) {
val matchedArtist = NlpFuzzyMatcher.findBestMatch(query, allArtistNames)
if (matchedArtist != null) {
val songs = allSongs.filter { it.artist.equals(matchedArtist, ignoreCase = true) }
matchingSongs.addAll(songs)
resolvedTargets.add(matchedArtist)
} else {
val matchedGenre = NlpFuzzyMatcher.findBestMatch(query, allGenres)
if (matchedGenre != null) {
val songs = allSongs.filter { it.genre.equals(matchedGenre, ignoreCase = true) }
matchingSongs.addAll(songs)
resolvedTargets.add(matchedGenre)
}
}
}

if (matchingSongs.isEmpty()) {
return NlpCommandResult.Error(
"No artists or genres found matching: ${intent.targetQueries.joinToString(", ")}. " +
"Check the spelling."
)
}

val uniqueSongs = matchingSongs.distinctBy { it.id }
val songIds = uniqueSongs.map { it.id }

playlistPreferencesRepository.createPlaylist(
name = intent.playlistName,
songIds = songIds
)

return NlpCommandResult.Success(
"✓ Playlist \"${intent.playlistName}\" created with ${uniqueSongs.size} songs " +
"(matched: ${resolvedTargets.joinToString(", ")})."
)
}

private suspend fun resolveDeleteArtist(intent: NlpCommandIntent.DeleteArtist): NlpCommandResult {
val allSongs = musicRepository.getAllSongsOnce()
val allArtistNames = allSongs.map { it.artist }.distinct()

val matchingSongs = mutableListOf<Song>()
val resolvedArtists = mutableListOf<String>()

for (query in intent.targetQueries) {
val matchedArtist = NlpFuzzyMatcher.findBestMatch(query, allArtistNames)
if (matchedArtist != null) {
val songs = allSongs.filter { it.artist.equals(matchedArtist, ignoreCase = true) }
matchingSongs.addAll(songs)
resolvedArtists.add(matchedArtist)
}
}

if (matchingSongs.isEmpty()) {
return NlpCommandResult.Error(
"No artists found matching: ${intent.targetQueries.joinToString(", ")}. " +
"Check the spelling."
)
}

val uniqueSongs = matchingSongs.distinctBy { it.id }

// Only delete local songs (filter out cloud providers)
val localSongPaths = uniqueSongs
.filter { it.path.isNotBlank() && !it.contentUriString.startsWith("telegram://")
&& !it.contentUriString.startsWith("netease://")
&& !it.contentUriString.startsWith("gdrive://")
&& !it.contentUriString.startsWith("qqmusic://")
&& !it.contentUriString.startsWith("navidrome://")
&& !it.contentUriString.startsWith("jellyfin://") }
.map { it.path }

val artistListStr = resolvedArtists.joinToString(" & ")
return NlpCommandResult.PendingConfirmation(
message = "⚠️ This will permanently delete ${uniqueSongs.size} song(s) by " +
"\"$artistListStr\" from your device. This action cannot be undone.",
songFilePaths = localSongPaths,
confirmedIntent = intent.copy(targetQueries = resolvedArtists)
)
}

suspend fun executeDeleteArtist(
songFilePaths: List<String>,
artistName: String
): NlpCommandResult = withContext(Dispatchers.IO) {
var deletedFiles = 0
var failedFiles = 0

for (path in songFilePaths) {
if (path.isBlank()) continue
try {
val file = File(path)
if (file.exists()) {
if (file.delete()) {
deletedFiles++
Log.d(TAG, "Deleted file: $path")
} else {
failedFiles++
Log.w(TAG, "Could not delete file: $path")
}
}
} catch (e: Exception) {
failedFiles++
Log.e(TAG, "Error deleting file: $path", e)
}
}

val resolvedArtists = artistName.split(" & ")

try {
val allSongs = musicRepository.getAllSongsOnce()
val artistSongIds = allSongs
.filter { song -> resolvedArtists.any { it.equals(song.artist, ignoreCase = true) } }
.map { it.id.toLongOrNull() }
.filterNotNull()

if (artistSongIds.isNotEmpty()) {
musicDao.deleteSongsAndRelatedData(artistSongIds)
}
} catch (e: Exception) {
Log.e(TAG, "Error removing DB rows for artists: $artistName", e)
}

val message = buildString {
append("✓ Deleted $deletedFiles file(s) for \"$artistName\".")
if (failedFiles > 0) {
append("\n⚠️ $failedFiles file(s) could not be removed (check app permissions).")
}
}

NlpCommandResult.Success(message)
}

private suspend fun categorizeByGenre(intent: NlpCommandIntent.CategorizeGenre): NlpCommandResult {
val allSongs = musicRepository.getAllSongsOnce()
val allGenres = allSongs.mapNotNull { it.genre }.distinct()
if (allGenres.isEmpty()) {
return NlpCommandResult.Error(
"No genre tags found in your library. Add genre metadata to your music files first."
)
}

val matchingSongs = mutableListOf<Song>()
val resolvedGenres = mutableListOf<String>()

for (query in intent.genreNames) {
val matchedGenres = NlpFuzzyMatcher.findAllMatches(query, allGenres)
if (matchedGenres.isNotEmpty()) {
val primaryGenre = matchedGenres.first()
val songs = allSongs.filter { song ->
matchedGenres.any { genre -> song.genre.equals(genre, ignoreCase = true) }
}
matchingSongs.addAll(songs)
resolvedGenres.add(primaryGenre)
}
}

if (matchingSongs.isEmpty()) {
return NlpCommandResult.Error(
"No genres found matching: ${intent.genreNames.joinToString(", ")}. " +
"Check the spelling."
)
}

val uniqueSongs = matchingSongs.distinctBy { it.id }
val playlistName = resolvedGenres.joinToString(" & ") + " Mix"

playlistPreferencesRepository.createPlaylist(
name = playlistName,
songIds = uniqueSongs.map { it.id }
)

return NlpCommandResult.Success(
"✓ Playlist \"$playlistName\" created with ${uniqueSongs.size} songs " +
"(matched genres: ${resolvedGenres.joinToString(", ")})."
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.theveloper.pixelplay.data.nlp

sealed class NlpCommandResult {
data class Success(val message: String) : NlpCommandResult()

data class PendingConfirmation(
val message: String,
val songFilePaths: List<String>,
val confirmedIntent: NlpCommandIntent
) : NlpCommandResult()

data class Error(val message: String) : NlpCommandResult()
}
Loading