Compare commits
5 Commits
3f3a6b286b
...
nim-refact
Author | SHA1 | Date | |
---|---|---|---|
22d3966755 | |||
a7a9cf8620 | |||
c75438d409 | |||
eadf3946e7 | |||
2d44d2c328 |
@ -1,43 +1,334 @@
|
|||||||
import std/[os, uri]
|
import std/[json, options, os, paths, sequtils, strutils, terminal, times,
|
||||||
import mpv
|
unicode]
|
||||||
|
import cliutils, docopt, namespaced_logging, mpv
|
||||||
|
|
||||||
|
import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary,
|
||||||
|
messagebuffer, models, nonblockingio, scrolltext, usage]
|
||||||
|
|
||||||
|
type
|
||||||
|
CliMode {.pure.} = enum Direct, Command
|
||||||
|
|
||||||
|
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).
|
||||||
|
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 result.initialize()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
import wdiwtlt/[models, db]
|
|
||||||
|
|
||||||
when isMainModule:
|
when isMainModule:
|
||||||
var ctx: ptr handle
|
|
||||||
|
discard enableLogging()
|
||||||
try:
|
try:
|
||||||
if paramCount() != 1:
|
let args = docopt(USAGE, version=VERSION)
|
||||||
echo "pass a single media file as argument"
|
|
||||||
quit(QuitFailure)
|
|
||||||
|
|
||||||
ctx = mpv.create()
|
ctx = initContext(args)
|
||||||
if ctx.isNil:
|
setNonBlockingTty(true)
|
||||||
echo "failed creating mpv context"
|
|
||||||
quit(QuitFailure)
|
|
||||||
|
|
||||||
# Enable default key bindings, so the user can actually interact with
|
mainLoop(ctx)
|
||||||
# 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")
|
|
||||||
|
|
||||||
# Done setting up options.
|
|
||||||
check_error ctx.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
|
|
||||||
except Exception:
|
except Exception:
|
||||||
echo "wdiwtlt: " & getCurrentExceptionMsg()
|
let ex = getCurrentException()
|
||||||
|
getLogger("wdiwtlt").error(%*{
|
||||||
|
"msg": "Unhandled exception",
|
||||||
|
"error": ex.msg,
|
||||||
|
"trace": ex.getStackTrace()
|
||||||
|
})
|
||||||
|
cleanup()
|
||||||
quit(QuitFailure)
|
quit(QuitFailure)
|
||||||
finally:
|
finally:
|
||||||
mpv.terminate_destroy(ctx)
|
cleanup()
|
||||||
|
18
src/main/nim/wdiwtlt/cliconstants.nim
Normal file
18
src/main/nim/wdiwtlt/cliconstants.nim
Normal 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
|
||||||
|
"""
|
247
src/main/nim/wdiwtlt/commandbuffer.nim
Normal file
247
src/main/nim/wdiwtlt/commandbuffer.nim
Normal 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)
|
@ -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 db_connector/db_sqlite
|
||||||
import waterpark/sqlite
|
import waterpark/sqlite
|
||||||
import fiber_orm, timeutils, uuids
|
import fiber_orm, timeutils, uuids
|
||||||
@ -11,67 +11,203 @@ export sqlite.close
|
|||||||
|
|
||||||
import ./[logging, models]
|
import ./[logging, models]
|
||||||
|
|
||||||
|
const schemaDDL = readFile("src/main/sql/media-library-schema.sql")
|
||||||
|
.split(";")
|
||||||
|
.filterIt(not isEmptyOrWhitespace(it))
|
||||||
|
|
||||||
type
|
type
|
||||||
WdiwtltDb* = SqlitePool
|
WdiwtltDb* = SqlitePool
|
||||||
|
|
||||||
|
|
||||||
func toJsonHook*(dt: DateTime): JsonNode = %(dt.formatIso8601)
|
func toJsonHook*(dt: DateTime): JsonNode = %(dt.formatIso8601)
|
||||||
proc fromJsonHook*(dt: var DateTime, n: JsonNode): void =
|
proc fromJsonHook*(dt: var DateTime, n: JsonNode): void =
|
||||||
dt = n.getStr.parseIso8601
|
dt = n.getStr.parseIso8601
|
||||||
|
|
||||||
|
|
||||||
proc initDB*(dbPath: string): SqlitePool =
|
proc createTables*(db: WdiwtltDb) =
|
||||||
newSqlitePool(10, dbPath)
|
## 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,
|
generateProcsForModels(WdiwtltDb,
|
||||||
[Artist, Album, MediaFile, Tag, Playlist, Bookmark, Image])
|
[Artist, Album, MediaFile, Tag, Playlist, Bookmark, Image])
|
||||||
|
|
||||||
generateJoinTableLookups(WdiwtltDb, Artist, Album, "artists_albums")
|
generateJoinTableProcs(WdiwtltDb, Artist, Album, "artists_albums")
|
||||||
generateJoinTableLookups(WdiwtltDb, Artist, MediaFile, "artists_media_files")
|
generateJoinTableProcs(WdiwtltDb, Artist, MediaFile, "artists_media_files")
|
||||||
generateJoinTableLookups(WdiwtltDb, Album, MediaFile, "albums_media_files")
|
generateJoinTableProcs(WdiwtltDb, Album, MediaFile, "albums_media_files")
|
||||||
generateJoinTableLookups(WdiwtltDb, Playlist, MediaFile, "playlists_media_files")
|
generateJoinTableProcs(WdiwtltDb, Playlist, MediaFile, "playlists_media_files")
|
||||||
generateJoinTableLookups(WdiwtltDb, MediaFile, Tag, "media_files_tags")
|
generateJoinTableProcs(WdiwtltDb, MediaFile, Tag, "media_files_tags")
|
||||||
generateJoinTableLookups(WdiwtltDb, Artist, Image, "artists_images")
|
generateJoinTableProcs(WdiwtltDb, Artist, Image, "artists_images")
|
||||||
generateJoinTableLookups(WdiwtltDb, Album, Image, "albums_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.
|
## Remove albums that have no media files.
|
||||||
|
|
||||||
let query = """
|
db.withConnection conn:
|
||||||
DELETE FROM albums WHERE id IN (
|
let query = """
|
||||||
SELECT DISTINCT al.id
|
DELETE FROM albums WHERE id IN (
|
||||||
FROM albums al LEFT OUTER JOIN albums_media_files almf ON
|
SELECT DISTINCT al.id
|
||||||
al.id = almf.album_id
|
FROM albums al LEFT OUTER JOIN albums_media_files almf ON
|
||||||
WHERE almf.album_id IS NULL)"""
|
al.id = almf.album_id
|
||||||
|
WHERE almf.album_id IS NULL)"""
|
||||||
|
|
||||||
getLogger("wdiwtlt/db").debug(%*{
|
getLogger("wdiwtlt/db").debug(%*{
|
||||||
"msg": "Deleting empty albums.",
|
"msg": "Deleting empty albums.",
|
||||||
"query": query})
|
"query": query})
|
||||||
|
|
||||||
proc removeEmptyArtists*(sql: WdiwtltDb) =
|
conn.exec(sql(query))
|
||||||
|
|
||||||
|
proc removeEmptyArtists*(db: WdiwtltDb) =
|
||||||
## Remove artists that have no albums.
|
## Remove artists that have no albums.
|
||||||
|
|
||||||
let query = """
|
db.withConnection conn:
|
||||||
DELETE FROM artists WHERE id IN (
|
let query = """
|
||||||
SELECT DISTINCT ar.id
|
DELETE FROM artists WHERE id IN (
|
||||||
FROM artists ar LEFT OUTER JOIN artists_albums aral ON
|
SELECT DISTINCT ar.id
|
||||||
ar.id = aral.artist_id
|
FROM artists ar LEFT OUTER JOIN artists_albums aral ON
|
||||||
WHERE aral.artist_id IS NULL)"""
|
ar.id = aral.artist_id
|
||||||
|
WHERE aral.artist_id IS NULL)"""
|
||||||
|
|
||||||
getLogger("wdiwtlt/db").debug(%*{
|
getLogger("wdiwtlt/db").debug(%*{
|
||||||
"msg": "Deleting empty artists.",
|
"msg": "Deleting empty artists.",
|
||||||
"query": query})
|
"query": query})
|
||||||
|
|
||||||
proc removeEmptyPlaylists*(sql: WdiwtltDb) =
|
conn.exec(sql(query))
|
||||||
|
|
||||||
|
proc removeEmptyPlaylists*(db: WdiwtltDb) =
|
||||||
## Remove playlists that have no media files.
|
## Remove playlists that have no media files.
|
||||||
|
|
||||||
let query = """
|
db.withConnection conn:
|
||||||
DELETE FROM playlists WHERE id IN (
|
let query = """
|
||||||
SELECT DISTINCT pl.id
|
DELETE FROM playlists WHERE id IN (
|
||||||
FROM playlists pl LEFT OUTER JOIN playlists_media_files plmf ON
|
SELECT DISTINCT pl.id
|
||||||
pl.id = plmf.playlist_id
|
FROM playlists pl LEFT OUTER JOIN playlists_media_files plmf ON
|
||||||
WHERE plmf.playlist_id IS NULL)"""
|
pl.id = plmf.playlist_id
|
||||||
|
WHERE plmf.playlist_id IS NULL)"""
|
||||||
|
|
||||||
getLogger("wdiwtlt/db").debug(%*{
|
getLogger("wdiwtlt/db").debug(%*{
|
||||||
"msg": "Deleting empty playlists.",
|
"msg": "Deleting empty playlists.",
|
||||||
"query": query})
|
"query": query})
|
||||||
|
|
||||||
|
conn.exec(sql(query))
|
||||||
|
39
src/main/nim/wdiwtlt/incremental_md5.nim
Normal file
39
src/main/nim/wdiwtlt/incremental_md5.nim
Normal 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)
|
@ -1,6 +1,7 @@
|
|||||||
import std/[options, os, strutils, unicode]
|
import std/[options, os, strutils, unicode]
|
||||||
import namespaced_logging, zero_functional
|
import namespaced_logging, zero_functional
|
||||||
import ./db
|
|
||||||
|
from fiber_orm import enableDbLogging
|
||||||
|
|
||||||
export namespaced_logging
|
export namespaced_logging
|
||||||
|
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import std/[paths, times]
|
import std/[dirs, files, json, options, paths, sequtils, strutils, sugar, times]
|
||||||
import namespaced_logging
|
import namespaced_logging, timeutils, uuids
|
||||||
|
|
||||||
import ./[db, models]
|
import ./[db, incremental_md5, logging, models, taglib]
|
||||||
|
|
||||||
type
|
type
|
||||||
MediaLibrary* = ref object
|
MediaLibrary* = ref object
|
||||||
db: WdiwtltDb
|
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) =
|
proc clean*(lib: MediaLibrary) =
|
||||||
removeEmptyAlbums(lib.db)
|
removeEmptyAlbums(lib.db)
|
||||||
@ -15,26 +22,226 @@ proc clean*(lib: MediaLibrary) =
|
|||||||
|
|
||||||
let expirationDate = now() - weeks(1)
|
let expirationDate = now() - weeks(1)
|
||||||
|
|
||||||
let expiredPlaylists = lib.db.findPlaylstsWhere(
|
let expiredPlaylists = lib.db.findPlaylistsWhere(
|
||||||
"user_created = false AND last_used_at < ?",
|
"user_created = false AND last_used_at < ?",
|
||||||
[expirationDate])
|
[expirationDate.formatIso8601])
|
||||||
|
|
||||||
let expiredBookmarks = lib.db.findBookmarksWhere(
|
let expiredBookmarks = lib.db.findBookmarksWhere(
|
||||||
"user_created = false AND last_used_at < ?",
|
"user_created = false AND last_used_at < ?",
|
||||||
[expirationDate])
|
[expirationDate.formatIso8601])
|
||||||
|
|
||||||
expiredPlaylists.applyIt(lib.db.deletePlaylist(it.id))
|
for p in expiredPlaylists.records: discard lib.db.deletePlaylist(p.id)
|
||||||
expiredBookmarks.applyIt(lib.db.deleteBookmark(it.id))
|
for b in expiredBookmarks.records: discard lib.db.deleteBookmark(b.id)
|
||||||
|
|
||||||
proc rescanLibrary*(lib: MediaLibrary) =
|
proc findOrCreateArtist*(lib: MediaLibrary, name: string): Artist =
|
||||||
# TODO: Implement this. Below is AI-generated code.
|
## Find or create an artist record.
|
||||||
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
|
|
||||||
|
|
||||||
let fileHash = filePath.hashFile
|
let existing = lib.db.findArtistsByName(name)
|
||||||
if fileHash != mf.fileHash:
|
if existing.records.len > 0:
|
||||||
lib.db.updateMediaFile(mf.id, fileHash: fileHash)
|
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
|
||||||
|
167
src/main/nim/wdiwtlt/messagebuffer.nim
Normal file
167
src/main/nim/wdiwtlt/messagebuffer.nim
Normal 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)
|
263
src/main/nim/wdiwtlt/nonblockingio.nim
Normal file
263
src/main/nim/wdiwtlt/nonblockingio.nim
Normal 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))
|
64
src/main/nim/wdiwtlt/scrolltext.nim
Normal file
64
src/main/nim/wdiwtlt/scrolltext.nim
Normal 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
|
167
src/main/nim/wdiwtlt/taglib.nim
Normal file
167
src/main/nim/wdiwtlt/taglib.nim
Normal 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
|
111
src/main/nim/wdiwtlt/terminal.nim
Normal file
111
src/main/nim/wdiwtlt/terminal.nim
Normal 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")
|
5
src/main/nim/wdiwtlt/usage.nim
Normal file
5
src/main/nim/wdiwtlt/usage.nim
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
func usageOf*(command: seq[string]): string =
|
||||||
|
case command[0]
|
||||||
|
of "log":
|
||||||
|
# TODO
|
||||||
|
discard
|
7
src/main/nim/wdiwtlt/util.nim
Normal file
7
src/main/nim/wdiwtlt/util.nim
Normal 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
|
@ -23,7 +23,7 @@ CREATE TABLE media_files (
|
|||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
file_hash TEXT NOT NULL,
|
file_hash TEXT NOT NULL,
|
||||||
meta_info_source TEXT NOT NULL, -- 'tag' or 'filesystem'
|
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,
|
last_played TEXT,
|
||||||
present_locally INTEGER NOT NULL DEFAULT TRUE,
|
present_locally INTEGER NOT NULL DEFAULT TRUE,
|
||||||
comment TEXT DEFAULT ''
|
comment TEXT DEFAULT ''
|
||||||
@ -55,8 +55,8 @@ CREATE TABLE playlists (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
media_file_count INTEGER NOT NULL DEFAULT 0,
|
media_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
copied_from_id TEXT DEFAULT NULL,
|
copied_from_id TEXT DEFAULT NULL,
|
||||||
created_at 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', date())
|
last_used TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime()))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE playlists_media_files (
|
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,
|
media_file_id TEXT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
play_index INTEGER NOT NULL,
|
play_index INTEGER NOT NULL,
|
||||||
play_time_ms INTEGER NOT NULL DEFAULT 0,
|
play_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at 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', date())
|
last_used TEXT NOT NULL DEFAULT (strftime('%F %TZ', datetime()))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX bookmarks_playlist_id_idx ON bookmarks (playlist_id);
|
CREATE INDEX bookmarks_playlist_id_idx ON bookmarks (playlist_id);
|
@ -11,7 +11,8 @@ bin = @["wdiwtlt"]
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires "nim >= 2.2.0"
|
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
|
# Dependencies from https://git.jdb-software.com/jdb/nim-packages
|
||||||
requires @["db_migrate", "fiber_orm"]
|
requires @["cliutils", "db_migrate", "fiber_orm", "namespaced_logging"]
|
||||||
|
Reference in New Issue
Block a user