Implement selection, formatted text, help contents.

This commit is contained in:
Jonathan Bernard 2022-10-26 08:38:34 -05:00
parent 00a49723a9
commit d15ea02a15
10 changed files with 1273 additions and 94 deletions

View File

@ -1,23 +1,32 @@
import std/json, std/logging
import std/[json, logging, options]
import docopt
import wdiwtlt/private/cliconstants
import wdiwtlt/[cli, config, db]
when isMainModule:
try:
var logFile: File
try:
let args = docopt(USAGE, version = WDIWTLT_VERSION)
let consoleLogger = newConsoleLogger(
levelThreshold =
if args["--debug"]: lvlDebug
if args["--verbose"]: lvlDebug
else: lvlInfo,
fmtStr="pit - $levelname: ")
logging.addHandler(consoleLogger)
let cfg = loadConfig(args)
if cfg.logFile.isSome:
info "Logging to '" & cfg.logFile.get & "'"
try: logFile = open(cfg.logFile.get)
except:
warn "Unable to open the log file '" & cfg.logFile.get & "'"
let fileLogger = newFileLogger(logFile, lvlDebug)
logging.addHandler(fileLogger)
if args["init-library"]:
info "Initializing a new library database at '" & cfg.dbPath & "'"
let newDb = initDb(cfg.dbPath)
@ -37,3 +46,6 @@ when isMainModule:
fatal getCurrentExceptionMsg()
debug getStackTrace(getCurrentException())
quit(QuitFailure)
finally:
if not logFile.isNil: logFile.close()

View File

