Initial commit
This commit is contained in:
396
lollypopint/__init__.py
Executable file
396
lollypopint/__init__.py
Executable file
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
from urllib import parse
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import re
|
||||
from os.path import exists
|
||||
from bisect import bisect_left, bisect_right
|
||||
import sqlite3
|
||||
|
||||
MATCH_RATE = 0.7
|
||||
|
||||
'''
|
||||
Contains a cache of song values. This will change depending on the search terms
|
||||
'''
|
||||
songVals = {}
|
||||
|
||||
'''
|
||||
A sorting method for songs. It works by pushing file names with more matches up higher.
|
||||
|
||||
@param song - The song to evaluate
|
||||
@param terms - The search terms to use.
|
||||
@return A tuple containing the number of matches that were made as well as the
|
||||
total weighted value.
|
||||
'''
|
||||
def getSongValue(song, terms):
|
||||
if not "terms" in songVals or songVals["terms"] != terms:
|
||||
songVals.clear()
|
||||
songVals["terms"] = terms
|
||||
val = 0
|
||||
sortval = 0
|
||||
if song in songVals:
|
||||
val, sortval = songVals[song[1]][0]
|
||||
else:
|
||||
#print(song[1])
|
||||
#print('\t', end='')
|
||||
for arg in terms:
|
||||
i = 1
|
||||
for part in song[3:]:
|
||||
part = re.sub('[^A-Za-z ]', '', part).lower()
|
||||
if arg in part:
|
||||
val += 1
|
||||
# This helps make title matches more valuable
|
||||
sortval += 3 - 1
|
||||
#print("%d %s - %s" % (val, arg, part), end="\t")
|
||||
i += 1
|
||||
songVals[song[1]] = (val, sortval)
|
||||
#print()
|
||||
# Double every point received by a playlist
|
||||
if song[0] == True:
|
||||
val *= 2
|
||||
return (val, sortval)
|
||||
|
||||
'''
|
||||
Merges certain words into one. This is helpful for voice commands
|
||||
'''
|
||||
def __mergeWords(argList):
|
||||
i = 0
|
||||
quoting = False
|
||||
while i < len(argList):
|
||||
# Merge underscore for handling later
|
||||
if quoting:
|
||||
if argList[i] != "unquote":
|
||||
argList[i - 1] = argList[i - 1] + " " + argList[i]
|
||||
else:
|
||||
quoting = False
|
||||
del argList[i]
|
||||
i -= 1
|
||||
elif argList[i] == "quote":
|
||||
quoting = True
|
||||
del argList[i]
|
||||
elif i < len(argList) - 1 and argList[i] == "under" and argList[i + 1] == "score":
|
||||
argList[i] = "underscore"
|
||||
del argList[i + 1]
|
||||
elif i < len(argList) - 1 and argList[i] == "play" and argList[i + 1] == "list":
|
||||
argList[i] = "playlist"
|
||||
del argList[i + 1]
|
||||
if argList[i] == "dot":
|
||||
argList[i - 1] = argList[i - 1] + "."
|
||||
del argList[i]
|
||||
i -= 1
|
||||
elif argList[i] == "underscore":
|
||||
argList[i - 1] = argList[i - 1] + "_"
|
||||
del argList[i]
|
||||
i -= 1
|
||||
elif i < len(argList) - 1 and argList[i] == "dash":
|
||||
argList[i] = "-" + argList[i + 1]
|
||||
del argList[i + 1]
|
||||
elif argList[i - 1].endswith(".") or argList[i - 1].endswith("_"):
|
||||
argList[i - 1] = argList[i - 1] + argList[i]
|
||||
del argList[i]
|
||||
i -= 1
|
||||
i += 1
|
||||
|
||||
return argList
|
||||
|
||||
def __extractOptions(args):
|
||||
options = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
# Some dashed fields have special meanings.
|
||||
if args[i].startswith("-") and args[i] != "-or" and args[i] != "-and" and args[i] != "--or" and args[i] != "--and":
|
||||
if args[i].startswith("--"):
|
||||
if "=" in args[i]:
|
||||
val = args[i][args[i].index("=") + 1:]
|
||||
if re.sub('\.', '', val).isnumeric():
|
||||
if '.' in val:
|
||||
val = float(val)
|
||||
else:
|
||||
val = int(val)
|
||||
options[args[i][2:args[i].index("=")]] = val
|
||||
else:
|
||||
options[args[i][2:]] = True
|
||||
# These two have special meanings
|
||||
else:
|
||||
lastChar = None
|
||||
numBuilder = ''
|
||||
for char in args[i][1:]:
|
||||
if char.isnumeric() or char == '.':
|
||||
numBuilder += char
|
||||
else:
|
||||
if numBuilder:
|
||||
if '.' in numBuilder:
|
||||
options[lastChar] = float(numBuilder)
|
||||
else:
|
||||
options[lastChar] = int(numBuilder)
|
||||
numBuilder = ''
|
||||
lastChar = char
|
||||
options[char] = True
|
||||
if lastChar and numBuilder:
|
||||
options[lastChar] = float(numBuilder)
|
||||
numBuilder = ''
|
||||
del args[i]
|
||||
elif args[i] == "playlist" or args[i] == "m3u":
|
||||
options["playlist"] = True
|
||||
del args[i]
|
||||
elif args[i].endswith(".m3u"):
|
||||
options["playlist"] = True
|
||||
args[i] = args[i][0:args[i].rindex(".m3u")]
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return options
|
||||
|
||||
'''
|
||||
Compiles all arguments in the argument list into a SQLite search term
|
||||
|
||||
@param argList - The SQL query array to add to.
|
||||
@param args - The search keywords
|
||||
@param matchAll - Designs the search query to match all arguments, not just
|
||||
any argument.
|
||||
@param playlist - If true, then a playlist query is built.
|
||||
@return A set of search keywords provided, stripped of all extra arguments and
|
||||
unused words (the, and, etc.)
|
||||
'''
|
||||
def __appendDatabaseArguments(argList, args, matchAll = False, playlist = False):
|
||||
# Common words that are in a lot of song titles that we should avoid
|
||||
commonWords = ["the", "off", "for", "from", "with", "and"]
|
||||
notArgs = []
|
||||
terms = set()
|
||||
if len(args) > 0:
|
||||
for arg in args:
|
||||
if arg.startswith("!"):
|
||||
notArgs.append(arg[1:])
|
||||
elif arg == "AND" or arg == "and" or arg == "-and" or arg =="--and" or arg == "&&":
|
||||
# Override whatever AND/OR we had before
|
||||
argList.pop()
|
||||
argList.append("AND")
|
||||
elif arg == "OR" or arg == "or" or arg == "-or" or arg =="--or" or arg == "||":
|
||||
# Override whatever AND/OR we had before
|
||||
argList.pop()
|
||||
argList.append("OR")
|
||||
elif len(arg) >= 3 and not arg in commonWords:
|
||||
argList.append("(")
|
||||
if playlist:
|
||||
argList.append("playlists.name")
|
||||
argList.append("LIKE")
|
||||
argList.append("'%" + arg + "%'")
|
||||
else:
|
||||
argList.append("tracks.name")
|
||||
argList.append("LIKE")
|
||||
argList.append("'%" + arg + "%'")
|
||||
argList.append("OR")
|
||||
argList.append("albums.name")
|
||||
argList.append("LIKE")
|
||||
argList.append("'%" + arg + "%'")
|
||||
argList.append("OR")
|
||||
argList.append("artists.name")
|
||||
argList.append("LIKE")
|
||||
argList.append("'%" + arg + "%'")
|
||||
argList.append(")")
|
||||
terms.add(arg)
|
||||
if matchAll:
|
||||
argList.append("AND")
|
||||
else:
|
||||
argList.append("OR")
|
||||
if argList[len(argList) - 1] == "AND" or argList[len(argList) - 1] == "OR":
|
||||
argList.pop()
|
||||
if len(notArgs) > 0:
|
||||
argList.append("AND")
|
||||
for arg in notArgs:
|
||||
argList.append("tracks.name")
|
||||
argList.append("NOT")
|
||||
argList.append("LIKE")
|
||||
argList.append("'%" + arg + "%'")
|
||||
argList.append("AND")
|
||||
argList.pop()
|
||||
return terms
|
||||
|
||||
'''
|
||||
Searches the databases for songs that match a series of provided keywords
|
||||
|
||||
@param terms - A list of string options to match. If "playlist" is among
|
||||
them, then playlist names will be added to the search
|
||||
parameters.
|
||||
@param matchRate - The minimum percentage of terms that must be present in each
|
||||
entry in order for it to be included in the results.
|
||||
Defaults to 0.7
|
||||
@param sortRandom - Whether nor not the entries should be randomized within
|
||||
their match rates (i.e. two songs with a match rate of 2
|
||||
terms each will be randomly inserted, but a song with a
|
||||
match rate of 3 will always be after one with a rate of 2.)
|
||||
Defaults to True.
|
||||
@param playlist - Whether or not to include playlists in the search. Defaults
|
||||
to False.
|
||||
@param database - The path to the track database.
|
||||
@param playlists - The path to the playlist database.
|
||||
@return A list of tuples, with each one containing whether or not the track was
|
||||
part of a playlist, a track URI, a lollypop track ID, an album name,
|
||||
and an artist name. The list is sorted with more relevent results
|
||||
appearing farther down the list.
|
||||
'''
|
||||
def searchDatabase(terms, matchRate = MATCH_RATE, matchAll = False, sortRandom = True, playlist = False, database = Path(Path.home(), ".local/share/lollypop/lollypop.db"), playlists = Path(Path.home(), ".local/share/lollypop/playlists.db")):
|
||||
files = []
|
||||
addedUris = []
|
||||
vals = []
|
||||
matchRate = MATCH_RATE
|
||||
|
||||
db = sqlite3.connect(str(database))
|
||||
if playlist:
|
||||
dbPl = sqlite3.connect(str(playlists));
|
||||
findArgs = "SELECT tracks.uri, playlists.name FROM tracks INNER JOIN playlists ON tracks.playlist_id = playlists.id WHERE".split(" ")
|
||||
newTerms = __appendDatabaseArguments(findArgs, terms, matchAll = matchAll, playlist = True)
|
||||
findArgs = " ".join(findArgs) + ";"
|
||||
results = dbPl.execute(findArgs);
|
||||
|
||||
for row in results:
|
||||
matches = 0
|
||||
for term in newTerms:
|
||||
if term in re.sub('[^A-Za-z ]', '', row[1]).lower():
|
||||
matches += 1
|
||||
if matches >= matchRate * len(newTerms):
|
||||
# Make playlist matches more important
|
||||
matches *= 10
|
||||
# Get the actual song data, as the playlist database o
|
||||
songResults = db.execute("SELECT tracks.uri, tracks.id, tracks.name FROM tracks INNER JOIN albums ON tracks.album_id = albums.id INNER JOIN track_artists ON tracks.id = track_artists.track_id INNER JOIN artists ON track_artists.artist_id = artists.id WHERE tracks.uri='%s' GROUP BY tracks.uri" % (row[0])).fetchone()
|
||||
if songResults:
|
||||
songVals[songResults[0]] = (matches, matches)
|
||||
if sortRandom:
|
||||
point = random.randint(bisect_left(vals, matches), bisect_right(vals, matches))
|
||||
else:
|
||||
point = bisect_right(vals, matches)
|
||||
vals.insert(point, matches)
|
||||
files.insert(point, (True,) + songResults)
|
||||
addedUris.append(songResults[0])
|
||||
dbPl.close()
|
||||
|
||||
# Focus on actual songs, now.
|
||||
findArgs = "SELECT tracks.uri, tracks.id, tracks.name, albums.name, artists.name FROM tracks INNER JOIN albums ON tracks.album_id = albums.id INNER JOIN track_artists ON tracks.id = track_artists.track_id INNER JOIN artists ON track_artists.artist_id = artists.id WHERE".split(" ")
|
||||
newTerms = __appendDatabaseArguments(findArgs, terms, matchAll = matchAll, playlist = False)
|
||||
findArgs.append("GROUP")
|
||||
findArgs.append("BY")
|
||||
findArgs.append("tracks.uri")
|
||||
findArgs = " ".join(findArgs) + ";"
|
||||
#print(findArgs)
|
||||
results = db.execute(findArgs);
|
||||
|
||||
for row in results:
|
||||
# Skip any songs added as part of a playlist
|
||||
if row[0] in addedUris:
|
||||
continue
|
||||
#print(row[1])
|
||||
row = (False,) + row
|
||||
val = getSongValue(row, newTerms)
|
||||
if val[0] >= matchRate * len(newTerms):
|
||||
# Inserts it in order, but random to matches of an equal stature
|
||||
if sortRandom:
|
||||
point = random.randint(bisect_left(vals, val[1]), bisect_right(vals, val[1]))
|
||||
else:
|
||||
point = bisect_right(vals, matches)
|
||||
vals.insert(point, val[1])
|
||||
files.insert(point, row)
|
||||
db.close()
|
||||
|
||||
return files
|
||||
|
||||
'''
|
||||
Plays a list of songs
|
||||
|
||||
@param files - A list of songs as returned by searchDatabase. At minimum, each
|
||||
element needs to be a tuple containing a boolean of whether or
|
||||
not the song came from a playlist, the URI of the song file,
|
||||
and the lollypop song ID.
|
||||
@param separate - Whether or not to launch the Lollypop process in a separate
|
||||
process. Defaults to false (i.e. this process is replaced
|
||||
with Lollypop
|
||||
'''
|
||||
def play(files, separate = False):
|
||||
for song in files:
|
||||
if song[1] in songVals:
|
||||
print("%02d - %s" % (songVals[song[1]][0], song[1]))
|
||||
else:
|
||||
print("Pl - %s" % (song[1]))
|
||||
ids = []
|
||||
for i in range(len(files)):
|
||||
ids.append("t:%s" % (files[i][2]))
|
||||
files[i] = files[i][1]
|
||||
|
||||
vlcArgs = ["lollypop"]
|
||||
vlcArgs.append("-a")
|
||||
vlcArgs.append(";".join(ids))
|
||||
#print(" ".join(vlcArgs))
|
||||
print("Launching Lollypop: %s" % (vlcArgs))
|
||||
if separate:
|
||||
subprocess.Popen(vlcArgs)
|
||||
else:
|
||||
os.execvp(vlcArgs[0], vlcArgs)
|
||||
|
||||
def __main__():
|
||||
# Checks if Lollypop is installed
|
||||
missing = subprocess.call("command -v lollypop >> /dev/null", shell=True)
|
||||
if missing:
|
||||
print("Lollypop Music Player is not installed", file=sys.stderr)
|
||||
exit(1)
|
||||
|
||||
__mergeWords(sys.argv)
|
||||
options = __extractOptions(sys.argv)
|
||||
if "h" in options or "help" in options:
|
||||
print("""Usage: %s [OPTIONS]... [TERM]...
|
||||
Searches for a song within the music collection
|
||||
|
||||
OPTIONS:
|
||||
-a The search must match every single term
|
||||
-s Only the first option will play.
|
||||
-h, --help Shows this message
|
||||
-m[rate], --match=<rate>All matches must have this percentage of matching
|
||||
keywords in order to be included. Defaults to 0.65.
|
||||
|
||||
TERMS:
|
||||
For the most part, you can just enter a single-word term, and it will be added
|
||||
to the possible substrings included in the file name search. However, there are
|
||||
a few additional ways to refine your search.
|
||||
|
||||
-and, --and, AND, and Any of these keywords will cause the previous and next
|
||||
terms to be linked together, so that they MUST appear
|
||||
for the file to qualify.
|
||||
-or, --or, OR, or Any of these keywords will cause the previous and next
|
||||
terms to be linked together, so that only one or the
|
||||
other needs to appear for the file to qualify.
|
||||
Overrides the -a option for those two terms.
|
||||
! Add this to the beginning of a term to ensure that the
|
||||
keyword does NOT appear in any matching file paths.
|
||||
m3u, playlist Normally, playlists are ignored during the file search.
|
||||
However, this term will not only allow for their
|
||||
search, but will also give them higher precedence.""" % (sys.argv[0]))
|
||||
exit(0)
|
||||
|
||||
minAcceptance = MATCH_RATE
|
||||
if "match" in options:
|
||||
minAcceptance = options["match"]
|
||||
elif "m" in options:
|
||||
minAcceptance = options["m"]
|
||||
if minAcceptance > 10:
|
||||
minAcceptance /= 100
|
||||
elif minAcceptance > 1:
|
||||
minAcceptance /= 10
|
||||
|
||||
files = searchDatabase(sys.argv[1:], matchRate = minAcceptance, matchAll = "a" in options or "all" in options, playlist = "playlist" in options)
|
||||
|
||||
if len(files) == 0:
|
||||
print("No files found", file=sys.stderr)
|
||||
exit(1)
|
||||
|
||||
files.reverse()
|
||||
|
||||
if "s" in options:
|
||||
files = [files[0]]
|
||||
|
||||
play(files)
|
||||
exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
__main__()
|
||||
Reference in New Issue
Block a user