WIP: Initial library implementation, JSON-based persistence layer, models, etc.

This commit is contained in:
Jonathan Bernard 2022-10-16 22:45:06 -05:00
parent 5493fd4143
commit 87202437a8
7 changed files with 412 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
*.sw? *.sw?
.gradle/ .gradle/
build/ build/
/wdiwtlt
/wdiwtlt.exe

View File

@ -0,0 +1,27 @@
import json
const WDIWTLT_VERSION* = "1.0.0"
const USAGE* = "wdiwtlt v" & WDIWTLT_VERSION & """
Usage:
wdiwtlt [options]
wdiwtlt --version
wdiwtlt --help
wdiwtlt init-library [options]
Options:
-c --config <config-file>
Config file for wdiwtlt CLI.
-L --library-root <root-dir>
The path to a local media library directory.
-D --database-path <database-path>
The path to the JSON database file.
"""
const ONLINE_HELP* = ""

72
src/main/nim/wdiwtlt.nim Normal file
View File

@ -0,0 +1,72 @@
import std/json, std/logging
import docopt
import private/cliconstants
import private/libvlc
import wdiwtlt/config
import wdiwtlt/db
import wdiwtlt/library
type WdiwtltContext = ref object
cfg: WdiwtltConfig
player: VlcMediaPlayer
vlc: LibVlcInstance
library: WdiwtltLibrary
proc initContext(cfg: WdiwtltConfig): WdiwtltContext =
let vlc = newVlc()
return WdiwtltContext(
cfg: cfg,
player: vlc.newMediaPlayer,
vlc: vlc,
library: initLibrary(cfg.libraryPath, cfg.dbPath))
proc release(ctx: WdiwtltContext) =
if not ctx.player.isNil: ctx.player.release
if not ctx.vlc.isNil: ctx.vlc.release
when isMainModule:
var ctx: WdiwtltContext
try:
let consoleLogger = newConsoleLogger(
#levelThreshold=lvlInfo,
levelThreshold=lvlDebug,
fmtStr="pit - $levelname: ")
logging.addHandler(consoleLogger)
let args = docopt(USAGE, version = WDIWTLT_VERSION)
let cfg = loadConfig(args)
if args["init-library"]:
info "Initializing a new library database at '" & cfg.dbPath & "'"
let newDb = initDb(cfg.dbPath)
newDb.persist
info "Writing configuration to '" & cfg.cfgPath & "'"
cfg.cfgPath.writeFile(pretty(%*{
"dbPath": cfg.dbPath,
"libraryPath": cfg.libraryPath
}))
quit(0)
ctx = cfg.initContext
let media = ctx.vlc.mediaFromPath("file:///C:/Users/Jonathan Bernard/programs/vlc-3.0.18/2022-08-06.wav")
ctx.player.setMedia(media)
debug "Initialized LibVLC instance. Playing..."
discard ctx.player.play
discard stdin.readline
ctx.player.stop
media.release
except:
fatal getCurrentExceptionMsg()
quit(QuitFailure)
finally:
if not ctx.isNil: ctx.release
debug "Released LibVLC instance."

View File

@ -0,0 +1,52 @@
import std/json, std/logging, std/os, std/strutils, std/tables
import cliutils, docopt, zero_functional
type WdiwtltConfig* = object
dbPath*: string
libraryPath*: string
cfgPath*: string
cfg*: CombinedConfig
const DEFAULT_CFG_CONTENTS = """{
"dbPath": "./wdiwtlt.db.json",
"libraryPath": "./wdiwtlt-library"
}"""
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): WdiwtltConfig =
var cfgLocations = newSeq[string]()
if args["--config"]: cfgLocations.add($args["--config"])
cfgLocations.add($getEnv("WDIWTLTRC", ".wdiwtltrc"))
if existsEnv("HOME"):
cfgLocations.add($getEnv("HOME") / ".wdiwtltrc")
elif existsEnv("USERPROFILE"):
cfgLocations.add($getEnv("USERPROFILE") / ".wdiwtltrc")
var cfgFilename : string = cfgLocations -->
fold("", if len(it) > 0: it else: a)
if not fileExists(cfgFilename):
warn "could not find .wdiwtltrc file: " & cfgFilename
if isEmptyOrWhitespace(cfgFilename):
cfgFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File
try:
cfgFile = open(cfgFilename, fmWrite)
cfgFile.write(DEFAULT_CFG_CONTENTS)
except: warn "could not write default .wdiwtlt to " & cfgFilename
finally: close(cfgFile)
var cfgJson: JsonNode
try: cfgJson = parseFile(cfgFilename)
except: raise newException(IOError,
"unable to read config file: " & cfgFilename &
"\p" & getCurrentExceptionMsg())
let cfg = CombinedConfig(docopt: args, json: cfgJson)
result = WdiwtltConfig(
dbPath: cfg.getVal("db-path"),
libraryPath: cfg.getVal("library-location"),
cfgPath: cfgFilename,
cfg: cfg)

