Compare commits

5 Commits

16 changed files with 1826 additions and 102 deletions

View File

@ -1,43 +1,334 @@
import std/[os, uri]
import mpv
import std/[json, options, os, paths, sequtils, strutils, terminal, times,
unicode]
import cliutils, docopt, namespaced_logging, mpv
import wdiwtlt/[models, db]
import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary,
messagebuffer, models, nonblockingio, scrolltext, usage]
when isMainModule:
var ctx: ptr handle
try:
if paramCount() != 1:
echo "pass a single media file as argument"
quit(QuitFailure)
type
CliMode {.pure.} = enum Direct, Command
ctx = mpv.create()
if ctx.isNil:
echo "failed creating mpv context"
quit(QuitFailure)
MessageBuffers* = ref object
playing: MessageBuffer[ScrollText]
status: MessageBuffer[ScrollText]
longform: MessageBuffer[string]
input: MessageBuffer[string]
History = ref object
entries: seq[string]
idx: int
CliContext = ref object
cfg: CombinedConfig
cmd: CommandBuffer
curMediaFile: Option[MediaFile]
lib: MediaLibrary
log: Option[Logger]
logs: seq[LogMessage]
logThreshold: Level
mode: CliMode
msgs: MessageBuffers
mpv: ptr handle
msgDuration: Duration
stop*: bool
width*: int
height*: int
var ctx {.threadvar.}: CliContext
const FRAME_DUR_MS = 200
proc initMpv(): ptr handle =
result = mpv.create()
if result.isNil:
raise newException(ValueError, "failed creating mpv context")
# Enable default key bindings, so the user can actually interact with
# the player (and e.g. close the window).
ctx.set_option("terminal", "no")
ctx.set_option("input-default-bindings", "yes")
ctx.set_option("input-vo-keyboard", "yes")
ctx.set_option("osc", false)
ctx.set_property("volume", "50.0")
discard ctx.request_log_messages("no")
result.set_option("terminal", "no")
result.set_option("input-default-bindings", "yes")
result.set_option("input-vo-keyboard", "yes")
result.set_option("osc", false)
#result.set_property("volume", "50.0")
discard result.request_log_messages("no")
# Done setting up options.
check_error ctx.initialize()
check_error result.initialize()
# Play this file.
discard ctx.command_string("loadfile " & "file://" & encodeUrl(paramStr(1), false))
# discard ctx.command_string(["loadfile ", "file://" & encodeUrl(paramStr(1), false)])
while true:
let event = ctx.wait_event(10000)
echo "event: ", mpv.event_name(event.event_id)
if event.event_id == mpv.EVENT_SHUTDOWN:
break
proc cleanup() =
terminate_destroy(ctx.mpv)
setNonBlockingTty(false)
echo ""
#[
proc exitHandlerHook() {.noconv.} =
# This is called when the user presses Ctrl+C
# or when the program exits normally.
terminate_destroy(ctx.mpv)
echo ""
quit(QuitSuccess)
]#
proc initContext(args: Table[string, Value]): CliContext =
var wdiwtltCfgFilename: string
let log = getLogger("wdiwtlt")
let cfgLocations =
if args["--config"]: @[$args["--config"]]
elif existsEnv("XDG_CONFIG_HOME"): @[getEnv("XDG_CONFIG_HOME") / "wdiwtlt.json"]
else: @[$getEnv("HOME") / ".config" / "wdiwtlt.json" ]
try: wdiwtltCfgFilename = findConfigFile(".wdiwtlt.json", cfgLocations)
except ValueError:
log.error "could not find wdiwtlt config file: " & wdiwtltCfgFilename
if not args["--config"]:
wdiwtltCfgFilename = cfgLocations[0]
var cfgFile: File
try:
cfgFile = open(wdiwtltCfgFilename, fmWrite)
cfgFile.write((%*{
"libraryRoot":
if args["--library-root"]:
$args["--library-root"]
else:
getEnv("XDG_MUSIC_DIR", getEnv("HOME") / "music"),
"dbPath": getEnv("XDG_DATA_HOME", (getEnv("HOME") / ".local/share")) / "wdiwtlt/db.sqlite",
}).pretty)
log.info "created sample config file at " & wdiwtltCfgFilename
except CatchableError:
log.error "could not write default .wdiwtlt.json to " &
wdiwtltCfgFilename
finally: close(cfgFile)
quit(QuitFailure)
log.debug("loading config from '$#'" % [wdiwtltCfgFilename])
let cfg = initCombinedConfig(wdiwtltCfgFilename, args)
let width = min(terminalWidth(), cfg.getVal("maxWidth", "80").parseInt)
let height = min(terminalHeight(), cfg.getVal("maxHeight", "60").parseInt)
let msgs = MessageBuffers(
playing: initMessageBuffer[ScrollText](
dims = BufferDimensions(width: width, height: 1, x: 0, y: 0),
initialMsg = "Nothing playing"),
status: initMessageBuffer[ScrollText](
dims = BufferDimensions(width: width, height: 1, x: 0, y: 1),
initialMsg = ""),
input: initMessageBuffer[string](
dims = BufferDimensions(width: width, height: 1, x: 0, y: 2),
initialMsg = ""),
longform: initMessageBuffer[string](
dims = BufferDimensions(width: width, height: height - 3, x: 0, y: 4),
initialMsg = ""))
result = CliContext(
cfg: cfg,
cmd: initCommandBuffer(msgs.input),
curMediaFile: none(MediaFile),
lib: initMediaLibrary(
rootDir = getVal(cfg, "libraryRoot").Path,
dbPath = getVal(cfg, "dbPath").Path),
log: log,
mode: CliMode.Direct,
msgs: msgs,
mpv: initMpv(),
msgDuration: initDuration(seconds = 5, milliseconds = 0),
stop: false,
width: width,
height: height)
if logService.isSome:
let customLogAppender = initCustomLogAppender(doLogMessage =
proc (msg: LogMessage): void = ctx.logs.add(msg))
logService.get.clearAppenders()
logService.get.addAppender(customLogAppender)
proc nextTick(ctx: var CliContext) =
ctx.msgs.playing.nextTick
ctx.msgs.status.nextTick
ctx.msgs.longform.nextTick
proc setMode(ctx: var CliContext, mode: CliMode) =
ctx.mode = mode
case mode
of CliMode.Direct:
hideCursor()
of CliMode.Command:
showCursor()
ctx.cmd.setMode(EditMode.Insert)
proc statusMsg(ctx: var CliContext, msg: string) =
ctx.msgs.status.showMsg(msg, some(ctx.msgDuration))
proc longMsg[T: string or seq[string]](ctx: var CliContext, msg: T) =
ctx.msgs.longform.showMsg(msg, some(ctx.msgDuration))
proc viewLogLoops(ctx: var CliContext) =
let logLines = ctx.logs
.filterIt(it.level > ctx.logThreshold)
.mapIt("$# - [$#]: $#" % [$it.level, $it.scope, it.message])
.join("\p")
ctx.msgs.longform.showMsg(logLines, none[Duration]())
var viewLogs = true
while viewLogs:
case getKeyAsync()
of Key.Q: viewLogs = false
of Key.K, Key.Up: ctx.msgs.longform.scrollLines(-1)
of Key.J, Key.Down: ctx.msgs.longform.scrollLines(1)
of Key.H, Key.Left: ctx.msgs.longform.scrollColumns(-1)
of Key.L, Key.Right: ctx.msgs.longform.scrollColumns(1)
of Key.PageUp: ctx.msgs.longform.scrollPages(-1)
of Key.PageDown: ctx.msgs.longform.scrollPages(1)
else: discard
sleep(FRAME_DUR_MS)
ctx.msgs.longform.clear()
proc scanLibrary(ctx: var CliContext) =
ctx.statusMsg("Scanning media library...")
let counts = ctx.lib.rescanLibrary()
var scanResults = @[
"".repeat(ctx.width),
"Scan complete:",
"\t$# files total." % [ $counts.total ]
]
if counts.new > 0:
scanResults.add("\t$# new files added." % [ $counts.new ])
if counts.ignored > 0:
scanResults.add("\t$# files ignored." % [ $counts.ignored ])
if counts.absent > 0:
scanResults.add(
"\t$# files in the database but not stored locally." %
[ $counts.absent ])
ctx.longMsg(scanResults.join("\p"))
proc processLogs(ctx: var CliContext, args: seq[string]) =
case args[0]
of "view": ctx.viewLogLoops()
of "clear": ctx.logs = @[]
of "set-threshold":
if args.len < 2:
ctx.longMsg(("missing $# argument to $#" %
[ "log-threshold", "logs set-threshold" ]) &
usageOf(@["logs", "set-threshold"]))
# TODO
else: ctx.statusMsg("Unrecognized logs command: " & args[0])
proc processCommand(ctx: var CliContext, command: string) =
let parts = command.strip.split(' ')
case parts[0]
of "scan": scanLibrary(ctx)
of "q", "quit", "exit": ctx.stop = true
of "logs": ctx.processLogs(parts[1..^1])
else: statusMsg(ctx, "Unrecognized command: " & command)
proc handleKey(ctx: var CliContext, key: Key) =
if ctx.mode == CliMode.Direct:
case key
of Key.Q: ctx.stop = true
of Key.Colon, Key.I: ctx.setMode(CliMode.Command)
else: discard
elif ctx.mode == CliMode.Command:
case key
of Key.Enter:
let command = $ctx.cmd
ctx.cmd.clear
ctx.cmd.mode = EditMode.Insert
processCommand(ctx, command)
of Key.Backspace: ctx.cmd.handleInput(Key.Backspace)
of Key.Escape:
if ctx.cmd.mode == EditMode.Command:
ctx.setMode(CliMode.Direct)
ctx.cmd.clear
else: ctx.cmd.handleInput(Key.Escape)
else: ctx.cmd.handleInput(key)
proc mainLoop(ctx: var CliContext) =
hideCursor()
var frame = 0
while not ctx.stop:
# This sleep below will be the primary driver of the frame rate of the
# application (examples below):
#
# | frames/sec | sec/frame | ms/frame |
# |------------|-----------|----------|
# | 5.00 | 0.200 | 200 |
# | 10.00 | 0.100 | 100 |
# | 12.50 | 0.080 | 80 |
# | 20.00 | 0.050 | 50 |
# | 25.00 | 0.040 | 40 |
# | 31.25 | 0.032 | 32 |
# | 50.00 | 0.020 | 20 |
# | 62.50 | 0.016 | 16 |
#
# Previously, when we were using illwill and rendering every frame we
# targeted a faster FPS to allow for a responsive feeling to the command
# input, etc. In this case, we still had a "logic update" period that was
# lower than our rendering FPS. Specifically, we were targeting 60fps
# rendering speed, but 5fps for logic updates (scrolling text, expiring
# messages, etc.).
#
# Now that we are directly rendering (components of the UI render updates
# themselves immediately) we don't need to render at 60fps to have a
# responsive UI. Now we really only care about the frequency of logic
# updates, so we're targeting 5fps and triggering our logic updates every
# frame.
handleKey(ctx, getKeyAsync())
sleep(FRAME_DUR_MS)
nextTick(ctx) # tick on every frame
frame = (frame + 1) mod 1000 # use a max frame count to prevent overflow
when isMainModule:
discard enableLogging()
try:
let args = docopt(USAGE, version=VERSION)
ctx = initContext(args)
setNonBlockingTty(true)
mainLoop(ctx)
except Exception:
echo "wdiwtlt: " & getCurrentExceptionMsg()
let ex = getCurrentException()
getLogger("wdiwtlt").error(%*{
"msg": "Unhandled exception",
"error": ex.msg,
"trace": ex.getStackTrace()
})
cleanup()
quit(QuitFailure)
finally:
mpv.terminate_destroy(ctx)
cleanup()

