WIP: vi-like input system.
This commit is contained in:
parent
eadf3946e7
commit
c75438d409
@ -1,43 +1,207 @@
|
||||
import std/[os, uri]
|
||||
import mpv
|
||||
import std/[json, options, os, paths, strutils]
|
||||
import cliutils, docopt, illwill, mpv
|
||||
|
||||
import wdiwtlt/[models, db]
|
||||
import wdiwtlt/[cliconstants, commandbuffer, logging, medialibrary, models,
|
||||
scrolltext]
|
||||
|
||||
type
|
||||
CliMode {.pure.} = enum Direct, Command
|
||||
|
||||
ScrollingDisplays* = ref object
|
||||
playing: ScrollText
|
||||
status: ScrollText
|
||||
|
||||
History = ref object
|
||||
entries: seq[string]
|
||||
idx: int
|
||||
|
||||
CliContext = ref object
|
||||
cfg: CombinedConfig
|
||||
cmd: CommandBuffer
|
||||
curMediaFile: Option[MediaFile]
|
||||
frame: int
|
||||
lib: MediaLibrary
|
||||
log: Option[Logger]
|
||||
mode: CliMode
|
||||
mpv: ptr handle
|
||||
stop*: bool
|
||||
tb: TerminalBuffer
|
||||
d: ScrollingDisplays
|
||||
|
||||
var ctx {.threadvar.}: CliContext
|
||||
|
||||
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)
|
||||
illWillDeinit()
|
||||
showCursor()
|
||||
echo ""
|
||||
|
||||
proc exitHandlerHook() {.noconv.} =
|
||||
# This is called when the user presses Ctrl+C
|
||||
# or when the program exits normally.
|
||||
echo "Ctrl-C"
|
||||
terminate_destroy(ctx.mpv)
|
||||
illWillDeinit()
|
||||
quit(QuitSuccess)
|
||||
|
||||
proc setMode(ctx: var CliContext, mode: CliMode) =
|
||||
ctx.mode = mode
|
||||
case mode
|
||||
of CliMode.Direct:
|
||||
hideCursor()
|
||||
ctx.tb.clear()
|
||||
of CliMode.Command:
|
||||
showCursor()
|
||||
ctx.cmd.mode = EditMode.Insert
|
||||
|
||||
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)
|
||||
|
||||
result = CliContext(
|
||||
cfg: cfg,
|
||||
cmd: initCommandBuffer(),
|
||||
curMediaFile: none(MediaFile),
|
||||
d: ScrollingDisplays(
|
||||
playing: initScrollText("Nothing playing", terminalWidth() - 1),
|
||||
status: initScrollText("Idle", terminalWidth() - 1)),
|
||||
frame: 0,
|
||||
lib: initMediaLibrary(
|
||||
rootDir = getVal(cfg, "libraryRoot").Path,
|
||||
dbPath = getVal(cfg, "dbPath").Path),
|
||||
log: log,
|
||||
mode: CliMode.Direct,
|
||||
mpv: initMpv(),
|
||||
stop: false,
|
||||
tb: newTerminalBuffer(terminalWidth(), terminalHeight()))
|
||||
|
||||
if logService.isSome:
|
||||
let customLogAppender = initCustomLogAppender(doLogMessage =
|
||||
proc (msg: LogMessage): void =
|
||||
ctx.tb.write(0, 0, msg.message))
|
||||
|
||||
logService.get.clearAppenders()
|
||||
logService.get.addAppender(customLogAppender)
|
||||
|
||||
proc render(ctx: CliContext) =
|
||||
if ctx.frame mod 20 == 0: discard ctx.d.playing.nextTick
|
||||
ctx.tb.write(0, 0, ctx.d.playing)
|
||||
|
||||
if ctx.mode == CliMode.Command:
|
||||
case ctx.cmd.mode:
|
||||
of EditMode.Insert: stdout.write("\x1b[5 q")
|
||||
of EditMode.Overwrite: stdout.write("\x1b[0 q")
|
||||
else:
|
||||
stdout.write("\x1b[2 q")
|
||||
ctx.tb.write(0, 1, ":$#" % [$ctx.cmd])
|
||||
|
||||
ctx.tb.display()
|
||||
|
||||
proc mainLoop(ctx: var CliContext) =
|
||||
|
||||
hideCursor()
|
||||
|
||||
while not ctx.stop:
|
||||
let key = getKey()
|
||||
|
||||
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:
|
||||
ctx.tb.write(0, 1, ":", ' '.repeat(($ctx.cmd).len))
|
||||
case key
|
||||
of Key.Enter:
|
||||
let command = $ctx.cmd
|
||||
ctx.cmd.clear
|
||||
# TODO: process 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)
|
||||
|
||||
render(ctx)
|
||||
|
||||
# target 50 FPS
|
||||
sleep(20)
|
||||
ctx.frame = (ctx.frame + 1 mod 50)
|
||||
|
||||
when isMainModule:
|
||||
var ctx: ptr handle
|
||||
|
||||
discard enableLogging()
|
||||
try:
|
||||
if paramCount() != 1:
|
||||
echo "pass a single media file as argument"
|
||||
quit(QuitFailure)
|
||||
let args = docopt(USAGE, version=VERSION)
|
||||
|
||||
ctx = mpv.create()
|
||||
if ctx.isNil:
|
||||
echo "failed creating mpv context"
|
||||
quit(QuitFailure)
|
||||
illWillInit(fullscreen=true)
|
||||
ctx = initContext(args)
|
||||
|
||||
# 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")
|
||||
mainLoop(ctx)
|
||||
|
||||
# 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:
|
||||
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()
|
||||
|
7
src/main/nim/wdiwtlt/ansi.nim
Normal file
7
src/main/nim/wdiwtlt/ansi.nim
Normal file
@ -0,0 +1,7 @@
|
||||
import std/nre
|
||||
|
||||
const CSI* = "\x1b["
|
||||
const ANSI_REGEX_PATTERN* = "\x1b\\[([0-9;]*)([a-zA-Z])"
|
||||
|
||||
func stripAnsi*(text: string): string =
|
||||
text.replace(re(ANSI_REGEX_PATTERN), "")
|
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
|
||||
"""
|
161
src/main/nim/wdiwtlt/commandbuffer.nim
Normal file
161
src/main/nim/wdiwtlt/commandbuffer.nim
Normal file
@ -0,0 +1,161 @@
|
||||
import std/[options]
|
||||
|
||||
import illwill
|
||||
|
||||
type
|
||||
EditMode* {.pure.} = enum Command, Insert, Overwrite, Visual
|
||||
|
||||
Command = ref object
|
||||
buffer: string
|
||||
idx: int
|
||||
selectionStartIdx: Option[int]
|
||||
|
||||
CommandBuffer* = ref object
|
||||
history: seq[Command]
|
||||
idx: int
|
||||
mode*: EditMode
|
||||
|
||||
func clamp(value, min, max: int): int =
|
||||
if value < min:
|
||||
return min
|
||||
elif value > max:
|
||||
return max
|
||||
else:
|
||||
return value
|
||||
|
||||
func initCommandBuffer*(): CommandBuffer =
|
||||
result = CommandBuffer(
|
||||
history: @[Command(
|
||||
buffer: "",
|
||||
idx: 0,
|
||||
selectionStartIdx: none(int))],
|
||||
idx: 0,
|
||||
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 cmd.idx > 0:
|
||||
if backspace:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx - 1] & cmd.buffer[cmd.idx..^1]
|
||||
else:
|
||||
cmd.buffer = cmd.buffer[0..<cmd.idx] & cmd.buffer[cmd.idx + 1..^1]
|
||||
cmd.idx -= 1
|
||||
|
||||
proc clear*(cb: var CommandBuffer) =
|
||||
cb.history.add(Command(
|
||||
buffer: "",
|
||||
idx: 0,
|
||||
selectionStartIdx: none(int)))
|
||||
cb.idx = cb.history.len - 1
|
||||
|
||||
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
|
||||
|
||||
func toChar(k: Key): char = cast[char](ord(k))
|
||||
|
||||
proc handleInput*(cb: var CommandBuffer, key: Key) =
|
||||
case cb.mode
|
||||
of EditMode.Insert, EditMode.Overwrite:
|
||||
case key
|
||||
of Key.Escape: cb.mode = 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.I: cb.mode = EditMode.Insert
|
||||
of Key.ShiftR: cb.mode = EditMode.Overwrite
|
||||
of Key.V:
|
||||
cb.mode = EditMode.Visual
|
||||
cb.cur.selectionStartIdx = some(cb.cur.idx)
|
||||
else: discard
|
||||
|
||||
of EditMode.Visual:
|
||||
case key
|
||||
of Key.Escape:
|
||||
cb.mode = 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]()
|
||||
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.V:
|
||||
cb.mode = EditMode.Command
|
||||
cb.cur.selectionStartIdx = none(int)
|
||||
else: discard
|
||||
|
||||
|
||||
func `$`*(cb: CommandBuffer): string = cb.cur.buffer
|
@ -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,67 +11,203 @@ 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.
|
||||
|
||||
let query = """
|
||||
DELETE FROM albums WHERE id IN (
|
||||
SELECT DISTINCT al.id
|
||||
FROM albums al LEFT OUTER JOIN albums_media_files almf ON
|
||||
al.id = almf.album_id
|
||||
WHERE almf.album_id IS NULL)"""
|
||||
db.withConnection conn:
|
||||
let query = """
|
||||
DELETE FROM albums WHERE id IN (
|
||||
SELECT DISTINCT al.id
|
||||
FROM albums al LEFT OUTER JOIN albums_media_files almf ON
|
||||
al.id = almf.album_id
|
||||
WHERE almf.album_id IS NULL)"""
|
||||
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"msg": "Deleting empty albums.",
|
||||
"query": query})
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"msg": "Deleting empty albums.",
|
||||
"query": query})
|
||||
|
||||
proc removeEmptyArtists*(sql: WdiwtltDb) =
|
||||
conn.exec(sql(query))
|
||||
|
||||
proc removeEmptyArtists*(db: WdiwtltDb) =
|
||||
## Remove artists that have no albums.
|
||||
|
||||
let query = """
|
||||
DELETE FROM artists WHERE id IN (
|
||||
SELECT DISTINCT ar.id
|
||||
FROM artists ar LEFT OUTER JOIN artists_albums aral ON
|
||||
ar.id = aral.artist_id
|
||||
WHERE aral.artist_id IS NULL)"""
|
||||
db.withConnection conn:
|
||||
let query = """
|
||||
DELETE FROM artists WHERE id IN (
|
||||
SELECT DISTINCT ar.id
|
||||
FROM artists ar LEFT OUTER JOIN artists_albums aral ON
|
||||
ar.id = aral.artist_id
|
||||
WHERE aral.artist_id IS NULL)"""
|
||||
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"msg": "Deleting empty artists.",
|
||||
"query": query})
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"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.
|
||||
|
||||
let query = """
|
||||
DELETE FROM playlists WHERE id IN (
|
||||
SELECT DISTINCT pl.id
|
||||
FROM playlists pl LEFT OUTER JOIN playlists_media_files plmf ON
|
||||
pl.id = plmf.playlist_id
|
||||
WHERE plmf.playlist_id IS NULL)"""
|
||||
db.withConnection conn:
|
||||
let query = """
|
||||
DELETE FROM playlists WHERE id IN (
|
||||
SELECT DISTINCT pl.id
|
||||
FROM playlists pl LEFT OUTER JOIN playlists_media_files plmf ON
|
||||
pl.id = plmf.playlist_id
|
||||
WHERE plmf.playlist_id IS NULL)"""
|
||||
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"msg": "Deleting empty playlists.",
|
||||
"query": query})
|
||||
getLogger("wdiwtlt/db").debug(%*{
|
||||
"msg": "Deleting empty playlists.",
|
||||
"query": query})
|
||||
|
||||
conn.exec(sql(query))
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
48
src/main/nim/wdiwtlt/scrolltext.nim
Normal file
48
src/main/nim/wdiwtlt/scrolltext.nim
Normal file
@ -0,0 +1,48 @@
|
||||
type ScrollText* = ref object
|
||||
text: string
|
||||
maxWidth: int
|
||||
scrollIdx: int # Current index of the first character to show.
|
||||
lastRender: string
|
||||
|
||||
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 " " & 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] & " " &
|
||||
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 = max(0, text.len - 10)
|
||||
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: 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 + 1)
|
||||
|
||||
return st.lastRender
|
||||
|
||||
func `$`*(st: ScrollText): string = st.lastRender
|
||||
|
||||
converter toString*(st: ScrollText): string = st.lastRender
|
@ -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);
|
@ -11,7 +11,8 @@ bin = @["wdiwtlt"]
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.2.0"
|
||||
requires @["checksums", "mpv", "nimterop", "taglib", "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"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user