From c75438d409e32004a9ae2978ee8519cebcdfb97e Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Sat, 15 Mar 2025 07:19:30 -0500 Subject: [PATCH] WIP: vi-like input system. --- src/main/nim/wdiwtlt.nim | 230 +++++++++++++--- src/main/nim/wdiwtlt/ansi.nim | 7 + src/main/nim/wdiwtlt/cliconstants.nim | 18 ++ src/main/nim/wdiwtlt/commandbuffer.nim | 161 ++++++++++++ src/main/nim/wdiwtlt/db.nim | 218 +++++++++++++--- src/main/nim/wdiwtlt/logging.nim | 3 +- src/main/nim/wdiwtlt/medialibrary.nim | 247 ++++++++++++++++-- src/main/nim/wdiwtlt/scrolltext.nim | 48 ++++ ...schema-up.sql => media-library-schema.sql} | 10 +- wdiwtlt.nimble | 5 +- 10 files changed, 845 insertions(+), 102 deletions(-) create mode 100644 src/main/nim/wdiwtlt/ansi.nim create mode 100644 src/main/nim/wdiwtlt/cliconstants.nim create mode 100644 src/main/nim/wdiwtlt/commandbuffer.nim create mode 100644 src/main/nim/wdiwtlt/scrolltext.nim rename src/main/sql/{20151209054632-initial-schema-up.sql => media-library-schema.sql} (90%) diff --git a/src/main/nim/wdiwtlt.nim b/src/main/nim/wdiwtlt.nim index ab4a356..2b096c2 100644 --- a/src/main/nim/wdiwtlt.nim +++ b/src/main/nim/wdiwtlt.nim @@ -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() diff --git a/src/main/nim/wdiwtlt/ansi.nim b/src/main/nim/wdiwtlt/ansi.nim new file mode 100644 index 0000000..b6d78ba --- /dev/null +++ b/src/main/nim/wdiwtlt/ansi.nim @@ -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), "") diff --git a/src/main/nim/wdiwtlt/cliconstants.nim b/src/main/nim/wdiwtlt/cliconstants.nim new file mode 100644 index 0000000..a21edba --- /dev/null +++ b/src/main/nim/wdiwtlt/cliconstants.nim @@ -0,0 +1,18 @@ +const VERSION* = "0.2.0" + +const USAGE* = "wdiwtlt v" & VERSION & """ + +Usage: + wdiwtlt [options] + wdiwtlt --version + wdwtlt --help + +Options: + -c, --config Path to the configuration file. Defaults to: + ~/.config/wdiwtlt/config.json + + -L, --library-root The path to a local media library directory. + + -D, --database-config Path to the database file (SQLite3). Defaults to: + ~/.config/wdiwtlt/db.sqlite +""" diff --git a/src/main/nim/wdiwtlt/commandbuffer.nim b/src/main/nim/wdiwtlt/commandbuffer.nim new file mode 100644 index 0000000..accffe0 --- /dev/null +++ b/src/main/nim/wdiwtlt/commandbuffer.nim @@ -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.buffer.len: + cmd.buffer = cmd.buffer[0.. 0: + if backspace: + cmd.buffer = cmd.buffer[0.. 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 diff --git a/src/main/nim/wdiwtlt/db.nim b/src/main/nim/wdiwtlt/db.nim index 97a49bb..1799c4e 100644 --- a/src/main/nim/wdiwtlt/db.nim +++ b/src/main/nim/wdiwtlt/db.nim @@ -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)) diff --git a/src/main/nim/wdiwtlt/logging.nim b/src/main/nim/wdiwtlt/logging.nim index 9f81a4b..aef21e9 100644 --- a/src/main/nim/wdiwtlt/logging.nim +++ b/src/main/nim/wdiwtlt/logging.nim @@ -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 diff --git a/src/main/nim/wdiwtlt/medialibrary.nim b/src/main/nim/wdiwtlt/medialibrary.nim index 02d51ba..933221a 100644 --- a/src/main/nim/wdiwtlt/medialibrary.nim +++ b/src/main/nim/wdiwtlt/medialibrary.nim @@ -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 diff --git a/src/main/nim/wdiwtlt/scrolltext.nim b/src/main/nim/wdiwtlt/scrolltext.nim new file mode 100644 index 0000000..6d94a65 --- /dev/null +++ b/src/main/nim/wdiwtlt/scrolltext.nim @@ -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..= 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"]