View File

@ -0,0 +1,18 @@
const VERSION* = "0.2.0"
const USAGE* = "wdiwtlt v" & VERSION & """
Usage:
wdiwtlt [options]
wdiwtlt --version
wdwtlt --help
Options:
-c, --config <cfgPath> Path to the configuration file. Defaults to:
~/.config/wdiwtlt/config.json
-L, --library-root <root-dir> The path to a local media library directory.
-D, --database-config <dbCfg> Path to the database file (SQLite3). Defaults to:
~/.config/wdiwtlt/db.sqlite
"""

View File

@ -0,0 +1,247 @@
import std/[options, strutils]
import ./[messagebuffer, nonblockingio, terminal, util]
type
EditMode* {.pure.} = enum Command, Insert, Overwrite, Visual
Command = ref object
buffer: string
idx: int
selectionStartIdx: Option[int]
CommandBuffer* = ref object
history: seq[Command]
mb: MessageBuffer[string]
idx: int
visibleIdx: int # first visible character in the cmd buffer
mode*: EditMode
func initCommandBuffer*(mb: MessageBuffer[string]): CommandBuffer =
result = CommandBuffer(
history: @[Command(
buffer: "",
idx: 0,
selectionStartIdx: none(int))],
idx: 0,
mb: mb,
mode: Insert)
proc cur(cb: CommandBuffer): Command = cb.history[cb.idx]
proc cur(cb: var CommandBuffer): Command = cb.history[cb.idx]
proc prev*(cb: var CommandBuffer) =
cb.idx = clamp( cb.idx - 1, 0, cb.history.len - 1)
proc next*(cb: var CommandBuffer) =
cb.idx = clamp( cb.idx + 1, 0, cb.history.len - 1)
proc insert*(cb: var CommandBuffer, s: string) =
var cmd = cb.history[cb.idx]
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx..^1]
cmd.idx += s.len
proc overwrite*(cb: var CommandBuffer, s: string) =
var cmd = cb.history[cb.idx]
if cmd.idx + s.len > cmd.buffer.len:
cmd.buffer = cmd.buffer[0..<cmd.idx] & s
else:
cmd.buffer = cmd.buffer[0..<cmd.idx] & s & cmd.buffer[cmd.idx + s.len..^1]
cmd.idx += s.len
proc write*(cb: var CommandBuffer, s: string) =
if cb.mode == EditMode.Insert: cb.insert(s)
elif cb.mode == EditMode.Overwrite: cb.overwrite(s)
proc delete*(cb: var CommandBuffer, backspace = true) =
var cmd = cb.history[cb.idx]
if backspace and cmd.idx > 0:
cmd.buffer = cmd.buffer[0..<cmd.idx - 1] & cmd.buffer[cmd.idx..^1]
cmd.idx -= 1
elif not backspace and cmd.idx < cmd.buffer.len:
cmd.buffer = cmd.buffer[0..<cmd.idx] & cmd.buffer[cmd.idx + 1..^1]
proc clear*(cb: var CommandBuffer) =
cb.history.add(Command(
buffer: "",
idx: 0,
selectionStartIdx: none(int)))
cb.idx = cb.history.len - 1
cb.visibleIdx = 0
proc left*(cb: var CommandBuffer) =
cb.cur.idx = clamp(cb.cur.idx - 1, 0, cb.cur.buffer.len)
proc right*(cb: var CommandBuffer) =
cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len)
proc toHome*(cb: var CommandBuffer) = cb.cur.idx = 0
proc toEnd*(cb: var CommandBuffer) = cb.cur.idx = cb.cur.buffer.len
proc backWord*(cb: var CommandBuffer) =
var cmd = cb.history[cb.idx]
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] == ' ': cmd.idx -= 1
while cmd.idx > 0 and cmd.buffer[cmd.idx - 1] != ' ': cmd.idx -= 1
proc forwardWord*(cb: var CommandBuffer) =
var cmd = cb.history[cb.idx]
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] != ' ': cmd.idx += 1
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] == ' ': cmd.idx += 1
proc endOfWord*(cb: var CommandBuffer) =
var cmd = cb.history[cb.idx]
cmd.idx = clamp(cmd.idx + 1, 0, cmd.buffer.len - 1)
while cmd.idx < cmd.buffer.len and cmd.buffer[cmd.idx] == ' ': cmd.idx += 1
while cmd.idx < cmd.buffer.len - 1 and
cmd.buffer[cmd.idx + 1] != ' ':
cmd.idx += 1
func toChar(k: Key): char = cast[char](ord(k))
proc setMode*(cb: var CommandBuffer, mode: EditMode) =
cb.mode = mode
case mode
of EditMode.Insert: setCursorType(ctBarBlink)
of EditMode.Overwrite: setCursorType(ctBlock)
else: setCursorType(ctBlockBlink)
func `$`*(cb: CommandBuffer): string =
if cb.cur.selectionStartIdx.isNone:
return cb.cur.buffer
else:
let selIdx = cb.cur.selectionStartIdx.get
let curIdx = cb.cur.idx
if selIdx < curIdx:
return cb.cur.buffer[0..<selIdx] &
invert(cb.cur.buffer[selIdx..<curIdx]) &
cb.cur.buffer[curIdx..^1]
else:
return cb.cur.buffer[0..<curIdx] &
invert(cb.cur.buffer[curIdx..<selIdx]) &
cb.cur.buffer[selIdx..^1]
func idx*(cb: CommandBuffer): int = cb.cur.idx
proc render*(cb: var CommandBuffer) =
write(cb.mb.dims.x, cb.mb.dims.y, ' '.repeat(cb.mb.dims.width))
let cmd = cb.cur
var inputLine = cmd.buffer
let spaceAvailable = cb.mb.dims.width - 2
# Check to see that the line entered fits entirely within the space
# available to display it. We need two characters of space: one for the
# colon and one for the cursor. If we don't have enough space, we need to
# decide which portion of the line to display.
if len(cmd.buffer) >= spaceAvailable:
# If the user has moved the cursor left of our visible index, follow them
# back
if cmd.idx < cb.visibleIdx: cb.visibleIdx = cmd.idx
# If the user has moved right, push our index to the right to follow them
elif (cmd.idx - cb.visibleIdx) > spaceAvailable:
cb.visibleIdx = cmd.idx - spaceAvailable
# If the user has started backspacing (we are showing less that we
# could), follow them back
elif (len(cmd.buffer) - cb.visibleIdx) < spaceAvailable:
cb.visibleIdx = len(cmd.buffer) - spaceAvailable
# Show the portion of the line starting at the visible index and ending
# either with the end of the line or the end of the visible space
# (whichever comes first). We need to check both because the user may
# delete characters from the end of the line, which would make the
# portion of the line we're currently showing shorter than the visible
# space (don't try to access an index past the end of the string).
inputLine = cmd.buffer[
cb.visibleIdx ..<
min(cb.visibleIdx + spaceAvailable, len(cmd.buffer))]
elif cb.visibleIdx > 0:
# We know the line fits within the space available, but we're still
# showing a subset of the line (probably because it was longer and the
# user backspaced). Let's just reset and show the whole line now that we
# can.
cb.visibleIdx = 0
# TODO: implement VISUAL mode selection highlighting
cb.mb.showMsg(":" & inputLine)
setCursorPosition(
cb.mb.dims.x + (cmd.idx - cb.visibleIdx) + 1,
cb.mb.dims.y)
proc handleInput*(cb: var CommandBuffer, key: Key) =
case cb.mode
of EditMode.Insert, EditMode.Overwrite:
case key
of Key.Escape: cb.setMode(EditMode.Command)
of Key.Backspace: cb.delete()
of Key.Delete: cb.delete(backspace = false)
of Key.Left: cb.left()
of Key.Right: cb.right()
of Key.Up: cb.prev()
of Key.Down: cb.next()
of Key.Home: cb.toHome()
of Key.End: cb.toEnd()
of Key.CtrlH: cb.backWord()
of Key.CtrlL: cb.forwardWord()
elif key >= Key.Space and key <= Key.Tilde: cb.write($toChar(key))
else: discard
of EditMode.Command:
case key
of Key.Backspace: cb.delete()
of Key.Delete, Key.X: cb.delete(backspace = false)
of Key.Left, Key.H: cb.left()
of Key.Right, Key.L: cb.right()
of Key.Up, Key.J: cb.prev()
of Key.Down, Key.K: cb.next()
of Key.Home, Key.Zero: cb.toHome()
of Key.End, Key.Dollar: cb.toEnd()
of Key.B: cb.backWord()
of Key.W: cb.forwardWord()
of Key.E: cb.endOfWord()
of Key.I: cb.setMode(EditMode.Insert)
of Key.A:
cb.cur.idx = clamp(cb.cur.idx + 1, 0, cb.cur.buffer.len)
cb.setMode(EditMode.Insert)
of Key.ShiftA:
cb.cur.idx = cb.cur.buffer.len
cb.setMode(EditMode.Insert)
of Key.ShiftI:
cb.cur.idx = 0
cb.setMode(EditMode.Insert)
of Key.ShiftR:
cb.setMode(EditMode.Overwrite)
of Key.V:
cb.setMode(EditMode.Visual)
cb.cur.selectionStartIdx = some(cb.cur.idx)
else: discard
of EditMode.Visual:
case key
of Key.Escape:
cb.setMode(EditMode.Command)
cb.cur.selectionStartIdx = none(int)
of Key.Backspace: cb.left()
of Key.Left, Key.H: cb.left()
of Key.Right, Key.L: cb.right()
of Key.Up, Key.J:
cb.cur.selectionStartIdx = none[int]()
cb.prev()
of Key.Down, Key.K:
cb.cur.selectionStartIdx = none[int]()
of Key.Home, Key.Zero: cb.toHome()
of Key.End, Key.Dollar: cb.toEnd()
of Key.B: cb.backWord()
of Key.W: cb.forwardWord()
of Key.V:
cb.setMode(EditMode.Command)
cb.cur.selectionStartIdx = none(int)
else: discard
render(cb)