174
src/main/nim/wdiwtlt/db.nim Normal file
View File

@ -0,0 +1,174 @@
import std/json, std/jsonutils, std/os, std/strtabs, std/tables, std/times
import timeutils, uuids, zero_functional
import ./models
type
DbRoot = ref object
albums: TableRef[UUID, Album]
artists: TableRef[UUID, Artist]
bookmarks: TableRef[UUID, Bookmark]
mediaFiles: TableRef[UUID, MediaFile]
playlists: TableRef[UUID, Playlist]
tags: StringTableRef
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]]
WdiwtltDb* = ref object
jsonFilePath: string
root: DbRoot
## API Contract
proc initDb*(path: string): WdiwtltDb;
proc loadDb*(path: string): WdiwtltDb;
proc persist*(db: WdiwtltDb): void;
proc delete*(db: WdiwtltDb, a: Album);
proc delete*(db: WdiwtltDb, a: Artist);
proc delete*(db: WdiwtltDb, b: Bookmark);
proc delete*(db: WdiwtltDb, mf: MediaFile);
proc delete*(db: WdiwtltDb, p: Playlist);
proc delete*(db: WdiwtltDb, t: Tag);
proc remove*(db: WdiwtltDb, a: Album, mf: MediaFile);
proc remove*(db: WdiwtltDb, a: Artist, mf: MediaFile);
proc remove*(db: WdiwtltDb, artist: Artist, album: Album);
proc pruneStalePlaylists*(db: WdiwtltDb, ts: DateTime);
proc pruneStaleBookmarks*(db: WdiwtltDb, ts: DateTime);
proc removeEmptyAlbums*(db: WdiwtltDb): void;
proc removeEmptyArtists*(db: WdiwtltDb): void;
proc removeEmptyPlaylists*(db: WdiwtltDb): void;
## Internals
proc toJsonHook(uuid: UUID): JsonNode = %($uuid)
proc fromJsonHook(u: var UUID,node: JsonNode) =
u = parseUUID(node.getStr)
proc toJsonHook(dt: DateTime): JsonNode = %(dt.formatIso8601)
proc fromJsonHook(dt: var DateTime, node: JsonNode) =
dt = parseIso8601(node.getStr)
## API Implementation
proc initDb*(path: string): WdiwtltDb =
WdiwtltDb(
jsonFilePath: path,
root: DbRoot(
albums: newTable[UUID, Album](),
artists: newTable[UUID, Artist](),
bookmarks: newTable[UUID, Bookmark](),
mediaFiles: newTable[UUID, MediaFile](),
playlists: newTable[UUID, Playlist](),
tags: newStringTable(),
albumsToMediaFiles: newTable[UUID, seq[UUID]](),
artistsToMediaFiles: newTable[UUID, seq[UUID]](),
artistsAndAlbums: @[],
playlistsToMediaFiles: newTable[UUID, seq[UUID]](),
tagsAndMediaFiles: @[]
))
proc loadDb*(path: string): WdiwtltDb =
if not fileExists(path):
raise newException(Exception, "Unable to open database file '" & path & "'")
let jsonRoot = parseJson(path)
var root: DbRoot
root.fromJson(jsonRoot)
result = WdiwtltDb(
jsonFilePath: path,
root: root)
proc persist*(db: WdiwtltDb): void =
let jsonRoot = db.root.toJson
db.jsonFilePath.writeFile($jsonRoot)
proc delete*(db: WdiwtltDb, a: Album) =
db.root.albums.del(a.id)
db.root.albumsToMediaFiles.del(a.id)
db.root.artistsAndAlbums =
db.root.artistsAndAlbums --> filter(it.albumId != a.id)
proc delete*(db: WdiwtltDb, a: Artist) =
db.root.artists.del(a.id)
db.root.artistsToMediaFiles.del(a.id)
db.root.artistsAndAlbums =
db.root.artistsAndAlbums --> filter(it.artistId != a.id)
proc delete*(db: WdiwtltDb, b: Bookmark) = db.root.bookmarks.del(b.id)
proc delete*(db: WdiwtltDb, mf: MediaFile) =
db.root.mediaFiles.del(mf.id)
for albumId, mfIds in db.root.albumsToMediaFiles.pairs:
if mfIds.contains(mf.id):
db.remove(db.root.albums[albumId], mf)
proc delete*(db: WdiwtltDb, p: Playlist) =
db.root.playlists.del(p.id)
db.root.playlistsToMediaFiles.del(p.id)
for b in db.root.bookmarks.values:
if b.playlistId == p.id: db.delete(b)
proc delete*(db: WdiwtltDb, t: Tag) =
db.root.tags.del(t.name)
db.root.tagsAndMediaFiles = db.root.tagsAndMediaFiles -->
filter(it.tagName != t.name)
proc remove*(db: WdiwtltDb, a: Album, mf: MediaFile) =
db.root.albumsToMediaFiles[a.id] =
db.root.albumsToMediaFiles[a.id] -->
filter(it != mf.id)
proc remove*(db: WdiwtltDb, a: Artist, mf: MediaFile) =
db.root.artistsToMediaFiles[a.id] =
db.root.artistsToMediaFiles[a.id] -->
filter(it != mf.id)
proc remove*(db: WdiwtltDb, artist: Artist, album: Album) =
db.root.artistsAndAlbums = db.root.artistsAndAlbums -->
filter(it.albumId != album.id or it.artistId != artist.id)
proc pruneStalePlaylists*(db: WdiwtltDb, ts: DateTime) =
db.root.playlists.values -->
filter(it.lastUsed > ts).foreach(db.delete(it))
proc pruneStaleBookmarks*(db: WdiwtltDb, ts: DateTime) =
db.root.bookmarks.values -->
filter(it.lastUsed > ts).foreach(db.delete(it))
proc removeEmptyAlbums*(db: WdiwtltDb): void =
let emptyAlbumIds = db.root.albumsToMediaFiles.pairs -->
filter(it[1].len == 0).map(it[0])
for i in emptyAlbumIds:
db.root.albums.del(i)
db.root.albumsToMediaFiles.del(i)
db.root.artistsAndAlbums = db.root.artistsAndAlbums -->
filter(it.albumId != i)
proc removeEmptyArtists*(db: WdiwtltDb): void =
let emptyArtistIds = db.root.artistsToMediaFiles.pairs -->
filter(it[1].len == 0).map(it[0])
for i in emptyArtistIds:
db.root.artists.del(i)
db.root.artistsToMediaFiles.del(i)
db.root.artistsAndAlbums = db.root.artistsAndAlbums -->
filter(it.artistId != i)
proc removeEmptyPlaylists*(db: WdiwtltDb): void =
let emptyPlaylistIds = db.root.playlistsToMediaFiles.pairs -->
filter(it[1].len == 0).map(it[0])
for i in emptyPlaylistIds:
db.root.playlists.del(i)
db.root.playlistsToMediaFiles.del(i)
let emptyBookmarkIds = db.root.bookmarks.pairs -->
filter(it[1].playlistId == i).map(it[0])
for j in emptyBookmarkIds: db.root.bookmarks.del(j)