@ -1,21 +1,38 @@
import std/[os, strutils]
import ./config, ./library, ./libvlc, ./models
import private/cliconstants
import std/[algorithm, logging, nre, options, os, random, strutils]
import uuids, zero_functional
import ./config, ./db, ./library, ./libvlc, ./models, ./whenmatched
import private/cliconstants, private/styledtext
type CliCtx = ref object
cfg: WdiwtltConfig
library: WdiwtltLibrary
vlc: LibVlcInstance
player: VlcMediaPlayer
type
Selection = ref object
models*: seq[BaseModel]
CliCtx = ref object
cfg: WdiwtltConfig
library: WdiwtltLibrary
player: VlcMediaPlayer
vlc: LibVlcInstance
currentBookmark: Option[Bookmark]
currentSelection: Option[Selection]
playQueue: Option[Playlist]
curMediaFile: Option[MediaFile]
const STOP_CMDS = ["stop", "exit", "quit", ":q"]
var cmdChannel: Channel[string]
var readyForCmd: Channel[bool]
proc initSelection(models: seq[SomeModel]): Selection =
Selection(models: models --> map(it.BaseModel))
proc handleCmd(ctx: CliCtx, cmd: string): bool;
proc err(msg: string) = raise newException(CliErr, msg)
proc initCliCtx(cfg: WdiwtltConfig): CliCtx =
let vlc = newVlc()
randomize()
return CliCtx(
cfg: cfg,
player: vlc.newMediaPlayer,
@ -35,7 +52,6 @@ proc readInputLoop() =
while not readyForCmd.tryRecv().dataAvailable: sleep(200)
proc renderLoop(ctx: CliCtx) =
var shouldStop = false
while not shouldStop:
@ -70,18 +86,133 @@ proc processScan(ctx: CliCtx, fullRescan = false) =
stdout.writeLine("Sanning media library...")
ctx.library.scan(fullRescan)
const SELECTABLE_MODELS = "album|artist|file|playlist|tag"
proc selectFrom(ctx: CliCtx, mf: MediaFile, modelType: ModelType): Selection =
let db = ctx.library.db
case modelType:
of mtAlbum: result = initSelection(db.findAlbumsByMediaFile(mf))
of mtArtist: result = initSelection(db.findArtistsByMediaFile(mf))
of mtBookmark:
if ctx.currentBookmark.isSome:
result = initSelection(@[ctx.currentBookmark.get])
else: err "no current bookmark to select"
of mtMediaFile: result = initSelection(@[mf])
of mtPlaylist:
if ctx.playQueue.isSome: result = initSelection(@[ctx.playQueue.get])
else: err "no current playlist to select"
of mtTag: result = initSelection(db.findTagsByMediaFile(mf))
proc selectRandom(
ctx: CliCtx,
count: int,
source: Selection,
mt: ModelType
): Selection =
var options = ctx.library.db.findAllRelatedModels(source.models, mt)
if count >= options.len: return initSelection(options)
shuffle(options)
return initSelection(options[0..<count])
proc select(ctx: CliCtx, criteria: string): Selection =
debug "Selecting. Options: " & criteria
let db = ctx.library.db
# playing
criteria.whenMatched(re("playing ($#)" % SELECTABLE_MODELS)):
if ctx.curMediaFile.isNone: err "Nothing selected."
return ctx.selectFrom(
ctx.curMediaFile.get,
mapModelType(match.captures[0]))
# <count> random <model-type>s from <selection-criteria>
criteria.whenMatched(re(
"(\\d+ )?random ($#)s?( from (.+$))?" % SELECTABLE_MODELS)):
let count =
if match.captures.contains(0): parseInt(match.captures[0].strip)
else: 1
let modelType = mapModelType(match.captures[1])
let source: Selection =
if match.captures.contains(3): ctx.select(match.captures[3].strip)
else: initSelection(getAll(db, modelType))
return ctx.selectRandom(count, source, modelType)
# selected <model-ype>
criteria.whenMatched(re("selected ($#)s?" % SELECTABLE_MODELS)):
if ctx.currentSelection.isNone: err "Nothing is selected."
let modelType = mapModelType(match.captures[0])
return initSelection(
db.findAllRelatedModels(ctx.currentSelection.get.models, modelType))
# absent files
criteria.whenMatched(re("absent files")):
return initSelection(db.allMediaFiles --> filter(not it.presentLocally))
# files tagged as <tags>...
criteria.whenMatched(re("files tagged( as){0,1}((\\s[^\\s]+)+)")):
let tags: seq[Tag] = match.captures[1].split(re"\s") -->
map(it.strip).
map(db.findTagByName(it)).
filter(it.isSome).
map(it.get)
if tags.len == 0: err "Nothing is selected."
return initSelection(tags --> map(db.findMediaFilesByTag(it)).flatten())
# <model-type> from <selection-criteria>
criteria.whenMatched(re("($#)s? from (.+)" % SELECTABLE_MODELS)):
let modelType = mapModelType(match.captures[0])
let source = ctx.select(match.captures[1])
if source.models.len > 0 and modelMatchesType(source.models[0], modelType):
return source
return initSelection(db.findAllRelatedModels(source.models, modelType))
# <model-type> <id>
#criteria.whenMatched(re("($#)s?((\\s\\[a-fA-F0-9]+)+)" % SELECTABLE_MODELS)):
# let modelType = mapModelType(match.captures[0])
# let ids = match.captures[1].split(re"\s") --> map(parseUUID(it))
proc processList(ctx: CliCtx, options: string) =
debug "Listing things. Options: " & options
proc processHelp(ctx: CliCtx, options: string) =
stdout.writeStyledText(
case options:
of "": HELP_GENERAL
of "scan": HELP_SCAN
of "rescan": HELP_RESCAN
of "quit": HELP_QUIT
else: FE & options & " is not a recognized command.\p"
)
proc handleCmd(ctx: CliCtx, cmd: string): bool =
result = false
if STOP_CMDS.contains(cmd): return true
try:
result = false
if STOP_CMDS.contains(cmd): return true
let cmdParts = cmd.split(" ", 1)
if cmdParts.len == 0: return
let command = cmdParts[0].toLower
let rest =
if cmdParts.len > 1: cmdParts[1]
else: ""
let cmdParts = cmd.split(" ", 1)
if cmdParts.len == 0: return
let command = cmdParts[0].toLower
let rest =
if cmdParts.len > 1: cmdParts[1]
else: ""
case command:
of "scan": ctx.processScan()
of "rescan": ctx.processScan(true)
else: stdout.writeLine("Unrecognized command: '" & cmdParts[0] & "'")
case command:
of "scan": ctx.processScan()
of "rescan": ctx.processScan(true)
of "list": ctx.processList(rest)
of "help": ctx.processHelp(rest)
else: stdout.writeLine("Unrecognized command: '" & cmdParts[0] & "'")
except:
stdout.writeStyledText(FE & getCurrentExceptionMsg() & FN)

View File

@ -1,10 +1,11 @@
import std/json, std/logging, std/os, std/strutils, std/tables
import std/[json, logging, options, os, strutils, tables]
import cliutils, docopt, zero_functional
type WdiwtltConfig* = object
dbPath*: string
libraryPath*: string
cfgPath*: string
logFile*: Option[string]
cfg*: CombinedConfig
const DEFAULT_CFG_CONTENTS = """{
@ -54,3 +55,6 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): Wdiwt
libraryPath: cfg.getVal("library-path"),
cfgPath: cfgFilename,
cfg: cfg)
try: result.logFile = some(cfg.getVal("log-file"))
except: result.logFile = none[string]()

View File

@ -1,8 +1,10 @@
import std/[logging, json, options, os, tables, times]
import std/[logging, json, options, os, sets, tables, times]
import timeutils, uuids, zero_functional
import ./jsonutils, ./models
from std/sequtils import deduplicate, toSeq
type
DbRoot = ref object
albums: TableRef[UUID, Album]
@ -10,13 +12,14 @@ type
bookmarks: TableRef[UUID, Bookmark]
mediaFiles: TableRef[UUID, MediaFile]
playlists: TableRef[UUID, Playlist]
tags: TableRef[string, Option[string]]
tags: TableRef[UUID, Tag]
tagSet: HashSet[string]
albumsToMediaFiles: TableRef[UUID, seq[UUID]]
artistsToMediaFiles: TableRef[UUID, seq[UUID]]
artistsAndAlbums: seq[tuple[artistId, albumId: UUID]]
playlistsToMediaFiles: TableRef[UUID, seq[UUID]]
tagsAndMediaFiles: seq[tuple[tagName: string, mediaFileId: UUID]]
tagsAndMediaFiles: seq[tuple[tagId: UUID, mediaFileId: UUID]]
mediaFileHashToId: TableRef[string, UUID]
@ -52,27 +55,45 @@ proc add*(db: WdiwtltDb, a: Album);
proc remove*(db: WdiwtltDb, a: Album);
proc update*(db: WdiwtltDb, a: Album);
proc findAlbumsByArtist*(db: WdiwtltDb, a: Artist): seq[Album];
proc findAlbumsByName*(db: WdiwtltDb, name: string): seq[Album];
proc add*(db: WdiwtltDb, a: Album, mf: MediaFile);
proc remove*(db: WdiwtltDb, a: Album, mf: MediaFile);
proc allAlbums*(db: WdiwtltDb): seq[Album];
proc findAlbumById*(db: WdiwtltDb, id: UUID): Option[Album];
proc findAlbumsByArtist*(db: WdiwtltDb, a: Artist): seq[Album];
proc findAlbumsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Album];
proc findAlbumsByName*(db: WdiwtltDb, name: string): seq[Album];
proc findAllRelatedAlbums*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Album];
## Artists
## --------------------
proc add*(db: WdiwtltDb, a: Artist);
proc remove*(db: WdiwtltDb, a: Artist);
proc update*(db: WdiwtltDb, a: Artist);
proc findArtistsByAlbum*(db: WdiwtltDb, a: Album): seq[Artist];
proc findArtistsByName*(db: WdiwtltDb, name: string): seq[Artist];
proc add*(db: WdiwtltDb, a: Artist, mf: MediaFile);
proc remove*(db: WdiwtltDb, a: Artist, mf: MediaFile);
proc isAssociated*(db: WdiwtltDb, artist: Artist, album: Album): bool;
proc associate*(db: WdiwtltDb, artist: Artist, album: Album);
proc disassociate*(db: WdiwtltDb, artist: Artist, album: Album);
proc isAssociated*(db: WdiwtltDb, artist: Artist, album: Album): bool;
proc allArtists*(db: WdiwtltDb): seq[Artist];
proc findArtistById*(db: WdiwtltDb, id: UUID): Option[Artist];
proc findArtistsByAlbum*(db: WdiwtltDb, a: Album): seq[Artist];
proc findArtistsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Artist];
proc findArtistsByName*(db: WdiwtltDb, name: string): seq[Artist];
proc findAllRelatedArtists*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Artist];
## Bookmarks
## --------------------
@ -80,15 +101,39 @@ proc add*(db: WdiwtltDb, b: Bookmark);
proc update*(db: WdiwtltDb, b: Bookmark);
proc remove*(db: WdiwtltDb, b: Bookmark);
proc allBookmarks*(db: WdiwtltDb): seq[Bookmark];
proc findBookmarkById*(db: WdiwtltDb, id: UUID): Option[Bookmark];
proc findBookmarksByPlaylist*(db: WdiwtltDb, p: Playlist): seq[Bookmark];
proc findBookmarksByName*(db: WdiwtltDb, name: string): seq[Bookmark];
proc findAllRelatedBookmarks*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Bookmark];
## Media Files
## --------------------
proc add*(db: WdiwtltDb, mf: MediaFile);
proc remove*(db: WdiwtltDb, mf: MediaFile);
proc update*(db: WdiwtltDb, mf: MediaFile);
proc allMediaFiles*(db: WdiwtltDb): seq[MediaFile];
proc findMediaFileByHash*(db: WdiwtltDb, hash: string): Option[MediaFile];
proc findMediaFileById*(db: WdiwtltDb, id: UUID): Option[MediaFile];
proc findMediaFileByPath*(db: WdiwtltDb, path: string): Option[MediaFile];
proc findMediaFilesByAlbum*(db: WdiwtltDb, a: Album): seq[MediaFile];
proc findMediaFilesByArtist*(db: WdiwtltDb, a: Artist): seq[MediaFile];
proc findMediaFileByHash*(db: WdiwtltDb, hash: string): Option[MediaFile];
proc findMediaFileByPath*(db: WdiwtltDb, path: string): Option[MediaFile];
proc findMediaFilesByBookmark*(db: WdiwtltDb, b: Bookmark): seq[MediaFile];
proc findMediaFilesByName*(db: WdiwtltDb, name: string): seq[MediaFile];
proc findMediaFilesByPlaylist*(db: WdiwtltDb, p: Playlist): seq[MediaFile];
proc findMediaFilesByTag*(db: WdiwtltDb, tag: Tag): seq[MediaFile];
proc findAllRelatedMediaFiles*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[MediaFile];
## Playlists
## --------------------
@ -96,12 +141,45 @@ proc add*(db: WdiwtltDb, p: Playlist);
proc remove*(db: WdiwtltDb, p: Playlist);
proc update*(db: WdiwtltDb, p: Playlist);
proc allPlaylists*(db: WdiwtltDb): seq[Playlist];
proc findPlaylistById*(db: WdiwtltDb, id: UUID): Option[Playlist];
proc findPlaylistsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Playlist];
proc findPlaylistsByName*(db: WdiwtltDb, name: string): seq[Playlist];
proc findAllRelatedPlaylists*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Playlist];
## Tags
## --------------------
proc add*(db: WdiwtltDb, t: Tag);
proc remove*(db: WdiwtltDb, t: Tag);
proc update*(db: WdiwtltDb, t: Tag);
proc allTags*(db: WdiwtltDb): seq[Tag];
proc findTagById*(db: WdiwtltDb, id: UUID): Option[Tag];
proc findTagByName*(db: WdiwtltDb, name: string): Option[Tag];
proc findTagsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Tag];
proc findAllRelatedTags*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Tag];
## General
## --------------------
proc getAll*(db: WdiwtltDb, mt: ModelType): seq[BaseModel];
proc findAllRelatedModels*(
db: WdiwtltDb,
sourceModels: seq[BaseModel],
targetModelType: ModelType
): seq[BaseModel];
## Housekeeping
## --------------------
proc pruneStalePlaylists*(db: WdiwtltDb, ts: DateTime);
@ -137,9 +215,9 @@ proc `%`(tup: tuple[artistId, albumId: UUID]): JsonNode =
result["artistId"] = %($tup.artistId)
result["albumId"] = %($tup.albumId)
proc `%`(tup: tuple[tagName: string, mediaFileId: UUID]): JsonNode =
proc `%`(tup: tuple[tagId: UUID, mediaFileId: UUID]): JsonNode =
result = newJObject()
result["tagName"] = %($tup.tagName)
result["tagId"] = %($tup.tagId)
result["mediaFileId"] = %($tup.mediaFileId)
proc `%`(root: DbRoot): JsonNode =
@ -180,11 +258,9 @@ proc parsePlaylistsTable(n: JsonNode): TableRef[UUID, Playlist] =
result = newTable[UUID, Playlist]()
for strId in n.keys: result[parseUUID(strId)] = parsePlaylist(n[strId])
proc parseTagsTable(n: JsonNode): TableRef[string, Option[string]] =
result = newTable[string, Option[string]]()
for tagName in n.keys:
if n[tagName].getStr("").len > 0: result[tagName] = some(n[tagName].getStr)
else: result[tagName] = none[string]()
proc parseTagsTable(n: JsonNode): TableRef[UUID, Tag] =
result = newTable[UUID, Tag]()
for strId in n.keys: result[parseUUID(strId)] = parseTag(n[strId])
proc parseMediaFilesAssociationTable(n: JsonNode): TableRef[UUID, seq[UUID]] =
result = newTable[UUID, seq[UUID]]()
@ -196,21 +272,22 @@ proc parseArtistsAndAlbumsList(n: JsonNode):
n.getElems --> map((it.parseUUID("artistId"), it.parseUUID("albumId")))
proc parseTagsAndMediaFilesList(n: JsonNode):
seq[tuple[tagName: string, mediaFileId: UUID]] =
n.getElems --> map((it.getOrFail("tagName").getStr, it.parseUUID("mediaFileId")))
seq[tuple[tagId: UUID, mediaFileId: UUID]] =
n.getElems --> map((it.parseUUID("tagId"), it.parseUUID("mediaFileId")))
proc parseMediaFileHashToIdTable(n: JsonNode): TableRef[string, UUID] =
result = newTable[string, UUID]()
for hash in n.keys: result[hash] = parseUUID(n[hash].getStr)
proc parseDbRoot(n: JsonNode): DbRoot =
let tagsTable = n.getOrFail("tags").parseTagsTable
result = DbRoot(
albums: n.getOrFail("albums").parseAlbumsTable,
artists: n.getOrFail("artists").parseArtistsTable,
bookmarks: n.getOrFail("bookmarks").parseBookmarksTable,
mediaFiles: n.getOrFail("mediaFiles").parseMediaFilesTable,
playlists: n.getOrFail("playlists").parsePlaylistsTable,
tags: n.getOrFail("tags").parseTagsTable,
tags: tagsTable,
albumsToMediaFiles:
n.getOrFail("albumsToMediaFiles").parseMediaFilesAssociationTable,
@ -226,6 +303,8 @@ proc parseDbRoot(n: JsonNode): DbRoot =
n.getOrFail("mediaFileHashToId").parseMediaFileHashToIdTable
)
result.tagSet = toHashSet(tagsTable.values.toSeq --> map(it.name))
## Internals
## --------------------
@ -236,12 +315,13 @@ proc initDbRoot(): DbRoot =
bookmarks: newTable[UUID, Bookmark](),
mediaFiles: newTable[UUID, MediaFile](),
playlists: newTable[UUID, Playlist](),
tags: newTable[string, Option[string]](),
tags: newTable[UUID, Tag](),
albumsToMediaFiles: newTable[UUID, seq[UUID]](),
artistsToMediaFiles: newTable[UUID, seq[UUID]](),
artistsAndAlbums: @[],
playlistsToMediaFiles: newTable[UUID, seq[UUID]](),
tagsAndMediaFiles: @[],
tagSet: initHashSet[string](),
mediaFileHashToId: newTable[string, UUID]())
proc initDb*(path: string): WdiwtltDb =
@ -273,16 +353,6 @@ proc remove*(db: WdiwtltDb, a: Album) =
proc update*(db: WdiwtltDb, a: Album) = db.add(a)
proc findAlbumsByArtist*(db: WdiwtltDb, a: Artist): seq[Album] =
db.root.artistsAndAlbums -->
filter(it.artistId == a.id and db.root.artists.contains(a.id)).
map(db.root.albums[it.albumId])
proc findAlbumsByName*(db: WdiwtltDb, name: string): seq[Album] =
result = @[]
for a in db.root.albums.values:
if a.name == name: result.add(a)
proc add*(db: WdiwtltDb, a: Album, mf: MediaFile) =
if not db.root.albumsToMediaFiles.contains(a.id):
db.root.albumsToMediaFiles[a.id] = @[mf.id]
@ -296,6 +366,52 @@ proc remove*(db: WdiwtltDb, a: Album, mf: MediaFile) =
db.root.albumsToMediaFiles[a.id] =
db.root.albumsToMediaFiles[a.id] --> filter(it != mf.id)
proc allAlbums*(db: WdiwtltDb): seq[Album] = db.root.albums.values.toSeq
proc findAlbumById*(db: WdiwtltDb, id: UUID): Option[Album] =
if not db.root.albums.contains(id): return none[Album]()
return some(db.root.albums[id])
proc findAlbumsByArtist*(db: WdiwtltDb, a: Artist): seq[Album] =
db.root.artistsAndAlbums -->
filter(it.artistId == a.id and db.root.artists.contains(a.id)).
map(db.root.albums[it.albumId])
proc findAlbumsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Album] =
db.root.albumsToMediaFiles.pairs.toSeq -->
filter(db.root.albums.contains(it[0]) and it[1].contains(mf.id)).
map(db.root.albums[it[0]])
proc findAlbumsByName*(db: WdiwtltDb, name: string): seq[Album] =
db.root.albums.values.toSeq --> filter(it.name == name)
proc findAllRelatedAlbums(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Album] =
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Album: return sourceModels --> map(it.Album)
elif firstModel of Artist:
return sourceModels -->
map(db.findAlbumsByArtist(it.Artist)).flatten()
elif firstModel of Bookmark or
firstModel of Playlist or
firstModel of MediaFile or
firstModel of Tag:
return deduplicate(
db.findAllRelatedMediaFiles(sourceModels) -->
map(db.findAlbumsByMediaFile(it)).flatten())
else:
warn "unknown source model type, returning empty selection"
debug "findAllRelatedAlbums: first source model was " & $firstModel
## Artists
## --------------------
proc add*(db: WdiwtltDb, a: Artist) = db.root.artists[a.id] = a
@ -308,15 +424,52 @@ proc remove*(db: WdiwtltDb, a: Artist) =
proc update*(db: WdiwtltDb, a: Artist) = db.add(a)
proc allArtists*(db: WdiwtltDb): seq[Artist] = db.root.artists.values.toSeq
proc findArtistById*(db: WdiwtltDb, id: UUID): Option[Artist] =
if not db.root.artists.contains(id): return none[Artist]()
return some(db.root.artists[id])
proc findArtistsByAlbum*(db: WdiwtltDb, a: Album): seq[Artist] =
db.root.artistsAndAlbums -->
filter(it.albumId == a.id and db.root.albums.contains(a.id)).
map(db.root.artists[it.artistId])
proc findArtistsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Artist] =
db.root.artistsToMediaFiles.pairs.toSeq -->
filter(db.root.artists.contains(it[0]) and it[1].contains(mf.id)).
map(db.root.artists[it[0]])
proc findArtistsByName*(db: WdiwtltDb, name: string): seq[Artist] =
result = @[]
for a in db.root.artists.values:
if a.name == name: result.add(a)
db.root.artists.values.toSeq --> filter(it.name == name)
proc findAllRelatedArtists(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Artist] =
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Album:
return deduplicate(
sourceModels -->
map(db.findArtistsByAlbum(it.Album)).flatten())
elif firstModel of Artist: return sourceModels --> map(it.Artist)
elif firstModel of Bookmark or
firstModel of Playlist or
firstModel of MediaFile or
firstModel of Tag:
return deduplicate(
db.findAllRelatedMediaFiles(sourceModels) -->
map(db.findArtistsByMediaFile(it)).flatten())
else:
warn "unknown source model type, returning empty selection"
debug "findAllRelatedArtists: first source model was " & $firstModel
proc add*(db: WdiwtltDb, a: Artist, mf: MediaFile) =
if not db.root.artistsToMediaFiles.contains(a.id):
@ -354,6 +507,66 @@ proc add*(db: WdiwtltDb, b: Bookmark) = db.root.bookmarks[b.id] = b
proc remove*(db: WdiwtltDb, b: Bookmark) = db.root.bookmarks.del(b.id)
proc update*(db: WdiwtltDb, b: Bookmark) = db.add(b)
proc allBookmarks*(db: WdiwtltDb): seq[Bookmark] =
db.root.bookmarks.values.toSeq
proc findBookmarkById*(db: WdiwtltDb, id: UUID): Option[Bookmark] =
if not db.root.bookmarks.contains(id): return none[Bookmark]()
return some(db.root.bookmarks[id])
proc findBookmarksByName*(db: WdiwtltDb, name: string): seq[Bookmark] =
db.root.bookmarks.values.toSeq --> filter(it.name == name)
proc findBookmarksByPlaylist*(db: WdiwtltDb, p: Playlist): seq[Bookmark] =
db.root.bookmarks.values.toSeq -->
filter(it.playlistId == p.id)
proc findAllRelatedBookmarks*(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Bookmark] =
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Album:
return deduplicate(
sourceModels -->
map(db.findMediaFilesByAlbum(it.Album)).flatten().
map(db.findPlaylistsByMediaFile(it)).flatten().
map(db.findBookmarksByPlaylist(it)).flatten())
elif firstModel of Artist:
return deduplicate(
sourceModels -->
map(db.findMediaFilesByArtist(it.Artist)).flatten().
map(db.findPlaylistsByMediaFile(it)).flatten().
map(db.findBookmarksByPlaylist(it)).flatten())
elif firstModel of Bookmark:
return sourceModels --> map(it.Bookmark)
elif firstModel of MediaFile:
return deduplicate(
sourceModels -->
map(db.findPlaylistsByMediaFile(it.MediaFile)).flatten().
map(db.findBookmarksByPlaylist(it)).flatten())
elif firstModel of Playlist:
return deduplicate(
sourceModels --> map(db.findBookmarksByPlaylist(it.Playlist)).flatten())
elif firstModel of Tag:
return deduplicate(
sourceModels -->
map(db.findMediaFilesByTag(it.Tag)).flatten().
map(db.findPlaylistsByMediaFile(it)).flatten().
map(db.findBookmarksByPlaylist(it)).flatten())
else:
warn "unknown source model type, returning empty selection"
debug "findAllRelatedBookmarks: first source model was " & $firstModel
## Media Files
## --------------------
proc add*(db: WdiwtltDb, mf: MediaFile) =
@ -371,19 +584,8 @@ proc remove*(db: WdiwtltDb, mf: MediaFile) =
proc update*(db: WdiwtltDb, mf: MediaFile) = db.add(mf)
proc findMediaFilesByAlbum*(db: WdiwtltDb, a: Album): seq[MediaFile] =
result = @[]
if db.root.albumsToMediaFiles.contains(a.id):
return db.root.albumsToMediaFiles[a.id] -->
filter(db.root.mediaFiles.contains(it)).
map(db.root.mediaFiles[it])
proc findMediaFilesByArtist*(db: WdiwtltDb, a: Artist): seq[MediaFile] =
result = @[]
if db.root.artistsToMediaFiles.contains(a.id):
return db.root.artistsToMediaFiles[a.id] -->
filter(db.root.mediaFiles.contains(it)).
map(db.root.mediaFiles[it])
proc allMediaFiles*(db: WdiwtltDb): seq[MediaFile] =
db.root.mediaFiles.values.toSeq
proc findMediaFileByHash*(db: WdiwtltDb, hash: string): Option[MediaFile] =
if db.root.mediaFileHashToId.contains(hash):
@ -393,11 +595,87 @@ proc findMediaFileByHash*(db: WdiwtltDb, hash: string): Option[MediaFile] =
return none[MediaFile]()
proc findMediaFileById*(db: WdiwtltDb, id: UUID): Option[MediaFile] =
if not db.root.mediaFiles.contains(id): return none[MediaFile]()
return some(db.root.mediaFiles[id])
proc findMediaFileByPath*(db: WdiwtltDb, path: string): Option[MediaFile] =
for mf in db.root.mediaFiles.values:
if mf.filePath == path: return some(mf)
return none[MediaFile]()
proc findMediaFilesByAlbum*(db: WdiwtltDb, a: Album): seq[MediaFile] =
if db.root.albumsToMediaFiles.contains(a.id):
return db.root.albumsToMediaFiles[a.id] -->
filter(db.root.mediaFiles.contains(it)).
map(db.root.mediaFiles[it])
proc findMediaFilesByArtist*(db: WdiwtltDb, a: Artist): seq[MediaFile] =
if db.root.artistsToMediaFiles.contains(a.id):
return db.root.artistsToMediaFiles[a.id] -->
filter(db.root.mediaFiles.contains(it)).
map(db.root.mediaFiles[it])
proc findMediaFilesByBookmark*(db: WdiwtltDb, b: Bookmark): seq[MediaFile] =
if db.root.playlists.contains(b.playlistId):
return db.findMediaFilesByPlaylist(db.root.playlists[b.playlistId])
proc findMediaFilesByName*(db: WdiwtltDb, name: string): seq[MediaFile] =
db.root.mediaFiles.values.toSeq --> filter(it.name == name)
proc findMediaFilesByPlaylist*(db: WdiwtltDb, p: Playlist): seq[MediaFile] =
if db.root.playlistsToMediaFiles.contains(p.id):
return db.root.playlistsToMediaFiles[p.id] -->
filter(db.root.mediaFiles.contains(it)).
map(db.root.mediaFiles[it])
proc findMediaFilesByTag*(db: WdiwtltDb, tag: Tag): seq[MediaFile] =
return db.root.tagsAndMediaFiles -->
filter(db.root.mediaFiles.contains(it.mediaFileId) and it.tagId == tag.id).
map(db.root.mediaFiles[it.mediaFileId])
proc findAllRelatedMediaFiles(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[MediaFile] =
# Note, contrary to other model types, we do not deduplicate the media file
# lookup as the expected behavior when listing by multiple
# albums/artists/etc. is to get all the media files for the model in order.
# In general there should be duplication unless the exact same media file is
# present in multiple artist/album/etc. collections. This would need to be
# done intentionally, as the current scan implementation has no mechanism for
# creating this kind of duplicate link (being path-based).
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Album:
return sourceModels -->
map(db.findMediaFilesByAlbum(it.Album)).flatten()
elif firstModel of Artist:
return sourceModels -->
map(db.findMediaFilesByArtist(it.Artist)).flatten()
elif firstModel of Bookmark:
return sourceModels -->
map(db.findMediaFilesByBookmark(it.Bookmark)).flatten()
elif firstModel of MediaFile:
return sourceModels --> map(it.MediaFile)
elif firstModel of Playlist:
return sourceModels -->
map(db.findMediaFilesByPlaylist(it.Playlist)).flatten()
elif firstModel of Tag:
return sourceModels -->
map(db.findMediaFilesByTag(it.Tag)).flatten()
else:
warn "unknown source model type, returning empty selection"
debug "findAllRelatedMediaFiles: first source model was " & $firstModel
## Playlists
## --------------------
proc add*(db: WdiwtltDb, p: Playlist) = db.root.playlists[p.id] = p
@ -410,16 +688,150 @@ proc remove*(db: WdiwtltDb, p: Playlist) =
proc update*(db: WdiwtltDb, p: Playlist) = db.add(p)
proc allPlaylists*(db: WdiwtltDb): seq[Playlist] =
db.root.playlists.values.toSeq
proc findPlaylistById*(db: WdiwtltDb, id: UUID): Option[Playlist] =
if not db.root.playlists.contains(id): return none[Playlist]()
return some(db.root.playlists[id])
proc findPlaylistsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Playlist] =
db.root.playlistsToMediaFiles.pairs.toSeq -->
filter(db.root.playlists.contains(it[0]) and it[1].contains(mf.id)).
map(db.root.playlists[it[0]])
proc findPlaylistsByName*(db: WdiwtltDb, name: string): seq[Playlist] =
db.root.playlists.values.toSeq --> filter(it.name == name)
proc findAllRelatedPlaylists(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Playlist] =
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Bookmark:
return sourceModels -->
map(it.Bookmark).
filter(db.root.playlists.contains(it.playlistId)).
map(db.root.playlists[it.playlistId])
elif firstModel of Playlist:
return sourceModels --> map(it.Playlist)
elif firstModel of Album or
firstModel of Artist or
firstModel of MediaFile or
firstModel of Tag:
return deduplicate(
db.findAllRelatedMediaFiles(sourceModels) -->
map(db.findPlaylistsByMediaFile(it)).flatten())
else:
warn "unknown source model type, returning empty selection"
debug "findAllRelatedPlaylists: first source model was " & $firstModel
## Tags
## --------------------
proc add*(db: WdiwtltDb, t: Tag) = db.root.tags[t.name] = t.description
proc add*(db: WdiwtltDb, t: Tag) =
db.root.tags[t.id] = t
db.root.tagSet.incl(t.name)
proc remove*(db: WdiwtltDb, t: Tag) =
db.root.tags.del(t.name)
db.root.tags.del(t.id)
db.root.tagSet.excl(t.name)
db.root.tagsAndMediaFiles = db.root.tagsAndMediaFiles -->
filter(it.tagName != t.name)
filter(it.tagId != t.id)
proc update*(db: WdiwtltDb, t: Tag) = db.add(t)
proc update*(db: WdiwtltDb, t: Tag) =
if db.root.tags.contains(t.id):
db.root.tagSet.excl(db.root.tags[t.id].name)
db.add(t)
proc allTags*(db: WdiwtltDb): seq[Tag] =
db.root.tags.values.toSeq
proc findTagById*(db: WdiwtltDb, id: UUID): Option[Tag] =
if not db.root.tags.contains(id): return none[Tag]()
return some(db.root.tags[id])
proc findTagByName*(db: WdiwtltDb, name: string): Option[Tag] =
if not db.root.tagSet.contains(name): return none[Tag]()
let foundTags = db.root.tags.values.toSeq --> filter(it.name == name)
if foundTags.len == 0: return none[Tag]()
else: return some(foundTags[0])
proc findTagsByMediaFile*(db: WdiwtltDb, mf: MediaFile): seq[Tag] =
db.root.tagsAndMediaFiles -->
filter(it.mediaFileId == mf.id).
map(db.root.tags[it.tagId])
proc findAllRelatedTags(
db: WdiwtltDb,
sourceModels: seq[BaseModel]
): seq[Tag] =
if sourceModels.len == 0: return @[]
let firstModel = sourceModels[0]
if firstModel of Tag:
return sourceModels --> map(it.Tag)
elif firstModel of Album or
firstModel of Artist or
firstModel of Bookmark or
firstModel of MediaFile or
firstModel of Playlist:
return deduplicate(
db.findAllRelatedMediaFiles(sourceModels) -->
map(db.findTagsByMediaFile(it)).flatten())
else:
warn "unknown source model type, returning empty selection"
## General
## --------------------
proc getAll*(db: WdiwtltDb, mt: ModelType): seq[BaseModel] =
case mt:
of mtAlbum: db.root.albums.values.toSeq --> map(it.BaseModel)
of mtArtist: db.root.artists.values.toSeq --> map(it.BaseModel)
of mtBookmark: db.root.bookmarks.values.toSeq --> map(it.BaseModel)
of mtMediaFile: db.root.bookmarks.values.toSeq --> map(it.BaseModel)
of mtPlaylist: db.root.playlists.values.toSeq --> map(it.BaseModel)
of mtTag: db.root.tags.values.toSeq --> map(it.BaseModel)
proc findAllRelatedModels*(
db: WdiwtltDb,
sourceModels: seq[BaseModel],
targetModelType: ModelType
): seq[BaseModel] =
if sourceModels.len <= 0: return @[]
case targetModelType:
of mtAlbum:
return db.findAllRelatedAlbums(sourceModels) --> map(it.BaseModel)
of mtArtist:
return db.findAllRelatedArtists(sourceModels) --> map(it.BaseModel)
of mtBookmark:
return db.findAllRelatedBookmarks(sourceModels) --> map(it.BaseModel)
of mtMediaFile:
return db.findAllRelatedMediaFiles(sourceModels) --> map(it.BaseModel)
of mtPlaylist:
return db.findAllRelatedPlaylists(sourceModels) --> map(it.BaseModel)
of mtTag:
return db.findAllRelatedTags(sourceModels) --> map(it.BaseModel)
## Housekeeping
## --------------------

View File

@ -0,0 +1,40 @@
import md5, streams
import os
proc fileToMD5*(filename: string) : 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 = filename.open.newFileStream
md5Init(c)
buffer = fs.readStr(blockSize)
while buffer.len > 0:
md5Update(c, buffer, buffer.len)
buffer = fs.readStr(blockSize)
md5Final(c, d)
except IOError: echo("File not found.")
finally:
if fs != nil:
close(fs)
result = $d
when isMainModule:
if paramCount() > 0:
let arguments = commandLineParams()
echo("MD5: ", fileToMD5(arguments[0]))
else:
echo("Must pass filename.")
quit(-1)

View File

@ -1,13 +1,13 @@
import std/[logging, nre, options, os, times, unicode]
import std/[logging, nre, options, os, sets, times, unicode]
import std/strutils except strip
import console_progress, uuids
import console_progress, uuids, zero_functional
import ./db, ./incremental_md5, ./libvlc, ./models
type
WdiwtltLibrary* = ref object
rootPath: string
#autoDeletePeriodMs*: int # 1000 * 60 * 60 * 24 * 6 # one week
db: WdiwtltDb
db*: WdiwtltDb
vlc: LibVlcInstance
let FILENAME_PAT = re"^(\d+)?[:\-_ ]*(.+)$"
@ -127,8 +127,9 @@ proc scan*(l: WdiwtltLibrary, fullRescan = false) =
var fileCount = 0
for f in l.walkMediaFiles: fileCount += 1
let progress = newProgress(stdout, fileCount)
let progress = newProgress(stdout, fileCount + 1)
var seenMediaFileIds = initHashSet[UUID]()
var curCount = 0
debug "Scanning media library root at " & l.rootPath
for f in l.walkMediaFiles:
@ -141,7 +142,14 @@ proc scan*(l: WdiwtltLibrary, fullRescan = false) =
var existingMf = l.db.findMediaFileByPath(f)
#let hash = fileToMD5(fullfn)
#l.db.findMediaFileByHash(hash)
if not fullRescan and existingMf.isSome: continue
if existingMf.isSome:
seenMediaFileIds.incl(existingMf.get.id)
# TODO: the implementation that follows will create duplicate
# entries when rescanning the library. Until that is fixes, we will not
# support recan
if not fullRescan:
raise newException(CliErr, "rescan is not currently supported")
#continue
# Process this new file
let (mf, artistsFromMeta, albumsFromMeta, trackTotal) =
@ -192,6 +200,13 @@ proc scan*(l: WdiwtltLibrary, fullRescan = false) =
for artist in allArtists:
l.db.associate(allArtists[0], allAlbums[0])
progress.updateProgress(curCount, "")
progress.updateProgress(curCount, "Looking for absent media files.")
l.db.allMediaFiles -->
filter(not seenMediaFileIds.contains(it.id)).
foreach(it.presentLocally = false)
progress.updateProgress(curCount, "Looking for absent media files.")
stdout.writeLine("Scan complete")
l.db.persist

View File

@ -1,10 +1,14 @@
import std/[json, jsonutils, options, strutils, times]
import timeutils, uuids
import ./jsonutils as addtnl_jsonutils
type
MetaSource* = enum msTagInfo = "tag info", msFileLocation = "file location"
BaseModel* = object of RootObj
ModelType* = enum
mtAlbum, mtArtist, mtBookmark, mtMediaFile, mtPlaylist, mtTag
BaseModel* {.inheritable.} = object
id*: UUID
name*: string
@ -45,10 +49,33 @@ type
createdAt*: DateTime
lastUsed*: DateTime
Tag* = object
name*: string
Tag* = object of BaseModel
description*: Option[string]
CliErr* = object of CatchableError
SomeModel* = BaseModel | Album | Artist | Bookmark |
MediaFile | Playlist | Tag
proc mapModelType*(str: string): ModelType =
case str.toLower:
of "album": return mtAlbum
of "artist": return mtArtist
of "bookmark": return mtBookmark
of "file": return mtMediaFile
of "playlist": return mtPlaylist
of "tag": return mtTag
else: raise newException(CliErr, "unrecognized item type: " & str)
proc modelMatchesType*(model: BaseModel, mt: ModelType): bool =
case mt:
of mtAlbum: return model of Album
of mtArtist: return model of Artist
of mtBookmark: return model of Bookmark
of mtMediaFile: return model of MediaFile
of mtPlaylist: return model of Playlist
of mtTag: return model of Tag
proc `$`*(mf: MediaFile): string =
if mf.trackNumber.isSome: align($mf.trackNumber.get, 2) & " - " & mf.name
else: mf.name
@ -59,10 +86,6 @@ proc `$`*(a: Album): string =
proc `$`*(m: BaseModel): string = m.name
proc `$`*(t: Tag): string =
result = t.name
if t.description.isSome: result &= t.description.get
proc toJsonHook(uuid: UUID): JsonNode = %($uuid)
proc fromJsonHook(u: var UUID, node: JsonNode) =
u = parseUUID(node.getStr)

View File

@ -1,3 +1,5 @@
import std/strformat
const WDIWTLT_VERSION* = "1.0.0"
const USAGE* = "wdiwtlt v" & WDIWTLT_VERSION & """
@ -21,9 +23,443 @@ Options:
The path to the JSON database file.
--debug
--log-file <log-file>
Enable debug logging.
Write debug logs to <log-file>
--verbose
Enable verbose debug logging to stdout.
"""
const ONLINE_HELP* = ""
const FMT_MARK* = ""
const FC* = FMT_MARK & "COMMAND" & FMT_MARK
const FE* = FMT_MARK & "ERROR" & FMT_MARK
const FN* = FMT_MARK & "NORMAL" & FMT_MARK
const HELP_GENERAL* = fmt("""Available Commands:
scan Scan the media library for new files.
rescan Re-scan the media library, new and existing files.
quit Quit this program.
Aliases: {FC}exit{FN}
A new user is advised to read the help section for the {FC}select{FN} command.
For a quick list of options, consider reading the {FC}summary{FN} section.
""")
const HELP_QUIT* = fmt("""
{FC}quit{FN} Close the wdiwtlt program.
{FC}exit{FN}
""")
const HELP_SCAN* = fmt("""
{FC}scan{FN}
Scan the media library for new files. This will skip over files that the
library is already aware of.
""")
const HELP_RESCAN* = fmt("""
{FC}rescan{FN}
Scan the media library, processing all files in the library directory,
including files we have already scanned previously.
""")
const HELP_LIST* = fmt("""
{FC}list bookmarks{FN} List all bookmarks.
{FC}list selection{FN} List the currently selected items.
{FC}list <select-criteria>{FN} Make a selection using the {FC}select{FN} syntax and then
list the selection.
""")
const HELP_SELECT* = fmt("""
{FC}select playing {{ album | artist | file | playlist | tag }}{FN}
Select the currently playing items into the selection buffer. Specifically,
this selects items that are associated with the currently playing media
file.
{FC}select queued {{ albums | artists | files | playlists | tags }}{FN}
Select the items currently in the queue.
{FC}select <count> random {{ albums | artists | files | playlists | tags }}{FN}
Select one or more items randomly.
{FC}select <count> random {{ albums | artists | files | playlists | tags }} from <select-criteria>{FN}
Make a selection, then select one or more items randomly from it.
{FC}select selected {{ album | artist | file | playlist | tag }}{FN}
Select the items associated with the current selection buffer into the
selection buffer. This is useful to change the type of the selection. For
example, the following commands would select all of the albums in the
library by Bob Dylan:
select artist Bob Dylan
select selected albums
Another example, to select media files based on a set of tags:
select tags instrumental orchestral
select selected files
This example would select all files that are tagged as *either*
instrumental or orchestral
{FC}select absent files{FN}
Select al media files which have entries in the database but are not
actually present locally on disk.
{FC}select files tagged as <tag-name>...{FN}
Select all media files tagged with the given tags. If multiple tags are
given then only files which have all the given tags are selected. In
contrast to the previous example:
select files tagged as instrumental orchestral
This selects all files that are tagged as *both* instrumental and
orchestral.
{FC}select untagged files{FN}
Select all media files that do not have any tags associated with them.
{FC}select {{ album | artist | file | playlist | tag }} where <criteria>...{FN}
{FE}Not yet implemented.{FN}
{FC}select {{ album | artist | file | playlist | tag }} <id | name>{FN}
Select a single item by ID or by name. When selecting by name, the name can
include spaces and can be a substring of the whole name ("Lonely Hearts"
for "Sgt. Pepper's Lonely Hearts Club Band" for example, quotations not
required).
{FC}select {{ albums | artists | files | playlists | tags }} <id>...{FN}
Select multiple items by ID. Multiple IDs can be given, separated by
spaces. If no IDs are given, all of the items are returned.
{FC}queue{FN}
Select the current play queue
""")
const HELP_CREATE* = fmt("""
{FC}create bookmark named <name>{FN}
Create a new bookmark at the current play position in the currently playing
playlist.
{FC}create bookmark named <name> on playlist <playlist-name | id> at <file-name | id>{FN}
Create a bookmark on the named media file in the named playlist.
{FC}create playlist named <name>{FN}
Create a new playlist.
{FC}create playlist named <name> from {{ queue | selection}}{FN}
Create a new playlist and populate it with the contents of either the
current play queue or the current selection.
""")
const HELP_DELETE* = fmt("""
{FC}delete playlist <name | id>
delete bookmark <name | id>{FN}
Delete a playlist or bookmark.
""")
const HELP_PLAY* = fmt("""
{FC}play{FN}
With no options, play the current file (inverse of pause).
{FC}play selection{FN}
Clear the play queue, enqueue the current selection, and begin playback.
{FC}play bookmark <id | name>{FN}
Load the bookmarked playlist as the play queue and begin playback at the
bookmarked media file.
{FC}play <select-criteria>{FN}
Make a selection using the {FC}select{FN} syntax and then play the selection.
""")
const HELP_ENQUEUE* = fmt("""
{FC}enqueue selection{FN}
Add the media files for the selected items to the end of the current play
queue.
{FC}enqueue <select-criteria>{FN}
Make a selection using the {FC}select{FN} syntax and then enqueue
the selection.
""")
const HELP_ADD* = fmt("""
{FC}add selection to playlist <id | name>{FN}
Lookup a playlist by id or name (including partial match) then add the
media files for the selected items to the end of that playlist.
{FC}add <select-criteria> to playlist <id | name>{FN}
Lookup a playlist by id or name (including partial match), select a set of
media files using the {FC}select{FN} syntax, then add the media files
for the selected items to the end of that playlist.
""")
const HELP_REMOVE* = fmt("""
{FC}remove selection from {{ queue | selection }}{FN}
{FC}remove selection from playlist <id | name>{FN}
Remove the media files for the current selection from the current
selection, the current play queue, or from a playlist looked up by ID or
name (including partial match).
{FC}remove <select-criteria> from {{ queue | selection }}{FN}
{FC}remove <select-criteria> from playlist <id | name>{FN}
Make a selection using the {FC}select{FN} syntax then remove those media
files from either the current selection, the play queue, or from a
playlist looked up by ID or name (including partial match).
""")
const HELP_RANDOMIZE* = fmt("""
{FC}randomize queue{FN}
{FC}randomize playlist <id | name>{FN}
Randomize the order of all elements in either the current play queue or the
named playlist.
""")
const HELP_REORDER* = fmt("""
{FC}reorder queue move <file id | name> to <position>{FN}
{FC}reorder playlist <id | name> move <file id | name> to <position>{FN}
Move a single file from its current position to the named position in the
play queue/playlist.
{FC}reorder queue as <file id>... starting at <position>{FN}
{FC}reorder playlist <id | name> as <file id>... starting at <position>{FN}
Take a set of files in the playlist, reorder them into given order, and
move them to the given starting position (defaults to the end of the
playlist)
""")
const HELP_TAG* = fmt("""
{FC}tag <tag>...{FN}
Tag the currently playing file with the given tags. Multiple tags may be
provided, separated by spaces (tags cannot include spaces).
{FC}tag selection as <tag>...{FN}
Tag all of the media files in the current selection with the given tags.
Multiple tags may be provided separated by spaces (tags cannot include
spaces).
{FC}tag <select-criteria> as <tag>...{FN}
Make a selection using the {FC}select{FN} syntax then tag all of the
media files in the selection with the given tags. Multiple tags may be
provided separated by spaces (tags cannot include spaces).
""")
const HELP_UNTAG* = fmt("""
{FC}untag <tag>...{FN}
Remove the given tags from the currently playing file. Multiple tags may be
provided, separated by spaces (tags cannot include spaces).
{FC}untag selection as <tag>...{FN}
Remove the given tags from all of the media files in the current selection.
Multiple tags may be provided separated by spaces (tags cannot include
spaces).
{FC}untag <select-criteria> as <tag>...{FN}
Make a selection using the {FC}select{FN} syntax then remove the
given tags from all of the media files in the selection. Multiple tags may
be provided separated by spaces (tags cannot include spaces).
""")
const HELP_CLEAR* = fmt("""
{FC}clear{FN} Clear the terminal display.
{FC}clear queue{FN} Clear the play queue.
{FC}clear selection{FN} Clear the selection buffer.
{FC}clear playlist <id | name>{FN} Clear the given playlist
""")
const HELP_PAUSE* = "{FC}pause{FN} Pause playback."
const HALPE_STOP* = "{FC}stop{FN} Stop playback."
const HELP_NEXT* = fmt(
"""
{FC}next <count>{FN} Move forward in the play queue by <count> items. <count> is
optional and defaults to 1
""")
const HELP_PREV* = fmt(
"""
prev <count> Move backward in the play queue by <count> items. <count> is
optional and defaults to 1
""")
const HELP_JUMP* = fmt("""
{FC}jump to <media file ID or name>{FN}
Find the given media file by ID or name in the current play queue and
resume playback starting from that file.
""")
const HELP_FF* = fmt("""
{FC}ff <amount> <unit>{FN}
Jump forward in the playback of the current media by <amount> specified in
<unit>s. <amount> must be an integer. <unit> may be one of: 'millisecons',
'seconds', or 'minutes'. The following abbreviations are allowed: 'ms',
'millis', 's', 'sec', 'm', 'min'.
""")
const HELP_RW* = fmt("""
{FC}rw <amount> <unit>{FN}
Jump backward in the playback of the current media by <amount> specified in
<unit>s. <amount> must be an integer. <unit> may be one of: 'millisecons',
'seconds', or 'minutes'. The following abbreviations are allowed: 'ms',
'millis', 's', 'sec', 'm', 'min'.
""")
const HELP_REPEAT* = fmt("""
{FC}repeat {{ all | one | none }}{FN}
Set the playlist repeat mode to:
all: repeat the entire play queue
one: loop the currently playing song
none: do not repeat.
""")
const HELP_VOL* = fmt(
"""
volume Display the current volume setting.
volume <percent> Set the volume. <percent> may be any value from 0 to 200.
""")
const HELP_HELP* = fmt(
"""
help summary Display a quick summary of available commands.
help <commmand> Display detailed information about the given command.""")
const HELP_SUMMARY* = fmt(
"""
Selecting files:
select {{ album | artist | file | playlist | tag }} <id | name>
select {{ albums | artists | files | playlists | tags }} where <criteria>
select files tagged as <tag>... and not as <tag>...
select playing {{ albums | artists | files | playlists | tags }}
select queued {{ albums | artists | files | playlists | tags }}
select absent files
list selection
list <selection-criteria>
list selected {{ albums | artists | files | playlists | tags }}
Play and Controlling Media:
play
play selection
play bookmark <id | name>
play <selection-criteria>
next <count> (alias: n)
prev <count> (alias: p)
repeat {{ all | one | off }}
stop
pause
fasforward <duration> <time-unit> (aliases: ff, fwd)
rewind <duration> <time-unit> (aliases: rw, rwd)
jump <id | name>
Playlist and Queue Management:
enqueue selection
enqueue <selection-criteria>
add selection to playlist to playlist <id | name>
add <selection-criteria> to playlist <id | name>
remove selection from playlist <id | name>
remove <selection-criteria> from playlist <id | name>
remove selection from {{ queue | selection }}
remove <selection-criteria> from {{ queue | selection }}
create bookmark named <name>
create bookmark named <name> on playlist <playlist name | id> at <file name | id>
create playlist named <name> from {{ selection | queue }}
create playlist named <name from <selection-criteria>
copy playlist <id | name> as <new name>
create bookmark <id | name>
update bookmark <id | name>
delete playlist <id | name>
delete bookmark <id | name>
randomize {{ queue | playlist | selection }}
reorder queue move <file id | name> to <position>
reorder playlist <id | name> move <file id | name> to <position>
reorder queue as <file id>...
reorder playlist <id | name> as <file id>...
clear
clear queue
clear selection
clear playlist <id | name>
Library Management:
scan
""")

View File

@ -0,0 +1,99 @@
import std/[logging, strutils, tables, terminal]
import ./cliconstants
type
TextStyle* = enum
tsAlbum, tsArtist, tsCommand, tsError, tsFile, tsNormal, tsOption,
tsPlaylist, tsPrompt, tsStatus, tsTitle,
StyledText* = object
style: TextStyle
text: string
let styleToColorMap = newTable[TextStyle, ForegroundColor]([
(tsAlbum, fgBlue),
(tsArtist, fgRed),
(tsCommand, fgYellow),
(tsError, fgRed),
(tsFile, fgGreen),
(tsNormal, fgWhite),
(tsOption, fgGreen),
(tsPlaylist, fgGreen),
(tsPrompt, fgYellow),
(tsStatus, fgCyan),
(tsTitle, fgWhite)
])
let styleToFormatMap = newTable[TextStyle, set[Style]]([
(tsAlbum, {}),
(tsArtist, {}),
(tsCommand, {styleBright}),
(tsError, {styleBright}),
(tsFile, {}),
(tsNormal, {}),
(tsOption, {styleBright}),
(tsPlaylist, {}),
(tsPrompt, {styleBright}),
(tsStatus, {}),
(tsTitle, {styleBright})
])
func parseStyledText*(text: string): seq[StyledText] =
result = newseq[StyledText]()
var curIdx = 0
var curStyle = tsNormal
while curIdx < text.len:
let nextMarkerIdx = text.find(FMT_MARK, curIdx)
if nextMarkerIdx == -1:
result.add(StyledText(
style: curStyle,
text: text[curIdx..^1]))
curIdx = text.len
else:
let endMarkerIdx = text.find(FMT_MARK, nextMarkerIdx + FMT_MARK.len)
if endMarkerIdx == -1:
raise newException(Exception,
"invalid text formatting, missing ending marker")
result.add(StyledText(
style: curStyle,
text: text[curIdx..<nextMarkerIdx]))
let styleName = text[(nextMarkerIdx+FMT_MARK.len)..<endMarkerIdx]
case styleName:
of "NORMAL": curStyle = tsNormal
of "COMMAND": curStyle = tsCommand
of "ALBUM": curStyle = tsAlbum
of "ARTIST": curStyle = tsArtist
of "ERROR": curStyle = tsError
of "FILE": curStyle = tsFile
of "OPTION": curStyle = tsOption
of "PLAYLIST": curStyle = tsPlaylist
of "PROMPT": curStyle = tsPrompt
of "STATUS": curStyle = tsStatus
of "TITLE": curStyle = tsTitle
else: curStyle = tsNormal
curIdx = endMarkerIdx + FMT_MARK.len
proc writeStyledText*(file: File, text: string) =
let parsed = parseStyledText(text)
for p in parsed:
if styleToColorMap.contains(p.style) and
styleToFormatMap.contains(p.style):
file.styledWrite(
styleToColorMap[p.style], styleToFormatMap[p.style], p.text)
else:
warn "Missing text styling for the " & $p.style & " style."
file.styledWrite(p.text)
proc writeStyledTextLine*(file: File, text: string) =
file.writeStyledText(text)
file.writeLine ""

View File

@ -0,0 +1,7 @@
import nre
template whenMatched*(str: string, pattern: Regex, body: untyped): untyped =
let m = str.match(pattern)
if m.isSome:
let match {.inject.} = m.get
body