Implementing artist/album lookup in CLI.

This commit is contained in:
Jonathan Bernard 2016-02-10 09:53:12 -06:00
parent 6e6defe544
commit 90a11569da
6 changed files with 377 additions and 152 deletions

View File

@ -11,8 +11,8 @@ dependencies {
compile 'ch.qos.logback:logback-core:1.1.3'
compile 'org.slf4j:slf4j-api:1.7.14'
compile 'com.offbytwo:docopt:0.6.0.20150202'
compile 'com.jdbernard:jdb-util:4.2'
compile 'org.fusesource.jansi:jansi-project:1.11'
compile 'com.jdbernard:jdb-util:4.3'
compile 'jline:jline:2.12'
compile project(":wdiwtlt-core")
testCompile 'junit:junit:4.12'

View File

@ -3,11 +3,16 @@ package com.jdbernard.wdiwtlt.cli
import com.jdbernard.wdiwtlt.ConfigWrapper
import com.jdbernard.wdiwtlt.MediaLibrary
import com.jdbernard.wdiwtlt.db.ORM
import com.jdbernard.wdiwtlt.db.models.*
import com.jdbernard.io.NonBlockingInputStreamReader
import com.jdbernard.util.AnsiEscapeCodeSequence as ANSI
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.docopt.Docopt
import jline.console.ConsoleReader
import java.util.regex.Matcher
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import static com.jdbernard.util.AnsiEscapeCodeSequence.*
@ -44,33 +49,46 @@ Options:
Configuration:
"""
private static Logger logger =
LoggerFactory.getLogger(CommandLineInterface)
private Properties cliConfig
private MediaLibrary library
private String[] linesBuffer = new String[1024]
private StringBuilder currentLine = new StringBuilder()
private int nextAvailableLineIdx = 0
private int newLineIdx = 0
private String selectedLineIdx = 0
private InputStream sin
private InputStream inStream
private OutputStream outStream
private PrintStream out
private ConsoleReader reader
private Thread consoleReaderThread
private List<String> consoleReadBuffer =
Collections.synchronizedList(new ArrayList<String>())
private synchronized boolean running
private boolean running
private String titleStyle, normalStyle, promptStyle, errorStyle
private String clearRemainingLine = new ANSI().eraseLine(Erase.ToEnd).toString()
private String titleStyle, normalStyle, statusStyle, promptStyle,
artistStyle, albumStyle, fileStyle, errorStyle
private String clearLine = new ANSI().eraseLine(Erase.All).toString()
private String afterInput =
new ANSI().eraseLine(Erase.All).scrollUp().cursorUp().toString()
private String beforeLeader =
new ANSI().saveCursor().cursorPrevLine(2).toString()
private String afterLeader =
new ANSI().restoreCursor().eraseLine(Erase.ToEnd).toString()
private String eraseLeader =
new ANSI().cursorPrevLine().eraseLine(Erase.All).cursorPrevLine().eraseLine(Erase.All).toString()
private String errorMsg = ""
private int lastPromptLineCount = 0
private int displayWidth = 79
private long msgTimeout
private ScrollText currentlyPlaying = new ScrollText(
maxWidth: displayWidth - 15 - VERSION.size(),
text: "No media currently playing.")
private ScrollText status = new ScrollText(maxWidth: displayWidth)
private Date dismissMsgDate = new Date()
def selection = [:]
public static void main(String[] args) {
def opts = new Docopt(DOC).withVersion("wdiwtlt v$VERSION").parse(args)
println opts
def exitErr = { msg ->
System.out.err.println("wdiwtlt: $msg")
System.exit(1) }
@ -148,164 +166,248 @@ Configuration:
}
public CommandLineInterface(MediaLibrary library, InputStream sin,
PrintStream out, Properties cliConfig) {
OutputStream out, Properties cliConfig) {
this.cliConfig = cliConfig
this.library = library
this.out = out
this.sin = sin
this.inStream = sin
this.outStream = out
this.msgTimeout = cliConfig["message.timeout"] ?: 5000l
setupTextStyles()
this.consoleReaderThread = new Thread().start(this.&runReaderThread)
}
void setupTextStyles() {
titleStyle = new ANSI().color(Colors.BLUE, Colors.DEFAULT, true).toString()
titleStyle = new ANSI().color(Colors.GREEN, Colors.DEFAULT, false).toString()
normalStyle = new ANSI().resetText().toString()
promptStyle = new ANSI().color(Colors.YELLOW, Colors.DEFAULT, true).toString()
statusStyle = new ANSI().color(Colors.CYAN, Colors.DEFAULT, false).toString()
artistStyle = new ANSI().color(Colors.RED, Colors.DEFAULT, false).toString()
albumStyle = new ANSI().color(Colors.BLUE, Colors.DEFAULT, false).toString()
fileStyle = new ANSI().color(Colors.GREEN, Colors.DEFAULT, false).toString()
errorStyle = new ANSI().color(Colors.RED, Colors.DEFAULT, true).toString()
}
private void runReaderThread() {
this.reader = new ConsoleReader(this.inStream, this.outStream)
this.reader.setPrompt(promptStyle + "> " + normalStyle)
String line
while(this.running && !Thread.currentThread().isInterrupted()) {
line = reader.readLine()
if (line == null || line == "quit" || line == "exit") running = false
else {
consoleReadBuffer.add(line)
outStream.print(afterInput)
outStream.flush() } } }
public void repl() {
this.running = true
String line = null
printPrompt()
outStream.println ""
outStream.println ""
drawLeader()
while(this.running) {
line = readInputLine()
if (line != null) {
out.flush()
errorMsg = ""
if (new Date() > dismissMsgDate) resetStatus()
if (consoleReadBuffer.size() > 0) {
line = consoleReadBuffer.remove(0)
switch(line) {
case ~/quit|exit|\u0004/:
case ~/quit|exit|.+\u0004$/:
running = false
consoleReaderThread.interrupt()
break
case 'scan':
scanMediaLibrary()
break
case 'debug':
outStream.println(
"\n\nConfig: \n" +
cliConfig.collect { "\t${it.key}: ${it.value}" }
.join("\n") +
"\n\n\n")
drawLeader(true)
break
case "artist":
selection.artist = null
resetStatus()
break
case ~/artist (.+)/:
def input = Matcher.lastMatcher[0][1]?.trim()
def match = library.getByIdOrName(input, Artist)
if (!match) setErr("No artist matches '$input'.")
else if (match.size() > 1)
setErr("Multiple artists match '$input': " +
match.collect { "${it.id}: ${it.name}" }
.join(", "))
else {
selection.artist = match[0]
resetStatus() }
break
case "album":
selection.album = null
resetStatus()
break
case ~/album (.+)/:
def input = Matcher.lastMatcher[0][1]?.trim()
def match = library.getByIdOrName(input, Album)
if (!match) setErr("No album matches '$input'.")
else if (match.size() > 1)
setErr("Multiple albums match '$input': " +
match.collect { "${it.id}: ${it.name}" }
.join(", "))
else {
selection.album = match[0]
resetStatus() }
break
case "list artists for album":
if (!selection.album) {
setErr("No album is selected.")
break }
outStream.println(makeArtistList(
library.getArtistsByAlbumId(selection.album.id),
selection.album))
drawLeader(true)
break
case ~/list artists( .+)?/:
def name = Matcher.lastMatcher[0][1]?.trim()
def artists
if (name) artists = library.getArtistsByName(name)
else artists = library.getArtists()
outStream.println(makeArtistList(artists))
drawLeader(true)
break
case "list albums for artist":
if (!selection.artist) {
setErr("No artist selected.")
break }
outStream.println(makeAlbumList(
library.getAlbumsByArtistId(selection.artist.id),
selection.artist))
drawLeader(true)
break
case ~/list albums( .+)?/:
def name = Matcher.lastMatcher[0][1]?.trim()
def albums
if (name) albums = library.getAlbumsByName(name)
else albums = library.getAlbums()
outStream.println(makeAlbumList(albums))
drawLeader(true)
break
default:
errorMsg = "Unrecognized command: '$line'"
Thread.sleep(200)
status.text = errorStyle +
"Unrecognized command: '$line'${normalStyle}"
dismissMsgDate = new Date(new Date().time + msgTimeout)
drawLeader()
Thread.sleep(250)
break
}
printPrompt(true)
} else {
printPrompt(true)
Thread.sleep(200)
drawLeader()
Thread.sleep(250)
}
}
}
private void printPrompt(boolean redraw = false) {
StringBuilder prompt = new StringBuilder()
private void scanMediaLibrary() {
status.text = "Scanning media library..."
library.rescanLibrary()
status.text = "Scanned ? files."
dismissMsgDate = new Date(new Date().time + msgTimeout) }
if (redraw) prompt.append(new ANSI()
.saveCursor()
.cursorPrevLine(lastPromptLineCount))
private void drawLeader(afterOutput = false) {
String leader = beforeLeader + getLeader() +
(afterOutput ?
(promptStyle + "> " + normalStyle) :
afterLeader)
outStream.print(leader)
outStream.flush() }
private String getLeader() {
StringBuilder leader = new StringBuilder()
.append(clearLine)
prompt.append(titleStyle)
.append(titleStyle)
.append("WDIWTLT - v")
.append(VERSION)
.append("-- ")
.append(normalStyle)
.append("TODO\n")
if (errorMsg) prompt
.append(" -- ")
.append(statusStyle)
.append(currentlyPlaying)
.append("\n")
.append(clearLine)
.append(errorStyle)
.append(errorMsg)
.append(normalStyle)
.append(status)
.append("\n")
if (redraw) prompt.append(new ANSI().restoreCursor())
else prompt.append(promptStyle)
.append("> ")
return leader.toString() }
private String setErr(String errMsg) {
status.text = errorStyle + errMsg
dismissMsgDate = new Date(new Date().time + msgTimeout) }
private String makeAlbumList(def albums, Artist artist = null) {
def result = new StringBuilder()
.append(eraseLeader)
.append("--------------------\nAlbums")
if (artist) result.append(" (for artist '$artist'):\n")
else result.append(":\n\n")
result.append(albums.collect { "${it.id}: ${it}" }.join("\n"))
.append("\n\n\n")
return result.toString() }
private String makeArtistList(def artists, Album album = null) {
def result = new StringBuilder()
.append(eraseLeader)
.append("--------------------\nArists")
if (album) result.append(" (for album '$album'):\n")
else result.append(":\n\n")
result.append(artists.collect { "${it.id}: ${it.name}" }.join("\n"))
.append("\n\n\n")
return result.toString() }
private String resetStatus() {
StringBuilder s = new StringBuilder()
if (selection.artist) s.append(artistStyle)
.append(selection.artist)
.append(normalStyle)
.append(" / ")
if (selection.album) s.append(albumStyle)
.append(selection.album)
.append(normalStyle)
.append(" / ")
lastPromptLineCount = 1 + (errorMsg ? 1 : 0)
if (selection.mediaFile) s.append(fileStyle)
.append(selection.mediaFile)
out.print(prompt.toString())
out.flush()
}
if (s.size() == 0) status.text = "No current media selections."
else status.text = s.toString()
private void replaceCurrentLine(String newLine) {
String toPrint = new StringBuilder()
.append(new ANSI().cursorHorizontalAbsolute(3))
.append(clearRemainingLine)
.append(newLine)
.toString()
currentLine = new StringBuilder(newLine)
out.print(toPrint)
out.flush()
}
private String readInputLine() {
int bytesAvailable = sin.available()
if (bytesAvailable) {
byte[] input = new byte[bytesAvailable]
sin.read(input)
int idx = 0
while (idx < input.length) {
switch (input[idx]) {
case 0x08: // backspace
case 0x0B: // enter
linesBuffer[newLineIdx] = currentLine.toString()
newLineIdx = (newLineIdx + 1) % linesBuffer.length
linesBuffer[newLineIdx] = null
replaceCurrentLine("")
break
case 0x1B: // escape (prefix to arrows, etc)
// arrows
if (input.length > idx + 2 && input[idx + 1] == 0x5B) {
switch(input[idx + 2]) {
case 0x41: // up
int ni = selectedLineIdx
ni = (ni - 1 + linesBuffer.length) %
linesBuffer.length
if (linesBuffer[ni] != null) {
selectedLineIdx = ni
replaceCurrentLine(linesBuffer[ni]) }
else {
out.print(new ANSI().cursorBackwards(4) +
clearRemainingLine)
out.flush() }
break
case 0x42: // down
int ni = selectedLineIdx
ni = (ni + 1) % linesBuffer.length
if (linesBuffer[ni] != null) {
selectedLineIdx = ni
replaceCurrentLine(linesBuffer[ni]) }
else {
out.print(new ANSI().cursorBackwards(4) +
clearRemainingLine)
out.flush() }
break
case 0x43: case 0x44: // right & left
out.print(new ANSI().cursorBackwards(4) +
clearRemainingLine)
out.flush()
break
default: break
}
idx += 2
}
else currentLine.append((char)input[idx])
break
default:
currentLine.append((char)input[idx])
break
}
idx++
}
}
if (linesBuffer[nextAvailableLineIdx] != null) {
String line = linesBuffer[nextAvailableLineIdx]
nextAvailableLineIdx =
(nextAvailableLineIdx + 1) % linesBuffer.length
return line }
else return null
}
return status.text}
}

View File

@ -0,0 +1,31 @@
package com.jdbernard.wdiwtlt.cli
public class ScrollText {
public String text = ""
public int maxWidth
private int scrollIdx
public void setMaxWidth(int max) {
this.maxWidth = Math.max(max, 1) }
public void setText(String t) {
this.text = t
this.scrollIdx = Math.max(0, text.size() - 10) }
public String getNextScroll() {
if (text.size() < maxWidth) return text
else {
scrollIdx = (scrollIdx + 1) % text.size()
int endIdx = Math.min(scrollIdx + maxWidth, text.size())
int wrapIdx = (scrollIdx + maxWidth) % text.size()
// don't need to wrap past the end
if (wrapIdx == endIdx) return text[scrollIdx..<endIdx]
return new StringBuilder()
.append(text[scrollIdx..<endIdx])
.append(" ").append(text[0..<wrapIdx]) } }
public String toString() { return getNextScroll() }
}

View File

@ -0,0 +1,19 @@
import ch.qos.logback.core.*;
import ch.qos.logback.core.encoder.*;
import ch.qos.logback.core.read.*;
import ch.qos.logback.core.rolling.*;
import ch.qos.logback.core.status.*;
import ch.qos.logback.classic.net.*;
import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
appender("FILE", FileAppender) {
file = "wdiwtlt.log"
append = true
encoder(PatternLayoutEncoder) {
pattern = "%level %logger - %msg%n"
}
}
root(OFF)
logger("com.jdbernard.wdiwtlt", DEBUG, ["FILE"])

View File

@ -17,7 +17,7 @@ public class MediaLibrary {
private static Logger logger = LoggerFactory.getLogger(MediaLibrary)
private ORM orm
@Delegate ORM orm
private File libraryRoot
public MediaLibrary(ORM orm, File rootDir) {
@ -66,7 +66,7 @@ public class MediaLibrary {
mf.name = fileTag?.getFirst(TITLE)?.trim() ?: f.name
mf.filePath = relPath
mf.comment = fileTag?.getFirst(COMMENT)?.trim()
mf.trackNumber = (fileTag?.getFirst(TRACK) ?: null) as Integer
mf.trackNumber = safeToInteger(fileTag?.getFirst(TRACK))
def folderParts = mf.filePath.split("[\\\\/]")[1..<-1] as LinkedList
@ -91,6 +91,20 @@ public class MediaLibrary {
}
}
public def getByIdOrName(String input, Class modelClass) {
def match
if (safeToInteger(input)) match = [orm.getById(safeToInteger(input), modelClass)]
else {
match = orm.getByName(input, modelClass)
if (!match) match = orm.getLike(["name"], [input], modelClass) }
return match }
public List<Artist> getArtistsByName(String name) {
return orm.getArtistByName(name) ?: orm.getArtistsLikeName(name) }
public List<Album> getAlbumsByName(String name) {
return orm.getAlbumByName(name) ?: orm.getAlbumsLikeName(name) }
private void associateWithArtistAndAlbum(MediaFile mf, String artistName,
String albumName, JATag fileTag) {
Artist artist = null
@ -108,9 +122,13 @@ public class MediaLibrary {
album = orm.getAlbumByName(albumName)
if (!album) {
newAlbumOrArtist = true
album = new Album(name: albumName,
year: (fileTag?.getFirst(YEAR) ?: null) as Integer,
trackTotal: (fileTag?.getFirst(TRACK_TOTAL) ?: null) as Integer)
try {
album = new Album(name: albumName,
year: safeToInteger(fileTag?.getFirst(YEAR)),
trackTotal: safeToInteger(fileTag?.getFirst(TRACK_TOTAL))) }
catch (UnsupportedOperationException use) {
album = new Album(name: albumName,
year: safeToInteger(fileTag?.getFirst(YEAR))) }
orm.create(album) } }
if (artist && album && newAlbumOrArtist)
@ -144,4 +162,8 @@ public class MediaLibrary {
return (['.'] + childPath[b..<childPath.length]).join('/') }
public static Integer safeToInteger(def val) {
if (val == null) return null
try { return val as Integer }
catch (NumberFormatException nfe) { return null } }
}

View File

@ -32,6 +32,12 @@ public class ORM {
public void shutdown() { dataSource.shutdown() }
/// ### Common
public def getAll(Class modelClass) {
def query = "SELECT * FROM " +
pluralize(nameFromModel(modelClass.simpleName))
logger.debug("Selecting models.\n\tSQL: {}", query)
return sql.rows(query).collect { recordToModel(it, modelClass) } }
public def getById(int id, Class modelClass) {
def query = new StringBuilder()
.append("SELECT * FROM ")
@ -50,7 +56,7 @@ public class ORM {
.toString()
logger.debug("Selecting model.\n\tSQL: {}\n\tPARAMS: {}", query, name)
return recordToModel(sql.firstRow(query, [name]), modelClass) }
return sql.rows(query, [name]).collect { recordToModel(it, modelClass) } }
public def getBy(List<String> columns, List<Object> values,
Class modelClass) {
@ -65,6 +71,20 @@ public class ORM {
return sql.rows(query, values)
.collect { recordToModel(it, modelClass) } }
public def getLike(List<String> columns, List<Object> values,
Class modelClass) {
values = values.collect { "%$it%".toString() }
String query = new StringBuilder()
.append("SELECT * FROM ")
.append(pluralize(nameFromModel(modelClass.simpleName)))
.append(" WHERE ")
.append(columns.collect { """"$it" LIKE ?"""}.join(' AND '))
.toString()
logger.debug("Selecting models.\n\tSQL: {}\n\tPARAMS: {}", query, values)
return sql.rows(query, values)
.collect { recordToModel(it, modelClass) } }
public def save(def model) {
if (model.id > 0) return update(model)
else return create(model) }
@ -130,13 +150,13 @@ public class ORM {
public def associate(Class modelClass1, Class modelClass2 , Integer firstId, Integer secondId) {
String linkTable = pluralize(nameFromModel(modelClass1.simpleName)) +
"_" + pluralize(nameFromModel(modelClass2.simpleName))
String col1 = nameFromModel(modelClass1) + "_id"
String col2 = nameFromModel(modelClass2) + "_id"
String col1 = nameFromModel(modelClass1.simpleName) + "_id"
String col2 = nameFromModel(modelClass2.simpleName) + "_id"
withTransaction {
def query = """\
SELECT * FROM $linkTable
WHERE "${col1}"_id = ? AND "${col2}" = ?"""
WHERE "${col1}" = ? AND "${col2}" = ?"""
def params = [firstId, secondId]
// Look first for an existing association before creating one.
@ -154,7 +174,10 @@ public class ORM {
/// ### Album-specific methods
public Album getAlbumById(int id) { return getById(id, Album) }
public Album getAlbumByName(String name) { return getByName(name, Album) }
public List<Album> getAlbumByName(String name) {
return getByName(name, Album) }
public Album getAlbumByNameAndArtistId(String name, int artistId) {
def query = """\
SELECT al.*
@ -170,7 +193,7 @@ public class ORM {
.collect { recordToModel(it, Album) }
return albums ? albums[0] : null }
public Album getAlbumsByArtistId(int artistId) {
public List<Album> getAlbumsByArtistId(int artistId) {
def query = """\
SELECT al.*
FROM albums al JOIN
@ -182,14 +205,21 @@ public class ORM {
return sql.rows(query, [artistId])
.collect { recordToModel(it, Album) } }
public List<Album> getAlbums() { return getAll(Album) }
public List<Album> getAlbumsLikeName(String name) {
return getLike(["name"], [name], Album) }
public List<Album> removeEmptyAlbums() {
throw new UnsupportedOperationException("Not yet implemented.");
}
/// ### Artist-specific methods
public Artist getArtistById(int id) { return getById(id, Artist) }
public Artist getArtistByName(String name) { return getByName(name, Artist) }
public Artist getArtistsByAlbum(int albumId) {
public List<Artist> getArtistByName(String name) {
return getByName(name, Artist) }
public List<Artist> getArtists() { return getAll(Artist) }
public List<Artist> getArtistsByAlbumId(int albumId) {
var query = """\
SELECT ar.*
FROM artists ar JOIN
@ -200,6 +230,9 @@ public class ORM {
return sql.rows(query, [albumId])
.collect { recordToModel(it, Artist) } }
public List<Artist> getArtistsLikeName(String name) {
return getLike(["name"], [name], Artist) }
public List<Artist> removeEmptyArtists() {
throw new UnsupportedOperationException("Not yet implemented.");
}
@ -209,14 +242,25 @@ public class ORM {
/// ### Bookmark-specific methods
public Bookmark getBookmarkById(int id) { return getById(id, Bookmark) }
public Bookmark getBookmarkByName(String name) { return getByName(name, Bookmark) }
public List<Bookmark> getBookmarkByName(String name) {
return getByName(name, Bookmark) }
public List<Bookmark> getBookmarks() { return getAll(Bookmark) }
/// ### Image-specific methods
public Image getImageById(int id) { return getById(id, Image) }
/// ### MediaFile-specific methods
public MediaFile getMediaFileById(int id) { return getById(id, MediaFile) }
public MediaFile getMediaFileByName(String name) { return getByName(name, MediaFile) }
public List<MediaFile> getMediaFileByName(String name) {
return getByName(name, MediaFile) }
public List<MediaFile> getMediaFiles() { return getAll(MediaFile) }
public List<MediaFile> getMediaFilesLikeName(String name) {
return getLike(["name"], [name], MediaFile) }
public MediaFile getMediaFileByFilePath(String filePath) {
def files = getBy(["file_path"], [filePath], MediaFile)
@ -245,7 +289,11 @@ public class ORM {
/// ### Playlist-specific methods
public Playlist getPlaylistById(int id) { return getById(id, Playlist) }
public Playlist getPlaylistByName(String name) { return getByName(name, Playlist) }
public List<Playlist> getPlaylistByName(String name) {
return getByName(name, Playlist) }
public List<Playlist> getPlaylists() { return getAll(Playlist) }
public List<Playlist> removeEmptyPlaylists() {
throw new UnsupportedOperationException("Not yet implemented.");
@ -253,10 +301,13 @@ public class ORM {
/// ### Tag-specific methods
public Tag getTagById(int id) { return getById(id, Tag) }
public Tag getTagByName(String name) { return getByName(name, Tag) }
public Tag getTagByName(String name) { return getByName(name, Tag)[0] }
/// ### Utility functions
public void withTransaction(Closure c) { sql.withTransaction(c) }
public void withTransaction(Closure c) {
try { sql.execute("BEGIN TRANSACTION"); c() }
finally { sql.execute("COMMIT") }
}
public static String nameToModel(String name) {
def pts = name.toLowerCase().split('_')
return pts.length == 1 ? pts[0] :