View File

@ -1,4 +1,4 @@
import std/[json, options, sequtils, strutils, times]
import std/[dirs, files, json, options, paths, sequtils, strutils, times]
import db_connector/db_sqlite
import waterpark/sqlite
import fiber_orm, timeutils, uuids
@ -11,32 +11,160 @@ export sqlite.close
import ./[logging, models]
const schemaDDL = readFile("src/main/sql/media-library-schema.sql")
.split(";")
.filterIt(not isEmptyOrWhitespace(it))
type
WdiwtltDb* = SqlitePool
func toJsonHook*(dt: DateTime): JsonNode = %(dt.formatIso8601)
proc fromJsonHook*(dt: var DateTime, n: JsonNode): void =
dt = n.getStr.parseIso8601
proc initDB*(dbPath: string): SqlitePool =
newSqlitePool(10, dbPath)
proc createTables*(db: WdiwtltDb) =
## Create the database tables if they don't exist.
db.withConnection conn:
let rows = conn.getRow(sql("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artists'"))
if rows.len == 0 or isEmptyOrWhitespace(rows[0]):
for ddl in schemaDDL:
try: conn.exec(sql(ddl))
except DbError:
let ex = getCurrentException()
getLogger("wdiwtlt/db").error(%*{
"msg": "Failed creating initial schema.",
"ddl": ddl,
"error": ex.msg})
raise ex
proc initDB*(dbPath: Path): SqlitePool =
# Create the DB file if it doesn't exist.
if not fileExists(dbPath):
let dbDir = dbPath.splitFile().dir
if not dirExists(dbDir):
createDir(dbDir)
result = newSqlitePool(10, dbPath.string)
# Create the database tables if they don't exist.
result.createTables()
generateProcsForModels(WdiwtltDb,
[Artist, Album, MediaFile, Tag, Playlist, Bookmark, Image])
generateJoinTableLookups(WdiwtltDb, Artist, Album, "artists_albums")
generateJoinTableLookups(WdiwtltDb, Artist, MediaFile, "artists_media_files")
generateJoinTableLookups(WdiwtltDb, Album, MediaFile, "albums_media_files")
generateJoinTableLookups(WdiwtltDb, Playlist, MediaFile, "playlists_media_files")
generateJoinTableLookups(WdiwtltDb, MediaFile, Tag, "media_files_tags")
generateJoinTableLookups(WdiwtltDb, Artist, Image, "artists_images")
generateJoinTableLookups(WdiwtltDb, Album, Image, "albums_images")
generateJoinTableProcs(WdiwtltDb, Artist, Album, "artists_albums")
generateJoinTableProcs(WdiwtltDb, Artist, MediaFile, "artists_media_files")
generateJoinTableProcs(WdiwtltDb, Album, MediaFile, "albums_media_files")
generateJoinTableProcs(WdiwtltDb, Playlist, MediaFile, "playlists_media_files")
generateJoinTableProcs(WdiwtltDb, MediaFile, Tag, "media_files_tags")
generateJoinTableProcs(WdiwtltDb, Artist, Image, "artists_images")
generateJoinTableProcs(WdiwtltDb, Album, Image, "albums_images")
proc removeEmptyAlbums*(sql: WdiwtltDb) =
generateLookup(WdiwtltDb, Artist, @["name"])
generateLookup(WdiwtltDb, Album, @["artistId"])
generateLookup(WdiwtltDb, Album, @["name"])
generateLookup(WdiwtltDb, Album, @["name", "year"])
generateLookup(WdiwtltDb, MediaFile, @["filePath"])
generateLookup(WdiwtltDb, MediaFile, @["fileHash"])
proc findAlbumsByArtistAndName*(
db: WdiwtltDb,
artistId: UUID,
albumName: string,
page: Option[PaginationParams] = none[PaginationParams]()): PagedRecords[Album] =
## Find albums by artist and name.
db.withConnection conn:
var query = """
SELECT """ & columnNamesForModel(Album).join(",") & """
FROM albums al
JOIN artists_albums aral ON al.id = aral.album_id
WHERE aral.artist_id = ? AND al.name = ? """
let countStmt = """
SELECT COUNT(*)
FROM albums al
JOIN artists_albums aral ON al.id = aral.album_id
WHERE aral.artist_id = ? AND al.name = ? """
if page.isSome: query &= getPagingClause(page.get)
logQuery("findAlbumsByArtistAndName", query,
[("artist_id", $artistId), ("album.name", albumName)])
let values = @[$artistId, albumName]
let records = conn.getAllRows(sql(query), values).mapIt(rowToModel(Album, it))
result = PagedRecords[Album](
pagination: page,
records: records,
totalRecords:
if page.isNone: records.len
else: conn.getRow(sql(countStmt), values)[0].parseInt)
proc findAlbumsByArtistAndName*(
db: WdiwtltDb,
artist: Artist,
albumName: string,
page: Option[PaginationParams] = none[PaginationParams]()): PagedRecords[Album] =
## Find albums by artist and name.
return db.findAlbumsByArtistAndName(artist.id, albumName, page)
proc findAlbumsByArtistNameAndYear*(
db: WdiwtltDb,
artistId: UUID,
albumName: string,
year: int,
page: Option[PaginationParams] = none[PaginationParams]()): PagedRecords[Album] =
## Find albums by artist name and year.
db.withConnection conn:
var query = """
SELECT """ & columnNamesForModel(Album).join(",") & """
FROM albums al
JOIN artists_albums aral ON al.id = aral.album_id
WHERE aral.artist_id = ? AND al.name = ? AND al.year = ? """
let countStmt = """
SELECT COUNT(*)
FROM albums al
JOIN artists_albums aral ON al.id = aral.album_id
WHERE aral.artist_id = ? AND al.name = ? AND al.year = ? """
if page.isSome: query &= getPagingClause(page.get)
logQuery("findAlbumsByArtistNameAndYear", query,
[("artist_id", $artistId), ("album.name", albumName), ("album.year", $year)])
let values = @[$artistId, albumName, $year]
let records = conn.getAllRows(sql(query), values).mapIt(rowToModel(Album, it))
result = PagedRecords[Album](
pagination: page,
records: records,
totalRecords:
if page.isNone: records.len
else: conn.getRow(sql(countStmt), values)[0].parseInt)
proc findAlbumsByArtistNameAndYear*(
db: WdiwtltDb,
artist: Artist,
albumName: string,
year: int,
page: Option[PaginationParams] = none[PaginationParams]()): PagedRecords[Album] =
## Find albums by artist name and year.
return db.findAlbumsByArtistNameAndYear(artist.id, albumName, year, page)
proc removeEmptyAlbums*(db: WdiwtltDb) =
## Remove albums that have no media files.
db.withConnection conn:
let query = """
DELETE FROM albums WHERE id IN (
SELECT DISTINCT al.id
@ -48,9 +176,12 @@ proc removeEmptyAlbums*(sql: WdiwtltDb) =
"msg": "Deleting empty albums.",
"query": query})
proc removeEmptyArtists*(sql: WdiwtltDb) =
conn.exec(sql(query))
proc removeEmptyArtists*(db: WdiwtltDb) =
## Remove artists that have no albums.
db.withConnection conn:
let query = """
DELETE FROM artists WHERE id IN (
SELECT DISTINCT ar.id
@ -62,9 +193,12 @@ proc removeEmptyArtists*(sql: WdiwtltDb) =
"msg": "Deleting empty artists.",
"query": query})
proc removeEmptyPlaylists*(sql: WdiwtltDb) =
conn.exec(sql(query))
proc removeEmptyPlaylists*(db: WdiwtltDb) =
## Remove playlists that have no media files.
db.withConnection conn:
let query = """
DELETE FROM playlists WHERE id IN (
SELECT DISTINCT pl.id
@ -75,3 +209,5 @@ proc removeEmptyPlaylists*(sql: WdiwtltDb) =
getLogger("wdiwtlt/db").debug(%*{
"msg": "Deleting empty playlists.",
"query": query})
conn.exec(sql(query))

View File

@ -0,0 +1,39 @@
import std/[paths, streams]
import checksums/md5
proc fileToMD5*(filename: Path) : string =
const blockSize: int = 8192 # read files in 8KB chunnks
var
c: MD5Context
d: MD5Digest
fs: FileStream
buffer: string
#read chunk of file, calling update until all bytes have been read
try:
fs = newFileStream(open($filename))
md5Init(c)
buffer = fs.readStr(blockSize)
while buffer.len > 0:
md5Update(c, buffer.cstring, buffer.len)
buffer = fs.readStr(blockSize)
md5Final(c, d)
finally:
if fs != nil:
close(fs)
result = $d
when isMainModule:
if paramCount() > 0:
let arguments = commandLineParams()
echo("MD5: ", fileToMD5(arguments[0]))
else:
echo("Must pass filename.")
quit(-1)

View File

@ -1,6 +1,7 @@
import std/[options, os, strutils, unicode]
import namespaced_logging, zero_functional
import ./db
from fiber_orm import enableDbLogging
export namespaced_logging

View File

@ -1,12 +1,19 @@
import std/[paths, times]
import namespaced_logging
import std/[dirs, files, json, options, paths, sequtils, strutils, sugar, times]
import namespaced_logging, timeutils, uuids
import ./[db, models]
import ./[db, incremental_md5, logging, models, taglib]
type
MediaLibrary* = ref object
db: WdiwtltDb
libraryRoot: Path
root: Path
proc initMediaLibrary*(rootDir: Path, dbPath: Path): MediaLibrary =
## Initialize the media library.
let db = initDB(dbPath)
return MediaLibrary(db: db, root: rootDir)
proc clean*(lib: MediaLibrary) =
removeEmptyAlbums(lib.db)
@ -15,26 +22,226 @@ proc clean*(lib: MediaLibrary) =
let expirationDate = now() - weeks(1)
let expiredPlaylists = lib.db.findPlaylstsWhere(
let expiredPlaylists = lib.db.findPlaylistsWhere(
"user_created = false AND last_used_at < ?",
[expirationDate])
[expirationDate.formatIso8601])
let expiredBookmarks = lib.db.findBookmarksWhere(
"user_created = false AND last_used_at < ?",
[expirationDate])
[expirationDate.formatIso8601])
expiredPlaylists.applyIt(lib.db.deletePlaylist(it.id))
expiredBookmarks.applyIt(lib.db.deleteBookmark(it.id))
for p in expiredPlaylists.records: discard lib.db.deletePlaylist(p.id)
for b in expiredBookmarks.records: discard lib.db.deleteBookmark(b.id)
proc rescanLibrary*(lib: MediaLibrary) =
# TODO: Implement this. Below is AI-generated code.
let mediaFiles = lib.db.getAllMediaFiles()
for mf in mediaFiles:
let filePath = lib.libraryRoot / mf.filePath
if not filePath.existsFile:
lib.db.deleteMediaFile(mf.id)
continue
proc findOrCreateArtist*(lib: MediaLibrary, name: string): Artist =
## Find or create an artist record.
let fileHash = filePath.hashFile
if fileHash != mf.fileHash:
lib.db.updateMediaFile(mf.id, fileHash: fileHash)
let existing = lib.db.findArtistsByName(name)
if existing.records.len > 0:
return existing.records[0]
getLogger("wdiwtlt/medialibrary").debug(%*{
"msg": "Creating missing Artist record.",
"method": "findOrCreateArtist",
"artistName": name })
return lib.db.createArtist(Artist(id: genUuid(), name: name))
proc findOrCreateAlbum*(
lib: MediaLibrary,
name: string,
artists: seq[Artist],
year: Option[int]): Album =
var foundAlbum = none[Album]()
# If we know the year the album was released, we can use that to narrow
# down the list of matching albums
if year.isSome:
# First look only at albums already associated with this artist
for artist in artists:
let existing = lib.db.findAlbumsByArtistNameAndYear(artist, name, year.get)
if existing.records.len > 0:
foundAlbum = some(existing.records[0])
break
# Then look at all albums with that name and year
if foundAlbum.isNone:
let existing = lib.db.findAlbumsByNameAndYear(name, $year.get)
if existing.records.len > 0: foundAlbum = some(existing.records[0])
if foundAlbum.isNone:
# If we don't know the year, or if there are no albums with that year,
# look at all albums by this artist
for artist in artists:
let existing = lib.db.findAlbumsByArtistAndName(artist, name)
if existing.records.len > 0:
foundAlbum = some(existing.records[0])
break
# If we still don't have a match, look at all albums only by name
if foundAlbum.isNone:
let existing = lib.db.findAlbumsByName(name)
if existing.records.len > 0: foundAlbum = some(existing.records[0])
# If we still don't have a match, create a new album
if foundAlbum.isNone:
getLogger("wdiwtlt/medialibrary").debug(%*{
"msg": "Creating missing Album record.",
"method": "findOrCreateAlbum",
"albumName": name,
"year": year.map((y) => $y).get("") })
foundAlbum = some(lib.db.createAlbum(Album(
id: genUuid(),
name: name,
year: year,
trackTotal: 0)))
return foundAlbum.get
proc associateWithAristsAndAlbums*(
lib: MediaLibrary,
mf: MediaFile,
artistNames: seq[string],
albumNames: seq[string],
year: Option[int]) =
# Find or create artist and album records.
let artists = artistNames.mapIt(lib.findOrCreateArtist(it))
# Find or create album records.
let albums = albumNames.mapIt(lib.findOrCreateAlbum(it, artists, year))
# Associate this file with the artists and albums.
for artist in artists: lib.db.associate(artist, mf)
for album in albums: lib.db.associate(album, mf)
# Make sure we have associations between all artists and albums.
for artist in artists:
let albumsForArtist = lib.db.findAlbumsByArtistId($artist.id)
for album in albums:
if not albumsForArtist.records.anyIt(album.id == it.id):
lib.db.associate(artist, album)
proc addFile*(lib: MediaLibrary, relativeFilePath: Path): MediaFile =
let fullPath = lib.root / relativeFilePath
let pathParts = splitFile(fullPath)
if not fullPath.fileExists:
raise newException(ValueError, "File does not exist: " & $fullPath)
let existing = lib.db.findMediaFilesByFilePath($relativeFilePath)
if existing.records.len > 0:
getLogger("wdiwtlt/medialibrary").debug(%*{
"msg": "File already exists in library, using existing record.",
"method": "addFile",
"relativeFilePath": $relativeFilePath,
"existingId": existing.records[0].id})
return existing.records[0]
var newMf = MediaFile(
id: genUuid(),
playCount: 0,
filePath: $relativeFilePath,
dateAdded: now(),
lastPlayed: none[DateTime](),
presentLocally: true,
comment: "")
newMf.fileHash = fileToMD5(fullPath)
let existingForHash = lib.db.findMediaFilesByFileHash(newMf.fileHash)
if existingForHash.records.len > 0:
getLogger("wdiwtlt/medialibrary").debug(%*{
"msg": "File with the same hash already exists in library, using existing record.",
"method": "addFile",
"relativeFilePath": $relativeFilePath,
"existingId": existingForHash.records[0].id})
return existingForHash.records[0]
var tagFile = openTags(fullPath)
defer: close(tagFile)
newMf.name =
if tagFile.title.strip().len == 0: $pathParts.name
else: tagFile.title.strip()
newMf.comment = tagFile.comment.strip()
newMf.discNumber = tagFile.discNumber
newMf.trackNumber =
if tagFile.track == 0: none[int]()
else: some(tagFile.track)
let folderParts = ($pathParts.dir).split("/")
var artistNames = newSeq[string]()
if tagFile.artist.strip().len > 0:
newMf.metaInfoSource = "tag info"
artistNames = tagFile.artist.split({'/', ';'}).mapIt(strip(it))
elif tagFile.albumArtist.strip().len > 0:
newMf.metaInfoSource = "tag info"
artistNames = tagFile.albumArtist.split({'/', ';'}).mapIt(strip(it))
elif folderParts.len > 1:
newMf.metaInfoSource = "folder"
artistNames = @[folderParts[folderParts.len - 2].strip()]
else:
newMf.metaInfoSource = "unknown"
artistNames = @[]
var albumNames = newSeq[string]()
if tagFile.album.strip().len > 0:
newMf.metaInfoSource = "tag info"
albumNames = tagFile.album.split({'/', ';'}).mapIt(strip(it))
elif folderParts.len > 0:
newMf.metaInfoSource = "folder"
albumNames = @[folderParts[folderParts.len - 1].strip()]
else:
newMf.metaInfoSource = "unknown"
albumNames = @[]
result = lib.db.createMediaFile(newMf)
let albumYear =
if tagFile.year == 0: none[int]()
else: some(tagFile.year)
lib.associateWithAristsAndAlbums(result, artistNames, albumNames, albumYear)
proc rescanLibrary*(lib: MediaLibrary):
tuple[ total, ignored, new, present, absent: int] =
var missingFiles = lib.db.getAllMediaFiles().records
var foundFiles = newSeq[MediaFile]()
let startDate = now()
for p in lib.root.walkDir(
relative = true,
checkDir = true,
skipSpecial = true):
if not p.path.fileExists: continue
let mf = lib.addFile(p.path)
result.total += 1
foundFiles.add(mf)
missingFiles = missingFiles.filterIt(it.id != mf.id)
if mf.dateAdded > startDate:
result.new += 1
for mf in foundFiles:
if not mf.presentLocally:
var updated = mf
updated.presentLocally = true
discard lib.db.updateMediaFile(updated)
for mf in missingFiles:
if mf.presentLocally:
var updated = mf
updated.presentLocally = false
discard lib.db.updateMediaFile(updated)
result.present = foundFiles.len
result.absent = missingFiles.len

View File

@ -0,0 +1,167 @@
import std/[options, sequtils, strutils, times, wordwrap]
import ./[scrolltext, terminal, util]
type
BufferDimensions* = object
width*, height*, x*, y*: int
MessageBuffer*[T: ScrollText or string] = ref object
content: T
dims*: BufferDimensions
dismissAfter: Option[DateTime]
firstColumnIdx: int
firstLineIdx: int
proc getLinePortion(line: string, start: int, width: int): string =
let cleanLine = stripFormatting(line)
if start == 0 and len(cleanLine) <= width:
result = line & ' '.repeat(width - len(cleanLine))
else:
result = ansiAwareSubstring(line, start, width)
proc render[T: ScrollText or string](mb: var MessageBuffer[T]) =
hideCursor()
when T is ScrollText: write(mb.dims.x, mb.dims.y, $mb.content)
when T is string:
let renderedContent = splitLines(mb.content)
.mapIt(wrapWords(
s = it,
maxLineWidth = mb.dims.width,
newLine = "\p").alignLeft(mb.dims.width))
.mapIt(getLinePortion(it, mb.firstColumnIdx, mb.dims.width))
.join("\p")
let lastLineIdx = min(mb.firstLineIdx + mb.dims.height, len(renderedContent))
write(mb.dims.x, mb.dims.y, renderedContent[mb.firstLineIdx..<lastLineIdx])
showCursor()
func variableMsgDuration[T: ScrollText or string](
mb: MessageBuffer[T],
initDur: Duration): Duration =
when T is ScrollText:
# text moves at 5 characters per second (192ms per character)
result = initDur + initDuration(milliseconds = len($mb.content) * 192)
when T is string:
result = initDur + initDuration(milliseconds = len(splitLines(mb.content)) * 250)
proc initMessageBuffer*[T: ScrollText or string](
dims: BufferDimensions,
initialMsg = ""): MessageBuffer[T] =
when T is ScrollText:
result = MessageBuffer[T](
content: initScrollText(initialMsg, max(dims.width, 1)),
dims: BufferDimensions(
width: max(dims.width, 1),
height: 1,
x: dims.x,
y: dims.y),
dismissAfter: none[DateTime](),
firstColumnIdx: 0,
firstLineIdx: 0)
when T is string:
result = MessageBuffer[T](
content: initialMsg,
dims: BufferDimensions(
width: max(dims.width, 1),
height: dims.height,
x: dims.x,
y: dims.y),
dismissAfter: none[DateTime](),
firstColumnIdx: 0,
firstLineIdx: 0)
render(result)
proc showMsg*[T: ScrollText or string](
mb: var MessageBuffer[T],
msg: string,
dur: Option[Duration] = none[Duration]()) =
when T is ScrollText: mb.content.text = msg
when T is string: mb.content = msg
if dur.isSome:
mb.dismissAfter = some(now() + mb.variableMsgDuration(dur.get))
else:
mb.dismissAfter = none[DateTime]()
render(mb)
proc clear*[T: ScrollText or string](mb: var MessageBuffer[T]) =
when T is ScrollText:
let clearText = ' '.repeat(mb.dims.width)
mb.content.text = ""
when T is string:
let clearText = splitLines(mb.content)
.mapIt(' '.repeat(mb.dims.width))
.join("\p")
mb.content = ""
hideCursor()
write(mb.dims.x, mb.dims.y, clearText)
showCursor()
mb.dismissAfter = none[DateTime]()
#[ TODO: re-implement taking BufferDimensions if needed
proc `maxWidth=`*[T: ScrollText or string](
mb: var MessageBuffer[T],
maxWidth: int) =
mb.maxWidth = max(maxWidth, 1)
when T is ScrollText: mb.content.maxWidth = mb.maxWidth
when T is string: mb.renderStringContent()
]#
proc nextTick*[T: ScrollText or string](mb: var MessageBuffer[T]) =
if mb.dismissAfter.isSome and now() > mb.dismissAfter.get:
mb.clear()
when T is ScrollText:
if mb.content.isScrolling:
discard mb.content.nextTick()
render(mb)
func hasContent*[T: ScrollText or string](
mb: MessageBuffer[T]): bool =
when T is ScrollText: return len($mb.content) > 0
when T is string: return len(mb.content) > 0
proc scrollLines*[T](mb: var MessageBuffer[T], lines: int) =
mb.firstLineIdx = clamp(
mb.firstLineIdx + lines,
0,
max(0, len(mb.content) - mb.dims.height))
render(mb)
proc scrollPages*[T](mb: var MessageBuffer[T], pages: int) =
scrollLines(mb, pages * mb.dims.height)
proc scrollColumns*(mb: var MessageBuffer[string], chars: int) =
mb.firstColumnIdx = clamp(
mb.firstColumnIdx + chars,
0,
max(0, len(mb.content) - mb.dims.width))
render(mb)
proc resetScroll*(
mb: var MessageBuffer[ScrollText]) =
mb.firstColumnIdx = 0
mb.firstLineIdx = 0
render(mb)

View File

@ -0,0 +1,263 @@
# Adapted from [illwill](https://github.com/johnnovak/illwill) by John Novak
import std/[posix, termios]
type
Key* {.pure.} = enum ## Supported single key presses and key combinations
None = (-1, "None"),
# Special ASCII characters
CtrlA = (1, "CtrlA"),
CtrlB = (2, "CtrlB"),
CtrlC = (3, "CtrlC"),
CtrlD = (4, "CtrlD"),
CtrlE = (5, "CtrlE"),
CtrlF = (6, "CtrlF"),
CtrlG = (7, "CtrlG"),
CtrlH = (8, "CtrlH"),
Tab = (9, "Tab"), # Ctrl-I
CtrlJ = (10, "CtrlJ"),
CtrlK = (11, "CtrlK"),
CtrlL = (12, "CtrlL"),
Enter = (13, "Enter"), # Ctrl-M
CtrlN = (14, "CtrlN"),
CtrlO = (15, "CtrlO"),
CtrlP = (16, "CtrlP"),
CtrlQ = (17, "CtrlQ"),
CtrlR = (18, "CtrlR"),
CtrlS = (19, "CtrlS"),
CtrlT = (20, "CtrlT"),
CtrlU = (21, "CtrlU"),
CtrlV = (22, "CtrlV"),
CtrlW = (23, "CtrlW"),
CtrlX = (24, "CtrlX"),
CtrlY = (25, "CtrlY"),
CtrlZ = (26, "CtrlZ"),
Escape = (27, "Escape"),
CtrlBackslash = (28, "CtrlBackslash"),
CtrlRightBracket = (29, "CtrlRightBracket"),
# Printable ASCII characters
Space = (32, "Space"),
ExclamationMark = (33, "ExclamationMark"),
DoubleQuote = (34, "DoubleQuote"),
Hash = (35, "Hash"),
Dollar = (36, "Dollar"),
Percent = (37, "Percent"),
Ampersand = (38, "Ampersand"),
SingleQuote = (39, "SingleQuote"),
LeftParen = (40, "LeftParen"),
RightParen = (41, "RightParen"),
Asterisk = (42, "Asterisk"),
Plus = (43, "Plus"),
Comma = (44, "Comma"),
Minus = (45, "Minus"),
Dot = (46, "Dot"),
Slash = (47, "Slash"),
Zero = (48, "Zero"),
One = (49, "One"),
Two = (50, "Two"),
Three = (51, "Three"),
Four = (52, "Four"),
Five = (53, "Five"),
Six = (54, "Six"),
Seven = (55, "Seven"),
Eight = (56, "Eight"),
Nine = (57, "Nine"),
Colon = (58, "Colon"),
Semicolon = (59, "Semicolon"),
LessThan = (60, "LessThan"),
Equals = (61, "Equals"),
GreaterThan = (62, "GreaterThan"),
QuestionMark = (63, "QuestionMark"),
At = (64, "At"),
ShiftA = (65, "ShiftA"),
ShiftB = (66, "ShiftB"),
ShiftC = (67, "ShiftC"),
ShiftD = (68, "ShiftD"),
ShiftE = (69, "ShiftE"),
ShiftF = (70, "ShiftF"),
ShiftG = (71, "ShiftG"),
ShiftH = (72, "ShiftH"),
ShiftI = (73, "ShiftI"),
ShiftJ = (74, "ShiftJ"),
ShiftK = (75, "ShiftK"),
ShiftL = (76, "ShiftL"),
ShiftM = (77, "ShiftM"),
ShiftN = (78, "ShiftN"),
ShiftO = (79, "ShiftO"),
ShiftP = (80, "ShiftP"),
ShiftQ = (81, "ShiftQ"),
ShiftR = (82, "ShiftR"),
ShiftS = (83, "ShiftS"),
ShiftT = (84, "ShiftT"),
ShiftU = (85, "ShiftU"),
ShiftV = (86, "ShiftV"),
ShiftW = (87, "ShiftW"),
ShiftX = (88, "ShiftX"),
ShiftY = (89, "ShiftY"),
ShiftZ = (90, "ShiftZ"),
LeftBracket = (91, "LeftBracket"),
Backslash = (92, "Backslash"),
RightBracket = (93, "RightBracket"),
Caret = (94, "Caret"),
Underscore = (95, "Underscore"),
GraveAccent = (96, "GraveAccent"),
A = (97, "A"),
B = (98, "B"),
C = (99, "C"),
D = (100, "D"),
E = (101, "E"),
F = (102, "F"),
G = (103, "G"),
H = (104, "H"),
I = (105, "I"),
J = (106, "J"),
K = (107, "K"),
L = (108, "L"),
M = (109, "M"),
N = (110, "N"),
O = (111, "O"),
P = (112, "P"),
Q = (113, "Q"),
R = (114, "R"),
S = (115, "S"),
T = (116, "T"),
U = (117, "U"),
V = (118, "V"),
W = (119, "W"),
X = (120, "X"),
Y = (121, "Y"),
Z = (122, "Z"),
LeftBrace = (123, "LeftBrace"),
Pipe = (124, "Pipe"),
RightBrace = (125, "RightBrace"),
Tilde = (126, "Tilde"),
Backspace = (127, "Backspace"),
# Special characters with virtual keycodes
Up = (1001, "Up"),
Down = (1002, "Down"),
Right = (1003, "Right"),
Left = (1004, "Left"),
Home = (1005, "Home"),
Insert = (1006, "Insert"),
Delete = (1007, "Delete"),
End = (1008, "End"),
PageUp = (1009, "PageUp"),
PageDown = (1010, "PageDown"),
F1 = (1011, "F1"),
F2 = (1012, "F2"),
F3 = (1013, "F3"),
F4 = (1014, "F4"),
F5 = (1015, "F5"),
F6 = (1016, "F6"),
F7 = (1017, "F7"),
F8 = (1018, "F8"),
F9 = (1019, "F9"),
F10 = (1020, "F10"),
F11 = (1021, "F11"),
F12 = (1022, "F12"),
Mouse = (5000, "Mouse")
const
KEYS_D = [Key.Up, Key.Down, Key.Right, Key.Left, Key.None, Key.End, Key.None, Key.Home]
KEYS_E = [Key.Delete, Key.End, Key.PageUp, Key.PageDown, Key.Home, Key.End]
KEYS_F = [Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.None, Key.F6, Key.F7, Key.F8]
KEYS_G = [Key.F9, Key.F10, Key.None, Key.F11, Key.F12]
{.push warning[HoleEnumConv]:off.}
func toKey(c: int): Key =
try:
result = Key(c)
except RangeDefect: # ignore unknown keycodes
result = Key.None
{.pop}
proc setNonBlockingTty*(enabled: bool) =
var ttyState: Termios
# get the terminal state
discard tcGetAttr(STDIN_FILENO, ttyState.addr)
if enabled:
# turn off canonical mode & echo
ttyState.c_lflag = ttyState.c_lflag and not Cflag(ICANON or ECHO)
# minimum of number input read
ttyState.c_cc[VMIN] = 0.char
else:
# turn on canonical mode & echo
ttyState.c_lflag = ttyState.c_lflag or ICANON or ECHO
# set the terminal attributes.
discard tcSetAttr(STDIN_FILENO, TCSANOW, ttyState.addr)
proc parseStdin[T](input: T): Key =
var ch1, ch2, ch3, ch4, ch5: char
result = Key.None
if read(input, ch1.addr, 1) > 0:
case ch1
of '\e':
if read(input, ch2.addr, 1) > 0:
if ch2 == 'O' and read(input, ch3.addr, 1) > 0:
if ch3 in "ABCDFH":
result = KEYS_D[int(ch3) - int('A')]
elif ch3 in "PQRS":
result = KEYS_F[int(ch3) - int('P')]
elif ch2 == '[' and read(input, ch3.addr, 1) > 0:
if ch3 in "ABCDFH":
result = KEYS_D[int(ch3) - int('A')]
elif ch3 in "PQRS":
result = KEYS_F[int(ch3) - int('P')]
elif ch3 == '1' and read(input, ch4.addr, 1) > 0:
if ch4 == '~':
result = Key.Home
elif ch4 in "12345789" and read(input, ch5.addr, 1) > 0 and ch5 == '~':
result = KEYS_F[int(ch4) - int('1')]
elif ch3 == '2' and read(input, ch4.addr, 1) > 0:
if ch4 == '~':
result = Key.Insert
elif ch4 in "0134" and read(input, ch5.addr, 1) > 0 and ch5 == '~':
result = KEYS_G[int(ch4) - int('0')]
elif ch3 in "345678" and read(input, ch4.addr, 1) > 0 and ch4 == '~':
result = KEYS_E[int(ch3) - int('3')]
else:
discard # if cannot parse full seq it is discarded
else:
discard # if cannot parse full seq it is discarded
else:
result = Key.Escape
of '\n':
result = Key.Enter
of '\b':
result = Key.Backspace
else:
result = toKey(int(ch1))
proc kbhit(ms: int): cint =
var tv: Timeval
tv.tv_sec = Time(ms div 1000)
tv.tv_usec = 1000 * (int32(ms) mod 1000) # int32 because of macos
var fds: TFdSet
FD_ZERO(fds)
FD_SET(STDIN_FILENO, fds)
discard select(STDIN_FILENO+1, fds.addr, nil, nil, tv.addr)
return FD_ISSET(STDIN_FILENO, fds)
proc getKeyAsync*(ms: int = 0): Key =
result = Key.None
if kbhit(ms) > 0:
result = parseStdin(cint(STDIN_FILENO))

View File

@ -0,0 +1,64 @@
import std/strutils
type ScrollText* = ref object
text: string
maxWidth: int
scrollIdx: int # Current index of the first character to show.
lastRender: string
const endBufLen = 4
proc render(st: ScrollText): string =
if st.text.len <= st.maxWidth:
return st.text
if st.scrollIdx == 0: return st.text[0..<st.maxWidth]
elif st.scrollIdx >= st.text.len:
return ' '.repeat(endBufLen - (st.scrollIdx - st.text.len)) &
st.text[0..<st.maxWidth - 1]
elif st.scrollIdx + st.maxWidth < st.text.len:
return st.text[st.scrollIdx..<(st.scrollIdx + st.maxWidth)]
else:
return st.text[st.scrollIdx..<st.text.len] & ' '.repeat(endBufLen) &
st.text[0..<max(0, (st.scrollIdx + st.maxWidth - st.text.len - 1))]
proc `text=`*(st: var ScrollText, text: string) =
st.text = text
st.scrollIdx = text.len
st.lastRender = render(st)
proc `maxWidth=`*(st: var ScrollText, maxWidth: int) =
st.maxWidth = max(maxWidth, 1)
st.lastRender = render(st)
proc initScrollText*(text: string, maxWidth: int): ScrollText =
result = ScrollText(
text: text,
maxWidth: max(1, maxWidth),
scrollIdx: 0)
result.lastRender = render(result)
proc nextTick*(st: var ScrollText): string =
st.lastRender = render(st)
# Advance the scroll index by one.
st.scrollIdx = (st.scrollIdx + 1) mod (st.text.len + endBufLen)
return st.lastRender
func isScrolling*(st: ScrollText): bool =
return st.text.len > st.maxWidth
func `$`*(st: ScrollText): string = st.lastRender
converter toString*(st: ScrollText): string = st.lastRender

View File

@ -0,0 +1,167 @@
import std/[paths, strutils]
{.passl: "-ltag_c".}
{.passc: "-ltag_c".}
type
TagLibFileType* {.size: sizeof(cint).} = enum
MPEG, OggVorbis, FLAC, MPC, OggFlac, WavPack, Speex, TrueAudio, MP4, ASF,
AIFF, WAV, APE, IT, Mod, S3M, XM, Opus, DSF, DSDIFF, SHORTEN
TagLibID3v2Encoding* {.size: sizeof(cint).} = enum
Latin1, UTF16, UTF16BE, UTF8, Encoding
CFile = pointer
CTag* = pointer
CAudioProperties = pointer
TagLibFIle* = object
path*: Path
cfile*: CFile
tag*: CTag
ap*: CAudioProperties
{.push importc.}
{.push cdecl.}
proc taglib_set_strings_unicode*(unicode: cint)
proc taglib_set_string_management_enabled*(enabled: cint)
proc taglib_free*(p: pointer)
proc taglib_file_new(filename: cstring): CFile
proc taglib_file_new_type(filename: cstring, `type`: TagLibFileType): CFile
proc taglib_file_free(file: CFile)
proc taglib_file_is_valid(file: CFile): cint
proc taglib_file_tag(file: CFile): CTag
proc taglib_file_audioproperties(file: CFile): CAudioProperties
proc taglib_file_save(file: CFile): cint
proc taglib_tag_title(tag: CTag): cstring
proc taglib_tag_artist(tag: CTag): cstring
proc taglib_tag_album(tag: CTag): cstring
proc taglib_tag_comment(tag: CTag): cstring
proc taglib_tag_genre(tag: CTag): cstring
proc taglib_tag_year(tag: CTag): cuint
proc taglib_tag_track(tag: CTag): cuint
proc taglib_tag_set_title(tag: CTag, title: cstring)
proc taglib_tag_set_artist(tag: CTag, artist: cstring)
proc taglib_tag_set_album(tag: CTag, album: cstring)
proc taglib_tag_set_comment(tag: CTag, comment: cstring)
proc taglib_tag_set_genre(tag: CTag, genre: cstring)
proc taglib_tag_set_year(tag: CTag, year: cuint)
proc taglib_tag_set_track(tag: CTag, year: cuint)
proc taglib_tag_free_strings()
proc taglib_audioproperties_length(ap: CAudioProperties): cint
proc taglib_audioproperties_bitrate(ap: CAudioProperties): cint
proc taglib_audioproperties_samplerate(ap: CAudioProperties): cint
proc taglib_audioproperties_channels(ap: CAudioProperties): cint
proc taglib_id3v2_set_default_text_encoding(encoding: TagLibID3v2Encoding)
proc taglib_property_set(file: CFile, prop: cstring, value: cstring)
proc taglib_property_set_append(file: CFile, prop: cstring, value: cstring)
#proc taglib_property_keys(file: CFile): cstring[]
proc taglib_property_get(file: CFile, prop: cstring): cstring
proc taglib_property_free(props: pointer)
{.pop.} # cdecl
{.pop.} # importc
taglib_set_strings_unicode(1)
proc initTagLibFile(path: Path, cfile: CFile): TagLibFile =
if cfile.isNil:
raise newException(IOError, "Failed to open file: " & $path)
if taglib_file_is_valid(cfile) < 0:
taglib_file_free(cfile)
raise newException(IOError, "Invalid TagLib file: " & $path)
result.path = path
result.cfile = cfile
result.tag = taglib_file_tag(cfile)
result.ap = taglib_file_audioproperties(cfile)
proc openTags*(path: Path): TagLibFile =
## Open a file and return a TagLibFile object.
let cfile = taglib_file_new(path.cstring)
initTagLibFile(path, cfile)
proc openTags*(path: Path, fileType: TagLibFileType): TagLibFile =
## Open a file of a specific type and return a TagLibFile object.
let cfile = taglib_file_new_type(path.cstring, fileType)
initTagLibFile(path, cfile)
proc writeTags*(file: TagLibFile) =
## Write tags to the file.
discard taglib_file_save(file.cfile)
proc close*(file: var TagLibFile) =
## Close the file and free resources.
if not file.cfile.isNil:
taglib_tag_free_strings()
taglib_file_free(file.cfile)
file.cfile = nil
file.tag = nil
file.ap = nil
#proc `=destroy`*(file: TagLibFile) =
# ## Destroy the file and free resources.
# close(file)
{.push inline.}
proc length*(file: TagLibFile): int = taglib_audioproperties_length(file.ap)
proc bitrate*(file: TagLibFile): int = taglib_audioproperties_bitrate(file.ap)
proc samplerate*(file: TagLibFile): int = taglib_audioproperties_samplerate(file.ap)
proc channels*(file: TagLibFile): int = taglib_audioproperties_channels(file.ap)
proc title*(file: TagLibFile): string = $taglib_tag_title(file.tag)
proc artist*(file: TagLibFile): string = $taglib_tag_artist(file.tag)
proc album*(file: TagLibFile): string = $taglib_tag_album(file.tag)
proc comment*(file: TagLibFile): string = $taglib_tag_comment(file.tag)
proc genre*(file: TagLibFile): string = $taglib_tag_genre(file.tag)
proc year*(file: TagLibFile): int = taglib_tag_year(file.tag).int
proc track*(file: TagLibFile): int = taglib_tag_track(file.tag).int
proc `title=`*(file: var TagLibFile, title: string) = taglib_tag_set_title(file.tag, title.cstring)
proc `artist=`*(file: var TagLibFile, artist: string) = taglib_tag_set_artist(file.tag, artist.cstring)
proc `album=`*(file: var TagLibFile, album: string) = taglib_tag_set_album(file.tag, album.cstring)
proc `comment=`*(file: var TagLibFile, comment: string) = taglib_tag_set_comment(file.tag, comment.cstring)
proc `genre=`*(file: var TagLibFile, genre: string) = taglib_tag_set_genre(file.tag, genre.cstring)
proc `year=`*(file: var TagLibFile, year: int) = taglib_tag_set_year(file.tag, year.cuint)
proc `track=`*(file: var TagLibFile, track: int) = taglib_tag_set_track(file.tag, track.cuint)
proc albumArtist*(file: TagLibFile): string =
## Get the album artist of the file.
let albumArtist = taglib_property_get(file.cfile, "ALBUMARTIST")
if albumArtist.isNil:
return ""
else:
return $albumArtist
proc `albumArtist=`*(file: var TagLibFile, albumArtist: string) =
## Set the album artist of the file.
taglib_property_set(file.cfile, "ALBUMARTIST", albumArtist.cstring)
proc discNumber*(file: TagLibFile): int =
## Get the disc number of the file.
let discNumber = taglib_property_get(file.cfile, "DISCNUMBER")
if discNumber.isNil:
return 0
else:
return parseInt($discNumber)
proc `discNumber=`*(file: var TagLibFile, discNumber: int) =
## Set the disc number of the file.
taglib_property_set(file.cfile, "DISCNUMBER".cstring, ($discNumber).cstring)
# Other properties we could use:
# - SUBTITLE
# - DATE
# - COMPOSER
# - TITLESORT
# - ARTISTSORT
# - ALBUMSORT
# - ALBUMARTISTSORT
{.pop.} # inline

View File

@ -0,0 +1,111 @@
import std/[nre, sequtils]
const CSI = "\x1b["
const RESET_FORMATTING* = "\x1b[0m"
const ANSI_ESCAPE_CODE_ENDINGS*: seq[char] = toSeq('A'..'Z') & toSeq('a'..'z')
let FORMATTING_REGEX* = re("\x1b\\[([0-9;]*)([a-zA-Z])")
type
CursorType* = enum
ctBlockBlink = 1, ctBlock, ctUnderlineBlink, ctUnderline, ctBarBlink, ctBar
EraseMode* = enum
emToEnd, emToStart, emAll
TerminalColors* = enum
cBlack, cRed, cGreen, cYellow, cBlue, cMagenta, cCyan, cWhite
proc stripFormatting*(text: string): string =
text.replace(FORMATTING_REGEX, "")
func ansiAwareSubstring*(s: string, start, length: int): string =
result = ""
var curAnsiEscCode = ""
var i = 0
var visibleLen = 0
while i < len(s) and visibleLen < length:
if len(s) > i + len(CSI) and
# We need to notice ANSI escape codes...
s[i..<i + len(CSI)] == CSI:
var j = i + len(CSI)
while j < s.len and s[j] notin ANSI_ESCAPE_CODE_ENDINGS: j += 1
# and remember it if we're before the start of the substring
if i < start: curAnsiEscCode = s[i..j]
# or add it without increasing our substring length
else: result.add(s[i..j])
# either way we want to pick up after it
i = j
else:
result.add(s[i])
visibleLen += 1
i += 1
result = curAnsiEscCode & result
func color*(text: string, fg = cWhite, bg = cBlack): string =
return CSI & $int(fg) & ";" & $(int(bg) + 40) & "m" & text & RESET_FORMATTING
func color*(text: string, fg: TerminalColors): string =
return CSI & $int(fg) & "m" & text & RESET_FORMATTING
func color*(text: string, bg: TerminalColors): string =
return CSI & $(int(bg) + 40) & "m" & text & RESET_FORMATTING
func invert*(text: string): string = return CSI & "7m" & text & RESET_FORMATTING
func striketrough*(text: string): string = return CSI & "9m" & text & RESET_FORMATTING
func eraseDisplay*(mode = emToEnd): string = return CSI & $int(mode) & "J"
func eraseLine*(mode = emToEnd): string = return CSI & $int(mode) & "K"
func blinkSlow*(text: string): string = return CSI & "5m" & text & RESET_FORMATTING
func blinkFast*(text: string): string = return CSI & "6m" & text & RESET_FORMATTING
func cursorUp*(n: int = 1): string = return CSI & $int(n) & "A"
func cursorDown*(n: int = 1): string = return CSI & $int(n) & "B"
func cursorForward*(n: int = 1): string = return CSI & $int(n) & "C"
func cursorBackward*(n: int = 1): string = return CSI & $int(n) & "D"
func cursorNextLine*(n: int = 1): string = return CSI & $int(n) & "E"
func cursorPrevLine*(n: int = 1): string = return CSI & $int(n) & "F"
func cursorHorizontalAbsolute*(col: int): string = return CSI & $int(col) & "G"
func cursorPosition*(row, col: int): string =
return CSI & $int(row) & ";" & $int(col) & "H"
proc cursorType*(ct: CursorType): string = return CSI & $int(ct) & " q"
func saveCursorPosition*(): string = return CSI & "s"
func restoreCursorPosition*(): string = return CSI & "u"
func scrollUp*(n: int = 1): string = return CSI & $int(n) & "S"
func scrollDown*(n: int = 1): string = return CSI & $int(n) & "T"
proc write*(f: File, x, y: int, text: string) =
f.write(cursorPosition(y, x) & text)
proc write*(x, y: int, text: string) =
stdout.write(cursorPosition(y, x) & text)
proc setCursorType*(ct: CursorType) = stdout.write(cursorType(ct))
proc setCursorPosition*(x, y: int) = stdout.write(cursorPosition(y, x))
proc hideCursor*() = stdout.write(CSI & "?25l")
proc showCursor*() = stdout.write(CSI & "?25l")

View File

@ -0,0 +1,5 @@
func usageOf*(command: seq[string]): string =
case command[0]
of "log":
# TODO
discard

View File

@ -0,0 +1,7 @@
func clamp*(value, min, max: int): int =
if value < min:
return min
elif value > max:
return max
else:
return value

View File

@ -23,7 +23,7 @@ CREATE TABLE media_files (
file_path TEXT NOT NULL,
file_hash TEXT NOT NULL,
meta_info_source TEXT NOT NULL, -- 'tag' or 'filesystem'
date_added TEXT NOT NULL DEFAULT strftime('%F %TZ', date()),
date_added TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime())),
last_played TEXT,
present_locally INTEGER NOT NULL DEFAULT TRUE,
comment TEXT DEFAULT ''
@ -55,8 +55,8 @@ CREATE TABLE playlists (
name TEXT NOT NULL,
media_file_count INTEGER NOT NULL DEFAULT 0,
copied_from_id TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT strftime('%F %TZ', date()),
last_used TEXT NOT NULL DEFAULT strftime('%F %TZ', date())
created_at TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime())),
last_used TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime()))
);
CREATE TABLE playlists_media_files (
@ -75,8 +75,8 @@ CREATE TABLE bookmarks (
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
play_index INTEGER NOT NULL,
play_time_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT strftime('%F %TZ', date()),
last_used TEXT NOT NULL DEFAULT strftime('%F %TZ', date())
created_at TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime())),
last_used TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime()))
);
CREATE INDEX bookmarks_playlist_id_idx ON bookmarks (playlist_id);

View File

@ -11,7 +11,8 @@ bin = @["wdiwtlt"]
# Dependencies
requires "nim >= 2.2.0"
requires @["mpv", "nimterop", "uuids", "waterpark"]
requires @["checksums", "docopt", "illwill", "mpv", "nimterop", "taglib",
"uuids", "waterpark"]
# Dependencies from https://git.jdb-software.com/jdb/nim-packages
requires @["db_migrate", "fiber_orm"]
requires @["cliutils", "db_migrate", "fiber_orm", "namespaced_logging"]