View File

@ -0,0 +1,21 @@
import times
import ./db, ./models
type
WdiwtltLibrary* = ref object
rootPath: string
#autoDeletePeriodMs*: int # 1000 * 60 * 60 * 24 * 6 # one week
db: WdiwtltDb
proc initLibrary*(rootPath: string, dbPath: string): WdiwtltLibrary =
WdiwtltLibrary(rootPath: rootPath, db: loadDb(dbPath))
proc clean*(l: WdiwtltLibrary) =
let staleDt = now() - 1.weeks
l.db.removeEmptyAlbums
l.db.removeEmptyArtists
l.db.removeEmptyPlaylists
l.db.pruneStalePlaylists(staleDt)
l.db.pruneStaleBookmarks(staleDt)
l.db.persist

View File

@ -0,0 +1,64 @@
import std/json, std/options, std/strutils, std/times
import uuids
type
MetaSource* = enum msTagInfo = "tag info", msFileLocation = "file location"
BaseModel* = object of RootObj
id*: UUID
name*: string
Album* = object of BaseModel
imageUri*: Option[string]
trackTotal: int
year*: Option[int]
Artist* = object of BaseModel
imageUri*: Option[string]
Bookmark* = object of BaseModel
playlistId*: UUID
mediaFileId*: UUID
playIdx*: int
playTimeMs*: int
userCreated*: bool
createdAt*: DateTime
lastUsed*: DateTime
MediaFile* = object of BaseModel
comment*: string
dateAdded*: DateTime
discNumber*: Option[string]
fileHash*: string
filePath*: string
imageUri*: Option[string]
lastPlayed*: DateTime
metaInfoSource*: MetaSource
playCount*: int
presentLocally*: bool
trackNumber*: Option[int]
Playlist* = object of BaseModel
userCreated*: bool
mediaFileCount*: int
copiedFromId: Option[UUID]
createdAt*: DateTime
lastUsed*: DateTime
Tag* = object
name*: string
description*: Option[string]
proc `$`*(mf: MediaFile): string =
if mf.trackNumber.isSome: align($mf.trackNumber.get, 2) & " - " & mf.name
else: mf.name
proc `$`*(a: Album): string =
result = a.name
if a.year.isSome: result &= " (" & $a.year & ")"
proc `$`*(m: BaseModel): string = m.name
proc `$`*(t: Tag): string =
result = t.name
if t.description.isSome: result &= t.description.get